Vue2响应式原理与实现
一、Vue模板渲染
上一篇文章中曾经实现了Vue的响应式零碎,接下来就是要将Vue的模板进行挂载并渲染出对应的界面。
渲染的入口就是调用Vue实例的$mount()办法,其会接管一个选择器名作为参数,Vue进行模板渲染的时候,所应用的模板是有肯定优先级的:
① 如果用户传递的options对象中蕴含render属性,那么就会优先应用用户配置的render()函数中蕴含的模板进行渲染;
② 如果用户传递的options对象中不蕴含render属性,然而蕴含template属性,那么会应用用户配置的template属性中对应的模板进行渲染;
③ 如果用户传递的options对象中不蕴含render属性,也不蕴含template属性,那么会应用挂载点el对应的DOM作为模板进行渲染。
实现上一步中遗留的的$mount()办法:
// src/init.jsimport {compileToFunction} from "./compile/index";import {mountComponent} from "./lifecyle";export function initMixin(Vue) { Vue.prototype.$mount = function(el) { // 传入el选择器 const vm = this; const options = vm.$options; el = document.querySelector(el); // 依据传入的el选择器拿到Vue实例的挂载点 if (!options.render) { // 如果没有配置render渲染函数 let template = options.template; // 看看用户有没有配置template模板属性 if (!template) { // 如果也没有配置模板 template = el.outerHTML; // 那么就用挂载点晓得DOM作为模板 } options.render = compileToFunction(template); // 拿到模板之后将其编译为渲染函数 } mountComponent(vm, el); // 传入vm实例和el开始挂载组件 }}
所以$mount()函数次要就是要初始化对应的渲染函数,有了渲染函数就能够开始渲染了。渲染属于生命周期的一部分,咱们将mountComponent()放到lifecycle.js中。
mountComponent办法中次要做的事件就是创立一个渲染Watcher。在创立渲染Watcher的时候会传入一个函数,这个函数就是用于更新组件的。而组件的更新须要做的就是执行render渲染函数拿到对应的虚构DOM,而后与旧的虚构DOM进行比拟,找到变动的局部并利用到实在DOM。
新增一个watcher.js,watcher属于数据观测的一部分,所以须要放到src/observer下,如:
// src/observer/watcher.jslet id = 0;export default class Watcher { constructor(vm, exprOrFn, cb, options, isRenderWatcher) { this.vm = vm; this.id = id++; if (typeof exprOrFn === "function") { // 创立Watcher的时候传递的是一个函数,这个函数会立刻执行 this.getter = exprOrFn; } this.cb = cb; this.options = options; this.isRenderWatcher = isRenderWatcher; this.get(); // 让传入的Watcher的函数或表达式立刻执行 } get() { this.getter.call(this.vm, this.vm); }}
// src/lifecycle.jsexport function mountComponent(vm, el) { vm.$el = el; // 将挂载点保留到Vue实例的$el属性上 // beforeMount let updateComponent = () => { vm._update(vm._render()); } // 创立一个渲染Watcher并传入updateComponent()函数,在创立渲染Watcher的时候会立刻执行 new Watcher(vm, updateComponent, () => {}, {}, true); // mounted}
随着渲染Watcher的创立,updateComponent()函数也跟着执行,即执行vm._render(),拿到虚构DOM,须要给Vue的原型上增加一个_render()办法,和之前一样通过renderMixin()将Vue混入,如:
// src/render.jsexport function renderMixin(Vue) { Vue.prototype._render = function() { const vm = this; const render = vm.$options.render; // 取出render渲染函数 return render.call(vm); // 让render渲染函数执行返回虚构DOM节点 }}
// src/index.js 在其中引入renderMixin并传入Vue,以便在Vue原型上混入_render()办法+ import {renderMixin} from "./render";function Vue() {}+ renderMixin(Vue);
假如咱们创立Vue实例的时候配置了一个template属性,值为:
<div id='app' style='background:red;height:300px;' key='haha'> hello {{name}} <span style='color:blue;'>{{name}} {{arr}}</span></div>
那么这个模板通过compileToFunction()函数编译后就会变成一个render渲染函数,如下所示:
(function anonymous() {with(this) {return _c("div", {id: "app",style: {"background":"red","height":"300px"},key: "haha"},_v("hello"+_s(name)),_c("span", {style: {"color":"blue"}},_v(_s(name)+_s(arr))) ) }})
渲染函数外部应用了with(this){},在执行渲染函数的时候会传入Vue实例,所以这个this就是指Vue实例,对于其中的_c()、_v()、_s()其实就是vm._c()、vm._v()、vm._s()。
所以咱们还须要在renderMixin()内给Vue原型混入_c()、_v()、_s()这几个办法。
① _s()办法次要是解析template模板中用到的数据,即Vue中的data数据,用户可能会在模板中应用Vue中不蕴含的数据,此时变量的值就是null,用户也可能应用到Vue中的对象数据,对于这些数据咱们须要进行stringify()一下转换为字符串模式显示在模板中。
② v()办法次要就是解析传入的文本字符串,并将其解析为一个虚构文本节点对象。
③ c()办法次要就是接管多个参数(标签名、属性对象、子节点数组),并解析为一个虚构元素节点对象
// src/render.jsimport {createTextNode, createElementNode} from "./vdom/index";export function renderMixin(Vue) { Vue.prototype._c = function(...args) { // 创立虚构元素节点对象 return createElementNode(...args); } Vue.prototype._v = function(text) { // 创立虚构文本节点对象 return createTextNode(text); } Vue.prototype._s = function(val) { // 解析变量的值 return val === null ? "" : typeof val === "object" ? JSON.stringify(val) : val; }}
二、虚构DOM
接下来开始创立虚构DOM,虚构DOM包含创立、比拟等各种操作,也是一个独立简单的过程,须要将对虚构DOM的操作独立成一个独自的模块,次要就是对外裸露createElementNode()、createTextNode()两个办法,如:
// src/vdom/index.jsfunction vnode(tag, key, attrs, children, text) { // 创立虚构DOM return { tag, // 标签名,元素节点专属 key, // 标签对应的key属性,元素节点专属 attrs, // 标签上的非key属性,元素节点专属 children, // 标签内的子节点,元素节点专属 text // 文本节点专属,非文本节点为undefined }}// 创立虚构文本节点,文本节点其余都为undefined,仅text有值export function createTextNode(text) { return vnode(undefined, undefined, undefined, undefined, text);}// 创立虚构元素节点export function createElementNode(tag, attrs, ...children) { const key = attrs.key; if (key) { delete attrs.key; } return vnode(tag, key, attrs, children);}
拿到虚构DOM之后,就开始执行vm._update(vnode)办法,所以须要给Vue原型上混入一个_update()办法,_update属于lifecycle的一部分,如下:
// src/lifecycle.jsimport {patch} from "./vdom/patch";export function lifecycleMixin(Vue) { // 更新的时候承受一个虚构DOM节点,而后与挂载点或旧DOM节点进行比拟 Vue.prototype._update = function(vnode) { const vm = this; const prevVnode = vm._vnode; // 拿到之前的虚构节点 vm._vnode = vnode; // 将以后最新的虚构节点保存起来,以便下次比拟的时候能够取出来作为旧的虚构节点 if (!prevVnode) { // 第一次没有旧节点,所以为undefined,须要传入实在节点 vm.$el = patch(vm.$el, vnode); } else { vm.$el = patch(prevVnode, vnode); } }}
第一次渲染的时候,旧节点为undefined,所以咱们间接传入实在的DOM挂载节点即可。接下来咱们实现patch()办法。
patch()办法要做的事件就是,将传入的新的虚构DOM节点渲染成实在的DOM节点,而后用新创建的实在DOM节点替换掉挂载点对应的DOM。
// src/vdom/patch.jsexport function patch(oldVnode, vnode) { // 接管新旧虚构DOM节点进行比拟 const isRealElement = oldVnode.nodeType; // 看看旧节点是否有nodeType属性,如果有则是实在DOM节点 if (isRealElement) { // 如果旧的节点是一个实在的DOM节点,间接渲染出最新的DOM节点并替换掉旧的节点即可 const parentElm = oldVnode.parentNode; // 拿到旧节点即挂载点的父节点,这里为<body>元素 const oldElm = oldVnode; const el = createElm(vnode); // 依据新的虚构DOM创立出对应的实在DOM parentElm.insertBefore(el, oldElm.nextSibling);// 将创立进去的新的实在DOM插入 parentElm.removeChild(oldElm); // 移除挂载点对应的实在DOM return el; // 返回最新的实在DOM,以便保留到Vue实例的$el属性上 } else { // 旧节点也是虚构DOM,这里进行新旧虚构DOMDIFF比拟 }}
实现将虚构DOM转换成实在的DOM,次要就是依据虚构DOM节点上保留的实在DOM节点信息,通过DOM API创立出实在的DOM节点即可。
// src/vdom/patch.jsfunction createElm(vnode) { if (vnode.tag) { // 如果虚构DOM上存在tag属性,阐明是元素节点 vnode.el = document.createElement(vnode.tag); // 依据tag标签名创立出对应的实在DOM节点 updateProperties(vnode); // 更新DOM节点上的属性 vnode.children.forEach((child) => { // 遍历虚构子节点,将其子节点也转换成实在DOM并退出到以后节点下 vnode.el.appendChild(createElm(child)); }); } else { // 如果不存在tag属性,阐明是文本节点 vnode.el = document.createTextNode(vnode.text); // 创立对应的实在文本节点 } return vnode.el;}
实现DOM节点上属性和款式的更新,如:
// src/vdom/patch.jsfunction updateProperties(vnode, oldAttrs = {}) { // 传入新的虚构DOM和旧DOM的属性对象 const el = vnode.el; // 更新属性前曾经依据元素标签名创立出了对应的实在元素节点,并保留到vnode的el属性上 const newAttrs = vnode.attrs || {}; // 取出新虚构DOM的属性对象 const oldStyles = oldAttrs.style || {}; // 取出旧虚构DOM上的款式 const newStyles = newAttrs.style || {}; // 取出新虚构DOM上的款式 // 移除新节点中不再应用的款式 for (let key in oldStyles) { // 遍历旧的款式 if (!newStyles[key]) { // 如果新的节点曾经没有这个款式了,则间接移除该款式 el.style[key] = ""; } } // 移除新节点中不再应用的属性 for (let key in oldAttrs) { if (!newAttrs[key]) { // 如果新的节点曾经没有这个属性了,则曾经移除该属性 el.removeAttribute(key); } } // 遍历新的属性对象,开始更新款式和属性 for (let key in newAttrs) { if (key === "style") { for (let styleName in newAttrs.style) { el.style[styleName] = newAttrs.style[styleName]; } } else if (key === "class") { el.className = newAttrs[key]; } else { el.setAttribute(key, newAttrs[key]); } }}
因为新旧虚构DOM节点上的款式style属性也是一个对象,所以必须将款式style对象独自拿进去进行遍历能力晓得新的款式中有没有之前旧的款式了。移除老的款式和属性之后,再遍历一下新的属性对象,更新一下最新的款式和属性。
三、实现响应式更新
所谓响应式更新,就是当咱们批改Vue中的data数据的时候,模板可能主动从新渲染出最新的界面。目前咱们只是渲染出了界面,当咱们去批改Vue实例中的数据的时候,发现模板并没有进行从新渲染,因为咱们尽管对Vue中的数据进行了劫持,然而模板的更新(从新渲染)是由渲染Watcher来执行的,或者确切的说是在创立渲染Watcher的时候传入的updateComponent()函数决定的,updateComponent()函数从新执行就会导致模板从新渲染,所以咱们须要在数据发生变化的时候告诉渲染Watcher更新(调用updateComponent()函数)。所以这里的要害就是要告诉渲染Watcher数据产生了变动。而告诉机制,咱们能够通过公布订阅模式来实现。实现形式如下:
① 将Vue中data数据的每一个key映射成一个发布者对象;
② 当Watcher去取数据的时候,用到了哪个key对应的值,那么就将以后Watcher对象退出到该key对应的发布者对象的订阅者列表中;
③ 当哪个key对应的值被批改的时候,就拿到该key对应的发布者对象,调用其公布告诉的办法,告诉订阅者列表中的watcher对象执行更新操作。
// src/observer/dep.jslet id = 0;export default class Dep { constructor() { this.id = id++; this.subs = []; // 订阅者列表,寄存以后dep对象中须要告诉的watcher对象 } addSub(watcher) { // 增加订阅者 this.subs.push(watcher); } notify() { // 公布告诉,告诉订阅者更新 this.subs.forEach((watcher) => { watcher.update(); }); } depend() { // 收集依赖,次要让watcher对象中记录一下依赖哪些dep对象 if (Dep.target) { // 如果存在以后Watcher对象 Dep.target.addDep(this); // 告诉watcher对象将以后dep对象退出到watcher中 } }}let stack = []; // 寄存取值过程中应用到的watcher对象// 取值前将以后watcher放到栈顶export function pushTarget(watcher) { Dep.target = watcher; // 记录以后Watcher stack.push(watcher); // 将以后watcher放到栈中}// 取完值后将以后watcher对象从栈顶移除export function popTarget() { stack.pop(); // 移除栈顶的watcher Dep.target = stack[stack.length - 1]; // 更新以后watcher}
这里每次watcher对象取值之前,都会调用pushTarget()办法将以后watcher对象保留到全局的Dep.target上,同时将以后watcher放到一个数组中,之所以要放到数组中,是因为计算属性也是一种wacther对象,当咱们执行渲染watcher对象的时候,此时Dep.target的值为渲染watcher对象,如果模板中应用到了计算属性,那么就要执行计算watcher去取值,此时就会将计算watcher保留到Dep.target中,当计算属性取值实现后,渲染Watcher可能还须要持续取值,所以还须要将Dep.target还原成渲染Watcher,为了可能还原回来,须要将watcher放到栈中保存起来。
批改watcher.js的get()办法,在取值前将以后Watcher对象保留到全局的Dep.target上,如:
// src/observer/watcher.jsimport {pushTarget, popTarget} from "./dep";export default class Watcher { constructor(vm, exprOrFn, cb, options, isRenderWatcher) { this.deps = []; // 以后Watcher依赖了哪些key this.depIds = new Set(); // 防止反复 } get() { pushTarget(this); // 取值前将以后watcher放到全局的Dep.target中 this.getter.call(this.vm, this.vm); popTarget(); // 取值实现后将以后watcher从栈顶移除 } // 让watcher记录下其依赖的dep对象 addDep(dep) { const id = dep.id; if (!this.depIds.has(id)) { // 如果不存在该dep的id this.deps.push(dep); this.depIds.add(id); dep.addSub(this); } }}
这里之所以先调用dep.depend()办法让以后watcher对象将其依赖的dep退出到其deps数组中,次要是为计算watcher设计的,如果渲染watcher中仅仅应用到了一个计算属性,因为渲染watcher并没有间接依赖Vue中data对象中的数据,所以data对象中各个key对应的dep对象并不会将渲染watcher退出到订阅者列表中,而是仅仅会将计算watcher放到订阅者列表中,此时用户去批改Vue中的数据,渲染watcher就不会收到告诉,导致无奈更新。前面实现计算watcher的时候会进一步解释。
四、实现对象的依赖收集
此时取值前曾经将Wacher放到了全局的Dep.target中,而取值的时候会被响应式数据系统的get()拦挡,咱们能够在get中收集依赖,在批改值的时候会被响应式数据系统的set()拦挡,咱们能够在set中进行公布告诉,如:
// src/observer/index.jsfunction defineReactive(data, key, value) { let ob = observe(value); let dep = new Dep(); // 给每个key创立一个对应的Dep对象 dep.name = key; Object.defineProperty(data, key, { get() { if (Dep.target) { // 如果曾经将以后Watcher对象保留到Dep.target上 dep.depend(); // 执行以后key对应的dep对象的depend()办法收集依赖 } return value; }, set(newVal) { if (newVal === value) { return; } observe(newVal); value = newVal; dep.notify(); // 数据会被批改后,通过对应key的dep对象给订阅者公布告诉 } });}
五、实现数组的依赖收集
通过下面的操作,咱们曾经实现了对对象的依赖收集,批改对象的某个key的值,能够告诉到渲染watcher进行更新。
如果Vue中data数据中有某个key的值为数组,比方,data: {arr: [1, 2, 3]},那么当咱们通过vm.arr.push(4)去批改数组的时候,会发现模板并没有更新,因为咱们目前仅仅对对象进行了依赖收集,也就是说,arr对应的dep对象中有渲染Watcher的依赖,然而arr的值[1, 2, 3]这对象并没有对应的dep对象,所以没方法告诉渲染watcher对象执行更新操作。
在后面响应式数据系统中,咱们进行了数据的递归观测,如果对象的key对应的值也是一个对象或者数组,那么会对这个值也进行观测,而一旦观测就会创立一个对应的Observer对象,所以咱们能够在Observer对象中增加一个dep对象用于收集数组收集依赖。
// src/observer/index.jsclass Observer { constructor(data) { this.dep = new Dep(); // 为了察看数组收集依赖用,间接察看数组自身,而不是数组对应的key,如{arr: [1, 2, 3]}, 间接察看[1, 2, 3]而不是察看arr }}function defineReactive(data, key, value) { let ob = observe(value); // 对值进行观测 get() { if (Dep.target) { if (ob) { // 如果被观测的值也是一个对象或者数组,则会返回一个Observer对象,否则为null ob.dep.depend(); // 对数组收集依赖 } } }}
对数组收集依赖后,咱们还须要在数组发生变化的时候进行告诉,之前响应式零碎中曾经对可能扭转数组的几个办法进行了重写,所以咱们能够在这些办法被调用的时候发动告诉,如:
// src/observer/array.jsmethods.forEach((method) => { arrayMethods[method] = function(...args) { ... if (inserted) { ob.observeArray(inserted); } ob.dep.notify(); // 在可能扭转数组的办法中发动告诉 }})
此时还存在一个问题,还是以data: {arr: [1, 2, 3]}为例,尽管咱们当初通过vm.arr.push(4)能够看到页面会更新,然而如果咱们push的是一个数组呢?比方,执行vm.arr.push([4, 5]),那么当咱们执行vm.arr[3].push(6)的时候发现页面并没有更新,因为咱们没有对arr中的[4,5]这个数组进行依赖收集,所以咱们须要对数组进行递归依赖收集。
// src/observer/index.jsfunction defineReactive(data, key, value) { let ob = observe(value); // 对值进行观测 get() { if (Dep.target) { if (ob) { // 如果被观测的值也是一个对象或者数组,则会返回一个Observer对象,否则为null ob.dep.depend(); // 对数组收集依赖 if (Array.isArray(value)) { // 如果这个值是一个数组 dependArray(value); } } } }}// 遍历数组中的每一项进行递归依赖收集function dependArray(value) { for (let i = 0; i < value.length; i++) { // 遍历数组中的每个元素 let current = value[i]; // 如果数组中的值是数组或者对象,那么该值也会被察看,即就会有观察者对象 current.__ob__ && current.__ob__.dep.depend(); // 对于其中的对象或者数组收集依赖,即给其加一个Watcher对象 if (Array.isArray(current)) { // 如果值还是数组,则递归收集依赖 dependArray(current) } }}