Vue的MVVM响应式
DreamYum 人气:0前言
这些天都在面试,每当我被面试官们问到Vue响应式原理时,回答得都很肤浅。如果您在回答时也只是停留在MVVM框架是model层、view层和viewmodel层这样的双向数据绑定,那么建议您彻底搞定Vue的MVVM响应式原理。
(全文约13900字,阅读时间约25分钟。建议有一定vue基础后再阅读)
怎么来的?
要想清楚的知道某件事物的原理,就该追根溯源,刨根问底。在Vue之前,各框架都是怎么去实现MVVM双向绑定的呢?
大致分为以下几种:
- 发布者-订阅者模式(backbone.js)脏值检查(angular.js)数据劫持(vue.js)
- 发布者-订阅者模式,通过sub、pub实现视图的监听绑定,通常的做法是vm.$set(‘property’, value)。
脏值检查,内部其实就是setnterval
,当然,为了节约性能,不显的那么low,一般是对特定的事件执行脏值检查:
DOM事件,如输入文本、点击按钮(ng-click)XHR响应事件($http)浏览器locaton 变更事件($location)Timer事件($timeout, $interval)执行$digest()或 $apply()
vue则是采用发布者-订阅者模式,通过Object.defineProperty()来劫持各个属性的getter和setter,在数据变动时发布消息给订阅者,触发相应的监听回调。
Vue的MVVM原理
话不多说,先上图
首先,请尽可能记住这一张图,并能够自己画出来,后面所有原理都是围绕这张图展开。感觉很懵逼对么?不过,相信许多人在Vue官方文档里看过这张图:
其实,这两张图要表达的是一个意思——二者都表示了双向数据绑定的原理流程,官方文档中展示的更为简洁一些。看您更能接受哪种描述,后面自己实现响应式原理后,这两张图都能记得住了。
这里就用第一张图来介绍,在我们创建一个vue实例时,其实vue做了这些事情:
创建了入口函数,分别new了一个数据观察者Observer和一个指令解析器Compile;Compile解析所有DOM节点上的vue指令,提交到更新器Updater(实际上是一个对象);Updater把数据(如{{}},msg,@click)替换,完成页面初始化渲染;Observer使用Object.defineProperty劫持数据,其中的getter和setter通知变化给依赖器Dep;Dep中加入观察者Watcher,当数据发生变化时,通知Watcher更新;Watcher取到旧值和新值,在回调函数中通知Updater更新视图;Compile中每个指令都new了一个Watcher,用于触发Watcher的回调函数进行更新。 简单实现Vue的响应式原理 完整源码:详见
按照前面的思路,下面我们来一步一步实现一个简单的MVVM响应式系统,加深我们对响应式原理的理解。
创建一个html示例
现在我们创建了一个简单的Vue渲染示例,我们要做的就是使用自己的MVue去把里面的data、msg、htmlStr、methods中的数据都渲染到标签上。完成数据驱动视图、视图驱动数据驱动视图。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Document</title> </head> <body> <div id ="app"> <h2>{{person.name}} -- {{person.age}}</h2> <h3>{{person.fav}}</h3> <ul> <li>1</li> <li>2</li> <li>3</li> </ul> <h3>{{msg}}</h3> <div v-text="person.fav"></div> <div v-text="msg"></div> <div v-html="htmlStr"></div> <input type="text" v-model="msg"> <button v-on:click="handleClick">click</button> <button @click="handleClick">@click2</button> </div> <script src="./Observer.js"></script> <script src="./MVue.js"></script> <script> //创建Vue实例,得到 ViewModel const vm = new MVue({ el: '#app', data: { person: { name: "我的vue", age: 18, fav: "坦克世界" }, msg: "学习MVVM框架原理", htmlStr: "<h3>热爱前端,金子总会发光</h3>" }, methods: { handleClick() { console.log(this); } } }); </script> </body> </html>
在MVue.js中创建MVue入口
class MVue { constructor(options) { this.$el = options.el; this.$data = options.data; this.$options = options; if (this.$el) { // 1、实现一个数据观察者 // 2、实现一个指令观察者 new Compile(this.$el, this); } }
思路:
首先自然是要构建MVue这一个类,MVue类构造函数中需要用到options参数和其中的el、data。
然后需要保证el存在条件下,先实现一个指令解析器Compile,后面再去实现Observer观察者。
显然,Compile应该需要传入MVue实例的el和整个MVue实例,用来解析标签的指令。
创建Compile
思路:在解析标签指令之前,我们首先做的是:
判断el是不是元素节点,如果不是,就要取到el这个标签,然后传入vm实例;递归拿到所有子节点,便于下一步去解析它们。【注意:这一步会频繁触发页面的回流和重绘,所以我们需要把节点先存入文档碎片对象中,就相当于把他们放到了内存中,减少了页面的回流和重绘。】在文档碎片对象中编译好模板;最后再把文档碎片对象追加到根元素上。
class Compile { constructor(el, vm) { this.el = this.isElementNode(el) ? el : document.querySelector(el); this.vm = vm; // 获取文档碎片对象 放入内存中会减少页面的回流和重绘 const fragment = this.node2Fragment(this.el); // 编译模板 this.compile(fragment); // 追加子元素到根元素 this.el.appendChild(fragment); }
这里我们先自己定义了几个方法:
- 判断是否是元素节点
isElementNode(el)
、 - 存入文档碎片对象
node2Fragment(el)
、 - 编译模板
compile(fragment)
分别在构造函数之后去实现:
node2Fragment(el) { // 创建文档碎片对象 const f = document.createDocumentFragment(); // 递归放入 let firstChild; while ((firstChild = el.firstChild)) { f.appendChild(firstChild); } return f; } isElementNode(node) { return node.nodeType === 1; }
编译模板compile(fragment)
实现思路:递归获取所有子节点,判断节点是元素节点还是文本节点,再分别定义两个方法compileElement(child)
和compileText(child)
去处理这两种节点。
compile(fragment) { // 获取子节点 const childNodes = fragment.childNodes; [...childNodes].forEach((child) => { if (this.isElementNode(child)) { // 是元素节点 // 编译元素节点 // console.log("元素节点",child); this.compileElement(child); } else { // 是文本节点 // 编译文本节点 // console.log("文本节点", child); this.compileText(child); } // 一层一层递归遍历 if (child.childNodes && child.childNodes.length) { this.compile(child); } }); }
好了,现在Compile的一个基本框架已经搭好了。希望看到这里的您还没有犯困,打起精神来!现在,我们继续往下淦元素节点和文本节点的处理。
1.处理元素节点compileElement(child)
思路:
拿到标签里的每个vue指令,如v-text v-html v-model v-on:click,显然它们都是以v-开头的,当然还有@开头的指令也不要忘记把节点、节点值、vm实例、(on的事件名)传入compileUtil
对象,后面用它处理每个指令,属性对应指令方法;别忘了,最后的视图标签上是没有vue指令的,所以我们要把它们从节点属性中删去。
compileElement(node) { const attributes = node.attributes; [...attributes].forEach((attr) => { const { name, value } = attr; if (this.isDirective(name)) { // 是一个指令 v-text v-html v-model v-on:click const [, directive] = name.split("-"); // text html model on:click const [dirName, eventName] = directive.split(":"); // text html model on // 更新数据 数据驱动视图 compileUtil[dirName](node, value, this.vm, eventName); // 删除有指令标签上的属性 node.removeAttribute("v-" + directive); } else if (this.isEventName(name)) { // @click='handleClick' let [, eventName] = name.split('@'); compileUtil["on"](node, value, this.vm, eventName); } }); }
判断是否是指令,以v-开头
isDirective(attrName) { return attrName.startsWith("v-"); }
2.处理文本节点compileText(child)
主要使用正则匹配双大括号即可:
compileText(node) { // {{}} v-text const content = node.textContent; if (/\{\{(.+?)\}\}/.test(content)) { compileUtil["text"](node, content, this.vm); } }
3.实现compileUtil指令处理
思路:
每个指令对应各自方法,除了on需要额外传入事件名称,其他的指令处理函数只需要传节点、值(或表达式expr)、vm
实例:
const compileUtil = { text(node, expr, vm) { }, html(node, expr, vm) { }, model(node, expr, vm) { }, on(node, expr, vm, eventName) { } };
没有一下子放出代码来的话,骨架原来这么简单啊,继续逐个击破它们!
v-html指令处理,思路:拿到值,把值传给updater更新器,更新,完事儿。
html(node, expr, vm) { const value = this.getVal(expr, vm); this.updater.htmlUpdater(node, value); },
v-model指令处理,同上。先实现数据=>视图这条线,双向绑定最后实现。
model(node, expr, vm) { const value = this.getVal(expr, vm); this.updater.modelUpdater(node, value); },
比较复杂的,v-on,思路:获取事件名,从methods中取到对应的函数,添加到事件中,注意this要绑定给vm实例,false默认事件冒泡。
on(node, expr, vm, eventName) { // 获取事件名, 从method里面取函数 let fn = vm.$options.methods && vm.$options.methods[expr]; node.addEventListener(eventName, fn.bind(vm), false) },
v-text指令处理:
text(node, expr, vm) { // expr:msg: "学习MVVM框架原理" // 对传入不同的字符串不同操作 <div v-text="person.name"></div> // {{}} let value; if (expr.indexOf('{{') !== -1) { // {{person.name}} -- {{person.age}} value = expr.replace(/\{\{(.+?)\}\}/g, (...args) => { return this.getVal(args[1], vm) }) } else { value = this.getVal(expr, vm); } this.updater.textUpdater(node, value); },
用到args这个数组,console.log一下args,发现args[1]就有我们要找的具体属性:
例如,取到person.name
后,传入到this.getVal('person.name',vm)
,最后能取到vm.$data.person.name
。
怎么拿到它们对应的值呢?
显然,不论是htmlStr、msg、person,它们都在实例vm的data内,在自定义方法getVal
中,可以使用split分割小圆点“.”得到数组,再用高逼格的reduce方法去遍历找到data每个属性(对象)下的每个属性的值,像这样:
getVal(expr, vm) { return expr.split(".").reduce((data, currentVal) => { return data[currentVal]; }, vm.$data); },
(不记得怎么用reduce?请在右上角新建标签页,去CSDN上补一补。)进阶拿到双大括号内对应的属性的值:
getContentVal(expr, vm) { return expr.replace(/\{\{(.+?)\}\}/g, (...args) => { console.log(args); return this.getVal(args[1], vm); }); },
更新器Updater更新数据
在指令方法的后面接着创建一个updater属性,实则是一个类,我们把它亲切地称作更新器,长得还很一目了然,您马上就能记住它的样子:
// 更新的函数 updater: { textUpdater(node, value) { node.textContent = value; }, htmlUpdater(node, value) { node.innerHTML = value; }, modelUpdater(node, value){ node.value = value; } },
在每个指令方法取到值后,更新到node节点上。
至此,我们已经完成了原理图上的MVVM到Compile到Updater这一条线:
实现数据观察者Observer
class MVue { constructor(options) { this.$el = options.el; this.$data = options.data; this.$options = options; if (this.$el) { // 1、实现一个数据观察者 new Observer(this.$data); // 2、实现一个指令观察者 new Compile(this.$el, this); } }
Observer类构造函数应该传什么给它?对,Observer要监听所有数据,所以我们将vm实例的data作为参数传入。
- 递归,将data中所有的属性、对象、子对象……都遍历出来
- 对每个key,使用Object.defineProperty劫持数据(Object.defineProperty()的作用就是直接在一个对象上定义一个新属性,或者修改一个已经存在的属性)
- Object.defineProperty下有get方法和set方法,也就是官方原理图上的getter和stter啦
- 在劫持数据之前,创建依赖器Dep实例dep
- 对于gettter,订阅数据变化时,往dep中添加观察者;
- 对于setter,当数据变化时,将newVal赋值为新值,并用notify通知dep变化。(此处正好对应官方原理图)
4、5、6这最后三点可以说是MVVM实现中最关键、最巧妙的3步,正是这画龙点睛的三笔,把整个系统桥梁成功架起来,注意它们各自放置在代码中位置。
class Observer { constructor(data) { this.observer(data); } observer(data) { /** { person:{ name:'张三', fav: { a: '爱好1', b: '爱好2' } } } */ if (data && typeof data === "object") { Object.keys(data).forEach((key) => { this.defineReactive(data, key, data[key]); }); } } defineReactive(obj, key, value) { // 递归遍历 this.observer(value); const dep = new Dep(); // 劫持数据 Object.defineProperty(obj, key, { // 是否可遍历 enumerable: true, // 是否可以更改编写 configurable: false, // 编译之前,初始化的时候 get() { // 订阅数据变化时,往Dep中添加观察者 Dep.target && dep.addSub(Dep.target); return value; }, // 外界修改数据的时候 set: (newVal) => { // 新值也要劫持 this.observer(newVal); // 这里的this要指向当前的实例,所以改用箭头函数向上查找 // 判断新值是否有变化 if (newVal !== value) { value = newVal; } // 告诉Dep通知变化 dep.notify(); }, }); } }
数据依赖器Dep
主要作用:
- 收集要更新的观察者
- 通知每个观察者去更新
// 数据依赖器 class Dep { constructor() { this.subs = []; } // 收集观察者 addSub(watcher) { this.subs.push(watcher); } // 通知观察者去更新 notify() { console.log("通知了观察者", this.subs); this.subs.forEach(w =>w.update()) } }
观察者Watcher
注意Dep.target = this;
这一步,是为了把观察者挂载到Dep实例上,关联起来。所以当观察者Watcher获取旧值后,应该解除关联,否则会重复地添加观察者,以下是未取消关联的错误示范:
最后,使用callback回调函数传递要处理的新值给Updater即可。
class Watcher { constructor(vm, expr, callback) { // 把新值通过cb传出去 this.vm = vm; this.expr = expr; this.callback = callback; // 先把旧值保存起来 this.oldVal = this.getOldVal(); } getOldVal() { // 把观察者挂载到Dep实例上,关联起来 Dep.target = this; const oldVal = compileUtil.getVal(this.expr, this.vm); // 获取旧值后,取消关联,就不会重复添加 Dep.target = null; return oldVal; } update() { // 更新,要取旧值和新值 const newVal = compileUtil.getVal(this.expr, this.vm); if (newVal !== this.oldVal) { this.callback(newVal); } } }
如何Updater如何接收从Watcher传来的新值做回调处理呢?
只需要在刚刚写好的compileUtil
对象的每个指令处理方法内都new(添加)一个Watcher实例即可。注意text指令方法下new Watcher
实例的value参数,可以用args[1]
传入,重新处理newVal。
const compileUtil = { getVal(expr, vm) { return expr.split(".").reduce((data, currentVal) => { return data[currentVal]; }, vm.$data); }, setVal(expr, vm, inputVal) { return expr.split(".").reduce((data, currentVal) => { data[currentVal] = inputVal; // 把当前新值复制给旧值 }, vm.$data); }, getContentVal(expr, vm) { return expr.replace(/\{\{(.+?)\}\}/g, (...args) => { console.log(args); return this.getVal(args[1], vm); }); }, text(node, expr, vm) { // expr:msg: "学习MVVM框架原理" // 对传入不同的字符串不同操作 <div v-text="person.name"></div> // {{}} let value; if (expr.indexOf('{{') !== -1) { // {{person.name}} -- {{person.age}} value = expr.replace(/\{\{(.+?)\}\}/g, (...args) => { new Watcher(vm, args[1], () => { // 额外处理expr: {{person.name}} -- {{person.age}} // 还要重新处理newVal this.updater.textUpdater(node, this.getContentVal(expr, vm)); }); return this.getVal(args[1], vm) }) } else { value = this.getVal(expr, vm); } this.updater.textUpdater(node, value); }, html(node, expr, vm) { const value = this.getVal(expr, vm); // 绑定观察者,将来数据发生变化 出发这里的回调 进行更新 new Watcher(vm, expr, newVal => { this.updater.htmlUpdater(node, newVal); }) this.updater.htmlUpdater(node, value); }, model(node, expr, vm) { const value = this.getVal(expr, vm); // 绑定更新函数 数据=>驱动视图 new Watcher(vm, expr, (newVal) => { this.updater.modelUpdater(node, newVal); }); // 视图 => 数据 => 视图 node.addEventListener('input', e => { // 设置值 this.setVal(expr, vm, e.target.value); }) this.updater.modelUpdater(node, value); }, on(node, expr, vm, eventName) { // 获取事件名, 从method里面取函数 let fn = vm.$options.methods && vm.$options.methods[expr]; node.addEventListener(eventName, fn.bind(vm), false) }, bind(node, expr, vm, attrName) { // 类似on。。。 }, // 更新的函数 updater: { textUpdater(node, value) { node.textContent = value; }, htmlUpdater(node, value) { node.innerHTML = value; }, modelUpdater(node, value){ node.value = value; } }, };
实现视图驱动数据驱动视图
还是借着上面这个代码块,我们只需要在model
指令方法下,为input
标签绑定事件,并自定义setVal
方法为node
赋值即可。
到这里,我们已经基本完整实现了Vue的MVVM双向数据绑定
小改进:
在MVue实例中,我们一开始使用的是$data
获取到数据,这里可以做一层代理proxy
,便于我们省略$data
methods: { handleClick() { // console.log(this); this.person.name = "这是做了一层代理" // 把this.$data 代理成 this this.$data.person.name = "数据更改了" } }
还是使用Object.defineProperty数据劫持,遍历data下的每个key,让getter返回data[key],setter设置data[key]直接等于newVal即可。
class MVue { constructor(options) { this.$el = options.el; this.$data = options.data; this.$options = options; if (this.$el) { // 1、实现一个数据观察者 new Observer(this.$data); // 2、实现一个指令观察者 new Compile(this.$el, this); this.proxyData(this.$data); } } proxyData(data) { for(const key in data) { Object.defineProperty(this, key, { get() { return data[key] }, set(newVal) { data[key] = newVal; } }) } } }
总结
再次体会官方文档对响应式原理的描述:
当我们把一个普通的 JavaScript 对象传入 Vue 实例作为 data 选项,Vue 将遍历此对象所有的 property,并使用 Object.defineProperty 把这些 property 全部转为 getter/setter。Object.defineProperty 是 ES5 中一个无法 shim 的特性,这也就是 Vue 不支持 IE8 以及更低版本浏览器的原因。
这些 getter/setter 对用户来说是不可见的,但是在内部它们让 Vue 能够追踪依赖,在 property
被访问和修改时通知变更。这里需要注意的是不同浏览器在控制台打印数据对象时对 getter/setter 的格式化并不同,所以建议安装
vue-devtools 来获取对检查数据更加友好的用户界面。每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据 property 记录为依赖。之后当依赖项的
setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。
以及开头时我自己总结的原理描述:
在我们创建一个vue实例时,其实vue做了这些事情:
创建了入口函数,分别new了一个数据观察者
- Observer和一个指令解析器Compile;
- Compile解析所有DOM节点上的vue指令,提交到更新器Updater(实际上是一个对象);
- Updater把数据(如{{}},msg,@click)替换,完成页面初始化渲染;Observer使用Object.defineProperty劫持数据,其中的getter和setter通知变化给依赖器Dep;
- Dep中加入观察者Watcher,当数据发生变化时,通知Watcher更新;
- Watcher取到旧值和新值,在回调函数中通知Updater更新视图;
- Compile中每个指令都new了一个Watcher,用于触发Watcher的回调函数进行更新。
在实现代码的过程中,我们能深刻地体会到Vue的数据驱动视图,视图驱动数据驱动视图 这一核心的巧妙,也知道了Object.defineProperty具体应用场景。
加载全部内容