一文彻底搞懂Vue的MVVM响应式原理
作者:DreamYum
前言
这些天都在面试,每当我被面试官们问到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具体应用场景。
到此这篇关于一文彻底搞懂Vue的MVVM响应式原理的文章就介绍到这了,更多相关 Vue的MVVM响应式 内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!