Vue中的模板渲染、响应式系统、虚拟DOM

前言

在看vue源码的时候,觉得这几个vue的核心理念需要总结一下,遂写篇文章,自己忘记的时候再回来看看。

模板渲染

Vue采用的是声明式渲染,与命令式渲染不同,声明式渲染只需要告诉程序,我们想要的什么效果,其他的事情让程序自己去做。而命令式渲染,需要命令程序一步一步根据命令执行渲染。例如:

let arr = [1, 2, 3, 4, 5];

// 命令式渲染,关心每一步、关心流程。用命令去实现
let newArr = [];
for (let i = 0; i < arr.length; i++) {
    newArr.push(arr[i] * 2);
}

// 声明式渲染,不用关心中间流程,只需要关心结果和实现的条件
let newArr1 = arr.map(function (item) {
    return item * 2;
});
Vue 实现了 iffor事件数据绑定等指令,允许采用简洁的模板语法来声明式地将数据渲染出视图。
为什么要进行模板编译?实际组件中的 template 语法是无法被浏览器解析的,因为它不是正确的 HTML 语法,而模板编译,就是将组件的 template 编译成可执行的 JavaScript 代码,即将 template 转化为真正的渲染函数。

模板编译分三个阶段,parseoptimizegenerate,最终生成render函数。

  1. parse阶段:使用正在表达式将template进行字符串解析,得到指令classstyle等数据,生成抽象语法树 AST
  2. optimize阶段:寻找 AST 中的静态节点进行标记,为后面 VNodepatch 过程中对比做优化。被标记为 static 的节点在后面的 diff 算法中会被直接忽略,不做详细的比较。
  3. generate阶段:根据 AST 结构拼接生成 render 函数的字符串。

预编译
对于 Vue 组件来说,模板编译只会在组件实例化的时候编译一次,生成 渲染函数 之后在也不会进行编译。因此,编译对组件的 runtime 是一种性能损耗。而模板编译的目的仅仅是将template转化为render function,而这个过程,正好可以在项目构建的过程中完成。
比如webpackvue-loader依赖了vue-template-compiler模块,在 webpack 构建过程中,将template预编译成 render 函数,在 runtime 可直接跳过模板编译过程。

/*回过头看,runtime 需要是仅仅是 render 函数,而我们有了预编译之后,我们只需要保证构建过程中
生成 render 函数。与 React 类似,在添加JSX的语法糖编译器babel-plugin-transform-vue-jsx
之后,我们可以在 Vue 组件中使用JSX语法直接书写 render 函数。*/

当然,假如同时声明了 template 标签和 render 函数,构建过程中,template 编译的结果将覆盖原有的 render 函数,即 template 的优先级高于直接书写的 render 函数。

响应式系统

Vue 是一款 MVVMJS框架,当对数据模型 data进行修改时,视图会自动得到更新,即框架帮我们完成了更新 DOM的操作,而不需要我们手动的操作 DOM。可以这么理解,当我们对数据进行赋值的时候, Vue 告诉了所有依赖该数据模型的组件,你依赖的数据有更新,你需要进行重渲染了,这个时候,组件就会重渲染,完成了视图的更新。

整个流程梳理

  1. 首先实例化Vue类;
  2. 在实例化时,先触发observe,递归地对所有data中的变量进行订阅;
  3. 每次订阅之前,都会生成一个dep实例,dep是指依赖;
  4. 每一个只要是Object类型的变量都有一个dep实例;
  5. 这个dep是闭包产生的,因此所有与dep有关的操作,都要放到defineReactive函数内部执行;
window.myapp = new Vue({
    el: "#app",
    data: {
        number: {
            big: 999
        },
    },
});

export default class Vue {
    constructor (options: any = {}) {
        this.$options = options;
        this.$el = options.el;
        this.$data = options.data;
        this.$methods = options.methods;
        this.observe(this.$data);
        new Compiler(this.$el, this);
    }

    observe (data) {
        if (!data || typeof data !== "object") {
            return;
        }
        Object.keys(data).forEach(key => {
            this.defineReactive(data, key, data[key]);
        })
    }

    defineReactive(data, key, value) {
        this.observe(value);
        let dep = new Dep();
        this.$dps.push(dep);
        Object.defineProperty(data, key, {
            enumerable: true,
            configurable: false,
            get () {
                 // 由于需要在闭包内添加watcher,所以通过Dep定义一个全局target属性,
                 // 暂存watcher, 添加完移除
                if (Dep.target)
                    // dep.addSub(Dep.target);
                    dep.depend();
                    /**
                     * dep.depend();
                     * 两种写法一致
                     */
                return value;
            },
        })
    }
}

Dep

  1. 先定义一个全局的uid,便于分别每一个dep实例,在创建dep的时候绑定并自加1,每一个dep,都会有一个subs队列,里面存放的是watcher
  2. 每一个data以及其中凡是对象的变量,都唯一对应一个dep
  3. 如果想要实现从model -> View的绑定,只需要这样做,把所有的发布者watcher都放到一个dep中。
  4. 当改变一个变量时,只需要拿到这个变量对应的dep即可,因为dep有一个subs队列,存放的全是相关的发布者watcher,只需要遍历subs并且调用其中发布者的update方法即可更新页面,这就是设计dep类的思想。

    let guid = 0;
    export default class Dep {
        static target: any = null;
        subs: Array;
        uid: number;
        constructor() {
            this.subs = [];
            this.uid = guid ++;
        }
        addSub(sub) {
            this.subs.push(sub);
        }
        depend () {
            Dep.target.addDep(this);
        }
        notify() {
            this.subs.forEach(sub => {
                sub.update();
            })
        }
    }
    Dep.target = null;

Dep.target是一个静态变量,所有的dep实例的target都指向同一个东西,也就是说这个target是全局唯一的,理解为全局变量即可,其实就是一个watcher。在definePropertyget事件被触发时会进行依赖收集。

编译模板compiler

compiler的主要作用是把 html节点和 watcher关联起来,至于 html的内容如何更新,都由 watchercallback/updater函数决定。这里暂时不做深入,这里只需要知道它是 watcher来更新 DOM的。

watcher和dep绑定
到了这一步,model已经闭包地拥有了自己的dephtml节点也和watcher关联了起来,就差把watcher推到对应的dep里了。然后先看看Watcher

export default class Watcher {
    private vm;
    private exp;
    private cb;
    private value;
    private depIds = {};
    constructor (vm, exp, cb) {
        this.vm = vm;
        this.exp = exp;
        this.cb = cb;
        // 创建时必须触发一次依赖收集
        this.triggerDepCollection();
    }
    update () {
        this.triggerDepCollection();
        this.cb(this.value);
    }
    addDep (dep) {
        if (!this.depIds.hasOwnProperty(dep.uid)) {
            dep.addSub(this);
            this.depIds[dep.uid] = dep;
        }
    }
    // 收集依赖,因为触发了definePropreity的get()
    // or re-collection
    triggerDepCollection () {
        Dep.target = this;
        this.value = this.getDeepValue();
        Dep.target = null;
    }
    getDeepValue () {
        let data = this.vm.$data;
        this.exp.split(".").forEach(key => {
            data = data[key];
        })
        return data;
    }
}
当编译 html代码时,我们碰到了一个需要收集的变量,现在为其创建一个 watcher,并在 watcher内部与 dep建立联系。我们称这步为依赖收集,我们可以看到,在构造函数的最后一行, triggerDepCollection()意味这个 watcher自己触发依赖收集,这个函数先把我们先前提到的 Dep.target设为 watcher自身,就是把自己作为一块砖头放在手上,然后 getDeepValue()这里你只需要知道去访问了一次 exp变量,这就触发了 exp变量的 get事件,就是提醒 expdep,“你可以收集我了”, get事件的主要内容就是收集这个依赖,然后再结合最开始提到的代码,触发 dep.depend()

前文的 defineReactive方法里面的 get方法中的 if (Dep.target) dep.depend(),它又调用了depDep.target.addDep(this),也就是当前的watcheraddDep(this)watcheraddDep(this)又调用了这个depaddSub()
意思就是,我现在要收集依赖,只需要dep调用自己的addSub(watcher),把watcher推到自己的subs队列就完事了,但现在,dep把自己传给watcher,然后watcher再把自己传给depdep再把watcher加到自己的队列,这样岂不是多此一举?其实不然。就在于watcheraddDep这一步,关键在于判断这个depuid是不是自己加入过的dep,也可以用defineReactive方法里面的set实现。
每次调用update()的时候会触发相应属性的getDeepvaluegetDeepvalue里面会触发dep.depend(),继而触发这里的addDep
1、假如相应属性的dep.id已经在当前watcherdepIds里,说明不是一个新的属性,仅仅是改变了其值而已,则不需要将当前watcher添加到该属性的dep里。

2、假如相应属性是新的属性,则将当前watcher添加到新属性的dep里,,因为新属性之前的setterdep 都已经失效,如果不把 watcher 加入到新属性的dep中,通过 obj.xxx = xxx 赋值的时候,对应的 watcher 就收不到通知,等于失效了。因此每次更新都要重新收集依赖。

3、每个子属性的watcher在添加到子属性的dep的同时,也会添加到父属性的dep,监听子属性的同时监听父属性的变更,这样,父属性改变时,子属性的watcher也能收到通知进行update,这一步是在 this.get() --> this.getVMVal() 里面完成,forEach时会从父级开始取值,间接调用了它的getter,触发了addDep(), 在整个forEach过程,当前wacher都会加入到每个父级过程属性的dep,例如:当前watcher的是child.child.name, 那么childchild.childchild.child.name这三个属性的dep都会加入当前watcher

至此,所有的内容就完成了,watcher也和dep绑定完毕。

Virtual DOM

Vue 中, template被编译成浏览器可执行的 render function,然后配合响应式系统,将 render function挂载在 render-watcher中,当有数据更改的时候,调度中心 Dep通知该 render-watcher执行 render function,完成视图的渲染与更新。
整个流程看似通顺,但是当执行 render function时,如果每次都全量删除并重建 DOM,这对执行性能来说,无疑是一种巨大的损耗,因为我们知道,浏览器的 DOM很“昂贵”的,当我们频繁的更新 DOM,会产生一定的性能问题。
为了解决这个问题, Vue 使用 JS 对象将浏览器的 DOM 进行的抽象,这个抽象被称为 Virtual DOMVirtual DOM 的每个节点被定义为 VNode,当每次执行 render function时, Vue 对更新前后的 VNode进行 Diff对比,找出尽可能少的需要更新的真实 DOM 节点,然后只更新需要更新的节点,从而解决频繁更新 DOM 产生的性能问题。

VNode
VNode,全称virtual node,即虚拟节点,对真实 DOM 节点的虚拟描述,在 Vue 的每一个组件实例中,会挂载一个$createElement函数,所有的VNode都是由这个函数创建的。

比如创建一个 div:
// 声明 render function
render: function (createElement) {
    // 也可以使用 this.$createElement 创建 VNode
    return createElement('div', 'hellow world');
}
// 以上 render 方法返回html片段 
hellow world

render 函数执行后,会根据VNode Tree将 VNode 映射生成真实 DOM,从而完成视图的渲染.

Diff

Diff 将新老 VNode 节点进行比对,然后将根据两者的比较结果进行最小单位地修改视图,而不是将整个视图根据新的 VNode 重绘,进而达到提升性能的目的。

patch

Vue内部的 diff 被称为patch。其 diff 算法的是通过同层的树节点进行比较,而非对树进行逐层搜索遍历的方式,所以时间复杂度只有O(n),是一种相当高效的算法。

首先定义新老节点是否相同判定函数sameVnode:满足键值key和标签名tag必须一致等条件,返回true,否则false
在进行patch之前,新老 VNode 是否满足条件sameVnode(oldVnode, newVnode),满足条件之后,进入流程patchVnode,否则被判定为不相同节点,此时会移除老节点,创建新节点。

patchVnode

patchVnode 的主要作用是判定如何对子节点进行更新,

如果新旧VNode都是静态的,同时它们的key相同(代表同一节点),并且新的 VNodeclone 或者是标记了 once(标记v-once属性,只渲染一次),那么只需要替换 DOM 以及 VNode 即可。

新老节点均有子节点,则对子节点进行 diff 操作,进行updateChildren,这个 updateChildren 也是 diff 的核心。

1.如果老节点没有子节点而新节点存在子节点,先清空老节点 DOM 的文本内容,然后为当前 DOM 节点加入子节点。
2.当新节点没有子节点而老节点有子节点的时候,则移除该 DOM 节点的所有子节点。
3.当新老节点都无子节点的时候,只是文本的替换。

updateChildren

Diff 的核心,对比新老子节点数据,判定如何对子节点进行操作,在对比过程中,由于老的子节点存在对当前真实 DOM 的引用,新的子节点只是一个 VNode 数组,所以在进行遍历的过程中,若发现需要更新真实 DOM 的地方,则会直接在老的子节点上进行真实 DOM 的操作,等到遍历结束,新老子节点则已同步结束。

1、 updateChildren内部定义了4个变量,分别是 oldStartIdxoldEndIdxnewStartIdxnewEndIdx,分别表示正在 Diff 对比的新老子节点的左右边界点索引,在老子节点数组中,索引在 oldStartIdxoldEndIdx中间的节点,表示老子节点中为被遍历处理的节点,所以小于 oldStartIdx或大于 oldEndIdx的表示未被遍历处理的节点。
2、同理,在新的子节点数组中,索引在 newStartIdxnewEndIdx中间的节点,表示老子节点中为被遍历处理的节点,所以小于 newStartIdx或大于 newEndIdx的表示未被遍历处理的节点。
3、每一次遍历, oldStartIdxoldEndIdxnewStartIdxnewEndIdx之间的距离会向中间靠拢。当 oldStartIdx > oldEndIdx 或者 newStartIdx > newEndIdx 时结束循环。
4、在遍历中,取出4索引对应的 Vnode节点:
(1). oldStartIdx:oldStartVnode
(2). oldEndIdx:oldEndVnode
(3). newStartIdx:newStartVnode
(4). newEndIdx:newEndVnode
5、 diff 过程中,如果存在 key,并且满足 sameVnode,会将该 DOM 节点进行复用,否则则会创建一个新的 DOM 节点。

比较过程

首先, oldStartVnodeoldEndVnodenewStartVnodenewEndVnode两两比较,一共有 2*2=4 种比较方法。
  1. 情况一:当oldStartVnodenewStartVnode满足 sameVnode,则oldStartVnodenewStartVnode进行 patchVnode,并且oldStartIdxnewStartIdx右移动。
  2. 情况二:与情况一类似,当oldEndVnodenewEndVnode满足 sameVnode,则oldEndVnodenewEndVnode进行 patchVnode,并且oldEndIdxnewEndIdx左移动。
  3. 情况三:当oldStartVnodenewEndVnode满足 sameVnode,则说明oldStartVnode已经跑到了oldEndVnode后面去了,此时oldStartVnodenewEndVnode进行 patchVnode 的同时,还需要将oldStartVnode的真实 DOM 节点移动到oldEndVnode的后面,并且oldStartIdx右移,newEndIdx左移。
  4. 情况四:与情况三类似,当oldEndVnodenewStartVnode满足 sameVnode,则说明oldEndVnode已经跑到了oldStartVnode前面去了,此时oldEndVnodenewStartVnode进行 patchVnode 的同时,还需要将oldEndVnode的真实 DOM 节点移动到oldStartVnode的前面,并且oldStartIdx右移,newEndIdx左移。
  5. 若不存在,说明newStartVnode为新节点,创建新节点放在oldStartVnode前面即可。
  6. oldStartIdx> oldEndIdx 或者 newStartIdx > newEndIdx,循环结束,这个时候我们需要处理那些未被遍历到的 VNode
  7. oldStartIdx > oldEndIdx 时,说明老的节点已经遍历完,而新的节点没遍历完,这个时候需要将新的节点创建之后放在oldEndVnode后面。
  8. newStartIdx > newEndIdx 时,说明新的节点已经遍历完,而老的节点没遍历完,这个时候要将没遍历的老的节点全都删除。
    借用网上的一个动图

    说明
    以上部分内容来源与自己复习时的网络查找,也主要用于个人学习,相当于记事本的存在,暂不列举链接文章。如果有作者看到,可以联系我将原文链接贴出。

你可能感兴趣的:(Vue中的模板渲染、响应式系统、虚拟DOM)