Vue2源码笔记(6)运行时-视图渲染(合集)

67 阅读2分钟

前言

在上文中我们已经了解了Vue2如何对数据进行数据代理和数据劫持,接下来我们进入到试图渲染的部分vm.$mount(vm.$options.el)。在这里我们也会衔接上编译时拿到的形如_c('div')这样的渲染函数结果,来讲解在运行时它是如何被渲染的。

(注意这里的代码我参考了一些博客和书籍中的讲解代码,并且比较了Vue2仓库中的源码,相对仓库源码会更简略些,但能更容易阅读理解所讲的实现逻辑和思想)

其中,.$mount也是通过原型拓展添加的:

Vue.prototype.$mount = function(el) {
    
}

在options中开发者会提供一个加载点标签,Vue2需要通过它拿到页面上的真实dom

Vue.prototype.$mount = function(el) {
    const vm = this;
    el = document.querySelector(el);
    vm.$el = el;
}

通过之前的编译时,我们知道模板最终会生成渲染函数,而Vue2也提供手写渲染函数的选项;对于这样实现同一结果的两条路径,代码中也需要进行处理,并且触发下一步逻辑:

Vue.prototype.$mount = function(el) {
    const vm = this;
    el = document.querySelector(el);
    vm.$el = el;
    
    const opts = vm.$options;
    if (!opts.render) {
        let template = opts.template;
        if (!template) template = el.outerHTML;
        let render = compileToFunction(template); // 这部分和编译时类似,就不再赘述
        opts.render = render;
    }
    // 不管是通过编译时、手写函数,最终都需要将 render 渲染到 el 元素上
    mountComponent(vm);
}

上一步我们的最终目的就是要获取对应render,进行渲染

Vue.prototype.$mount = function (vm) {
    // 记录挂载点
    vm.$el = el;
    // 调用beforeMount
    callHook(vm, 'beforeMount');
    // 定义更新函数
    let updateComponent
    updateComponent = () => {
      vm._update(vm._render(), hydrating);
    }
    // 渲染Watcher
    new Watcher(vm, updateComponent, noop, {
        before() {
            if (vm._isMounted && !vm._isDestroyed) {
                callHook(vm, 'beforeUpdate');
            }
        }
    }, true)
    
    return vm;
}

页面渲染

构建VNode

这已经是从源码copy出来的部分代码了,虽然看起来不多但内容其实十分丰富。我们还是把它再简化一下,就像前文所说地,仅仅是“下一步”执行_render()

Vue.prototype.$mount = function (vm) {
    vm._render();
}
​

很显然vm._render()并非opts.render,它还是来自于原型拓展:

通过renderMixin(Vue)

renderMixin(Vue) {
    /**
     * 原型拓展函数_render,用于渲染
     * @returns {VNode} 返回虚拟节点
     */
    Vue.prototype._render = function() {
        const vm = this; // 通过this拿到实例
        let { render } = vm.$options;
        let vnode = render.call(vm);
        return vnode;
    }
}

在这里,只要执行render.call(vm)就可以拿到vnode了。我们可以回想一下,所谓render其实就是那些带着_v_s_c的代码,如何处理它们呢?其实也是通过原型拓展添加的,_c的处理要复杂得多,对于_s_v还是相对简单的,在这里采用简略写法来实现相应的代码逻辑:

initRender(vm) {
    vm._c = function () {  // createElement 创建元素型的节点
        const vm = this;
        return createElement(vm);
    }
}
​
function createElement(vm, tag, data={}, children) {
    return new VNode(vm, tag, data={}, children)
}
​
Vue.prototype._v = function (text) {  // 创建文本的虚拟节点
    const vm = this;
    return new VNode(undefined, undefined, undefined, text);
}
Vue.prototype._s = function (val) {  // JSON.stringify
    if(isObject(val)){
        return JSON.stringify(val);
    } else {
        return val;
    }
}

在以上函数中取值时this.xxx是通过数据代理来取vm._data.xxx的,并且在读到vm._data.xxx时触发数据劫持。而“大名鼎鼎”的VNode,其实跟AST树类似,本质上也是一个JS对象装载的数据。

class VNode {
    constructor(vm, tag, data, children) {
        this.vm = vm;
        this.tag = tag;
        this.data = data;
        this.children = children;
    }
}

创建真实节点

_render运行的结果是构造VNode节点,这看起来还不足以完成页面渲染。再看我们一开始从仓库源码copy出来的代码,显然还有一些工作要做。接下来我们就来看_update,它也是通过原型拓展添加的,只是这个过程在lifeCycleMixin中:

function lifeCycleMixin(Vue) {
    /**
     * 原型拓展函数_update,用于渲染
     * @param {VNode} vnode
     */
    Vue.prototype._update = function (vnode) {
        const vm = this;
        vm.$el = patch(vm.$el, vnode);
    }
}

patch

将虚拟节点转换为真实节点的过程通过patch实现:

// vdom/patch.js/**
 * 将虚拟节点转为真实节点后插入到元素中
 * @param {*} el    当前真实元素
 * @param {*} vnode 虚拟节点
 * @returns         新的真实元素
 */
function patch(el, vnode) {
    // 1.根据vnode创建真实节点
    const elm = createElm(vnode);
    // 2.将新的真实节点插入到页面中
    const parentNode = el.parentNode;
    const nextSibling = el.nextSibling;
    parentNode.insertBefore(elm, nextSibling);  // 若nextSibling为 null,insertBefore 等价与 appendChild。总能把元素插入到正确位置
    parentNode.removeChild(el);
    
    return elm;
}
function createElm(vnode) {
    let el;
    let { tag, data, children, text, vm } = vnode;
    if (typeof tag === 'string') {
        el = document.createElement(tag);
        chidren.forEach(child => { // 循环创建子节点
            el.appendChild(createElm(child));
        });
    } else {
        el = document.createTextNode(text); // 创建文本节点
    }
    return el;
}

小结

此时已经能完成一个一次性的渲染过程,我们的主代码:

Vue.prototype.$mount = function (vm) {
    vm._update(vm._render());
}

依赖收集与视图更新

在日常的开发中我们知道,Vue的响应式或者说它的MVVM,要求数据的更新要驱动视图的更新。

在Vue2中,视图更新的实现通过Watcher类来实现,它是响应式的一部分。我们通过它来调用页面渲染的逻辑,以实现页面更新

Vue.prototype.$mount = function (vm) {
    let updateComponent = ()=>{
        vm._update(vm._render());  
    }
    // 每个组件都有一个 Watcher 实例
    new Watcher(vm, updateComponent, ()=>{}, true)
}
// observe/watcher.js
class Watcher {
    constructor(vm, fn, cb, options){
        this.vm = vm;
        this.fn = fn;
        this.cb = cb;
        this.options = options;
​
        this.getter = fn; // fn为传入的页面渲染函数
        this.get();
    }
​
    get(){
        this.getter();  // 调用页面渲染逻辑
    }
}

现在我们已经可以通过Watcher实例来控制页面渲染了,接下来要做的就是在数据发生改变的时候进行调用,在数据劫持中实现。

依赖收集

let id = 0;
class Dep {
    constructor() {
        this.id = id++;
        this.subs = [];
    }
    // 保存渲染watcher
    depend() {
        this.subs.push(Dep.target);
    }
}
Dep.target = null;

修改这篇中的代码

function defineReactive(obj, key, value) {
    observe(value);
    let dep = new Dep(); // 为每个属性创建一个dep实例
    Object.defineProperty(obj, key, {
        get() {
            if (Dep.target) {
                dep.depend(); // 通过get持有这个dep实例,保存当前组件的watcher实例
            }
            return value;
        },
        set(newValue) {
            if (newValue === value) return;
            observe(newValue);
            value = newValue;
        }
    })
}

Watcher中加入将当前watcher记录到Dep.target的逻辑,以完成initRender执行后,initState能够进行依赖收集的逻辑。

// observe/watcher.js
class Watcher {
    constructor(vm, fn, cb, options){
        this.vm = vm;
        this.fn = fn;
        this.cb = cb;
        this.options = options;
​
        this.getter = fn; // fn为传入的页面渲染函数
        this.get();
    }
​
    get(){
        Dep.target = this; // 当前组件wathcer记录
        this.getter();  // 调用页面渲染逻辑
        Dep.target = null; // 清除记录
    }
}

在Vue实例初始化的时候,initState阶段就会完成对数据的数据代理和数据劫持。在watcher实例调用页面渲染逻辑时,会触发_render,其中对数据的读操作会触发数据劫持的getter进而完成依赖收集。

边界情况-依赖收集查重

多次使用的数据:

<div>
    <p>{{title}}</p>
    <p>{{title}}</p>
</div>

此时会在_render时触发两次titlegetter,如果不进行查重的话一个watcher会被收集两次

let id = 0;
class Watcher {
    constructor(vm, fn, cb, options){
        this.vm = vm;
        this.fn = fn;
        this.cb = cb;
        this.options = options;
        this.id = id++; // watcher标记
​
        this.getter = fn;
        this.get();
    }
​
    get(){
        Dep.target = this;
        this.getter();
        Dep.target = null;
    }
}
let id = 0;
class Dep {
    constructor() {
        this.id = id++;
        this.subs = []
    }
    depend() { // 修改了depend实现
         Dep.target.addDep(this); // 调用当前watcher中的方法记录dep
    }
    // 保存渲染watcher
    addSup(watcher) {
        this.subs.push(watcher)
    }
}
Dep.target = null;

现在DepWatcher的实例都有唯一标识,并且都能够记录对方,也就是可以查重了

let id = 0;
class Watcher {
    constructor(vm, fn, cb, options){
        this.vm = vm;
        this.fn = fn;
        this.cb = cb;
        this.options = options;
        this.id = id++;
        
        this.depsId = new Set(); // watcher 保存 dep实例的id
        this.deps = []; // watcher 保存 dep实例
​
        this.getter = fn;
        this.get();
    }
    
    addDep(dep) {
        let id = dep.id;
        if (!this.depsId.has(id)) {
            this.depsId.add(id);
            this.deps.push(dep);
            dep.addSub(this); // 在dep通过Dep.target调用addDep的时候,让dep调用addSub记录watcher
        }
    }
​
    get(){
        Dep.target = this;
        this.getter();
        Dep.target = null;
    }
}

视图更新

依赖收集已经完成,接下来就可以进行视图更新了:

let id = 0;
class Watcher {
    constructor(vm, fn, cb, options){
        this.vm = vm;
        this.fn = fn;
        this.cb = cb;
        this.options = options;
        this.id = id++;
        
        this.depsId = new Set(); // watcher 保存 dep实例的id
        this.deps = []; // watcher 保存 dep实例
​
        this.getter = fn;
        this.get();
    }
    
    addDep(dep) {
        let id = dep.id;
        if (!this.depsId.has(id)) {
            this.depsId.add(id);
            this.deps.push(dep);
            dep.addSub(this); // 在dep通过Dep.target调用addDep的时候,让dep调用addSub记录watcher
        }
    }
​
    get(){
        Dep.target = this;
        this.getter();
        Dep.target = null;
    }
    
    update() {
        this.get(); // 重新执行视图渲染
    }
}

在数据劫持的getter中我们完成了依赖收集,接下来我们在setter中进行视图更新:

function defineReactive(obj, key, value) {
    observe(value);
    let dep = new Dep();
    Object.defineProperty(obj, key, {
        get() {
            if (Dep.target) {
                dep.depend();
            }
            return value;
        },
        set(newValue) {
            if (newValue === value) return;
            observe(newValue);
            value = newValue;
            dep.notify(); // 通过dep实例更新
        }
    })
}

边界情况-数组\对象

在以上的情况里我们完成了一般数据的依赖收集和视图更新,但要知道我们在initState中有提到过的特例:数组、对象。

class Observer {
    constructor() {
        // ...省略
        this.dep = new Dep(); // 为整个对象或数组创建一个 Dep 实例
    }
}
function defineReactive(obj, key, value) {
    let childOb = observe(value); // 如果 childOb 有值,说明数据是数组或对象类型
    let dep = new Dep();
    Object.defineProperty(obj, key, {
        get() {
            if (Dep.target) {
                dep.depend();
                if (childOb) {
                    childOb.dep.depend(); // 让数组和对象本身的 dep 记住当前 watcher
                    if (Array.isArray(value)) {
                        dependArray(value) // 可能数组中继续嵌套数组,需递归处理;多层对象本身已经在Observe时处理过了
                    }
                }
            }
            return value;
        },
        set(newValue) {
            if (newValue === value) return;
            observe(newValue);
            value = newValue;
            dep.notify();
        }
    })
}
​
function dependArray(value) {
    // 数组中如果有对象:[{}]或[[]],也要做依赖收集(后续会为对象新增属性)
    for(let i = 0; i < value.length; i++){
        let current = value[i];
        // current 上如果有__ob__,说明是对象
        current.__ob__ && current.__ob__.dep.depend();
        if(Array.isArray(current)){
            dependArray(current); // 嵌套数据,递归处理
        }
    }
}

数组的视图更新:

// Observer/array.js
let oldArrayPrototype = Array.prototype;
let arrayMethods = Object.create(oldArrayPrototype);
let methods = [
  'push',
  'pop',
  'shift',
  'unshift',
  'reverse',
  'sort',
  'splice'
]
​
methods.forEach(method => {
    arrayMethods[method] = function (...args) {
        oldArrayPrototype[method].call(this, ...args);
        let inserted = null;
        let ob = this.__ob__;
        switch(method) {
            case 'splice':
                inserted = args.slice(2);
                break;
            case 'push':
            case 'unshift':
                inserted = args;
                break;
        }
        if (inserted) ob.observeArray(inserted);
        ob.dep.notify(); // 通过ob拿到管理数组\对象的dep,调用 notify 触发 watcher 做视图更新
    }
});

边界情况-异步更新

在Vue的使用中我们都知道Vue是批量处理页面渲染也就是dom操作的,这是性能优化中极为重要的一点,但在我们目前的代码中还未体现这一点。异步更新带来了性能的优化,同样在开发中也制造了一些困难,所以在实际开发中我们通常要使用.$nextTick来处理异步更新带来的问题。

结语

本篇内容较多,将视图渲染、依赖收集、依赖更新以及一些常见边界情况的处理都整理到了一起。尽管如此还是简略了patch的内容,这是Vue2中的又一个重点,放到下一篇来整理。