vue内部运行机制
本文主要讲述 vue内部运行机制
阅读本文可收获
1.对vue内部运行机制有系统性的了解
2.数据劫持 + 发布订阅者模式 实现响应式
3.编译过程简单了解
4.vue的异步批量更新策略
5.为什么引入vNode 以及 diff 算法
6.patch 是如何比较虚拟节点并得出差异的
内部流程总览图
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()
,可以分成 parse
、optimize
与 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 字符串。
在经历过 parse
、optimize
与 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
双方皆不存在,则不做改动
一方存在的情况
-
ch 存在 oldChildren 不存在,然后将 ch 批量插入到节点oldVnode.elm下
-
ch 不存在,则 oldChildren 存在 则删除oldChildren
双方存在的情况
-
ch 与 oldChildren 都存在,且相等 不做改变(虚拟节点可以直接对比是否相等)
-
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 内部运行机制
转载自:https://juejin.cn/post/7144980485549064206