手写简化版Vue(五) _update的实现解析
本系列文章会以实现Vue的各个核心功能为标杆(初始化、相应式、编译、虚拟dom、更新、组件原理等等), 不会去纠结于非重点或者非本次学习目标的细节, 从头开始实现简化版Vue, 但是, 即使是简化, 也需要投入一定的时间和精力去学习, 而不可能毫不费力地学习到相对复杂的知识; 所有简化代码都会附上原版源码的路径, 简化版仅仅实现了基本功能, 如需了解更多细节, 可以去根据源码路径去阅读对应的原版源码;
首次渲染
得到VNode之后, 下一步就是要将其转换为真实dom了, 也就是我们编译的最后一步! 这一部分中, 将调vm._update用方法, 来实现vdom到dom的转换
// 源码路径 /src/core/instance/lifecycle.ts
export function lifecycleMixin (Vue) {
// 将虚拟dom转为真实节点
Vue.prototype._update = function (vnode) {
const vm = this
let prevNode = vm._vnode
let prevEl = vm.$el
vm._vnode = vnode
// 首次渲染
if (!prevNode) {
vm.$el = vm.__patch__(vm.$el, vnode)
// 更新渲染
} else {
vm.$el = vm.__patch__(prevNode, vnode)
}
if (prevEl) {
prevEl.__vue__ = null
}
}
}
整体上也很清晰明了; 重点就在于这里的__patch__方法, 而__patch__方法也是分了2种情况: 首次渲染以及更新渲染, 两者的区别在于, 首次渲染, 第一个参数为真实节点vm.$el, 更新渲染, 第一个参数为虚拟节点prevNode, 下面我们围绕patch方法展开讲解, 先看看运行时入口文件引入部分:
// 源码路径 /src/platforms/web/runtime/index.ts
import { patch } from './patch'
Vue.prototype.__patch__ = patch
export default Vue
patch方法由createPatchFunction方法创建:
// patch入口文件:
// 源码路径 /src/platforms/web/runtime/patch.ts
import { createPatchFunction } from "../../../core/vdom/patch"
// 对原生节点的操作方法
import * as nodeOps from './node-ops'
// 对属性/事件等模块的操作方法
import baseModules from './modules/index'
const modules = baseModules
// 创建patch方法
export const patch = createPatchFunction({nodeOps, modules})
// nodeOps上的方法定义:
// 源码路径: /src/platforms/web/runtime/node-opts.ts
export function tagName (node) {
return node.tagName
}
export function parentNode (node) {
return node.parentNode
}
export function createElement (tag) {
return document.createElement(tag)
}
export function appendChild (node, child) {
node.appendChild(child)
}
export function createTextNode (text) {
return document.createTextNode(text)
}
export function nextSibling (node) {
return node.nextSibling
}
export function insertBefore (parent, child, ref) {
parent.insertBefore(child, ref)
}
export function setTextContent (node, textContent) {
node.textContent = textContent
}
我们可以看到, 又是执行一个方法(createPatchFunction), 返回另一个可执行方法(patch), 就跟我们前面说的createCompiler方法的逻辑类似, 即一个偏函数, 而node-ops, modules/index中其实都是具体的属于web这个平台的操作逻辑, 这么做有利于代码的解耦, 提高可维护性; 这些操作逻辑会在createPatchFunction方法中用到; 由于createPatchFunction方法颇为复杂, 所以我们先重点关注return的patch方法部分:
// 源码地址: /src/core/vdom/patch.ts
import { isDef, isArray, isPrimitive, isUndef } from "../../shared/util"
import VNode from "./vnode"
// 空的虚拟dom
const emptyVnode = new VNode('', {}, [])
export function createPatchFunction (backend) {
const {nodeOps, modules} = backend
// ...其他逻辑暂时省略
// 实际返回的patch方法
return function patch (oldVnode, vnode) {
// 只有真实的dom节点才存在nodeType属性
const isRealElement = isDef(oldVnode.nodeType)
const insertedVNodeQueue = []
if (isDef(oldVnode)) {
// 更新渲染
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// 更新节点
patchVnode(oldVnode, vnode)
// oldVnode是真实节点 || oldVnode和vnode不同, 此时vnode都要重新渲染
} else {
// 首次渲染
if (isRealElement) {
// 转为Vnode
oldVnode = emptyNodeAt(oldVnode)
}
// 这个oldElm是一个真实的节点, 此处一般为#app
let oldElm = oldVnode.elm
// nodeOps, modules这些, 都是针对本平台(web platform)特定的操作方法, nodeOps里的其实就是一堆操作dom的原生方法:
let parentElm = nodeOps.parentNode(oldElm)
// 创建节点
createElem(vnode, insertedVNodeQueue, parentElm, nodeOps.nextSibling(oldElm))
}
}
}
// 真实节点转为VNode
function emptyNodeAt (elm) {
return new VNode(nodeOps.tagName(elm).toLowerCase(), {}, [], undefined, elm)
}
// ...其他逻辑暂时省略
}
目前patch的整体逻辑很简单, 就是判断patch的首个参数是否是真实节点, 如果是, 则说明是首次渲染, 那就通过createElem来创建节点, 如不是, 则通过patchVnode方法进行节点的更新; 一个个来, 我们先来看下首次渲染的情况.
首次渲染中的核心方法就是createElem方法, 其功能就是根据vnode的信息, 创建出真实的节点
export function createPatchFunction (backend) {
// ...其他部分暂时省略
// 将虚拟dom转为真实dom
function createElem (vnode, insertedVNodeQueue, parentElm, refElm, nested, owerArray, index) {
const children = vnode.children
const tag = vnode.tag
const data = vnode.data //属性
if (isDef(tag)) {
// 根据表情名创建真实dom节点
// 其实就是调用原生的document.createElement(tag)
vnode.elm = nodeOps.createElement(tag)
// 创建子节点
createChildren(vnode, children, insertedVNodeQueue)
// 如果存在属性
if (isDef(data)) {
// 执行开始的钩子方法
invokeCreateHooks(emptyVnode, vnode)
}
// 插入父节点
insert(parentElm, vnode.elm, refElm)
} else {
vnode.elm = nodeOps.createTextNode(String(vnode.text))
insert(parentElm, vnode.elm, refElm)
}
}
// 创建子节点
function createChildren (vnode, children, insertedVNodeQueue) {
// 有多个子节点
if (isArray(children)) {
for (let i = 0; i<children.length; i++) {
// 递归
createElem(children[i], insertedVNodeQueue, vnode.elm, null)
}
// 如果只是string/number等基础类型, 则直接插入
} else if (isPrimitive(children)) {
nodeOps.appendChild(vnode.elm, child)
}
}
// 插入节点操作
function insert(parent, elm, ref) {
if (isDef(parent)) {
if (isDef(ref)) {
if (nodeOps.parentNode(ref) === parent) {
nodeOps.insertBefore(parent, elm, ref)
}
} else {
nodeOps.appendChild(parent, elm)
}
}
}
// 执行开始生命周期
function invokeCreateHooks (emptyVnode, vnode) {
for (let i = 0; i < cbs.create.length; ++i) {
cbs.create[i](emptyVnode, vnode)
}
}
// ...其他部分暂时省略
}
可以看出, 逻辑还是非常简单的, 就是根据Vnode的标签名, 调用一个createElement的原生方法, 创建一个节点, 再执行invokeCreateHooks更新属性, 然后调用appendChild插入就行了, 关于invokeCreateHooks方法, 下面会继续分解
事件/属性绑定
组合模式的妙用
现在想想, 我们除了需要搞出真实节点及其子节点, 还要搞啥? 还漏了啥? 没错, 事件/属性/class/指令等等需要添加! 而vue在这里使用了组合模式, 即给各个模块(事件/属性/class/指令), 定义了属于不同周期的钩子函数('create', 'update', 'destroy'等), 然后invokeCreateHooks方法通过统一执行各个模块的create钩子, 来实现事件/属性等的添加, 一起来看看具体怎么实现的吧:
// ...其他部分暂时省略
const hooks = ['create', 'update', 'destroy']
export function createPatchFunction (backend) {
const {nodeOps, modules} = backend
let cbs = {}
// 将各个模块的钩子都组装到一个对象中去
for (let i = 0; i < hooks.length; ++i) {
cbs[hooks[i]] = []
for (let j = 0; j < modules.length; ++j) {
// 如果某个模块有对应的钩子函数, 则进行组装
if (isDef(modules[j][hooks[i]])) {
cbs[hooks[i]].push(modules[j][hooks[i]])
}
}
}
// ...其他部分暂时省略
}
modules是一个由不同模块组成的数组, 每个模块是一个以hook为键的对象, 值就是这个hook需要执行的逻辑,
形如: [{create: () => {}, update: () => {}}, {...}, ...]
而循环过后cbs的大体结构就是:
cbs = {
create: [() => {}, () => {}], // 该数组中包含了各个模块的create钩子函数
update: [() => {}, () => {}],
}
所以, 现在回过头来看前面的invokeCreateHooks方法, 其实就是执行所有模块的create钩子函数! 在createElem的方法中, 就是执行每个属性模块的create钩子函数, 很明显, 一个模块在create的钩子上肯定都是各种添加操作, 说白了就是为节点添加该方法/属性;
function invokeCreateHooks (emptyVnode, vnode) {
for (let i = 0; i < cbs.create.length; ++i) {
cbs.create[i](emptyVnode, vnode)
}
}
下面再来看看具体的模块逻辑吧
modules
在实际源码中, modules包含attrs, klass, events, domProps, style, transition等诸多内容, 这里应目前实际需要, 只展示events和domProps, 因为基本原理都是一样的,如果想更深入了解, 可以再去阅读源码;
- events
前面说了, modules在/src/platforms/web/runtime/patch.ts里被引入, 并以参数的形式传给上面的createPatchFunction方法的:
import modules from './modules/index'
export const patch = createPatchFunction({modules})
modules的入口文件index, 统一导出各个模块
// 源码路径: /src/platforms/web/runtime/modules/index.ts
import events from './events'
import domProps from './dom-props'
export default [events, domProps]
events模块, 负责所有事件的逻辑, 这里只列出了create部分
// 源码路径: /src/platforms/web/runtime/modules/events.ts
import { isUndef } from "../../../../shared/util"
import { updateListeners } from "../../../../core/vdom/helpers/update-listeners"
let target
// dom2级事件监听
function add (name, handler, capture, passive) {
target.addEventListener(name, handler)
}
function updateDomListeners (oldVnode, vnode) {
if (isUndef(oldVnode) && isUndef(vnode)){
return
}
// 取出真实节点
target = vnode.elm
// on:xxx, 即代表事件
const on = vnode.data.on
updateListeners(on, add)
}
export default {
create: updateDomListeners
}
// 源码路径: /src/core/vdom/helpers/update-listeners.ts
export function updateListeners (on, add) {
for (const name in on) {
add(name, on[name])
}
}
我们由此可以得知, vue的事件绑定其实是dom2级事件, 使用了addEventListener这个原生方法进行绑定的!
- domProps
属性设置较为简单, 就只是单纯地一个遍历赋值操作
// 更新属性
function updateDomProps (oldVnode, vnode) {
let props = vnode.data.domProps
const elm = vnode.elm
for (let key in props) {
if (key === 'value') {
elm[key] = props[key]
}
}
}
export default {
create: updateDomProps,
update: updateDomProps,
}
代码小节:
一. 设计模式上:
总体上, 事件/属性的绑定使用了组合模式, 即 每个模块先定义好各自的钩子函数, 通过遍历, 形成成为一个以钩子名为键, 以各个模块下, 属于这个钩子的方法组成的数组为值的对象; 这种写法的好处很明显:
- 符合了代码高内聚, 低耦合的设计原则, 各个模块相互分开, 互不影响;
- 代码可维护性高, 想要修改某个模块的行为, 直接修改对应的钩子即可;
- 统一调用, 由于使用了遍历, 所以不用担心某个方法没执行到; 前面的invokeCreateHooks方法, 也正是遍历执行cbs.update中的所有方法;
二. 虚拟dom转换:
- 所谓的VNode转为真实节点的基本逻辑, 其实很简单:
-
- 读取VNode中的tag, 再使用createElement原生dom方法创建原生同名节点, 并赋给VNode的elm属性
- 通过createChildren来遍历创建子节点, 如果子节点还有子节点, 则再递归调用createElm, 直到children为空;
- 将新创建的节点插入parentElm, 也就是父节点;
- 操作真实节点的方法均以参数形式传入nodeOps.xx, 这样极大提高了代码的可复用性; 要说VNode经过这么一波操作后, 有啥变化? 前面讲vm._render的时候, 说它们每个节点的elm属性, 都由undefined, 变为了实际存在的节点!
至此, 我们已经可以将页面渲染出来了!
new Vue({
el: '#app',
template: `
<div>
<div><span>姓名:</span>{{name}}</div>
<div><span>年龄:</span>{{age}}</div>
</div>
`,
data () {
return {
name: 'jack',
age: 18
}
}
})
更新渲染
完成了首次渲染, 我们来看看如果做到数据驱动页面更新, 回到刚才的patch方法, 如果非首次渲染, 那就会执行一个patchVnode方法, 接受oldVnode和VNode两个参数:
// 根据vnode改造oldvnode
function patchVnode (oldVnode, vnode) {
if (oldVnode === vnode) {
return
}
const elm = (vnode.elm = oldVnode.elm)
const ch = vnode.children
const oldCh = oldVnode.children
if (isDef(vnode.data)) {
for (let i = 0; i < cbs.update.length; ++i) {
(cbs.update[i])(oldVnode, vnode)
}
}
// 如果新的节点没有文本内容
if (isUndef(vnode.text)) {
// 如果新老节点都存在
if (ch && oldCh) {
// 新老节点不同, 说明需要更新
if (ch !== oldCh) {
// 更新子节点
updateChildren(elm, ch, oldCh)
}
// 原来没有子节点, 现在有, 则 增加子节点
} else if (isDef(ch)) {
if (oldVnode.text) {
nodeOps.setTextContent(vnode, '')
}
// 增加操作
addVnodes(elm, null, ch, 0, ch.length - 1)
// 原来有, 现在没有, 删除子节点
} else if (isDef(oldVnode)) {
// 删除操作
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
// 如果新老文本都存在, 且不同, 则按照新的文本内容设置
} else if (vnode.text !== oldVnode.text){
nodeOps.setTextContent(elm, vnode.text)
}
}
updateChildren
updateChildren方法, 也就是更新子节点, 这里, 就是diff算法的核心所在, 用于对比两组同层的节点之间的不同, 并根据这些不同, 去更改/新增/删除真实的节点
// 更新子节点, diff算法
function updateChildren (parentElm, ch, oldCh, insertedVnodeQueue) {
let oldStartIdx = 0
let oldEndIdx = oldCh.length - 1
let newStartIdx = 0
let newEndIdx = ch.length - 1
let oldStartVnode = oldCh[oldStartIdx]
let oldEndVnode = oldCh[oldEndIdx]
let newStartVnode = ch[newStartIdx]
let newEndVnode = ch[newEndIdx]
let keyToIndex, idxInOld
while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx]
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
// 左侧推进
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = ch[++newStartIdx]
// 右侧推进
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = ch[--newEndIdx]
// 旧的开始节点和新的结束节点相同
} else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode)
// 将新的开始的节点插到原最后一个节点的后面
nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = ch[--newEndIdx]
// 旧的结束节点和新的开始节点相同
} else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode)
nodeOps.insertBefore(parentElm, oldEndVnode.elm, newStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = ch[++newStartIdx]
} else {
// 创建key到index的映射表
keyToIndex = createKeyToIndex(oldCh, oldStartIdx, oldEndIdx)
idxInOld = newStartVnode.key ? keyToIndex[newStartVnode.key] : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
// 如果找不到对应的下标
if (isUndef(idxInOld)) {
createElem(newStartVnode, insertedVNodeQueue, parentElm, oldStartVnode.elm)
// 如果有下标
} else {
const vnodeToMove = oldCh[idxInOld]
// 如果相同
if (sameVnode(newStartVnode, vnodeToMove)) {
patchVnode(vnodeToMove, newStartVnode)
// 注意, 这个不处于边界元素, 必须设为undefined
oldCh[idxInOld] = undefined
nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
// 如果不同
} else {
createElem(newStartVnode, insertedVNodeQueue, parentElm, oldStartVnode.elm)
}
}
newStartVnode = [++newStartIdx]
}
}
if (oldStartIdx > oldEndIdx) {
// 新增
const refElm = isUndef(ch[newEndIdx + 1]) ? null : ch[newEndIdx + 1].elm
addVnodes(parentElm, refElm, ch, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
// 删除
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
}
虽然代码洋洋洒洒很多, 我们仍然可以一步步解析:
- 先从左侧开始向右, 逐个对比新老节点, 如相同, 则patchVnode, 索引增加++oldStartIdx, ++newStartIdx, 否则就进入下一步;
- 从右侧开始向左逐个对比, 确定是一个节点, 则patchVnode, 索引减少--oldStartIdx, --newStartIdx否则就进入下一步;
- 比较oldStartVnode和newEndVnode, 确定是一个节点, 则同化(patchVnode), 并将其真实节点(即 elm属性的值)插入到老节点的最后后一位nodeOps.nextSibling(oldEndVnode.elm), 索引增减++oldStartIdx, --newEndIdx,否则就进入下一步;
- 比较oldEndVnode和newStartVnode, 确定是一个节点, 则同化(patchVnode), 并将其真实节点(即 elm属性的值)插入到老节点的第一位oldStartVnode.elm 索引增减++newStartIdx, --oldEndIdx, 否则就进入下一步;
- 以上均没找到, 则需要使用到key
-
- 如果newStartVnode有key, 就直接用oldKeyToIdx[newStartVnode.key]找出与之key相同的老节点的索引idxInOld
-
- 如果newStartVnode没有key, 则遍历剩下的老节点(findIdxInOld), 找出和newStartVnode相同的老节点的索引idxInOld;
-
- 如果前面的步骤根本没找到idxInOld, 说明这是一个新节点, 那就直接在oldStartVnode.elm前创建一个节点createElem(newStartVnode, insertedVNodeQueue, parentElm, oldStartVnode.elm)
-
- 如果找到了idxInOld, 还要看对应的老节点是否和newStartVnode相同
-
-
- 相同, 则说明这个节点发生了位移, 并用这个老节点vnodeToMove, 插入到oldStartVnode.elm之前, 并将oldCh[idxInOld]设为undefined; 注意, 这里之所以要将其设为undefined, 而前面的几次插入都不用, 是因为, 前面几次都是在用一些边界节点(oldStartVnode.elm, oldEndVnode.elm)来插到新的位置上, 紧接着的索引加一或减一(++oldStartIdx, --oldEndIdx)操作, 又会使这个老节点直接离开了我们操作的区间(oldStartIdx到 oldEndIdx), 所以可以不管它, 但是, 此时, 我们的vnodeToMove, 肯定不在边界上, 所以后续索引增减, 又会轮到这个节点, 那就有问题了, 所以将其设为undefined, 这样, 当while再走到它时, 就会直接跳过;
-
-
-
- 不同, 则说明用户已经完全更改了这个节点, 那就和前面一样, 在老oldStartVnode.elm前创建一个节点createElem(newStartVnode, insertedVNodeQueue, parentElm, oldStartVnode.elm)
-
往期回顾
转载自:https://juejin.cn/post/7251182878112317499