JavaScript 进阶之深入理解数据双向绑定
前言
谈起当前前端最热门的 js 框架,必少不了 Vue、React、Angular,对于大多数人来说,我们更多的是在使用框架,对于框架解决痛点背后使用的基本原理往往关注不多,近期在研读 Vue.js 源码,也在写源码解读的系列文章。和多数源码解读的文章不同的是,我会尝试从一个初级前端的角度入手,由浅入深去讲解源码实现思路和基本的语法知识,通过一些基础事例一步步去实现一些小功能。
本场 Chat 是系列 Chat 的开篇,我会首先讲解一下数据双向绑定的基本原理,介绍对比一下三大框架的不同实现方式,同时会一步步完成一个简单的 mvvm 示例。读源码不是目的,只是一种学习的方式,目的是在读源码的过程中提升自己,学习基本原理,拓展编码的思维方式。
模板引擎实现原理
对于页面渲染,一般分为服务器端渲染和浏览器端渲染。一般来说服务器端吐 html 页面的方式渲染速度更快、更利于 SEO,但是浏览器端渲染更利于提高开发效率和减少维护成本,是一种相关舒服的前后端协作模式,后端提供接口,前端做视图和交互逻辑。前端通过 Ajax 请求数据然后拼接 html 字符串或者使用 js 模板引擎、数据驱动的框架如 Vue 进行页面渲染。
在 ES6 和 Vue 这类框架出现以前,前端绑定数据的方式是动态拼接 html 字符串和 js 模板引擎。模板引擎起到数据和视图分离的作用,模板对应视图,关注如何展示数据,在模板外头准备的数据, 关注那些数据可以被展示。模板引擎的工作原理可以简单地分成两个步骤:模板解析 / 编译(Parse / Compile)和数据渲染(Render)两部分组成,当今主流的前端模板有三种方式:
- String-based templating (基于字符串的 parse 和 compile 过程)
- Dom-based templating (基于 Dom 的 link 或 compile 过程)
- Living templating (基于字符串的 parse 和 基于 dom 的 compile 过程)
String-based templating
基于字符串的模板引擎,本质上依然是字符串拼接的形式,只是一般的库做了封装和优化,提供了更多方便的语法简化了我们的工作。基本原理如下:

典型的库:
之前的一篇文章中我介绍了 js 模板引擎的实现思路,感兴趣的朋友可以看看这里:JavaScript 进阶学习(一)—— 基于正则表达式的简单 js 模板引擎实现。这篇文章中我们利用正则表达式实现了一个简单的 js 模板引擎,利用正则匹配查找出模板中{{}}之间的内容,然后替换为模型中的数据,从而实现视图的渲染。
var template = function(tpl, data) {
var re = /{{(.+?)}}/g,
cursor = 0,
reExp = /(^( )?(var|if|for|else|switch|case|break|{|}|;))(.*)?/g,
code = "var r=[];\n";
// 解析html
function parsehtml(line) {
// 单双引号转义,换行符替换为空格,去掉前后的空格
line = line
.replace(/('|")/g, "\\$1")
.replace(/\n/g, " ")
.replace(/(^\s+)|(\s+$)/g, "");
code += 'r.push("' + line + '");\n';
}
// 解析js代码
function parsejs(line) {
// 去掉前后的空格
line = line.replace(/(^\s+)|(\s+$)/g, "");
code += line.match(reExp)
? line + "\n"
: "r.push(" + "this." + line + ");\n";
}
// 编译模板
while ((match = re.exec(tpl)) !== null) {
// 开始标签 {{ 前的内容和结束标签 }} 后的内容
parsehtml(tpl.slice(cursor, match.index));
// 开始标签 {{ 和 结束标签 }} 之间的内容
parsejs(match[1]);
// 每一次匹配完成移动指针
cursor = match.index + match[0].length;
}
// 最后一次匹配完的内容
parsehtml(tpl.substr(cursor, tpl.length - cursor));
code += 'return r.join("");';
return new Function(code.replace(/[\r\t\n]/g, "")).apply(data);
};
源代码:http://jsrun.net/yaYKp/embedded/all/light/
现在 ES6 支持了模板字符串,我们可以用比较简单的代码就可以实现类似的功能:
const template = data => `
<p>name: ${data.name}</p>
<p>age: ${data.profile.age}</p>
<ul>
${data.skills
.map(
skill => `
<li>${skill}</li>
`
)
.join("")}
</ul>`;
const data = {
name: "zhaomenghuan",
profile: { age: 24 },
skills: ["html5", "javascript", "android"]
};
document.body.innerHTML = template(data);
Dom-based templating

Dom-based templating 则是从 DOM 的角度去实现数据的渲染,我们通过遍历 DOM 树,提取属性与 DOM 内容,然后将数据写入到 DOM 树中,从而实现页面渲染。一个简单的例子如下:
function MVVM(opt) {
this.dom = document.querySelector(opt.el);
this.data = opt.data || {};
this.renderDom(this.dom);
}
MVVM.prototype = {
init: {
sTag: "{{",
eTag: "}}"
},
render: function(node) {
var self = this;
var sTag = self.init.sTag;
var eTag = self.init.eTag;
var matchs = node.textContent.split(sTag);
if (matchs.length) {
var ret = "";
for (var i = 0; i < matchs.length; i++) {
var match = matchs[i].split(eTag);
if (match.length == 1) {
ret += matchs[i];
} else {
ret = self.data[match[0]];
}
node.textContent = ret;
}
}
},
renderDom: function(dom) {
var self = this;
var attrs = dom.attributes;
var nodes = dom.childNodes;
Array.prototype.forEach.call(attrs, function(item) {
self.render(item);
});
Array.prototype.forEach.call(nodes, function(item) {
if (item.nodeType === 1) {
return self.renderDom(item);
}
self.render(item);
});
}
};
var app = new MVVM({
el: "#app",
data: {
name: "zhaomenghuan",
age: "24",
color: "red"
}
});
源代码:http://jsrun.net/faYKp/embedded/all/light/
页面渲染的函数 renderDom 是直接遍历 DOM 树,而不是遍历 html 字符串。遍历 DOM 树节点属性(attributes)和子节点(childNodes),然后调用渲染函数 render。当 DOM 树子节点的类型是元素时,递归调用遍历 DOM 树的方法。根据 DOM 树节点类型一直遍历子节点,直到文本节点。
render 的函数作用是提取{{}}中的关键词,然后使用数据模型中的数据进行替换。我们通过 textContent 获取 Node 节点的 nodeValue,然后使用字符串的 split 方法对 nodeValue 进行分割,提取{{}}中的关键词然后替换为数据模型中的值。
DOM 的相关基础
注:元素类型对应 NodeType
| 元素类型 | NodeType |
|---|---|
| 元素 | 1 |
| 属性 | 2 |
| 文本 | 3 |
| 注释 | 8 |
| 文档 | 9 |
childNodes 属性返回包含被选节点的子节点的 NodeList。childNodes 包含的不仅仅只有 html 节点,所有属性,文本、注释等节点都包含在 childNodes 里面。children 只返回元素如 input, span, script, div 等,不会返回 TextNode,注释。
数据双向绑定实现原理
js 模板引擎可以认为是一个基于 MVC 的结构,我们通过建立模板作为视图,然后通过引擎函数作为控制器实现数据和视图的绑定,从而实现实现数据在页面渲染,但是当数据模型发生变化时,视图不能自动更新;当视图数据发生变化时,模型数据不能实现更新,这个时候双向数据绑定应运而生。检测视图数据更新实现数据绑定的方法有很多种,目前主要分为三个流派,Angular 使用的是脏检查,只在特定的事件下才会触发视图刷新,Vue 使用的是 Getter/Setter 机制,而 React 则是通过 Virtual DOM 算法检查 DOM 的变动的刷新机制。
本文限于篇幅和内容在此只探讨一下 Vue.js 数据绑定的实现,对于 angular 和 react 后续再做说明,读者也可以自行阅读源码。Vue 监听数据变化的机制是把一个普通 JavaScript 对象传给 Vue 实例的 data 选项,Vue 将遍历此对象所有的属性,并使用 Object.defineProperty 把这些属性全部转为 getter/setter。Vue 2.x 对 Virtual DOM 进行了支持,这部分内容后续我们再做探讨。
引子
为了更好的理解 Vue 中视图和数据更新的机制,我们先看一个简单的例子:
var o = {
a: 0
};
Object.defineProperty(o, "b", {
get: function() {
return this.a + 1;
},
set: function(value) {
this.a = value / 2;
}
});
console.log(o.a); // "0"
console.log(o.b); // "1"
// 更新o.a
o.a = 5;
console.log(o.a); // "5"
console.log(o.b); // "6"
// 更新o.b
o.b = 10;
console.log(o.a); // "5"
console.log(o.b); // "6"
这里我们可以看出对象 o 的 b 属性的值依赖于 a 属性的值,同时 b 属性值的变化又可以改变 a 属性的值,这个过程相关的属性值的变化都会影响其他相关的值进行更新。反过来我们看看如果不使用 Object.defineProperty()方法,上述的问题通过直接给对象属性赋值的方法实现,代码如下:
let o = {
a: 0
};
o.b = o.a + 1;
console.log(o.a); // "0"
console.log(o.b); // "1"
// 更新o.a
o.a = 5;
o.b = o.a + 1;
console.log(o.a); // "5"
console.log(o.b); // "6"
// 更新o.b
o.b = 10;
o.a = o.b / 2;
o.b = o.a + 1;
console.log(o.a); // "5"
console.log(o.b); // "6"
很显然使用Object.defineProperty()方法可以更方便的监听一个对象的变化。当我们的视图和数据任何一方发生变化的时候,我们希望能够通知对方也更新,这就是所谓的数据双向绑定。既然明白这个道理我们就可以看看 Vue 源码中相关的处理细节。
Object.defineProperty()
Object.defineProperty()方法可以直接在一个对象上定义一个新属性,或者修改一个已经存在的属性, 并返回这个对象。语法:Object.defineProperty(obj, prop, descriptor)
参数:
- obj:需要定义属性的对象。
- prop:需被定义或修改的属性名。
- descriptor:需被定义或修改的属性的描述符。
**返回值:**返回传入函数的对象,即第一个参数 obj
该方法重点是描述,对象里目前存在的属性描述符有两种主要形式:数据描述符和存取描述符。数据描述符是一个拥有可写或不可写值的属性。存取描述符是由一对 getter-setter 函数功能来描述的属性。描述符必须是两种形式之一;不能同时是两者。
数据描述符和存取描述符均具有以下可选键值:
- configurable:当且仅当该属性的 configurable 为 true 时,该属性才能够被改变,也能够被删除。默认为 false。
- enumerable:当且仅当该属性的 enumerable 为 true 时,该属性才能够出现在对象的枚举属性中。默认为 false。
数据描述符同时具有以下可选键值:
- value:该属性对应的值。可以是任何有效的 JavaScript 值(数值,对象,函数等)。默认为 undefined。
- writable:当且仅当仅当该属性的 writable 为 true 时,该属性才能被赋值运算符改变。默认为 false。
存取描述符同时具有以下可选键值:
- get:一个给属性提供 getter 的方法,如果没有 getter 则为 undefined。该方法返回值被用作属性值。默认为 undefined。
- set:一个给属性提供 setter 的方法,如果没有 setter 则为 undefined。该方法将接受唯一参数,并将该参数的新值分配给该属性。默认为 undefined。
我们可以通过 Object.defineProperty()方法精确添加或修改对象的属性。比如,直接赋值创建的属性默认情况是可以枚举的,但是我们可以通过 Object.defineProperty()方法设置 enumerable 属性为 false 为不可枚举。
var obj = {
a: 0,
b: 1
}
for (var prop in obj) {
console.log(`obj.${prop} = ${obj[prop]}`);
}
结果:
"obj.a = 0"
"obj.b = 1"
我们通过 Object.defineProperty()修改如下:
var obj = {
a: 0,
b: 1
}
Object.defineProperty(obj, 'b', {
enumerable: false
})
for (var prop in obj) {
console.log(`obj.${prop} = ${obj[prop]}`);
}
结果:
"obj.a = 0"
这里需要说明的是我们使用 Object.defineProperty()默认情况下是 enumerable 属性为 false,例如:
var obj = {
a: 0
}
Object.defineProperty(obj, 'b', {
value: 1
})
for (var prop in obj) {
console.log(`obj.${prop} = ${obj[prop]}`);
}
结果:
"obj.a = 0"
其他描述属性使用方法类似,不做赘述。Vue 源码core/util/lang.jsS 中定义了这样一个方法:
/**
* Define a property.
*/
export function def(obj: Object, key: string, val: any, enumerable?: boolean) {
Object.defineProperty(obj, key, {
value: val,
enumerable: !!enumerable,
writable: true,
configurable: true
});
}
Object.getOwnPropertyDescriptor()
Object.getOwnPropertyDescriptor() 返回指定对象上一个自有属性对应的属性描述符。(自有属性指的是直接赋予该对象的属性,不需要从原型链上进行查找的属性) 语法:Object.getOwnPropertyDescriptor(obj, prop)
参数:
- obj:在该对象上查看属性
- prop:一个属性名称,该属性的属性描述符将被返回
**返回值:**如果指定的属性存在于对象上,则返回其属性描述符(property descriptor),否则返回 undefined。可以访问“属性描述符”内容,例如前面的例子:
var o = {
a: 0
};
Object.defineProperty(o, "b", {
get: function() {
return this.a + 1;
},
set: function(value) {
this.a = value / 2;
}
});
var des = Object.getOwnPropertyDescriptor(o, "b");
console.log(des);
console.log(des.get);
Vue 源码分析
本次我们主要分析一下 Vue 数据绑定的源码,这里我直接将 Vue.js 1.0.28 版本的代码稍作删减拿过来进行,2.x 的代码基于 flow 静态类型检查器书写的,代码除了编码风格在整体结构上基本没有太大改动,所以依然基于 1.x 进行分析,对于存在差异的部分加以说明。

监听对象变动
// 观察者构造函数
function Observer(value) {
this.value = value;
this.walk(value);
}
// 递归调用,为对象绑定getter/setter
Observer.prototype.walk = function(obj) {
var keys = Object.keys(obj);
for (var i = 0, l = keys.length; i < l; i++) {
this.convert(keys[i], obj[keys[i]]);
}
};
// 将属性转换为getter/setter
Observer.prototype.convert = function(key, val) {
defineReactive(this.value, key, val);
};
// 创建数据观察者实例
function observe(value) {
// 当值不存在或者不是对象类型时,不需要继续深入监听
if (!value || typeof value !== "object") {
return;
}
return new Observer(value);
}
// 定义对象属性的getter/setter
function defineReactive(obj, key, val) {
var property = Object.getOwnPropertyDescriptor(obj, key);
if (property && property.configurable === false) {
return;
}
// 保存对象属性预先定义的getter/setter
var getter = property && property.get;
var setter = property && property.set;
var childOb = observe(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
var value = getter ? getter.call(obj) : val;
console.log("访问:" + key);
return value;
},
set: function reactiveSetter(newVal) {
var value = getter ? getter.call(obj) : val;
if (newVal === value) {
return;
}
if (setter) {
setter.call(obj, newVal);
} else {
val = newVal;
}
// 对新值进行监听
childOb = observe(newVal);
console.log("更新:" + key + " = " + newVal);
}
});
}
定义一个对象作为数据模型,并监听这个对象。
let data = {
user: {
name: "zhaomenghuan",
age: "24"
},
address: {
city: "beijing"
}
};
observe(data);
console.log(data.user.name);
// 访问:user
// 访问:name
data.user.name = "ZHAO MENGHUAN";
// 访问:user
// 更新:name = ZHAO MENGHUAN
效果如下:

监听数组变动
上面我们通过 Object.defineProperty 把对象的属性全部转为 getter/setter 从而实现监听对象的变动,但是对于数组对象无法通过 Object.defineProperty 实现监听。Vue 包含一组观察数组的变异方法,所以它们也将会触发视图更新。
const arrayProto = Array.prototype;
const arrayMethods = Object.create(arrayProto);
function def(obj, key, val, enumerable) {
Object.defineProperty(obj, key, {
value: val,
enumerable: !!enumerable,
writable: true,
configurable: true
});
}
// 数组的变异方法
["push", "pop", "shift", "unshift", "splice", "sort", "reverse"].forEach(
function(method) {
// 缓存数组原始方法
var original = arrayProto[method];
def(arrayMethods, method, function mutator() {
var i = arguments.length;
var args = new Array(i);
while (i--) {
args[i] = arguments[i];
}
console.log("数组变动");
return original.apply(this, args);
});
}
);
Vue.js 1.x 在 Array.prototype 原型对象上添加了$set 和 $remove方法,在 2.X 后移除了,使用全局 API Vue.set 和 Vue.delete代替了,后续我们再分析。
定义一个数组作为数据模型,并对这个数组调用变异的七个方法实现监听。
let skills = ["JavaScript", "Node.js", "html5"];
// 原型指针指向具有变异方法的数组对象
skills.__proto__ = arrayMethods;
skills.push("java");
// 数组变动
skills.pop();
// 数组变动
效果如下:

我们将需要监听的数组的原型指针指向我们定义的数组对象,这样我们的数组在调用上面七个数组的变异方法时,能够监听到变动从而实现对数组进行跟踪。
对于__proto__属性,在 ES2015 中正式被加入到规范中,标准明确规定,只有浏览器必须部署这个属性,其他运行环境不一定需要部署,所以 Vue 是先进行了判断,当__proto__属性存在时将原型指针__proto__指向具有变异方法的数组对象,不存在时直接将具有变异方法挂在需要追踪的对象上。
我们可以在上面 Observer 观察者构造函数中添加对数组的监听,源码如下:
const hasProto = "__proto__" in {};
const arrayKeys = Object.getOwnPropertyNames(arrayMethods);
// 观察者构造函数
function Observer(value) {
this.value = value;
if (Array.isArray(value)) {
var augment = hasProto ? protoAugment : copyAugment;
augment(value, arrayMethods, arrayKeys);
this.observeArray(value);
} else {
this.walk(value);
}
}
// 观察数组的每一项
Observer.prototype.observeArray = function(items) {
for (var i = 0, l = items.length; i < l; i++) {
observe(items[i]);
}
};
// 将目标对象/数组的原型指针__proto__指向src
function protoAugment(target, src) {
target.__proto__ = src;
}
// 将具有变异方法挂在需要追踪的对象上
function copyAugment(target, src, keys) {
for (var i = 0, l = keys.length; i < l; i++) {
var key = keys[i];
def(target, key, src[key]);
}
}
原型链
对于不了解原型链的朋友可以看一下我这里画的一个基本关系图:

- 原型对象是构造函数的 prototype 属性,是所有实例化对象共享属性和方法的原型对象;
- 实例化对象通过 new 构造函数得到,都继承了原型对象的属性和方法;
- 原型对象中有个隐式的 constructor,指向了构造函数本身。
Object.create
Object.create 使用指定的原型对象和其属性创建了一个新的对象。
const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)
这一步是通过 Object.create 创建了一个原型对象为 Array.prototype 的空对象。然后通过 Object.defineProperty 方法对这个对象定义几个变异的数组方法。有些新手可能会直接修改 Array.prototype 上的方法,这是很危险的行为,这样在引入的时候会全局影响 Array 对象的方法,而使用 Object.create 实质上是完全了一份拷贝,新生成的 arrayMethods 对象的原型指针__proto__指向了 Array.prototype,修改 arrayMethods 对象不会影响 Array.prototype。
基于这种原理,我们通常会使用 Object.create 实现类式继承。
// 实现继承
var extend = function(Child, Parent) {
// 拷贝Parent原型对象
Child.prototype = Object.create(Parent.prototype);
// 将Child构造函数赋值给Child的原型对象
Child.prototype.constructor = Child;
};
// 实例
var Parent = function() {
this.name = "Parent";
};
Parent.prototype.getName = function() {
return this.name;
};
var Child = function() {
this.name = "Child";
};
extend(Child, Parent);
var child = new Child();
console.log(child.getName());
发布-订阅模式
在上面一部分我们通过 Object.defineProperty 把对象的属性全部转为 getter/setter 以及 数组变异方法实现了对数据模型变动的监听,在数据变动的时候,我们通过 console.log 打印出来提示了,但是对于框架而言,我们相关的逻辑如果直接写在那些地方,自然是不够优雅和灵活的,这个时候就需要引入常用的设计模式去实现,vue.js 采用了发布-订阅模式。发布-订阅模式主要是为了达到一种“高内聚、低耦合"的效果。
Vue 的 Watcher 订阅者作为 Observer 和 Compile 之间通信的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数,从而更新视图。
/**
* 观察者对象
*/
function Watcher(vm, expOrFn, cb) {
this.vm = vm;
this.cb = cb;
this.depIds = {};
if (typeof expOrFn === "function") {
this.getter = expOrFn;
} else {
this.getter = this.parseExpression(expOrFn);
}
this.value = this.get();
}
/**
* 收集依赖
*/
Watcher.prototype.get = function() {
// 当前订阅者(Watcher)读取被订阅数据的最新更新后的值时,通知订阅者管理员收集当前订阅者
Dep.target = this;
// 触发getter,将自身添加到dep中
const value = this.getter.call(this.vm, this.vm);
// 依赖收集完成,置空,用于下一个Watcher使用
Dep.target = null;
return value;
};
Watcher.prototype.addDep = function(dep) {
if (!this.depIds.hasOwnProperty(dep.id)) {
dep.addSub(this);
this.depIds[dep.id] = dep;
}
};
/**
* 依赖变动更新
*
* @param {Boolean} shallow
*/
Watcher.prototype.update = function() {
this.run();
};
Watcher.prototype.run = function() {
var value = this.get();
if (value !== this.value) {
var oldValue = this.value;
this.value = value;
// 将newVal, oldVal挂载到MVVM实例上
this.cb.call(this.vm, value, oldValue);
}
};
Watcher.prototype.parseExpression = function(exp) {
if (/[^\w.$]/.test(exp)) {
return;
}
var exps = exp.split(".");
return function(obj) {
for (var i = 0, len = exps.length; i < len; i++) {
if (!obj) return;
obj = obj[exps[i]];
}
return obj;
};
};
Dep 是一个数据结构,其本质是维护了一个 watcher 队列,负责添加 watcher,更新 watcher,移除 watcher,通知 watcher 更新。
let uid = 0;
function Dep() {
this.id = uid++;
this.subs = [];
}
Dep.target = null;
/**
* 添加一个订阅者
*
* @param {Directive} sub
*/
Dep.prototype.addSub = function(sub) {
this.subs.push(sub);
};
/**
* 移除一个订阅者
*
* @param {Directive} sub
*/
Dep.prototype.removeSub = function(sub) {
let index = this.subs.indexOf(sub);
if (index !== -1) {
this.subs.splice(index, 1);
}
};
/**
* 将自身作为依赖添加到目标watcher
*/
Dep.prototype.depend = function() {
Dep.target.addDep(this);
};
/**
* 通知数据变更
*/
Dep.prototype.notify = function() {
var subs = toArray(this.subs);
// stablize the subscriber list first
for (var i = 0, l = subs.length; i < l; i++) {
// 执行订阅者的update更新函数
subs[i].update();
}
};
模板编译
compile 主要做的事情是解析模板指令,将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图。
function Compile(el, value) {
this.$vm = value;
this.$el = this.isElementNode(el) ? el : document.querySelector(el);
if (this.$el) {
this.compileElement(this.$el);
}
}
Compile.prototype.compileElement = function(el) {
let self = this;
let childNodes = el.childNodes;
[].slice.call(childNodes).forEach(node => {
let text = node.textContent;
let reg = /\{\{((?:.|\n)+?)\}\}/;
// 处理element节点
if (self.isElementNode(node)) {
self.compile(node);
} else if (self.isTextNode(node) && reg.test(text)) {
// 处理text节点
self.compileText(node, RegExp.$1.trim());
}
// 解析子节点包含的指令
if (node.childNodes && node.childNodes.length) {
self.compileElement(node);
}
});
};
Compile.prototype.compile = function(node) {
let nodeAttrs = node.attributes;
let self = this;
[].slice.call(nodeAttrs).forEach(attr => {
var attrName = attr.name;
if (self.isDirective(attrName)) {
let exp = attr.value;
let dir = attrName.substring(2);
if (self.isEventDirective(dir)) {
compileUtil.eventHandler(node, self.$vm, exp, dir);
} else {
compileUtil[dir] && compileUtil[dir](node, self.$vm, exp);
}
node.removeAttribute(attrName);
}
});
};
Compile.prototype.compileText = function(node, exp) {
compileUtil.text(node, this.$vm, exp);
};
Compile.prototype.isDirective = function(attr) {
return attr.indexOf("v-") === 0;
};
Compile.prototype.isEventDirective = function(dir) {
return dir.indexOf("on") === 0;
};
Compile.prototype.isElementNode = function(node) {
return node.nodeType === 1;
};
Compile.prototype.isTextNode = function(node) {
return node.nodeType === 3;
};
// 指令处理集合
var compileUtil = {
text: function(node, vm, exp) {
this.bind(node, vm, exp, "text");
},
html: function(node, vm, exp) {
this.bind(node, vm, exp, "html");
},
model: function(node, vm, exp) {
this.bind(node, vm, exp, "model");
let self = this,
val = this._getVMVal(vm, exp);
node.addEventListener("input", function(e) {
var newValue = e.target.value;
if (val === newValue) {
return;
}
self._setVMVal(vm, exp, newValue);
val = newValue;
});
},
bind: function(node, vm, exp, dir) {
var updaterFn = updater[dir + "Updater"];
updaterFn && updaterFn(node, this._getVMVal(vm, exp));
new Watcher(vm, exp, function(value, oldValue) {
updaterFn && updaterFn(node, value, oldValue);
});
},
eventHandler: function(node, vm, exp, dir) {
var eventType = dir.split(":")[1],
fn = vm.$options.methods && vm.$options.methods[exp];
if (eventType && fn) {
node.addEventListener(eventType, fn.bind(vm), false);
}
},
_getVMVal: function(vm, exp) {
var val = vm;
exp = exp.split(".");
exp.forEach(function(k) {
val = val[k];
});
return val;
},
_setVMVal: function(vm, exp, value) {
var val = vm;
exp = exp.split(".");
exp.forEach(function(k, i) {
// 非最后一个key,更新val的值
if (i < exp.length - 1) {
val = val[k];
} else {
val[k] = value;
}
});
}
};
var updater = {
textUpdater: function(node, value) {
node.textContent = typeof value == "undefined" ? "" : value;
},
htmlUpdater: function(node, value) {
node.innerHTML = typeof value == "undefined" ? "" : value;
},
modelUpdater: function(node, value, oldValue) {
node.value = typeof value == "undefined" ? "" : value;
}
};
这种实现和我们讲到的 Dom-based templating 类似,只是更加完备,具有自定义指令的功能。在遍历节点属性和文本节点的时候,可以编译具备{{}}表达式或v-xxx的属性值的节点,并且通过添加 new Watcher()及绑定事件函数,监听数据的变动从而对视图实现双向绑定。
MVVM 实例
在数据绑定初始化的时候,我们需要通过new Observer()来监听数据模型变化,通过new Compile()来解析编译模板指令,并利用 Watcher 搭起 Observer 和 Compile 之间的通信桥梁。
/**
* @class 双向绑定类 MVVM
* @param {[type]} options [description]
*/
function MVVM(options) {
this.$options = options || {};
// 简化了对data的处理
let data = (this._data = this.$options.data);
// 监听数据
observe(data);
new Compile(options.el || document.body, this);
}
MVVM.prototype.$watch = function(expOrFn, cb) {
new Watcher(this, expOrFn, cb);
};
为了能够直接通过实例化对象操作数据模型,我们需要为 MVVM 实例添加一个数据模型代理的方法:
MVVM.prototype._proxy = function (key) {
Object.defineProperty(this, key, {
configurable: true,
enumerable: true,
get: () => this._data[key],
set: (val) => {
this._data[key] = val
}
})
}
至此我们可以通过一个小例子来说明本文的内容:
<div id="app">
<h3>{{user.name}}</h3>
<input type="text" v-model="modelValue">
<p>{{modelValue}}</p>
</div>
<script>
let vm = new MVVM({
el: '#app',
data: {
modelValue: '',
user: {
name: 'zhaomenghuan',
age: '24'
},
address: {
city: 'beijing'
},
skills: ['JavaScript', 'Node.js', 'html5']
}
})
vm.$watch('modelValue', val => console.log(`watch modelValue :${val}`))
</script>
本文目的不是为了造一个轮子,而是在学习优秀框架实现的过程中去提升自己,搞清楚框架发展的前因后果,由浅及深去学习基础,本文参考了网上很多优秀博主的文章,由于时间关系,有些内容没有做深入探讨,觉得还是有些遗憾,在后续的学习中会更多的独立思考,提出更多自己的想法。
参考文档
- 前端模板技术面面观
- Object.defineProperty()
- Vue.js 源码学习笔记
- vue 早期源码学习系列
- 解析最简单的 observer 和 watcher
- 剖析 Vue 实现原理 - 如何实现双向绑定 mvvm
说明
本文的完整代码及图片可以在这里下载:learn-javascript/mvvm
写这些代码也许就一两个小时的事,写一篇大家好接受的文章需要几天的酝酿,如果文章对您有帮助请我喝杯咖啡吧!