乐趣区

关于vue.js:Vue2组件挂载与对象数组依赖收集

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);
        }
    }
}

这里之所以先调用 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)
        }
    }
}
退出移动版