双向绑定
本文适合初级前端开发者,但是如果你没有学过前端,或者是前端先辈大佬,也不要停下来啊!!
随便问前端,vue核心是什么?大家都会告诉你双向绑定!
面试官:首先能告诉我你的 年龄 职业吗?
答:是前端。
面试官:哦,是前端(轻蔑),还在写jquery
吗?
答:(一转攻势)在写vue
单页面应用。
面试官:噢,在写vue
,基础不错,蛮扎实的吗(在杰难逃)来,给我康康~手写双向绑定
!
答:不要啊,杰哥!
面试挂~~
为了应对上面不会手写双向绑定,本文将尽力讲双 向 绑 定
,为的就是将双 向 绑 定
刻在快没多少位置的DNA里面。
文章目录
什么是双向绑定?
双向绑定就是,视图更新数据,数据更新视图
视图与数据的双向更新绑定。
实现单一的双向绑定,确实不难。
首先,视图更新数据。
我们可以监听事件,在事件中更新数据,如
监听输入框的input
事件(在输入框值改变时触发),修改我们的data
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>Learn Vue</title>
</head>
<body>
<input type="text" name="id" id="id">
<p id="p"></p>
<script>
var id = "114514"
var id_input = document.querySelector("#id")
id_input.addEventListener("input", updata)
id_input.value = id
var p = document.querySelector("#p")
p.innerText = id;
function updata(event) {
id = event.target.value
p.innerText = id
}
</script>
</body>
</html>
很简单~~,连道场附近的小鬼都会~~
那么怎么实现,数据更新视图呢?也就是我们为id
赋值的时候,让id_input
也变化?
怎么实现数据更新视图
我们从技术层面分析,有两种方法:
-
自动化轮询
我们设置定时器观察
id
,如果值与原来的id
不一样时,我们就去更新视图。这种方法比较耗费资源,效率很低。 -
脏检查
与轮询道理是差不多的,但是我们并没有定时器去一遍一遍检查,而是等到在逻辑上会发生数据更新视图的时候检查,什么意思?具有良好逻辑的代码,在使用数据更新视图的时候一般在**
ajax
、UI事件
**的时候,所以我们只在上面的情况中进行检查,称之为脏检查。 -
自动化的手动更新视图
比较绕口。大致的更新情况是这样的:当我们数据改变时,我们调用我们自己封装的更新视图方法。
比较常见的是React的
setState()
和微信小程序的setData()
。 -
数据劫持/拦截
我们的本意是在数据发生变化的时候渲染视图,那么有没有数据发生变化时的钩子(Hook)函数?
您好,有的。挺多语言都有,这里举
js
的例子Object.defineProperty
、Proxy
。
从技术层面我们可以通过上面4种方法实现,除了第一种比较拉胯,其他的都比较不错,所以造就了现在前端框架三足鼎立之势:React
手动通知、AngularJS
脏检查、Vue
数据劫持。
今天我们是探究数据劫持的,所以我要拿Vue
做例子。好了废话不多说,直接开冲!
Object.defineProperty
Object.defineProperties() 方法直接在一个对象上定义新的属性或修改现有属性,并返回该对象。
这个方法最主要能为我们提供属性值的get
、set
方法的自定义。
let obj = {}
Object.defineProperty(obj, "name", {
get() {
console.log("get!")
return this.value
},
set(value) {
console.log("set!")
this.value = value
}
})
obj.name = "123"
console.log(obj.name)
现在使用我们的新知识改造我们上面的例子。
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>Learn Vue1.1</title>
</head>
<body>
<input type="text" name="id" id="id">
<p id="p"></p>
<script>
var id_input = document.querySelector("#id")
var p = document.querySelector("#p")
var obj = {}
Object.defineProperty(obj,"id",{
get(){
return this.value
},
set(value){
this.value=value
id_input.value = value
p.innerText = value
}
})
obj.value = "114514"
id_input.addEventListener("input", updata)
id_input.value = obj.id
p.innerText = obj.id;
function updata(event) {
obj.id = event.target.value
p.innerText = obj.id
}
</script>
</body>
</html>
看上去也不难,而且和Vue
有点区别,别着急我们接着讲。
有一说一,确实。实现双向绑定不难,难的是解耦
与自动化
。就像上面的例子,强耦合,且是我们手动绑定,这很不爽。所以我们要开始解耦
、自动化
。
正如我们所熟知的解耦必定提高复杂性
,接下来一套操作可能会有点复杂,请细细品味。
观察者模式 Observer 实现
为了实现解耦
与自动化
,我们使用观察者模式
。好好思考我们的核心问题。我们定义了一个需要双向绑定的对象,当对象属性改变时调用更新视图方法,为了解耦,我们将对象属性改变时调用
这个操作委托给另一个组件来做这件事,这个组件就是观察者(Observer),而将调用更新视图方法
这个操作委托给监听者(Watcher)。
观察者(Observer)的工作是监听属性的set
方法调用。当观察者(Observer) 察 觉 了属性值被改变,将告诉监听者(Watcher),**监听者(Watcher)**会调用更新视图的方法。
为了让大伙更好理解,我们自上而下构建,即框架先搭出来,内容后填。注意我会用大量ES6语法,一定不要让ES6语法拉了胯啊。
首先,构建一个像Vue的语法。
let vm = MyVue({
el:"#root",
data:{
name:"yhy"
}
})
//ok 很像了 我们来内部实现一下
class MyVue{
constructor(options) {
this.$data = options.data;//挂载在$data上
this.$el = document.querySelector(options.el);
}
}
现在,为了监听$data
内部set
方法,我们必须实现Observer
,让它来处理Object.defineProperty
。
ok,继续
function isObject(obj) {//全局方法,检测是否为Object类型
return (obj && typeof obj === "object")
}
class Observer {
constructor(data) {//data是我们要监听的对象
this.init(data)
}
init(data) {
if (isObject(data)) {//对data每一个属性添加我们自定的get set方法
Object.keys(data).forEach((key, index) => {
this.defineReactive(data, key, data[key])
})
} else {
throw new Error("Observer必要Object参数!")
}
}
defineReactive(data, key, value) {
let watcher = null;//每一个被监听属性维护的观察者
Object.defineProperty(data, key, {
get: function () {
//我们应该在此处加上订阅者,添加进订阅器内。
return value
},
set: function (newValue) {
if (newValue !== value) {
//我们在此处应该通知 订阅器,告诉它值变化了。
}
console.log("监听到了!")
},
})
}
}
为什么在get中添加订阅者?
好好想一下,每一个被监听的属性是不是要维护一个观察者(Watch),他们是一对一的,但是属性本身没有内部成员了,我们无法像调用Object那样为它添加观察者(Watch),那么,我们要把它们(被监听属性与观察者(Watch))的关系映射到另一个对象中吗?如hashMap?
请不要这样做,这样的逻辑是不合理的!观察者(Watch)本身就是被监听属性的成员(姑且这么叫它),为了更好维护,我们必须让它们结合在一起,所以我们可以使用闭包,让get访问到外部变量(毕竟内部无法设置变量),且是唯一变量。get、与set本身就是闭包函数,所以能唯一访问上面代码中的
watcher
变量。那么为什么要在get中添加呢?上面说了get、set都是闭包函数,为什么我么要在get中进行?
答:get符合逻辑
当我们要为属性添加观察者(Watcher)的时候,我们应当调用get或者set,如果我们调用set等于无意义的赋值,不如使用get方法,vm.$data[prop]简单直接。
好的,不是很难理解吧!我们拒绝弹射起步!!!
现在,我们要把MyVue
和Observe
合并一下,先实现对MyVue
中的属性修改。让其通过通过我们的set
、get
方法!
class MyVue {
constructor(options) {
this.$options = options;
this.$data = options.data;
this.$el = document.querySelector(options.el);
this.init();
}
init(){
new Observer(this.$data)
}
}
function isObject(obj) {
return (obj && typeof obj === "object")
}
class Observer {
constructor(data) {
this.init(data)
}
init(data) {
if (isObject(data)) {
Object.keys(data).forEach((key, index) => {
this.defineReactive(data, key, data[key])
})
} else {
throw new Error("Observer必要Object参数!")
}
}
defineReactive(data, key, value) {
Object.defineProperty(data, key, {
get: function () {
return value
},
set: function (newValue) {
if (newValue !== value) {
}
console.log("监听到了!")
},
})
}
}
let vm = new MyVue({
el:"#root",
data:{
name:"田所浩二"
}
})
vm.$data.name = "野 兽 先 辈"
打开控制台,明显看到监听到了!
字样,很好,再试试别的。
let vm = new MyVue({
el:"#root",
data:{
obj:{
name:"田所浩二"
}
}
})
vm.$data.obj.name = "野 兽 先 辈"
我们把name
放在obj
之中,加深了一层,这次运行我们发现**没有输出监听到了!
**字样。怎么大家都挺猛到你这拉了胯呢?这是因为我们设置Object.defineProperty
的时候,只是为第一层设置了,所以我们要改进我们代码,为data
所有Object
也设置,同时考虑到Object
嵌套问题,我们可以使用递归来设置它们的Object.defineProperty
。
好的,改进我们代码!
class Observer{
/*some thing*/
init(data) {
if (isObject(data)) {
Object.keys(data).forEach((key, index) => {
if (isObject(data[key])) {
this.init(data[key])
}
this.defineReactive(data, key, data[key])
})
} else {
throw new Error("Observer必要Object参数!")
}
}
}
ok,解决了这些问题,我们要开始实现Watch
Watch实现
class Watch {
//为了对MyVue对象设置 我们必须得有三个参数
constructor(vm, prop, callback) {
this.vm = vm;//MyVue实例
this.prop = prop;//为MyVue实例prop属性添加的回调
this.callback = callback;//回调方法
this.setObserverTarget()//设置watch
}
//调用回调方法
updata(value) {
this.callback(value)
}
setObserverTarget() {
//将属性与watch联系起来
vm.$data[prop]//调用get方法
}
}
因为我们在与get
方法中设置,在set
方法中调用。所以我们肯定要修改Observe
中的get
、set
方法
// some things
let watcher = null
Object.defineProperty(data, key, {
get: function () {
if (!watcher) {
watcher = new Watch(vm,prop,()=>{
//do somethings
})
}
return value
},
set: function (newValue) {
if (newValue !== value) {
value = newValue
watch.updata(value)
}
},
})
到这一步,大家应该都能明白把,现在我们属性与Watcher属于一对一的关系,当属性改变时调用watch.updata
方法。但是这样的逻辑是不严谨的,我们一个属性可能与多个事件绑定!那么,我们与其让属性维护一个watcher不如直接让它维护一个WatchList,在WatchList中我们放上所有Watch,触发set
方法时,我们去广播通知WatchList
中所有的Watcher
!
WatchList
class WatchList extends Array {
constructor(...arr) {
super(...arr)
}
notify(value) {
for (let i in this) {
this[i].updata(value) ///调用每一个Watch的updata方法
}
}
}
现在对了,再次修改我们上面的代码
defineReactive(data, key, value) {
let watchList = new WatchList();
Object.defineProperty(data, key, {
get: function () {
if (/*这里面放什么?*/) {
}
return value
},
set: function (newValue) {
if (newValue !== value) {
value = newValue
watchList.notify(value)
}
console.log("监听到了!")
},
})
}
现在有个问题,看到我们上面的代码,if()
里面我们应该放什么?
if
里面应该添加我们的watch
,那么一一对应的watch
应该保存在哪?
这里我们可以用静态变量来做,因为添加属性的watch,不是get的事情,而是我们Watch
的事情,而watch
又是watchlist
维护的,那么我们可以这么写。
下面应该是你的代码的终极版!
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>Learn Vue1.4</title>
</head>
<body>
<input type="text" id="name"/>
<script>
function isObject(obj) {
return (obj && typeof obj === "object")
}
class MyVue {
constructor(options) {
this.$options = options;
this.$data = options.data;
this.$el = document.querySelector(options.el);
this.init();
}
init(){
new Observer(this.$data)
}
}
class Observer {
constructor(data) {
this.init(data)
}
init(data) {
if (isObject(data)) {
Object.keys(data).forEach((key, index) => {
if (isObject(data[key])) {
this.init(data[key])
}
this.defineReactive(data, key, data[key])
})
} else {
throw new Error("Observer必要Object参数!")
}
}
defineReactive(data, key, value) {
let watchList = new WatchList();
Object.defineProperty(data, key, {
get: function () {
if (WatchList.target) {
watchList.push(WatchList.target)
}
return value
},
set: function (newValue) {
if (newValue !== value) {
value = newValue
watchList.notify(value)
}
},
})
}
}
class WatchList extends Array {
static target = null;
constructor(...arr) {
super(...arr)
}
notify(value) {
for (let i in this) {
this[i].updata(value) ///调用每一个Watch的updata方法
}
}
}
class Watch {
constructor(vm, prop, callback) {
this.vm = vm;
this.prop = prop;
this.callback = callback;
this.setObserverTarget()
}
updata(value) {
this.callback(value)
}
setObserverTarget() {
WatchList.target = this
const value = this.vm.$data[this.prop]
WatchList.target = null;
}
}
let vm = new MyVue({
el:"#root",
data:{
name:"123",
obj:{
name:"1"
}
}
})
new Watch(vm,"name",(value)=>{
document.querySelector("#name").value = value
})
</script>
</body>
</html>
这一版的效果了不得啊,我们已经实现了数据更新视图,而且是低耦合,你现在可以打开F12
控制台工具,在console
里面输入vm.$data.name = "你好vue"
,不出意外(什么叫TM的意外?意外就是你还用IE8浏览器学代码!),就可以看到输入框的值变化了!
Compile 解析器 实现
上面我们实现了单向数据绑定
与解耦
,为了实现双向自动绑定
与自动化
。
我们的自动化一定是期望使用vue
的模板语法来实现的就像下面例子一样。
<input v-model="name"/>
<p>{{name}}<p>
所以这一节我们会大量使用DOM操作
。
class Compile {
constructor(vm) {
this.vm = vm;
this.el = vm.$el;
this.fragment = this.nodeFragment();
this.compileElement(this.fragment, this.vm)
this.el.appendChild(this.fragment)
}
nodeFragment() {
let el = this.el;
const fragment = document.createDocumentFragment();
let child = el.firstChild;
while (child) {
fragment.appendChild(child);
child = el.firstChild;
}
return fragment;
}
compileElement(node, vm) {
var reg = /\{\{(.*)\}\}/;
//节点类型为元素(input元素这里)
for (let item of Array.from(node.children)) {
if (item.nodeType === 1) {
let nodeAttrs = item.attributes;
[...nodeAttrs].forEach(attr => {
let name = attr.name;
if (this.isDirective(name)) {
let value = attr.value;
if (name === "v-model") {
this.compileModel(item, value);
}
}
});
let text = item.textContent;
if (reg.test(text)) {
let prop = reg.exec(text)[1];
this.compileText(item, prop); //替换模板
}
}
}
}
compileText(node, prop) {
let text = this.vm.$data[prop];
this.updataText(node, text);
new Watch(this.vm, prop, (value) => {
this.updataText(node, value);
});
}
isDirective(attr) {
return attr.indexOf('v-') !== -1;
}
updateModel(node, value) {
node.value = typeof value == 'undefined' ? '' : value;
}
updataText(node, value) {
node.innerText = typeof value == 'undefined' ? '' : value;
}
compileModel(node, prop) {
let val = this.vm.$data[prop];
node.value = val
new Watch(this.vm, prop, (value) => {
this.updateModel(node, value)
});
node.addEventListener('input', e => {
let newValue = e.target.value;
if (val === newValue) {
return;
}
this.vm.$data[prop] = newValue;
});
}
}
逻辑比较简单,稍微说一下吧,首先为了性能和尽量同一更改document
对象,我们使用了createDocumentFragment
来创建一个文档碎片,可以把它理解为不在document
中的节点。
然后我们遍历这个文档碎片,对其中我们要替换的/绑定的元素,进行设置Watch
。
ok,现在我们的解耦
、自动化
的双向绑定
就实现了,里面还有一些问题,我把它留给大家,请思考解决v-model="obj.name"
这种问题如何绑定?
最后