likes
comments
collection
share

vue内部运行机制

作者站长头像
站长
· 阅读数 41

本文主要讲述 vue内部运行机制

 阅读本文可收获
 1.对vue内部运行机制有系统性的了解
 2.数据劫持 + 发布订阅者模式 实现响应式
 3.编译过程简单了解
 4.vue的异步批量更新策略
 5.为什么引入vNode 以及 diff 算法
 6.patch 是如何比较虚拟节点并得出差异的

内部流程总览图

vue内部运行机制

1.简述内部流程

1.1 new Vue()初始化 与 数据劫持

实例化vue对象new Vue(),会调用_init()函数进行初始化,这个过程会初始化 生命周期、事件、 props、 methods、 data、 computed 与 watch 等。并通过Object.defineProperty进行数据劫持,设置get,set,用于后期的依赖收集以及响应式!

1.2 $mount 挂载

初始化结束,会执行$mount进行挂载,此时会判断是否存在render funtion,如果存在则执行,否则会进入compile编译阶段;

在main.js,会存render funtion 执行render 渲染最终挂载到 #app 这个dom中

v2
new Vue({
  router,
  render: (h) => h(App),
}).$mount('#app');

v3
createApp(App).use(store).use(router).mount("#app")

如果是页面组件或者业务组件会对 template 进行编译,进而得到 render function ,执行render 得到 vNode, 进行 patch 过程最终进行渲染并挂载到对应的文档dom

$mount()本质是想将render渲染模板挂载到文档dom中;

1.3 compile() 编译

编辑阶段会调用compile(),可以分成 parseoptimize 与 generate 三个阶段,最终需要得到 render function;

parse 会用正则等方式解析 template 模板中的指令、class、style等数据,形成AST。

我们大概看下AST是什么样的

<div :class="c" class="demo" v-if="isShow">
    <span v-for="item in sz">{{item}}</span>
</div>

{
    /* 标签属性的map,记录了标签上属性 */
    'attrsMap': {
        ':class': 'c',
        'class': 'demo',
        'v-if': 'isShow'
    },
    /* 解析得到的:class */
    'classBinding': 'c',
    /* 标签属性v-if */
    'if': 'isShow',
    /* v-if的条件 */
    'ifConditions': [
        {
            'exp': 'isShow'
        }
    ],
    /* 标签属性class */
    'staticClass': 'demo',
    /* 标签的tag */
    'tag': 'div',
    /* 子标签数组 */
    'children': [
        {
            'attrsMap': {
                'v-for': "item in sz"
            },
            /* for循环的参数 */
            'alias': "item",
            /* for循环的对象 */
            'for': 'sz',
            /* for循环是否已经被处理的标记位 */
            'forProcessed': true,
            'tag': 'span',
            'children': [
                {
                    /* 表达式,_s是一个转字符串的函数 */
                    'expression': '_s(item)',
                    'text': '{{item}}'
                }
            ]
        }
    ]
}

optimize 的主要作用是标记 static 静态节点(还标记了注释节点,文本节点等) 这是 Vue 在编译过程中的一处优化,后面当 update 更新界面时,会有一个 patch 的过程, diff 算法会直接跳过静态节点,从而减少了比较的过程,优化了 patch 的性能。

generate 是将 AST 转化成 render function 字符串的过程,得到结果是 render 的字符串以及 staticRenderFns 字符串。

在经历过 parseoptimize 与 generate 这三个阶段以后,组件中就会存 render function 了。render function 会被转化成 VNode 节点.

VNode 是就是一个json对象,用来描述改节点; 例如

Virtual DOM
{
    tag: 'div',                 /*说明这是一个div标签*/
    children: [                 /*存放该标签的子节点*/
        {
            tag: 'a',           /*说明这是一个a标签*/
            text: 'click me'    /*标签的内容*/
        }
    ]
}

也因为虚拟dom,本质是一个js对象,所以只要支持js就支持VNode,故它具有了跨平台的能力,比如说浏览器平台、Weex、Node 等!

1.4 响应式原理 与 批量异步更新策略

当reder function被渲染的时候需要「读」取值,此时会触发getter方法进行依赖收集,执行订阅者dep中的addSub方法,把当前的 Watcher 对象(存放在 Dep.target 中)收集到 Dep 类中

如果当该对象被「写」的时候,则会触发 setter方法,通知 Dep 类调用 notify 来触发所有 Watcher 对象的 update 方法,会触发批量异步更新策略(一种优化手段)来,来更新试图

1.5 patch 与 diff算法

render function 执行会得到一个新的 VNode 节点,我们会将新老VNode节点传入patch方法进行比较,通过diff算法计算出差异,最后只需要对差异的节点进行渲染即可!

2.核心概念讲解

2.1数据劫持-响应式原理

简述:new Vue()的时候会调用observer方法,对data中的数据进行遍历,对遍历每个数据执行defineReactive方法,首先会实例化一个dep类对象,用于依赖收集,接着使用Object.defineProperty方法进行劫持,重写其get以及set方法!

当数据被读取的时候,会执行其get方法,执行dep.addSub()进行依赖收集,将对应的watcher存入dep的subs中!

当数据被写入的时候,会在执行其set方法,首先判断数据是否变化,当数据变化的时候会触发dep.notify()遍历通知收集的依赖watcher变化,进而执行watcher的updata方法更新视图

数据劫持一个数据,会实例化其对应的dep类对象,每个数据都有其对应的订阅者dep!

class Vue {
      /* Vue构造类 */
      constructor(options) {
        this._data = options.data;
        observer(this._data);
      }
}
function observer(value) {
      if (!value || typeof value !== 'object') {
        return;
      }
      
      /* 
      * 为方便理解仅考虑简单类型,暂时不考虑 数组类型以及多层对象
      * 发现是数组类型则通过重写其数组操作方法使其能被响应
      * 但是也导致 仅通过 数组操作方法才被会被响应
      * 例如: 通过下标直接改变数组内容则不能被响应
      * 内部数据为多层对象则是通过递归循环劫持
      * 总得来 数据劫持 仅劫持data上面存在的数据
      * vue3使用proxy 进行双向绑定,不存在以上问题
      * proxy可以直接拦截整个对象,不需要再进行递归
      * 可以理解为 vue2是通过遍历以及递归为data内的每个数据进行拦截
      * 当新增数据的时候不会重新遍历,所以仅对data存在的数据响应(可以使用$set手动使其响应)
      * proxy 拦截的为data整个对象,无需遍历,data内部任意数据改变,均会触发其set
      */

      Object.keys(value).forEach(key => {
        defineReactive(value, key, value[key]);
      });
}
function defineReactive(obj, key, val) {
      Object.defineProperty(obj, key, {
        enumerable: true /* 属性可枚举 */,
        configurable: true /* 属性可被修改或删除 */,
        get: function reactiveGetter() {
          return val; /* 实际上会依赖收集 */
        },
        set: function reactiveSetter(newVal) {
          if (newVal === val) return;
          cb(newVal);
        },
      });
}

function cb(val) {
      /* 渲染视图 */
      console.log('视图更新啦~');
}

let o = new Vue({
      data: {
        test: 'I am test.',
      },
});
o._data.test = 'hello,world.';

思考延伸---为何需要收集依赖

new Vue({
    template: 
        `<div>
            <span>{{text1}}</span> 
            <span>{{text2}}</span> 
        <div>`,
    data: {
        text1: 'text1',
        text2: 'text2',
        text3: 'text3'
    }
});
this.text3 = 'modify text3';

我们修改了 data 中 text3 的数据,但是因为视图中并不需要用到 text3 ,所以我们并不需要触发上一章所讲的 cb 函数来更新视图,调用 cb 显然是不正确的!

所以我们需要收集依赖,当依赖变化的时候,我们才会去更新视图

2.2依赖收集与发布者-观察者模式


/* 发布者
*  作用 1.收集依赖 2.下发通知更新
*/
class Dep {
    constructor () {
        /* 用来存放Watcher对象的数组 */
        this.subs = [];
    }

    /* 在subs中添加一个Watcher对象 */
    addSub (sub) {
        this.subs.push(sub);
    }

    /* 通知所有Watcher对象更新视图 */
    notify () {
        this.subs.forEach((sub) => {
            sub.update();
        })
    }
}
/* 观察者
*  作用 接收到更新通知,执行更新视图
*/
class Watcher {
    constructor () {
        /* 在new一个Watcher对象时将该对象赋值给Dep.target,在get中会用到 */
        Dep.target = this;
    }

    /* 更新视图的方法 */
    update () {
        console.log("视图更新啦~");
    }
}

Dep.target = null;
class Vue {
    constructor(options) {
        this._data = options.data;
        observer(this._data);
        /* 新建一个Watcher观察者对象,这时候Dep.target会指向这个Watcher对象 */
        new Watcher();
        /* 在这里模拟render的过程,为了触发test属性的get函数 */
        console.log('render~', this._data.test);
    }
}
function defineReactive (obj, key, val) {
    /* 一个Dep类对象 */
    const dep = new Dep();
    
    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function reactiveGetter () {
            /* 将Dep.target(即当前的Watcher对象存入dep的subs中) */
            dep.addSub(Dep.target);
            return val;         
        },
        set: function reactiveSetter (newVal) {
            if (newVal === val) return;
            /* 在set的时候触发dep的notify来通知所有的Watcher对象更新视图 */
            dep.notify();
        }
    });
}

思考延伸----为何需要批量更新机制

for (let i = 0; i < 1000; i++) {
     o._data.test = i;
}

当一个数据被写入了1000次,岂不是要更新1000次视图?显然是不必要的,更新视图消耗巨大,用户只需要看到最后一个视图变化即可,从而需要批量异步更新策略,收集下发的多次更新请求,最终合并为一次更新!

2.3批量异步更新策略

简单的来说,就是一个数据一次性被修改了多次,会触发多次 set 从而触发多次 dep.notify进而触发了多次updata,正常我们每次updata都会取更新视图,批量异步更新策略 会在updata的时候把watcher 收集起来,并通过 watcher的id进行过滤,然后使用 Promise、setTimeout、setImmediate 进行更新视图

因为 Promise.then 为异步,会进入任务队列.当主线程中的任务执行完毕,就从任务队列中取出任务放进主线程中来进行执行,也就是 Promise.then 执行的时候, o._data.test 已经执行完毕第1000次写入了,此时 在进行驶入渲染,自然渲染的就是最终的结果了

setTimeout、setImmediate为宏任务,也会等待主线程中的任务执行完毕 在执行,同上!(注意setTimeout 执行顺序在 Promise.then 之后)

简单的描述一下一个event loop流程

首先初始状态 调用栈为空,微任务队列为空,宏任务队列只有一个script脚本

script脚本 入栈开始执行,首先执行同步代码,在执行过程中 可能会产生其他的 微任务(如异步调用接口),宏任务(定时器),当同步代码执行结束,script 脚本会被移出 宏任务队列,然后会检查微任务队列,同时执行一队微任务,微任务会逐个执行结束,并出队,当所有微任务都被清空队列,则会执行一次渲染操作,更新页面!

更新结束浏览器检查是否存在 Web worker 任务,如果有,则对其进行处理,此时可能会发现宏任务队列还有任务,会继续处理,过程同上

let uid = 0;
let has = {};
let queue = [];
let waiting = false;
let callbacks = [];
let pending = false;

//  观察者
class Watcher {
      constructor() {
        /* 在new一个Watcher对象时将该对象赋值给Dep.target,在get中会用到 */
        Dep.target = this;
        this.id = ++uid;
      }

      /* 更新视图的方法 */
      update() {
        console.log('watch' + this.id + ' update');
        /* 批量更新机制 传入watcher本身 */
        queueWatcher(this);
      }

      run() {
        console.log('watch' + this.id + '视图更新啦~');
      }
}

/* 进行过滤  */
function queueWatcher(watcher) {
    const id = watcher.id;
    /* 过滤相同id的数据,实际一个组件对应一个watcher-id */
    if (has[id] == null) {
        has[id] = true;
        <!--将对应watcher加入队列-->
        queue.push(watcher);
        if (!waiting) {
            waiting = true;
            <!--异步策略-->
            nextTick(flushSchedulerQueue);
        }
    }
}

function nextTick (cb) {
    <!--收集回调函数-->
    callbacks.push(cb);
    if (!pending) {
        <!--进入等待-->
        pending = true;
         <!--Vue.js 源码中分别用 Promise、setTimeout、setImmediate 等方式-->
        setTimeout(flushCallbacks, 0);
    }
}

function flushCallbacks () {
    pending = false;
    for (let i = 0; i < callbacks.length; i++) {
        callbacks[i]();
    }
    callbacks.length = 0;
}

function flushSchedulerQueue () {
    let watcher, id;
    for (index = 0; index < queue.length; index++) {
        watcher = queue[index];
        id = watcher.id;
        has[id] = null;
        watcher.run();
    }

    waiting  = false;
}


2.4patch机制与diff算法

set-dep.notify-watcher.updata-queueWatcher-patch-渲染视图

patch这个过程主要是对比新老VNode节点,通过diff算法计算得出「差异」,最终将这些「差异」更新到视图上!

diff 算法是通过同层的树节点进行比较而非对树进行逐层搜索遍历的方式

思考1: 使用 diff 算法 计算 vnode 差异,并将差异渲染到视图上,是否比我们直接操作做真实dom最终渲染性能更高?

数据修改 === vNode(diff算法) ==== 修改真实dom

数据修改 === 修改真实dom

首先确定,直接修改dom的性能肯定高于间接通过 vnode+diff 操作节点

既然直接操作dom吸能最佳,为何要引入 vnode

1.使用虚拟dom有着还不错的性能,虽然比不上通过原生 js 直接操作 DOM 的性能,但是也没差太多,能保证性能的下限。

2.使用VNode + diff避免了我们直接操作dom,也不用去关注dom操作,vue会用一种相对更规范的方式操作dom,尽最大可能的符合浏览器的优化机制!

使用diff算法尽可能的减少了 创建dom节点的性能消耗,如果使用 innerHtml直接插入会导致旧节点被清空,所有dom节点都会被新建,消耗性能巨大,所以需要对比处差异

diff对比的过程操作的是VNode节点,过程不会引起回流以及重绘,会一次性找到所有差异,最终进行渲染,既然要渲染,最终肯定还是要转化为真实dom进而操作真实dom进行渲染!

思考2: 我们通过diff算法计算出差异,如何做到只渲染差异部分,如果差异部分为几何属性修改,或者dom新增或者确实,依然会引起回流,回流就会导致整个页面重新渲染,得到差异部分又有何用?

当页面dom被新增或者删除或者修改了几何属性,都会造成页面的回流,重新计算样式以及为位置,最终再次渲染到页面!

但是回流不会导致dom节点新建与删除!也就是回流的时候dom节点已经存在,浏览器会通通过引擎解析并计算出对应的css样式已经位置,然后重新渲染到页面中!

可以理解为将整个页面填白,然后对内容重新渲染!

只会重新渲染差异部分 主要是和 innerHtml 进行对比 这种方式对比,避免大面积新建未改变的dom节点!

例如: 我们要修改div的内容

首先,假设不适用diff对比,使用innerHtml直接插入

会导致我们需要创建对应的节点,替换当前节点,

const htmlTemplate = `<div>hello world</div>`
wrap.innerHTML = htmlTemplate

通过diff算法,我们只需要 对比出差异,来最小化的修改变更的内容,首先发现整个标签可以复用,只是内容区域修改了,所以无需创建dom 直接替换节点即可!

当然,如果使用原生js直接操作dom,我们也可以精准的替换内容,无需创建节点,vue使用vnode + diff 多了计算对比的过程,但是js性能相对较高,所以性能也还是不错的!

而且使用原生js操作dom,用的好了性能极佳,但是就怕用的不好,一顿操作猛如虎,随便进行无用的dom操作,造成大量性能消耗!

const div = document.getElementById('div')
div.innerText = 'hello world'

故:引入vNode + diff的主要优势

1.避免我们不熟练的直接操作dom产生不可预知的性能消耗,让我们更专注与操作data

2.本身性能也不错,等于 降低了小幅度的性能,方便了使用者的使用!

步入正题 diff算法开始

function patch (oldVnode, vnode, parentElm) {
    if (!oldVnode) {
        addVnodes(parentElm, null, vnode, 0, vnode.length - 1);
    } else if (!vnode) {
        removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);
    } else {
        if (sameVnode(oldVNode, vnode)) {
            patchVnode(oldVNode, vnode);
        } else {
            removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);
            addVnodes(parentElm, null, vnode, 0, vnode.length - 1);
        }
    }
}

对比的时候会接收三个参数1.oldVnode,vnode,父级节点

1.首先在 oldVnode(老 VNode 节点)不存在的时候,相当于新的 VNode 替代原本没有的节点,所以直接用 addVnodes 将这些节点批量添加到 parentElm 上

2.在 vnode(新 VNode节点)不存在的时候,相当于要把老的节点删除,所以直接使用 removeVnodes 进行批量的节点删除即可

3.当新老VNode都存在的时候会通过sameVnode判断是否为同一节点

为相同节点时候,则对比差异

为不同节点时,删除旧节点,增加新节点

那么问题来了,如何判断为同一节点?

满足以下五条则判断为相同节点

1.key相同

2.tag类型相同

3.isComment(注释节点),同为注释节点或者同不为

4.都有data或者都没有

5.当tag类型为input 的时候判断其对应的type是否相同

function sameVnode () {
    return (
        a.key === b.key &&
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        (!!a.data) === (!!b.data) &&
        sameInputType(a, b)
    )
}

function sameInputType (a, b) {
    if (a.tag !== 'input') return true
    let i
    const typeA = (i = a.data) && (i = i.attrs) && i.type
    const typeB = (i = b.data) && (i = i.attrs) && i.type
    return typeA === typeB
}

如果判断为同一节点相同,则进入最后的比较差异的过程!

1.对比 vNode === oldVnode

虚拟dom均为json字符串,可以直接对比

相等 则直接复用 旧节点即可,不需要做任何改变

不相等则继续对比

2.判断新老vNode 是否均为静态节点,(vnode.isStatic在编译阶段会判断是否为静态节点),若均为静态节点,则跳过对比,直接复用

一方为静态节点,一方不是,则删除旧节点,新增新节点

双方均不为静态节点,则继续判断

3.判断vNode是否为文本节点(因为此时新老vNode tag类型相同所以不许判断老的)

3.1 vNode 为文本节点,则替换oldVnode的内容

3.2 vNode 不为文本节点

对比过程麻烦的很,可以自行感悟,以下为简单对比

此时判断vNode 与 oldVnode 是否有子节点 ch 与 oldChildren

双方皆不存在,则不做改动

一方存在的情况

  1. ch 存在 oldChildren 不存在,然后将 ch 批量插入到节点oldVnode.elm下

  2. ch 不存在,则 oldChildren 存在 则删除oldChildren

双方存在的情况

  1. ch 与 oldChildren 都存在,且相等 不做改变(虚拟节点可以直接对比是否相等)

  2. ch 与 oldChildren 都存在,且不相等 则使用 updateChildren 来更新

updateChildren 会对子元素进行一层层的对比,对比完头部对比尾部

<div>
 <ul>
   <li key='1'>1</li>
   <li key='2'>2</li>
   <li key='3'>3</li>
   <li key='4'>4</li>
 <ul/>
</div>

<div>
 <ul>
   <li key='1'>1</li>
   <li key='5'>5</li>
   <li key='2'>6</li>
   <li key='3'>3</li>
   <li key='4'>4</li>
 <ul/>
</div>

首先对比 新旧的 第一个li 通过 sameVnode 来判断是否为相同节点, 相同,对比 vNode === oldVnode相等,则完全复用,若不相等,会继续对比 是否为静态节点,文本节点,children

然后对比双方最后一个节点,过程一样,也相同则继续复用

然后对比双方第二个节点,key不同故为不同节点,不可复用,在旧节点全面匹配是否有相同的key值,发现有相同的key,则通过 sameVnode 来判断是否为相同节点,相同,对比 vNode === oldVnode不相等,则判断内容是否均为为文本节点,是则仅替换内容

然后对比倒数第二个,相同节点且vNode === oldVnode相等则复用

文章如存在错误,欢迎大家指出

参考文章 剖析Vue.js 内部运行机制