Vue.js内部运行机制(一)
Vue.js内部运行机制#
new vue
之后,vue
会调用_init
函数进行初始化,它会初始化生命周期、事件、props、methods、data、computed与watch等。其中最重要的是通过Object.defineProperty
设置setter
与getter
函数,用来实现响应式以及依赖收集。
初始化之后调用$mount
会挂载组件,如果是运行时编译,即不存在render function
但是存在template
的情况,需要进行 {编译} 步骤。
编译#
compile编译可以分成 parse
、optimize
与 generate
三个阶段,最终需要得到 render function
。
一、响应式系统(Object.defineProperty
)#
/*
obj: 目标对象
prop: 需要操作的目标对象的属性名
descriptor: 描述符
return value 传入对象
*/
Object.defineProperty(obj, prop, descriptor)
descriptor的一些属性,简单介绍几个属性,具体可以参考 MDN 文档。
enumerable
,属性是否可枚举,默认 false。configurable
,属性是否可以被修改或者删除,默认 false。get
,获取属性的方法。set
,设置属性的方法。
//模拟视图更新的方法
function cb (val) {
/* 渲染视图 */
console.log("视图更新啦~");
}
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);
}
});
}
当然这是不够的,我们需要在上面再封装一层 observer
。这个函数传入一个 value(需要「响应式」化的对象),通过遍历所有属性的方式对该对象的每一个属性都通过 defineReactive
处理。(注:实际上 observer 会进行递归调用,为了便于理解去掉了递归的过程。
function observer (value) {
if (!value || (typeof value !== 'object')) {
return;
}
Object.keys(value).forEach((key) => {
defineReactive(value, key, value[key]);
});
}
最后:用observer
封装一个Vue
class Vue {
/* Vue构造类 */
constructor(options) {
this._data = options.data;
observer(this._data);
}
}
let o = new Vue({
data: {
test: "I am test."
}
});
o._data.test = "hello,world."; /* 视图更新啦~ */
二、依赖收集#
// 订阅者 Dep
class Dep {
constructor () {
/* 用来存放Watcher对象的数组 */
this.subs = [];
}
/* 在subs中添加一个Watcher对象 */
addSub (sub) {
this.subs.push(sub);
}
/* 通知所有Watcher对象更新视图 */
notify () {
this.subs.forEach((sub) => {
sub.update();
})
}
}
// 观察者 Watcher
class Watcher {
constructor () {
/* 在new一个Watcher对象时将该对象赋值给Dep.target,在get中会用到 */
Dep.target = this;
}
/* 更新视图的方法 */
update () {
console.log("视图更新啦~");
}
}
Dep.target = null;
// 依赖收集
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();
}
});
}
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);
}
}
总结:
首先在 observer
的过程中会注册 get
方法,该方法用来进行「依赖收集」。在它的闭包中会有一个 Dep
对象,这个对象用来存放 Watcher 对象的实例。其实「依赖收集」的过程就是把 Watcher
实例存放到对应的 Dep
对象中去。get
方法可以让当前的 Watcher
对象(Dep.target)存放到它的 subs 中(addSub
)方法,在数据变化时,set
会调用 Dep
对象的 notify
方法通知它内部所有的 Watcher
对象进行视图更新。
这是 Object.defineProperty
的 set/get
方法处理的事情,那么「依赖收集」的前提条件还有两个:
- 触发
get
方法; - 新建一个 Watcher 对象。
这个我们在 Vue 的构造类中处理。新建一个 Watcher
对象只需要 new 出来,这时候 Dep.target
已经指向了这个 new 出来的 Watcher
对象来。而触发 get
方法也很简单,实际上只要把 render function 进行渲染,那么其中的依赖的对象都会被「读取」,这里我们通过打印来模拟这个过程,读取 test 来触发 get
进行「依赖收集」。
本章我们介绍了「依赖收集」的过程,配合之前的响应式原理,已经把整个「响应式系统」介绍完毕了。其主要就是 get
进行「依赖收集」。set
通过观察者来更新视图。
三、实现Virtual DOM下的一个VNode节点#
什么是VNode?
render function 会被转化成 VNode 节点。Virtual DOM 其实就是一棵以 JavaScript 对象(VNode 节点)作为基础的树,用对象属性来描述节点,实际上它只是一层对真实 DOM 的抽象。最终可以通过一系列操作使这棵树映射到真实环境上。由于 Virtual DOM 是以 JavaScript 对象为基础而不依赖真实平台环境,所以使它具有了跨平台的能力,比如说浏览器平台、Weex、Node 等。
实现一个VNode
VNode 归根结底就是一个 JavaScript 对象,只要这个类的一些属性可以正确直观地描述清楚当前节点的信息即可。我们来实现一个简单的 VNode
类,加入一些基本属性,为了便于理解,我们先不考虑复杂的情况。
class VNode {
constructor (tag, data, children, text, elm) {
/*当前节点的标签名*/
this.tag = tag;
/*当前节点的一些数据信息,比如props、attrs等数据*/
this.data = data;
/*当前节点的子节点,是一个数组*/
this.children = children;
/*当前节点的文本*/
this.text = text;
/*当前虚拟节点对应的真实dom节点*/
this.elm = elm;
}
}
总结:VNode 就是一个 JavaScript 对象,用 JavaScript 对象的属性来描述当前节点的一些状态,用 VNode 节点的形式来模拟一棵 Virtual DOM 树
四、template模板是怎样通过Compile编译的#
Compile
compile
编译可以分成 parse
、optimize
与 generate
三个阶段,最终需要得到 render function。
parse
首先是 parse
,parse
会用正则等方式将 template 模板中进行字符串解析,得到指令、class、style等数据,形成 AST(抽象语法树)
optimize
optimize
主要作用就跟它的名字一样,用作「优化」。
这个涉及到后面要讲 patch
的过程,因为 patch
的过程实际上是将 VNode 节点进行一层一层的比对,然后将「差异」更新到视图上。那么一些静态节点是不会根据数据变化而产生变化的,这些节点我们没有比对的需求,是不是可以跳过这些静态节点的比对,从而节省一些性能呢?
那么我们就需要为静态的节点做上一些「标记」,在 patch
的时候我们就可以直接跳过这些被标记的节点的比对,从而达到「优化」的目的。
经过 optimize
这层的处理,每个节点会加上 static
属性,用来标记是否是静态的。
得到如下结果:
{
'attrsMap': {
':class': 'c',
'class': 'demo',
'v-if': 'isShow'
},
'classBinding': 'c',
'if': 'isShow',
'ifConditions': [
'exp': 'isShow'
],
'staticClass': 'demo',
'tag': 'div',
/* 静态标志 */
'static': false,
'children': [
{
'attrsMap': {
'v-for': "item in sz"
},
'static': false,
'alias': "item",
'for': 'sz',
'forProcessed': true,
'tag': 'span',
'children': [
{
'expression': '_s(item)',
'text': '{{item}}',
'static': false
}
]
}
]
}
generate
generate
会将 AST 转化成 render funtion 字符串,最终得到 render 的字符串以及 staticRenderFns 字符串。
五、数据状态更新时的差异 diff 及 patch 机制#
patch 的核心是 diff 算法。用 diff 算法可以比对出两颗树的「差异」,我们来看一下,假设我们现在有如下两颗树,它们分别是新老 VNode 节点,这时候到了 patch
的过程,我们需要将他们进行比对。
diff 算法是通过同层的树节点进行比较而非对树进行逐层搜索遍历的方式,所以时间复杂度只有 O(n),是一种相当高效的算法。
patch 的过程有四种情况:
oldVnode
(老 VNode 节点)不存在的时候,相当于新的 VNode 替代原本没有的节点,所以直接用addVnodes
将这些节点批量添加到parentElm
上。- 在
vnode
(新 VNode 节点)不存在的时候,相当于要把老的节点删除,所以直接使用removeVnodes
进行批量的节点删除即可 oldVNode
与vnode
都存在并且都属于sameVnode
(相同的节点),则进行patchVnode(比对 VNode )操作,否则删除老节点,增加新节点。oldVNode
与vnode
都存在并且不属于sameVnode
(相同的节点),则删除老节点,增加新节点。
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);
}
}
}
何为sameVnode?
sameVnode
其实很简单,只有当 key
、 tag
、 isComment
(是否为注释节点)、 data
同时定义(或不定义),同时满足当标签类型为 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
}
patchVnode比对过程
- 新老VNode节点相同的情况下,不需要做任何改变,直接return掉
if (oldVnode === vnode) {
return;
}
- 新老 VNode 节点都是
isStatic
(静态的),并且key
相同时,只要将componentInstance
与elm
从老 VNode 节点“拿过来”即可。这里的isStatic
也就是前面提到过的「编译」的时候会将静态节点标记出来,这样就可以跳过比对的过程。
if (vnode.isStatic && oldVnode.isStatic && vnode.key === oldVnode.key) {
vnode.elm = oldVnode.elm;
vnode.componentInstance = oldVnode.componentInstance;
return;
}
- 当新 VNode 节点是文本节点的时候,直接用
setTextContent
来设置 text,这里的nodeOps
是一个适配层,根据不同平台提供不同的操作平台 DOM 的方法,实现跨平台。
if (vnode.text) {
nodeOps.setTextContent(elm, vnode.text);
}
-
当新 VNode 节点是非文本节点当时候,需要分几种情况。
-
oldCh
与ch
都存在且不相同时,使用updateChildren
函数来更新子节点,这个后面重点讲。 -
如果只有
ch
存在的时候,如果老节点是文本节点则先将节点的文本清除,然后将ch
批量插入插入到节点elm下。 -
同理当只有
oldch
存在时,说明需要将老节点通过removeVnodes
全部清除。 -
最后一种情况是当只有老节点是文本节点的时候,清除其节点文本内容。
-
if (oldCh && ch && (oldCh !== ch)) {
updateChildren(elm, oldCh, ch);
} else if (ch) {
if (oldVnode.text) nodeOps.setTextContent(elm, '');
addVnodes(elm, null, ch, 0, ch.length - 1);
} else if (oldCh) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (oldVnode.text) {
nodeOps.setTextContent(elm, '')
}
updateChildren
function updateChildren (parentElm, oldCh, newCh) {
let oldStartIdx = 0;
let newStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
let oldStartVnode = oldCh[0];
let oldEndVnode = oldCh[oldEndIdx];
let newEndIdx = newCh.length - 1;
let newStartVnode = newCh[0];
let newEndVnode = newCh[newEndIdx];
let oldKeyToIdx, idxInOld, elmToMove, refElm;
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (!oldStartVnode) {
oldStartVnode = oldCh[++oldStartIdx];
} else if (!oldEndVnode) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode);
nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode);
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
let elmToMove = oldCh[idxInOld];
if (!oldKeyToIdx) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
idxInOld = newStartVnode.key ? oldKeyToIdx[newStartVnode.key] : null;
if (!idxInOld) {
createElm(newStartVnode, parentElm);
newStartVnode = newCh[++newStartIdx];
} else {
elmToMove = oldCh[idxInOld];
if (sameVnode(elmToMove, newStartVnode)) {
patchVnode(elmToMove, newStartVnode);
oldCh[idxInOld] = undefined;
nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm);
newStartVnode = newCh[++newStartIdx];
} else {
createElm(newStartVnode, parentElm);
newStartVnode = newCh[++newStartIdx];
}
}
}
}
if (oldStartIdx > oldEndIdx) {
refElm = (newCh[newEndIdx + 1]) ? newCh[newEndIdx + 1].elm : null;
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx);
} else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列:基于图像分类模型对图像进行分类
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 25岁的心里话
· 闲置电脑爆改个人服务器(超详细) #公网映射 #Vmware虚拟网络编辑器
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 零经验选手,Compose 一天开发一款小游戏!
· 一起来玩mcp_server_sqlite,让AI帮你做增删改查!!