4

Vue2响应式原理与实现

一、Vue模板渲染

上一篇文章中已经实现了Vue的响应式系统,接下来就是要将Vue的模板进行挂载并渲染出对应的界面。

渲染的入口就是调用Vue实例的$mount()方法,其会接收一个选择器名作为参数,Vue进行模板渲染的时候,所使用的模板是有一定优先级的:
① 如果用户传递的options对象中包含render属性,那么就会优先使用用户配置的render()函数中包含的模板进行渲染;
② 如果用户传递的options对象中不包含render属性,但是包含template属性,那么会使用用户配置的template属性中对应的模板进行渲染;
③ 如果用户传递的options对象中不包含render属性,也不包含template属性,那么会使用挂载点el对应的DOM作为模板进行渲染。

实现上一步中遗留的的$mount()方法:

// src/init.js
import {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.js
let 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.js
export 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.js
export 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.js
import {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.js
function 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.js
import {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.js
export 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.js
function 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.js
function 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.js
let 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.js
import {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);
        }
    }
    update() { // watcher进行更新操作,以便页面能够更新
        this.get();
    }
}

这里之所以先调用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.js
function 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.js
class 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.js
methods.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.js
function 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)
        }
    }
}

JS_Even_JS
2.6k 声望3.7k 粉丝

前端工程师