前言
在上个版本中我们实现了点对点的数据响应更新,但是随之而来的内存消耗是极大问题,2x版本增加了虚拟dom和diff算法优化节点创建和更新,这里就来简单的实现一下。
全局变量
数组拦截需要使用
const originProto = Array.prototype;
const arrayProto = Object.create(originProto); // 备份数组原型
vue核心类
核心类中这次多了许多函数,丢弃了之前的Compile类
- constructor 实例化组件时执行的函数
- $mount 组件挂载
- $createElement 创建虚拟dom
- _update 更新
- __patch__ 创建dom并挂载
- updateChildren diff算法
- createElm 创建真实dom
constructor
实例化时的初始操作
constructor(options) {
this.$options = options; // 保存传入的选项
this.$data = options.data; // 保存传入的响应式数据
observe(this.$data); // 将数据进行响应式监听
proxy(this, "$data"); // 数据代理
if (options.el) this.$mount(options.el) // 挂载
}
$mount
$mount(el) {
// 获取宿主元素
this.$el = document.querySelector(el);
// 组件更新函数
const updateComponent = () => {
const { render } = this.$options; // 在vue中有编译器自动转换了dom结构,这里就简化实现,直接自己传入转换后的render
const vnode = render.call(this, this.$createElement); // 设置上下文为vue实例 传入参数 createElement 也就是vue源码中常见的 h() 函数;
console.log("vnode", vnode);
this._update(vnode); // vnode 转换为 dom
}
new Watcher(this, updateComponent); // 创建组件对应的watcher实例 也就是一个watcher对应一个组件级别的渲染函数
}
$createElement
$createElement(tag, data, children) { // 创建虚拟dom
return { tag, data, children }
}
_update
_update(vnode) {
const prevVnode = this._vnode;
if (!prevVnode) this.__patch__(this.$el, vnode) // 不存在就是初始化
else this.__patch__(prevVnode, vnode) // 否则就是更新 传入新老虚拟dom进行比较
this._vnode = vnode; // 保存vnode,这时候的vnode 有了el属性与真实dom元素关联
}
__patch__
__patch__(oldVnode, vnode) { // 更新或创建dom并挂载
if (oldVnode.nodeType) { // 如果是真实节点,那么是初始化
const parent = oldVnode.parentElement; // 获取oldVnode(el)的父节点
const refElm = oldVnode.nextSibling; // 获取oldVnode(el)的旁边的位置
const el = this.createElm(vnode); // 创建真实dom元素,并与虚拟dom关联
parent.insertBefore(el, refElm); // 追加到父节点参考元素的旁边
parent.removeChild(oldVnode); // 删除不需要的旧元素,也就是el根元素
} else {
// diff update
const el = oldVnode.el; // 获取之前保存在vnode中的真实dom
vnode.el = el; // 新的vnode 下一次就变成旧的了 所以这里也要保存
// props update todo 以后再做
// 如果标签相同 那么就是节点子元素更新
if (oldVnode.tag === vnode.tag) {
const oldCh = oldVnode.children;
const newCh = vnode.children;
if (typeof newCh === "string") {
if (typeof oldCh === "string") {
// 双方都是文本
if (newCh !== oldCh) el.textContent = newCh;
} else {
// old是数组,new是字符串
el.textContent = newCh;
}
} else {
if (typeof oldCh === "string") {
// old是文本 new是数组
el.innerHTML = ""; // 清空之前的内容
newCh.forEach(child => {
el.appendChild(this.createElm(child)); // 递归创建新节点的子元素并追加
})
} else {
// 重排
this.updateChildren(el, oldCh, newCh);
}
}
} else {
// replace todo
}
}
}
updateChildren
diff 算法 简易版,这里没有使用vue源码中的头尾游标两两移动,而是直接进行强制下标更新, 如果长度增加则创建,减少则删除。这样更能理清思绪。
updateChildren(parentElm, oldCh, newCh) {
// 直接更新相同索引的两个节点,如果相同索引节点相等那么更新,不相等那么删除旧的 创建新的,新的添加到el中
const len = Math.min(oldCh.length, newCh.length);
for (let i = 0; i < len; i++) {
this.__patch__(oldCh[i], newCh[i]);
}
// 如果newCh更长 说明是新增。
if (newCh.length > oldCh.length) {
newCh.slice(len).forEach(child => {
const el = this.createElm(child); // 创建
parentElm.appendChild(el); // 追加
})
} else if (newCh.length < oldCh.length) { // 删减
oldCh.slice(len).forEach(chlid => {
parentElm.removeChild(chlid.el); // 删除
})
}
}
createElm
createElm(vnode) {
const el = document.createElement(vnode.tag); // 创建元素
// props 留待以后实现
if (vnode.children) { // 如果有子节点
if (typeof vnode.children === "string") el.textContent = vnode.children; // 子节点为文本
else { // 子节点为元素
vnode.children.forEach(v => {
el.appendChild(this.createElm(v)); // 递归创建子节点,并追加到父节点里面
})
}
}
vnode.el= el; // 建立虚拟dom和节点元素之间的关系 未来更新需要使用
return el; // 返回创建的dom节点
}
Observer类
与1x版本没有变化
class Observer { // 执行数据响应化(分辨数据是对象还是数组)
constructor(obj) {
if (Array.isArray(obj)) {
obj.__proto__ = arrayProto; // 原型拦截
this.arrayCover(obj);
} else {
this.walk(obj);
}
}
walk(obj) {
Object.keys(obj).forEach(key => {
const value = obj[key];
defineReactive(obj, key, value);
})
}
arrayCover(obj) {
const methods = ["push", "pop", "shift", "unshift"];
methods.forEach(method => {
// 覆盖操作
arrayProto[method] = function() {
originProto[method].apply(this, arguments); // 执行原来数组本身操作
Dep.update.notify(); // 通知更新
Dep.update = null; // 清空
}
})
// 这里接收的obj是一个数组,那么数组里面可能也有对象或者数组,所以要继续递归遍历
const keys = Object.keys(obj);
keys.forEach(key => observe(obj[key]));
}
}
Watcher类
与1x版本相比,2x使用了一个组件一个watcher级别的渲染,所以这里会保存组件对应的渲染函数,当数据发生响应式变化时直接调用该函数进行更新
class Watcher { // 每一个组件对应一个watcher实例,用于更新组件
constructor(vm, componentRender) {
this.vm = vm; // vm实例
this.updaterFn = componentRender; // 对应组件的更新函数
this.get(); // 触发依赖收集并且建立dep和watcher的关系
}
get() {
Dep.target = this; // 保存当前需要监听的watcher,在defineReactive进行精确赋值
this.updaterFn(); // 执行渲染,渲染函数会访问响应式数据,然后触发依赖收集
Dep.target = null; // 等关系建立完成之后,重新置空,等待下一个watcher的建立,直至数据全部绑定完成
}
update() { // 将来dep会调用
this.updaterFn.call(this.vm);
}
}
Dep类
dep也发生了一点小变化,因为是每个组件一个watcher,所以同一个watcher只需入队一次
class Dep { // 保存watcher实例的依赖类,因为一个属性可能有多个使用的地方,所以一次要更新多个
constructor() {
this.deps = new Set(); // watcher 只需入队一次,所以用set结构
}
addDep(watcher) {
this.deps.add(watcher); // 保存组件对应的wathcer
}
notify() {
this.deps.forEach(watcher => watcher.update()) // 遍历执行每个wather的内部更新函数,让dom视图更新
}
}
defineReactive
与1x版本没有变化
function defineReactive(obj, key, value) {
observe(value); // 如果监听的是一个对象,再次进行递归处理
// 因为每个响应式数据都会走这个函数,所以在这里实例化dep
const dep = new Dep(); // 这里实例化的每个dep由于闭包关系 所以会和接收到的 key 进行一一对应。
Object.defineProperty(obj, key, {
get() {
console.log(`访问属性: ${key} => ${ value }`);
Dep.update = dep; // 当对数组进行push pop等操作时 会先触发 get 但是无法触发 set 导致视图不更新,这里保存一个对应的dep用于更新
Dep.target && dep.addDep(Dep.target); // 依赖关系收集 传入的是当前数据对应的watcher实例。
return value;
},
set(newVal) {
if (newVal !== value) {
value = newVal;
console.log(`设置属性: ${key} => ${ newVal }`);
observe(newVal); // 如果新值是对象,那么给这个对象添加数据响应式
dep.notify(); // 更新dep下收集到的所有对应key的
}
}
})
}
observe
与1x版本没有变化
function observe(obj) { // 观察者
if (typeof obj !== "object" || obj === null) return;
new Observer(obj);
}
proxy
与1x版本没有变化
function proxy(vm, agentPropertyName) { // 代理vm下的数据,如 vm.$data.name 映射成 vm.name 可以进行正确的访问
const watchData = vm[agentPropertyName];
Object.keys(watchData).forEach(key => {
Object.defineProperty(vm, key, {
get() {
return watchData[key];
},
set(newVal) {
watchData[key] = newVal;
}
})
})
}
window.Vue = Vue
html页面示例
这里只实现了简单的文本读取示例,手动创建了一个render函数传入,因为源码级别中需要处理的东西太多(且源码有对应的编译器),不拆分反而不好阅读,所以这里实现的主要是主体思路。
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
</head>
<body>
<div id="app">
{{ address }}
</div>
</body>
<script src="./vue/index2x.js">
</script>
<script>
const vm = new Vue({
el: "#app",
data: {
address: "成都"
},
render: function(h) {
console.log("this", this);
return h("h1", null, [
h("p", null, this.address),
h("p", null, this.address),
h("p", null, this.address)
])
},
methods: {
}
})
setTimeout(() => {
vm.address = "成都 武侯"
},1000)
</script>
</html>
源码中的updateChildren
vue2x中虚拟 dom diff 的完整过程 头尾比较,指针往中间移动,一旦新节点或者旧节点指针重合结束比对,进行扫尾工作,如果老的游标重合,代表是批量增加新的vnode树中剩下的节点。如果新的游标重合,代表删除,此时删除旧树中需要删除的节点。
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
// 四个游标
// 以及对应节点
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, vnodeToMove, refElm
// removeOnly is a special flag used only by <transition-group>
// to ensure removed elements stay in correct relative positions
// during leaving transitions
const canMove = !removeOnly
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(newCh)
}
// 开始循环:指针不能重合
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 前两个条件是校正工作,移动操作可能导致游标对应的位置变空,需要调整一下
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
// 首尾没有找到
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
// 新节点在老数组中的位置
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) { // New element
// 没有则创建
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
// 有则更新
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartVnode)) {
// 递归更新
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// 非相同节点直接替换
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
// 有游标重合
// 扫尾工作
if (oldStartIdx > oldEndIdx) {
// 老的结束,新的如果有剩下,批量创建
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
// 新的结束,如果老的又剩下,批量删除
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
}
function patchVnode (
oldVnode,
vnode,
insertedVnodeQueue,
ownerArray,
index,
removeOnly
) {
if (oldVnode === vnode) {
return
}
if (isDef(vnode.elm) && isDef(ownerArray)) {
// clone reused vnode
vnode = ownerArray[index] = cloneVNode(vnode)
}
const elm = vnode.elm = oldVnode.elm
if (isTrue(oldVnode.isAsyncPlaceholder)) {
if (isDef(vnode.asyncFactory.resolved)) {
hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
} else {
vnode.isAsyncPlaceholder = true
}
return
}
// reuse element for static trees.
// note we only do this if the vnode is cloned -
// if the new node is not cloned it means the render functions have been
// reset by the hot-reload-api and we need to do a proper re-render.
if (isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
vnode.componentInstance = oldVnode.componentInstance
return
}
let i
const data = vnode.data
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
i(oldVnode, vnode)
}
// 获取双方children
const oldCh = oldVnode.children
const ch = vnode.children
// 属性更新
if (isDef(data) && isPatchable(vnode)) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
}
// 新节点没有文本
if (isUndef(vnode.text)) {
// 双方都有子元素
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(ch)
}
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
removeVnodes(oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
// 文本更新
nodeOps.setTextContent(elm, vnode.text)
}
if (isDef(data)) {
if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
}
}
结语
Vue2.x降低watcher粒度,引⼊VNode和Patch算法,⼤幅提升了vue在⼤规模应⽤中的适⽤性、扩平台 的能⼒和性能表现,是⼀个⾥程碑版本。但是同时也存在⼀定问题:
- 数据响应式实现在性能上存在⼀些问题,对象和数组处理上还不⼀致,还引⼊了额外的API。
- 没有充分利⽤预编译的优势,patch过程还有不少优化空间。
- 响应式模块、渲染器模块都内嵌在核⼼模块中,第三⽅库扩展不便。
- 静态API设计给打包时的摇树优化造成困难。
- 选项式的编程⽅式在业务复杂时不利于维护。
- 混⼊的⽅式在逻辑复⽤⽅⾯存在命名冲突和来源不明等问题