第九章:runtime 运行时 - 构建 renderer 渲染器
01:前言
从本章开始我们将要在 VNode
的基础上构建 renderer
渲染器。
根据上一章中的描述我们知道,在 packages/runtime-core/src/renderer.ts
中存放渲染器相关的内容。
Vue
提供了一个 baseCreateRenderer
的函数,该函数会返回一个对象,我们把返回的这个对象叫做 renderer 渲染器
。
对于该对象而言,提供了三个方法:
render
:渲染函数hydrate
:服务端渲染相关createApp
:初始化方法
查看 baseCreateRenderer
的代码,我们也可以发现整个 baseCreateRenderer
中包含了 2000
行代码,可见整个渲染器是非常复杂的。
所以说,对于我们的实现而言,还是和之前一样,我们将谨遵 没有使用就当做不存在 和 最少代码的实现逻辑 这两个核心思想,来构建整个 render
的过程。
在实现 render
的过程中,我们也会和学习 h
函数时一样,利用 h
函数时的测试实例,配合上 render
函数来分类型进行依次处理。
那么明确好了这些内容之后,我们下面就来进入 渲染器的世界吧。
02:源码阅读:初见 render 函数,ELEMENT 节点的挂载操作
在上一小节,我们实现过一个这样的测试案例 packages/vue/examples/imooc/runtime/h-element.html
:
<script>
const { h } = Vue
const vnode = h('div', {
class: 'test'
}, 'hello render')
console.log(vnode);
</script>
这样我们可以得到一个对应的 vnode
,我们可以使用 render
函数来去渲染它。
render(vnode, document.querySelector('#app'))
我们可以在 packages/runtime-core/src/renderer.ts
的第 2327
行,增加 debugger
:
进入
render
函数render
函数接收三个参数:vnode
:虚拟节点container
:容器isSVG
:是否是SVG
执行
patch(container._vnode || null, vnode, container, null, null, null, isSVG)
根据我们之前所说,我们知道
patch
表示 更新 节点。这里传递的参数我们主要关注 前三个。container._vnode
表示 旧节点(n1
),vnode
表示 新节点(n2
),container
表示 容器执行
switch
,case
到if (shapeFlag & ShapeFlags.ELEMENT)
:我们知道此时
shapeFlag
的值是9
,转为为二进制:00000000 00000000 00000000 00001001
ShapeFlags.ELEMENT
的值是1
,转为二进制:00000000 00000000 00000000 00000001
两者执行 按位与 (&) ,得到的二进制结果为:
00000000 00000000 00000000 00000001 // 十进制 1
即
if (shapeFlag & ShapeFlags.ELEMENT)
===if (1)
,等同于if(true)
所以会进入
if
触发
processElement
方法:进入
processElement
方法因为当前为 挂载操作,所以 没有旧节点,即:
n1 === null
触发
mountElement
方法,即 挂载方法:进入
mountElement
方法执行
el = vnode.el = hostCreateElement(....)
,该方法为创建Element
的方法- 进入该方法,可以发现该方法指向
packages/runtime-dom/src/nodeOps.ts
中的createElement
方法 - 不知道大家还记不记得,之前我们说过:
vue
为了保持兼容性,把所有和浏览器相关的API
封装到了runtime-dom
中 - 在
createElement
中的代码非常简单就是通过document.createElement
方法创建dom
,并返回
- 进入该方法,可以发现该方法指向
此时
el
和vnode.el
的值为createElement
生成的div
实例接下来处理:子节点
执行
if (shapeFlag & ShapeFlags.TEXT_CHILDREN)
,同样的 按位与 (&),大家可以自己进行下二进制的转换触发
hostSetElementText
方法- 进入该方法,同样指向
packages/runtime-dom/src/nodeOps.ts
文件下的setElementText
方法 - 里面的代码非常简单只有一行
el.textContent = text
- 进入该方法,同样指向
那么至此 **
div
已经生成,并且textContent
存在值 **,如果此时触发div
的outerHTML
方法,得到<div>hello render</div>
那么此时,我们就 只缺少
class
属性 了,所以接下来将进入props
的处理执行
for
循环,进入hostPatchProp(...)
方法,此时key = class
,props = {class: 'test'}
进入
hostPatchProp(...)
方法,该方法位于
/packages/runtime-dom/src/patchProp.ts
下的patchProp
方法此时
key === class
,所以将触发patchClass
进入
patchClass
,我们可以看到它内部的代码也比较简单,主要分成了三种情况进行处理:js// value 此时的值为 test(即:类名) if (value == null) { el.removeAttribute('class') } else if (isSVG) { el.setAttribute('class', value) } else { el.className = value }
完成
class
设定
当执行完成
hostPatchProp
之后, 如果此时触发div
的outerHTML
方法,得到<div class="test">hello render</div>
现在
dom
已经构建好了,最后就只剩下 挂载 操作了继续执行代码将进入
hostInsert(el, container, anchor)
方法:- 进入
hostInsert
方法 - 该方法位于
packages/runtime-dom/src/modules
中insert
方法 - 内部同样只有一行代码:
parent.insertBefore(child, anchor || null)
- 我们知道 insertBefore 方法可以插入到
dom
到指定区域
- 进入
那么到这里,我们已经成功的把
div
插入到了dom
树中,执行完成hostInsert
方法之后,浏览器会出现对应的div
至此,整个
patchElement
执行完成
执行
container._vnode = vnode
,为 旧节点赋值
由以上代码可知:
- 整个挂载
Element | Text_Children
的过程分为以下步骤- 触发
patch
方法 - 根据
shapeFlag
的值,判定触发processElement
方法 - 在
processElement
中,根据 是否存在旧VNode
来判定触发 挂载 还是 更新 的操作- 挂载中分成了4大步:
- 生成
div
- 处理
textContent
- 处理
propx
- 挂载
dom
- 生成
- 挂载中分成了4大步:
- 通过
container._vnode = vnode
赋值 旧 VNode
- 触发
03:框架实现:构建 renderer 基本架构
在上一小节中,我们明确了 render
渲染 Element | Text_Children
的场景,那么接下来我们就可以根据阅读的源码来实现对应的框架渲染器了。
实现渲染器的过程我们将分为两部分:
- 搭建出
renderer
的基本架构:我们知道对于renderer
而言,它内部分为 core 和 dom 两部分,那么这两部分怎么交互,我们都会在基本架构这里处理 - 处理具体的
processElement
方法逻辑
那么这一小节,我们就先做第一部分:搭建出 renderer
的基本架构:
整个 基本架构 应该分为 三部分 进行处理:
renderer
渲染器本身,我们需要构建出baseCreateRenderer
方法- 我们知道所有和
dom
的操作都是与core
分离的,而和dom
的操作包含了 两部分:Element
操作:比如insert
、createElement
等,这些将被放入到runtime-dom
中props
操作:比如 设置类名,这些也将被放入到runtime-dom
中
renderer 渲染器本身
创建
packages/runtime-core/src/renderer.ts
文件:jsimport { ShapeFlags } from 'packages/shared/src/shapeFlags' import { Fragment } from './vnode' /** * 渲染器配置对象 */ export interface RendererOptions { /** * 为指定 element 的 prop 打补丁 */ patchProp(el: Element, key: string, prevValue: any, nextValue: any): void /** * 为指定的 Element 设置 text */ setElementText(node: Element, text: string): void /** * 插入指定的 el 到 parent 中,anchor 表示插入的位置,即:锚点 */ insert(el, parent: Element, anchor?): void /** * 创建指定的 Element */ createElement(type: string) } /** * 对外暴露的创建渲染器的方法 */ export function createRenderer(options: RendererOptions) { return baseCreateRenderer(options) } /** * 生成 renderer 渲染器 * @param options 兼容性操作配置对象 * @returns */ function baseCreateRenderer(options: RendererOptions): any { /** * 解构 options,获取所有的兼容性方法 */ const { insert: hostInsert, patchProp: hostPatchProp, createElement: hostCreateElement, setElementText: hostSetElementText } = options const patch = (oldVNode, newVNode, container, anchor = null) => { if (oldVNode === newVNode) { return } const { type, shapeFlag } = newVNode switch (type) { case Text: // TODO: Text break case Comment: // TODO: Comment break case Fragment: // TODO: Fragment break default: if (shapeFlag & ShapeFlags.ELEMENT) { // TODO: Element } else if (shapeFlag & ShapeFlags.COMPONENT) { // TODO: 组件 } } } /** * 渲染函数 */ const render = (vnode, container) => { if (vnode == null) { // TODO: 卸载 } else { // 打补丁(包括了挂载和更新) patch(container._vnode || null, vnode, container) } container._vnode = vnode } return { render } }
这样我们就构建出了渲染器框架本身。
封装 Element 操作
创建
packages/runtime-dom/src/nodeOps.ts
模块,对外暴露nodeOps
对象:jsconst doc = document export const nodeOps = { /** * 插入指定元素到指定位置 */ insert: (child, parent, anchor) => { parent.insertBefore(child, anchor || null) }, /** * 创建指定 Element */ createElement: (tag): Element => { const el = doc.createElement(tag) return el }, /** * 为指定的 element 设置 textContent */ setElementText: (el, text) => { el.textContent = text } }
封装 props 操作
创建
packages/runtime-dom/src/patchProp.ts
模块,暴露patchProp
方法:jsimport { isOn } from '@vue/shared' import { patchClass } from './modules/class' /** * 为 prop 进行打补丁操作 */ export const patchProp = (el, key, prevValue, nextValue) => { if (key === 'class') { patchClass(el, nextValue) } else if (key === 'style') { // TODO: style } else if (isOn(key)) { // TODO: 事件 } else { // TODO: 其他属性 } }
创建
packages/runtime-dom/src/modules/class.ts
模块,暴露patchClass
方法:js/** * 为 class 打补丁 */ export function patchClass(el: Element, value: string | null) { if (value == null) { el.removeAttribute('class') } else { el.className = value } }
在
packages/shared/src/index.ts
中,写入isOn
方法:jsconst onRE = /^on[^a-z]/ /** * 是否 on 开头 */ export const isOn = (key: string) => onRE.test(key)
三大块 全部完成,标记着整个
renderer
架构设计完成。
04:框架实现:基于 renderer 完成 ELEMENT 节点挂载
根据源码我们知道 Element
的挂载主要依赖于 processElement
方法,所以我们可以直接构建该方法。
在
packages/runtime-core/src/renderer.ts
中,创建processElement
方法:js/** * Element 的打补丁操作 */ const processElement = (oldVNode, newVNode, container, anchor) => { if (oldVNode == null) { // 挂载操作 mountElement(newVNode, container, anchor) } else { // TODO: 更新操作 } } /** * element 的挂载操作 */ const mountElement = (vnode, container, anchor) => { const { type, props, shapeFlag } = vnode // 创建 element const el = (vnode.el = hostCreateElement(type)) if (shapeFlag & ShapeFlags.TEXT_CHILDREN) { // 设置 文本子节点 hostSetElementText(el, vnode.children as string) } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { // TODO: 设置 Array 子节点 } // 处理 props if (props) { // 遍历 props 对象 for (const key in props) { hostPatchProp(el, key, null, props[key]) } } // 插入 el 到指定的位置 hostInsert(el, container, anchor) } const patch = (oldVNode, newVNode, container, anchor = null) => { if (oldVNode === newVNode) { return } const { type, shapeFlag } = newVNode switch (type) { case Text: // TODO: Text break case Comment: // TODO: Comment break case Fragment: // TODO: Fragment break default: if (shapeFlag & ShapeFlags.ELEMENT) { processElement(oldVNode, newVNode, container, anchor) } else if (shapeFlag & ShapeFlags.COMPONENT) { // TODO: 组件 } } }
根据源码的逻辑,我们在这里主要做了五件事情:
- 区分挂载、更新
- 创建
Element
- 设置
text
- 设置
class
- 插入
DOM
树
05:框架实现:合并渲染架构,得到可用的 render 函数
现在三大模块中,该完成的业务代码我们都已经完成了。但是还存在一个问题,那就是我们应该 如何使用 render
函数呢?
我们知道,在源码中,我们可以直接:
const { render } = Vue
render(vnode, document.querySelector('#app'))
但是我们来看咱们现在的代码,发现是 不可以 直接这样导出并使用的。
那么 vue
是怎么做的呢?
源代码
查看源代码,我们可以发现在 packages/runtime-dom/src/index.ts
中,存在这样的一段代码:
export const render = ((...args) => {
ensureRenderer().render(...args)
}) as RootRenderFunction<Element | ShadowRoot>
而这里导出的 render
就是我们 解构出来的 render
。
在这段代码中涉及到一个方法 ensureRenderer
,我们查看它的实现,可以发现:
function ensureRenderer() {
return (
renderer ||
(renderer = createRenderer<Node, Element | ShadowRoot>(rendererOptions))
)
}
它触发了我们导出的 createRenderer
,并且传递了一个 rendererOptions
参数,查看 rendererOptions
:
const rendererOptions = /*#__PURE__*/ extend({ patchProp }, nodeOps)
可以发现 rendererOptions
就是一个合并了 prop
和 nodeOps
的对象。
这样:我们就成功的把 options
参数传递到了 createRenderer
函数中,得到了 renderer
渲染器,从而最终拿到 render
函数了。
那么明确好了源代码的实现之后,下面我们就尝试自己实现一下。
实现
创建
packages/runtime-dom/src/index.ts
:jsimport { createRenderer } from '@vue/runtime-core' import { extend } from '@vue/shared' import { nodeOps } from './nodeOps' import { patchProp } from './patchProp' const rendererOptions = extend({ patchProp }, nodeOps) let renderer function ensureRenderer() { return renderer || (renderer = createRenderer(rendererOptions)) } export const render = (...args) => { ensureRenderer().render(...args) }
在
packages/runtime-core/src/index.ts
中导出createRenderer
在
packages/vue/src/index.ts
中导出render
在测试实例
packages/vue/examples/runtime/render-element.html
:
<script>
const { h, render } = Vue
const vnode = h('div', {
class: 'test'
}, 'hello render')
console.log(vnode);
render(vnode, document.querySelector('#app'))
</script>
vnode
可以正常渲染。
06:源码阅读:渲染更新,ELEMENT 节点的更新操作
我们知道对于 render
而言,除了有 挂载 操作之外,还存在 更新 操作。
所谓更新操作指的是:生成一个新的虚拟 DOM
树,运行时渲染器遍历这棵新树,将它与旧树进行比较,然后将必要的更新应用到真实 DOM 上去。
所以我们可以创建如下测试实例 packages/vue/examples/imooc/runtime/render-element-update.html
:
<script>
const { h, render } = Vue
const vnode = h('div', {
class: 'test'
}, 'hello render')
// 挂载
render(vnode, document.querySelector('#app'))
// 延迟两秒,生成新的 vnode,进行更新操作
setTimeout(() => {
const vnode2 = h('div', {
class: 'active'
}, 'update')
render(vnode2, document.querySelector('#app'))
}, 2000);
</script>
两秒之后,我们可以发现 DOM
发生了更新。
那么在这样的一个更新操作中,render
又是如何操作的呢?
我们知道每次的 render
渲染 ELEMENT
,其实都会触发 processElement
,所以我们可以直接在 processElement
中增加断点,进入 debugger
:
第一次触发
processElement
为 挂载 操作,可以直接 跳过第二次触发
processElement
为 更新操作:此时
n1(旧的)
存在值为n2(新的)
存在值为:代码执行
patchElement
,即:更新操作:执行
const el = (n2.el = n1.el!)
。使 新旧vnode
指向 同一个el
元素执行
patchChildren(...)
方法,表示 为子节点打补丁:- 进入
patchChildren
方法 - 执行
c1 = xx
、c2 = xx
,为c1、c2
赋值,此时c1
为 旧节点的 children,c2
为 新节点的 children - 执行
if (shapeFlag & ShapeFlags.TEXT_CHILDREN)
,我们知道此时 子 children 的shapeFlag = 9
,是 可以& ShapeFlags.TEXT_CHILDREN
- 而
prevShapeFlag = 9
,是 不可以& ShapeFlags.ARRAY_CHILDREN
的 - 所以会触发
hostSetElementText
。我们知道hostSetElementText
其实是一个 设置text
的方法 - 那么此时 text 内容更新完成,浏览器展示的 text 会发生变化
- 进入
代码继续执行
执行
patchProps(....)
方法,表示 为 props 打补丁进入
patchProps
方法,此时新旧props
为:查看代码可以发现代码执行了两次
for
循环操作:第一次循环执行
for in newProps
,执行hostPatchProp
方法设置新的props
第二次循环执行
for in oldProps
,执行hostPatchProp
,配合!(key in newProps)
判断,删除 没有被指定的旧属性 ,比如:js// 原属性: { class: 'test', id: 'test-id' } // 新属性: { class: 'active' }
则 删除
id
至此
props
更新完成
至此,更替更新完成
由以上代码可知:
- 无论是 挂载 还是 更新 都会触发
processElement
方法,状态根据oldValue
进行判定 Element
的更新操作有可能 会在同一个el
中完成。(注意: 仅限元素没有发生变化时,如果新旧元素不同,那么是另外的情况,后面会专门讲解 !!)- 更新操作分为:
children
更新props
更新
07:框架实现:渲染更新,ELEMENT 节点的更新实现
根据以上逻辑,我们可以直接为 processElement
方法,新增对应的 else
逻辑:
在
packages/runtime-core/src/renderer.ts
中,为processElement
增加新的判断:js/** * Element 的打补丁操作 */ const processElement = (oldVNode, newVNode, container, anchor) => { if (oldVNode == null) { // 挂载操作 mountElement(newVNode, container, anchor) } else { // 更新操作 patchElement(oldVNode, newVNode) } }
创建
patchElement
方法:js/** * element 的更新操作 */ const patchElement = (oldVNode, newVNode) => { // 获取指定的 el const el = (newVNode.el = oldVNode.el!) // 新旧 props const oldProps = oldVNode.props || EMPTY_OBJ const newProps = newVNode.props || EMPTY_OBJ // 更新子节点 patchChildren(oldVNode, newVNode, el, null) // 更新 props patchProps(el, newVNode, oldProps, newProps) }
创建
patchChildren
方法:js/** * 为子节点打补丁 */ const patchChildren = (oldVNode, newVNode, container, anchor) => { // 旧节点的 children const c1 = oldVNode && oldVNode.children // 旧节点的 prevShapeFlag const prevShapeFlag = oldVNode ? oldVNode.shapeFlag : 0 // 新节点的 children const c2 = newVNode.children // 新节点的 shapeFlag const { shapeFlag } = newVNode // 新子节点为 TEXT_CHILDREN if (shapeFlag & ShapeFlags.TEXT_CHILDREN) { // 旧子节点为 ARRAY_CHILDREN if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) { // TODO: 卸载旧子节点 } // 新旧子节点不同 if (c2 !== c1) { // 挂载新子节点的文本 hostSetElementText(container, c2 as string) } } else { // 旧子节点为 ARRAY_CHILDREN if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) { // 新子节点也为 ARRAY_CHILDREN if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { // TODO: 这里要进行 diff 运算 } // 新子节点不为 ARRAY_CHILDREN,则直接卸载旧子节点 else { // TODO: 卸载 } } else { // 旧子节点为 TEXT_CHILDREN if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) { // 删除旧的文本 hostSetElementText(container, '') } // 新子节点为 ARRAY_CHILDREN if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { // TODO: 单独挂载新子节点操作 } } } }
创建
patchProps
方法:js/** * 为 props 打补丁 */ const patchProps = (el: Element, vnode, oldProps, newProps) => { // 新旧 props 不相同时才进行处理 if (oldProps !== newProps) { // 遍历新的 props,依次触发 hostPatchProp ,赋值新属性 for (const key in newProps) { const next = newProps[key] const prev = oldProps[key] if (next !== prev) { hostPatchProp(el, key, prev, next) } } // 存在旧的 props 时 if (oldProps !== EMPTY_OBJ) { // 遍历旧的 props,依次触发 hostPatchProp ,删除不存在于新props 中的旧属性 for (const key in oldProps) { if (!(key in newProps)) { hostPatchProp(el, key, oldProps[key], null) } } } } }
至此,更新操作完成。
创建新的测试实例
packages/vue/examples/runtime/render-element-update.html
:js<script> const { h, render } = Vue const vnode = h('div', { class: 'test' }, 'hello render') render(vnode, document.querySelector('#app')) // 延迟两秒,生成新的 vnode,进行更新操作 setTimeout(() => { const vnode2 = h('div', { class: 'active' }, 'update') render(vnode2, document.querySelector('#app')) }, 2000); </script>
测试更新成功。
08:源码阅读:新旧节点不同元素时,ELEMENT 节点的更新操作
在上一小节中,我们完成了 Element
的更新操作,但是我们之前的更新操作是针对 相同 元素的,那么在 不同 元素下,ELEMENT
的更新操作会产生什么样的变化呢?
创建对应的测试实例 packages/vue/examples/imooc/runtime/render-element-update-2.html
<script>
const { h, render } = Vue
const vnode = h('div', {
class: 'test'
}, 'hello render')
// 挂载
render(vnode, document.querySelector('#app'))
// 延迟两秒,生成新的 vnode,进行更新操作
setTimeout(() => {
const vnode2 = h('h1', {
class: 'active'
}, 'update')
render(vnode2, document.querySelector('#app'))
}, 2000);
</script>
然后我们跟踪代码的逻辑,注意: 这次我们从 render
函数开始 debugger
:
等待第二次进入
render
vnode
存在,执行patch
方法进入
patch
方法此时所有参数分别为(重点关注
n1 、n2
):代码执行
if (n1 && !isSameVNodeType(n1, n2))
:此时
n1
肯定存在那么
isSameVNodeType
方法又是干什么的呢?我们来看一下进入
isSameVNodeType
方法可以发现该方法非常简单,只有一句话
jsreturn n1.type === n2.type && n1.key === n2.key
由以上代码可知,
isSameVNodeType
的作用主要就是判断n1
和n2
是否为:- 相同类型的元素。比如都是
div
- 同一个元素。
key
相同 表示为同一个元素
- 相同类型的元素。比如都是
那么此时则执行
unmount
方法,该方法从方法名看是 卸载 的方法- 进入
unmount
方法 - 在
unmount
方法中,虽然代码很多,但是大多数代码都没有执行 - 执行到
remove(vnode)
,表示删除vnode
- 进入
remove
方法 - 同样大多数大妈没有执行
- 直接到
performRemove()
- 进入
performRemove
- 执行
hostRemove(el!)
- 进入
hostRemove
,触发的是nodeOps
中的remove
方法- 代码为
parent.removeChild(child)
- 代码为
- 进入
- 进入
- 进入
- 进入
至此
el
被删除然后将
n1 = null
此时,进入
switch
,触发processElement
因为
n1 === null
,所以会触发mountElement
挂载新节点 操作
由以上代码可知:
- 当节点元素不同时,更新操作执行的其实是:先删除、后挂载 的逻辑
- 删除元素的代码从
unmount
开始,虽然逻辑很多,但是最终其实是触发了nodeOps
下的remove
方法,通过parent.removeChild(child)
完成的删除操作。
09:框架实现:处理新旧节点不同元素时,ELEMENT 节点的更新操作
根据上一小节我们知道,当新旧节点不同元素时,执行的是一个 先删除、后挂载 。依据此思路,我们可以直接进行对应的实现。
在
packages/runtime-core/src/renderer.ts
的patch
方法中增加type
判断:js/** * 判断是否为相同类型节点 */ if (oldVNode && !isSameVNodeType(oldVNode, newVNode)) { unmount(oldVNode) oldVNode = null }
在
packages/runtime-core/src/vnode.ts
中,创建isSameVNodeType
方法:js/** * VNode */ export interface VNode { key: any ... } /** * 根据 key || type 判断是否为相同类型节点 */ export function isSameVNodeType(n1: VNode, n2: VNode): boolean { return n1.type === n2.type && n1.key === n2.key }
在
packages/runtime-core/src/renderer.ts
实现unmount
方法:jsexport interface RendererOptions { /** * 卸载指定dom */ remove(el): void } /** * 解构 options,获取所有的兼容性方法 */ const { ... remove: hostRemove } = options const unmount = (vnode) => { hostRemove(vnode.el!) }
在
packages/runtime-dom/src/nodeOps.ts
中,实现remove
方法:js/** * 删除指定元素 */ remove: (child) => { const parent = child.parentNode if (parent) { parent.removeChild(child) } }
此时代码完成。
创建对应测试实例 packages/vue/examples/runtime/render-element-update-2.html
:
<script>
const { h, render } = Vue
const vnode = h('div', {
class: 'test'
}, 'hello render')
render(vnode, document.querySelector('#app'))
// 延迟两秒,生成新的 vnode,进行更新操作
setTimeout(() => {
const vnode2 = h('h1', {
class: 'active'
}, 'update')
render(vnode2, document.querySelector('#app'))
}, 2000);
</script>
测试成功
10:框架实现:删除元素,ELEMENT 节点的卸载操作
那么此时我们已经有了 unmount
函数,我们知道触发 unmount
函数,即可卸载元素。
那么接下来我们就可以基于这样的函数来去实现 卸载 操作了。
创建如下测试实例 packages/vue/examples/imooc/runtime/render-element-remove.html
:
<script>
const { h, render } = Vue
const vnode = h('div', {
class: 'test'
}, 'hello render')
// 挂载
render(vnode, document.querySelector('#app'))
// 延迟两秒,执行卸载操作
setTimeout(() => {
render(null, document.querySelector('#app'))
}, 2000);
</script>
当我们触发 render(null, document.querySelector('#app'))
时,vue
会删除之前渲染的 vnode
。即为:卸载操作。
这里查看对应源码逻辑,也非常简单,查看 packages/runtime-core/src/renderer.ts
中 render
函数:
const render: RootRenderFunction = (vnode, container, isSVG) => {
// 不存在新的 vnode 时
if (vnode == null) {
// 但是存在旧的 vnode
if (container._vnode) {
// 则直接执行卸载操作
unmount(container._vnode, null, null, true)
}
} else {
...
}
...
}
这块代码比较简单,我们直接实现即可:
在 packages/runtime-core/src/renderer.ts
中为 render
函数补充卸载逻辑:
/**
* 渲染函数
*/
const render = (vnode, container) => {
if (vnode == null) {
// 卸载
if (container._vnode) {
unmount(container._vnode)
}
} else {
// 打补丁(包括了挂载和更新)
patch(container._vnode || null, vnode, container)
}
container._vnode = vnode
}
创建对应测试实例,卸载成功。
11:源码阅读:class 属性和其他属性的区分挂载
查看我们的源代码 packages/runtime-dom/src/patchProp.ts
,可以发现,目前针对我们的代码而言,只能挂载 class 属性,而不能挂载其他属性。
那么针对于 其他属性 是如何进行挂载的呢?我们通过一个测试实例来看一下,创建 packages/vue/examples/imooc/runtime/render-element-props.html
测试实例:
<script>
const { h, render } = Vue
const vnode = h('textarea', {
class: 'test-class',
value: 'textarea value',
type: 'text'
})
// 挂载
render(vnode, document.querySelector('#app'))
</script>
在这个测试实例中,我们为 textarea
挂载了三个属性 class、value
和 type
,根据之前的源码阅读我们知道,属性的挂载是在 packages/runtime-dom/src/patchProp.ts
中的 patchProp
方法处进行的。
所以我们可以直接在这里进行 debugger
,因为我们设置了三个属性,所以会 三次,我们一个一个来看:
- 第一次进入,此时的
key = class
- 执行:
if (key === 'class')
:- 触发
patchClass
方法 - 通过
el.className = value
设置class
- 触发
- 这一块我们是比较熟悉的
- 执行:
- 至此
class
设置完成,通过el.className
- 第二次进入,此时
key = type
:- 执行
else if
,将触发shouldSetAsProp(el, key, nextValue, isSVG)
方法- 进入
shouldSetAsProp
方法 - 执行
if (key === 'type' && el.tagName === 'TEXTAREA')
,当前 满足条件,则直接 跳出
- 进入
- 执行
else
:- 触发
patchAttr(el, key, nextValue, isSVG, parentComponent)
方法- 进入
patchAttr
方法 - 最终执行
el.setAttribute(key, isBoolean ? '' : value)
设置type
- 进入
- 触发
- 执行
- 至此
type
设置完成,通过el.setAttribute
- 第三次进入,此时
key = value
- 执行
else if
,将触发shouldSetAsProp(el, key, nextValue, isSVG)
方法- 进入
shouldSetAsProp
方法 - 执行
return key in el
表达式,因为el = textarea
,key = value
- 所以
value in textarea DOM
,返回为true
- 进入
- 执行
patchDOMProp
方法:- 进入
patchDOMProp
方法 - 执行
el[key] = value
设置value
- 进入
- 执行
- 至此
value
设置完成,通过el[key] = value
至此三个属性全部设置完成。
由以上代码可知:
- 针对于三个属性,
vue
通过了 三种不同的方式 来进行了设置:class
属性:通过el.className
设定textarea 的 type
属性:通过el.setAttribute
设定textarea 的 value
属性:通过el[key] = value
设定
那么很多同学看到这里,就会非常疑惑了,为什么 要通过三种不同的形式挂载属性呢?
此时: 我们的测试案例已经成功运行到浏览器中了,让我们打开浏览器的 控制台,来测试如下代码:
// 初始状态:<textarea class="test-class" type="text"></textarea>
// 获取 dom 实例
const el = document.querySelector('textarea')
// 1:修改 class
el.setAttribute('class', 'm-class') // 成功
el['class'] = 'm-class' // 失败
el.className = 'm-class' // 成功
// 2:修改 type
el.setAttribute('type', 'input') // 成功
el['type'] = 'input' // 失败
// 3:修改 value
el.setAttribute('value', '你好 世界') // 失败
el['value'] = '你好 世界' // 成功
由以上代码可知,我们在 针对不同属性,使用不同的 API
时,得到的结果是 不同 的。
那么为什么会这样呢?
想要知道这个原因,那么我们就需要来看下一小节:HTML Attributes 和 DOM Properties
。
12:深入属性挂载:HTML Attributes 和 DOM Properties
当我们为一个 DOM
设置对应属性的时候,其实分成了两种情况:
HTML Attributes
DOM Properties
那么想要搞明白,上一小节中所出现的问题的原因,我们就需要搞明白上面的两种情况指的是什么意思。
HTML Attributes
HTML Attributes,所代表的的就是 定义在 HTML
标签上的属性。比如我们以上一小节渲染的 DOM
为例:
<textarea class="test-class" type="text"></textarea>
这里 HTML Attributes
指的就是 class="test-class"
和 type="text"
DOM Properties
DOM Properties ,所代表的就是 在 DOM 对象上的属性,比如我们上一小节的 ta
:
const el = document.querySelector('textarea')
我们就可以通过 .
的形式获取对应的属性:
el.type // 'textarea'
el.className // 'test-class'
el.value // 'textarea value'
对比
然后我们对比 HTML Attributes 和 DOM Properties
可以发现双方对于 同样属性的描述是不同 的。而这个也是 HTML Attributes 和 DOM Properties
之间的关键。
那么明确好了这个之后,我们再来看对应方法。根据上一小节的代码,我们可以知道,设置属性,我们一共使用了两个方法:
- Element.setAttribute():该方法可以 设置指定元素上的某个属性值,
- dom.xx :相当于 直接修改指定对象的属性
但是这两个方式却有一个很尴尬的问题,那就是 属性名不同。
比如:
- 针对于
class
获取:HTML Attributes
:ta.getAttribute('class')
DOM Properties
:ta.className
- 针对于
textarea 的 type
获取:HTML Attributes
:ta.getAttribute('type')
DOM Properties
:ta.type
无法获取
- 针对于
taxtarea 的 value
获取:HTML Attributes
:ta.getAttribute('value')
无法获取DOM Properties
:ta.value
所以为了解决这种问题,咱们就必须要能够 针对不同属性,通过不同方式 进行属性指定。所以:vue
才会通过一系列的判断进行处理
针对于 class
除了以上内容我们需要知道之外,还有另外一个我们需要了解的知识就是:既然 class
可以使用 setAttribute
设置,也可以通过 className
设置,那么这样就存在一个问题:我们应该通过哪种方式设定 class
呢?
我们知道对于 vue
的代码而言,在 packages/runtime-dom/src/modules/class.ts
中的 patchClass
中:
if (value == null) {
el.removeAttribute('class')
} else if (isSVG) {
el.setAttribute('class', value)
} else {
el.className = value
}
根据以上代码可知:只要 dom 不是 svg
,则通过 className
设置 class
。那么为什么要这么做呢?
我们创建以下测试案例,对两者之间的性能进行一下对比:
<body>
<div id="app"></div>
<div id="app2"></div>
</body>
<script>
const divEle1 = document.querySelector('#app')
const divEle2 = document.querySelector('#app2')
console.time('classname')
for (let i = 0; i < 10000; i++) {
divEle1.className = 'test-2'
}
console.timeEnd('classname')
console.time('attr')
for (let i = 0; i < 10000; i++) {
divEle2.setAttribute('class', 'test')
}
console.timeEnd('attr')
</script>
最后打印的结果为:
classname: 1.7470703125 ms
attr: 3.389892578125 ms
由打印结果可知 className
的性能 大于 setAttribute
的性能
所以:针对于 class
, 我们应该使用 className
来进行指定。
总结
本小节,我们主要分析了:两大块问题。
HTML Attributes 和 DOM Properties
:我们知道想要成功的进行各种属性的设置,那么需要 针对不同属性,通过不同方式 完成className
和setAttribute('class', '')
:因为className
的性能更高,所以我们应该尽量使用className
进行指定。
13:框架实现:区分处理 ELEMENT 节点的各种属性挂载
根据我们以上两节的描述,那么下面我们就可以着手实现对应的逻辑了。
在
packages/runtime-dom/src/patchProp.ts
中,增加新的判断条件:tsexport const patchProp = (el, key, prevValue, nextValue) => { ... else if (shouldSetAsProp(el, key)) { // 通过 DOM Properties 指定 patchDOMProp(el, key, nextValue) } else { // 其他属性 patchAttr(el, key, nextValue) } }
在
packages/runtime-dom/src/patchProp.ts
中,创建shouldSetAsProp
方法:ts/** * 判断指定元素的指定属性是否可以通过 DOM Properties 指定 */ function shouldSetAsProp(el: Element, key: string) { // #1787, #2840 表单元素的表单属性是只读的,必须设置为属性 attribute if (key === 'form') { return false } // #1526 <input list> 必须设置为属性 attribute if (key === 'list' && el.tagName === 'INPUT') { return false } // #2766 <textarea type> 必须设置为属性 attribute if (key === 'type' && el.tagName === 'TEXTAREA') { return false } return key in el }
在
packages/runtime-dom/src/modules/props.ts
中,增加patchDOMProp
方法:js/** * 通过 DOM Properties 指定属性 */ export function patchDOMProp(el: any, key: string, value: any) { try { el[key] = value } catch (e: any) {} }
在
packages/runtime-dom/src/modules/attrs.ts
中,增加patchAttr
方法:js/** * 通过 setAttribute 设置属性 */ export function patchAttr(el: Element, key: string, value: any) { if (value == null) { el.removeAttribute(key) } else { el.setAttribute(key, value) } }
至此,代码完成。
创建测试实例 packages/vue/examples/runtime/render-element-props.html
:
<script>
const { h, render } = Vue
const vnode = h('textarea', {
class: 'test-class',
value: 'textarea value',
type: 'text'
})
// 挂载
render(vnode, document.querySelector('#app'))
</script>
测试渲染成功。
14:源码阅读:ELEMENT 节点下, style 属性的挂载和更新
根据我们在 packages/runtime-dom/src/patchProp.ts
下 patchProp
中的方法可知,目前我们还缺少 style
和 事件
的挂载。
那么下面我们就来看下 style
属性的挂载的更新操作。
我们可以创建如下测试实例 packages/vue/examples/imooc/runtime/render-element-style.html
:
<script>
const { h, render } = Vue
const vnode = h('div', {
style: {
color: 'red'
},
}, '你好,世界')
// 挂载
render(vnode, document.querySelector('#app'))
setTimeout(() => {
const vnode2 = h('div', {
style: {
fontSize: '32px'
},
}, '你好,世界')
// 挂载
render(vnode2, document.querySelector('#app'))
}, 2000);
</script>
在 patchProp
方法中,跟踪源码实现:
第一次进入,执行 挂载 操作
进入
patchProp
方法,此时各参数的值为:代码进入
patchStyle
方法,开始进行打补丁操作执行
const style = (el as HTMLElement).style
,此时style
为当前element
的style
执行
const isCssString = isString(next)
方法:此时
next
的值为:{color: 'red'}
,不是一个string
所以
isCssString
的值为false
执行
if (next && !isCssString)
,进入if
执行
for
循环,拿到所有的key
执行
setStyle(style, key, next[key])
方法:进入
setStyle
方法,此时各参数的值为:val
不是Array
,直接进入else
执行
const prefixed = autoPrefix(style, name)
:- 进入
autoPrefix
方法 - 执行
let name = camelize(rawName)
方法:camelize
方法的内容比较简单,就是把key
变为 驼峰格式 字符串- 因为
rawName
此时为color
,所以 得到的name
依然为color
- 最后执行
return (prefixCache[rawName] = name)
把当前name
进行 缓存 ,并返回name
的值
- 进入
得到
prefixed = color
最后执行
style[prefixed as any] = val
,直接为style
对象进行赋值操作
至此
style
属性 挂载完成但是光指定完成还不够,我们还需要 卸载旧的
style
,以完成 更新
延迟两秒之后…
第二次进入,执行 更新 操作
忽略掉相同的挂载逻辑
代码执行到
patchStyle
方法下,if (prev && !isString(prev)) {......}
判断- 此时存在两个变量:
prev
:上一次的样式{color: 'red'}
next
:这一次的样式{fontSize: '32px'}
- 此时存在两个变量:
满足
if
判断条件,进入if
执行
for (const key in prev)
,遍历旧样式执行
if (next[key] == null)
,旧样式不存在于样式中则执行
setStyle(style, key, '')
方法再次进入
setStyle
方法,此时的参数为:注意: 此时
val
为''
再次执行
style[prefixed as any] = val
,即:style[color] = ''
完成 清理旧样式 操作
至此 更新 操作完成
由以上代码可知:
- 整个
style
赋值的逻辑还是比较简单的 - 在 不考虑边缘情况 的前提下,
vue
只是对style
进行了 缓存 和 赋值 两个操作 - 缓存是通过
prefixCache = {}
进行 - 赋值则是直接通过
style[xxx] = val
进行
15:框架实现:ELEMENT 节点下, style 属性的挂载和更新
根据上一小节的源码,我们可以直接实现对应的 patchStyle
函数。
在
packages/runtime-dom/src/patchProp.ts
中,处理style
情况:js/** * 为 prop 进行打补丁操作 */ export const patchProp = (el, key, prevValue, nextValue) => { ...... else if (key === 'style') { // style patchStyle(el, prevValue, nextValue) } ...... }
在
packages/runtime-dom/src/modules/style.ts
中,新建patchStyle
方法:js/** * 为 style 属性进行打补丁 */ export function patchStyle(el: Element, prev, next) { // 获取 style 对象 const style = (el as HTMLElement).style // 判断新的样式是否为纯字符串 const isCssString = isString(next) if (next && !isCssString) { // 赋值新样式 for (const key in next) { setStyle(style, key, next[key]) } // 清理旧样式 if (prev && !isString(prev)) { for (const key in prev) { if (next[key] == null) { setStyle(style, key, '') } } } } } /** * 赋值样式 */ function setStyle( style: CSSStyleDeclaration, name: string, val: string | string[] ) { style[name] = val }
代码完成
创建测试实例 packages/vue/examples/runtime/render-element-style.html
:
<script>
const { h, render } = Vue
const vnode = h('div', {
style: {
color: 'red'
},
}, '你好,世界')
// 挂载
render(vnode, document.querySelector('#app'))
setTimeout(() => {
const vnode2 = h('div', {
style: {
fontSize: '32px'
},
}, '你好,世界')
// 挂载
render(vnode2, document.querySelector('#app'))
}, 2000);
</script>
style
挂载和更新处理完成
16:源码阅读:ELEMENT 节点下,事件的挂载和更新
在处理完成 style
的挂载和更新之后,接下来我们来看 event
事件 的挂载和更新操作。
和之前一样,我们首先先创建对应的测试实例 packages/vue/examples/imooc/runtime/render-element-event.html
:
<script>
const { h, render } = Vue
const vnode = h('button', {
// 注意:不可以使用 onclick。因为 onclick 无法满足 /^on[^a-z]/ 的判断条件,这会导致 event 通过 :el[key] = value 的方式绑定(虽然这样也可以绑定 event),从而无法进入 patchEvent。在项目中,当我们通过 @click 绑定属性时,会得到 onClick 选项
onClick() {
alert('点击')
},
}, '点击')
// 挂载
render(vnode, document.querySelector('#app'))
setTimeout(() => {
const vnode2 = h('button', {
onDblclick() {
alert('双击')
},
}, '双击')
// 挂载
render(vnode2, document.querySelector('#app'))
}, 2000);
</script>
**注意:**不可以使用
onclick
。因为onclick
无法满足/^on[^a-z]/
的判断条件,这会导致event
通过 :el[key] = value
的方式绑定(虽然这样也可以绑定event
),从而无法进入patchEvent
。在项目中,当我们通过 @click 绑定属性时,会得到
onClick
选项
在 packages/runtime-dom/src/patchProp.ts
中进入 debugger
:
第一次进入
debugger
,执行 挂载 操作:此时各参数为:
执行
if (isOn(key))
判定:进入
isOn
方法:jsconst onRE = /^on[^a-z]/ export const isOn = (key: string) => onRE.test(key)
整体的代码比较简单,就是筛选出:**
on
开头,不接a-z
** 的字符串。即:
/^on[^a-z]/.test('onClick')
当前 满足条件,触发
patchEvent(el, key, prevValue, nextValue, parentComponent)
方法:进入
patchEvent
方法,此时各参数为:执行
const invokers = el._vei || (el._vei = {})
:- 这里涉及到了一个
_vei
对象 vue
对其进行了注释:vei = vue event invokers
,即:VUE事件调用者- 那么这个事件调用者是什么意思呢?
- 不要着急,我们继续往下看,大家现在需要记住,我们当前得到了一个
invokers
对象:invokers = {}
- 这里涉及到了一个
执行
const existingInvoker = invokers[rawName]
:- 因为当前
invokers
为{}
- 所以得到的
existingInvoker = undefined
- 因为当前
执行
else
判断条件:- 执行
const [name, options] = parseName(rawName)
:- 进入
parseName
- 该函数的作用就是拆解除 事件名
name
和 addEventListener 的options
- 我们这了可以直接忽略掉
options
- 得到
name
即可
- 进入
- 此时
name = click
- 执行
if (nextValue)
,当前nextValue: ƒ onClick()
函数- 进入
if
判断 - 执行
const invoker = (invokers[rawName] = createInvoker(nextValue, instance))
- 进入
createInvoker
方法: - 这里面的代码做了两件事情:
invoker.value = initialValue
:- 这个是需要我们 重点关注 的
- 当前的
initialValue
即为nextValue
- 即:
invoker
对象的value
属性即为onClick
函数
- 以
invoker.attached = getNow()
为主的 时间 处理- 这是一个边缘情况
- 主要应用于
onClick
为 数组[] 时,多个回调方法的触发时机问题 - 我们这里 无需关注
- 进入
- 得到
invoker
函数,并为invokers[rawName]
进行了 缓存 - 执行
addEventListener(el, name, invoker, options)
- 该方法的代码比较简单,就是执行了
el.addEventListener(event, handler, options)
- 对比参数,即执行了:
el.addEventListener(name, invoker, options)
- 该方法的代码比较简单,就是执行了
- 进入
- 执行
支持事件 挂载 完成
等待两秒之后,第二次进行,执行 更新 操作:
进入
patchEvent
方法因为在 挂载 时,我们已经进行了
invoker
的 缓存,所以再次进入时:invokers
不在为null
,而是一个对象,里面包含了上一次的onClick
方法existingInvoker
依然为null
existingInvoker
依然为null
进入
else
:- 生成
invoker
函数 - 通过
addEventListener
进行挂载
- 生成
那么此时 双击 事件被 挂载完成。
但是大家到此时可能会非常奇怪,目前为止我们分别完成了 单击事件的挂载 和 双击事件的挂载 ,但是有一个问题,那就是 旧事件(单击事件)此时依然存在,并没有被 卸载。这是为什么呢?
我们知道 属性的挂载 其实是在
packages/runtime-core/src/renderer.ts
中的patchProps
中进行的,观察内部方法,我们可以发现 内部进行了两次for
循环:- 第一次是新增新属性
- 第二次是卸载旧属性
所以说,此时,我们还会 第三次 进入
patchProp
方法,本次的目的是:卸载onClick
忽略相同逻辑,同样进入
patchEvent
方法,此时的参数为:此时
invokers
的值为:此时
existingInvoker
将存在值,值为onClick
的回调方法再次进入
else
注意: 此时因为
nextValue
为null
,而existingInvoker
存在所以会走 :
removeEventListener(el, name, existingInvoker, options) invokers[rawName] = undefined
至此 卸载旧事件 完成
由以上代码可知:
- 我们一共三次进入
patchEvent
方法- 第一次进入为 挂载
onClick
行为 - 第二次进入为 挂载 onDblclick 行为
- 第三次进入为 卸载
onClick
行为
- 第一次进入为 挂载
- 挂载事件,通过
el.addEventListener
完成 - 卸载事件,通过
el.removeEventListener
完成 - 除此之外,还有一个
_vei
即invokers
对象 和invoker
函数,我们说两个东西需要重点关注,那么这两个对象有什么意义呢?
下一小节,我们将详细说明 invokers
对象 和 invoker
函数 在当前事件处理中存在的作用。
17:深入事件更新:vue event invokers
那么这一小节我们来看下 **invokers
对象 和 invoker
函数 (即:vue event invokers。简称:vei
)**在事件处理中存在的作用。
在上一小节中,我们进行了两次挂载和一次卸载操作,本质上并没有对事件进行 更新 操作。而 vei
的作用是在更新中,才可以体现的。
我们知道,vue
对事件的处理是通过:
el.addEventListener
el.removeEventListener
来完成的。
那么现在我们来思考一下:
如果一个
button
最初的click
事件,点击之后打印hello
两秒之后,更新打印
你好
那么这样的一个更新操作,如果让我们通过 el.addEventListener
和 el.removeEventListener
来实现,那么我们会怎么做?
可能有同学说,这还不简单吗?很轻松的写出如下代码:
<script>
const btnEle = document.querySelector('button')
// 设置初始点击行为
const invoker = () => {
alert('hello')
}
btnEle.addEventListener('click', invoker)
// 两秒之后,更新点击事件
setTimeout(() => {
// 先删除
btnEle.removeEventListener('click', invoker)
// 再添加
btnEle.addEventListener('click', () => {
alert('你好')
})
}, 2000);
</script>
但是我们知道如果频繁的删除、新增事件是非常消耗性能的,那么有没有更好的方案呢?
肯定是有的,这个方案就是 vei
,我们来看下面这段代码:
<script>
const btnEle = document.querySelector('button')
// 设置初始点击行为
const invoker = () => {
invoker.value()
}
// 为 invoker 指定了 value 属性,对应的值是《事件点击行为》
invoker.value = () => {
alert('hello')
}
// 把 invoker 作为回调函数,invoker 内部通过触发 value,来触发真正的点击行为
btnEle.addEventListener('click', invoker)
// 两秒之后更新
setTimeout(() => {
// 因为真正的事件点击行为其实是 invoker.value,所以我们想要更新事件,就不需要再次触发 addEventListener API 了,只需要修改 invoker.value 的值即可。
invoker.value = () => {
alert('你好')
}
}, 2000);
</script>
vue
就是通过这样一种方式,来完成的事件更新操作。具体更新代码比较简单,可以看 packages/runtime-dom/src/modules/events.ts
第 77
行:
if (nextValue && existingInvoker) {
// patch
existingInvoker.value = nextValue
}
至于 invokers
则充当了一个事件缓存器,把所有的事件:以事件名为 key
,以事件行为为 value
。保存到 el._vei
中。
那么这样我们搞明白了 vei
它在事件处理中所存在的意义。
明白了这个之后,下面我们就可以实现 event
事件的挂载和更新操作了。
18:框架实现:ELEMENT 节点下,事件的挂载和更新
本小节我们就来实现事件的更新和挂载操作。
在
packages/runtime-dom/src/patchProp.ts
中,增加patchEvent
事件处理js} else if (isOn(key)) { // 事件 patchEvent(el, key, prevValue, nextValue) }
在
packages/runtime-dom/src/modules/events.ts
中,增加patchEvent
、parseName
、createInvoker
方法:js/** * 为 event 事件进行打补丁 */ export function patchEvent( el: Element & { _vei?: object }, rawName: string, prevValue, nextValue ) { // vei = vue event invokers const invokers = el._vei || (el._vei = {}) // 是否存在缓存事件 const existingInvoker = invokers[rawName] // 如果当前事件存在缓存,并且存在新的事件行为,则判定为更新操作。直接更新 invoker 的 value 即可 if (nextValue && existingInvoker) { // patch existingInvoker.value = nextValue } else { // 获取用于 addEventListener || removeEventListener 的事件名 const name = parseName(rawName) if (nextValue) { // add const invoker = (invokers[rawName] = createInvoker(nextValue)) el.addEventListener(name, invoker) } else if (existingInvoker) { // remove el.removeEventListener(name, existingInvoker) // 删除缓存 invokers[rawName] = undefined } } } /** * 直接返回剔除 on,其余转化为小写的事件名即可 */ function parseName(name: string) { return name.slice(2).toLowerCase() } /** * 生成 invoker 函数 */ function createInvoker(initialValue) { const invoker = (e: Event) => { invoker.value && invoker.value() } // value 为真实的事件行为 invoker.value = initialValue return invoker }
支持事件的打补丁处理完成。
可以创建如下测试实例
packages/vue/examples/runtime/render-element-event.html
:js<script> const { h, render } = Vue const vnode = h('button', { onClick() { alert('点击') }, }, '点击') // 挂载 render(vnode, document.querySelector('#app')) setTimeout(() => { const vnode2 = h('button', { // onClick() { // alert('click') // }, onDblclick() { alert('双击') }, }, '双击') // 挂载 render(vnode2, document.querySelector('#app')) }, 2000); </script>
测试成功
19:局部总结:ELEMENT 节点的挂载、更新、props 打补丁等行为总结
回顾一下本章节到目前为止的所有行为,我们已经完成了针对于 ELEMENT
的:
- 挂载
- 更新
- 卸载
patch props
打补丁class
style
event
attr
等行为的处理。
针对于 挂载、更新、卸载而言,我们主要使用了 packages/runtime-dom/src/nodeOps.ts
中的浏览器兼容方法进行的实现,比如:
doc.createElement
parent.removeChild
等等。
而对于 patch props
的操作而言,因为 HTML Attributes 和 DOM Properties
的不同问题,所以我们需要针对不同的 props
进行分开的处理。
而最后的 event
,本身并不复杂,但是 vei
的更新思路也是非常值得我们学习的一种事件更新方案。
那么到这里针对于 ELEMENT
的处理,我们就暂时告一段落了。下面我们就来看 Text
、Comment
以及 Component
的渲染行为。
20:源码阅读:renderer 渲染器下,Text 节点的挂载、更新行为
这一小节,我们来看 Text 文本
节点的挂载、更新、卸载 行为。
首先创建测试实例 packages/vue/examples/imooc/runtime/render-text.html
:
<script>
const { h, render, Text } = Vue
const vnode = h(Text, 'hello world')
// 挂载
render(vnode, document.querySelector('#app'))
// 延迟两秒,生成新的 vnode,进行更新操作
setTimeout(() => {
const vnode2 = h(Text, '你好,世界')
render(vnode2, document.querySelector('#app'))
}, 2000);
</script>
我们知道,对于节点的打补丁操作是从 packages/runtime-core/src/renderer.ts
中的 render
函数开始的,所以我们可以直接在这里进行 debugger
:
第一次进入
render
,执行挂载操作:进入
patch
方法,此时的参数为:执行
switch
判定,进入processText
方法:进入
processText
,此时的各参数为:因为
n1 === null
,所以 执行hostInsert
和hostCreateText
方法,即:挂载 操作首先进入
hostCreateText
方法:实际触发的是
packages/runtime-dom/src/nodeOps.ts
下的createText
方法:jscreateText: text => doc.createTextNode(text),
该方法比较简单,直接通过
doc.createTextNode(text)
生成Text
节点
其次进入
hostInsert
方法:该方法实际触发的是
packages/runtime-dom/src/nodeOps.ts
下的insert
方法:jsparent.insertBefore(child, anchor || null)
该方法我们之前实现过,就不在多说了。
至此 挂载 操作完成
延迟两秒,第二次进入
render
方法,执行 更新操作进入
patch
方法,此时的参数为:执行
switch
,触发processText
方法进入
processText
方法,此时参数为:此时
n1 !== null
,所以进入else
逻辑执行
const el = (n2.el = n1.el!)
获取同样的el
执行
hostSetText(el, n2.children as string)
进入
hostSetText
方法,其实触发的是packages/runtime-dom/src/nodeOps.ts
下的setText
方法:jsnode.nodeValue = text
该方法非常简单,只是修改
value
而已
至此,更新操作完成。
由以上代码可知:
- 对于
Text
节点的 挂载和更新,整体是非常简单的:- 挂载:通过
doc.createTextNode(text)
生成节点,在通过insertBefore
插入 - 更新:通过
node.nodeValue
直接指定即可。
- 挂载:通过
21:框架实现:renderer 渲染器下,Text 节点的挂载、更新行为
明确好了 Text
的源码逻辑之后,那么接下来我们就实现一下对应的代码:
在
packages/runtime-core/src/renderer.ts
中,增加processText
方法:js/** * Text 的打补丁操作 */ const processText = (oldVNode, newVNode, container, anchor) => { // 不存在旧的节点,则为 挂载 操作 if (oldVNode == null) { // 生成节点 newVNode.el = hostCreateText(newVNode.children as string) // 挂载 hostInsert(newVNode.el, container, anchor) } // 存在旧的节点,则为 更新 操作 else { const el = (newVNode.el = oldVNode.el!) if (newVNode.children !== oldVNode.children) { hostSetText(el, newVNode.children as string) } } }
为
RendererOptions
增加createText
与setText
方法:js/** * 渲染器配置对象 */ export interface RendererOptions { ... /** * 创建 Text 节点 */ createText(text: string) /** * 设置 text */ setText(node, text): void }
为
options
增加解析:js/** * 解构 options,获取所有的兼容性方法 */ const { ... createText: hostCreateText, setText: hostSetText } = options
在
patch
方法中,处理Text
节点:jscase Text: // Text processText(oldVNode, newVNode, container, anchor) break 代码块1234
在
packages/runtime-dom/src/nodeOps.ts
增加createText
和setText
方法:js/** * 创建 Text 节点 */ createText: (text) => doc.createTextNode(text), /** * 设置 text */ setText: (node, text) => { node.nodeValue = text }
代码完成。
创建测试实例 packages/vue/examples/runtime/render-text.html
:
<script>
const { h, render, Text } = Vue
const vnode = h(Text, 'hello world')
// 挂载
render(vnode, document.querySelector('#app'))
// 延迟两秒,生成新的 vnode,进行更新操作
setTimeout(() => {
const vnode2 = h(Text, '你好,世界')
render(vnode2, document.querySelector('#app'))
}, 2000);
</script>
测试挂载和更新成功
22:源码阅读:renderer 渲染器下,Comment 节点的挂载行为
完成了 Text
节点的挂载、更新之后,那么下面我们来看 Comment
节点的挂载操作。
这里大家要注意:vue
不支持动态的 comment
,即没有更新操作
其实对于 Comment
而言,它的整体流程和 Text
非常类似。
我们来看一下,创建测试实例 packages/vue/examples/imooc/runtime/render-comment.html
:
<script>
const { h, render, Comment } = Vue
const vnode = h(Comment, 'hello world')
// 挂载
render(vnode, document.querySelector('#app'))
</script>
跟踪代码实现:
进入
patch
方法,case Comment
,触发processCommentNode
函数进入
processCommentNode
函数:内部的代码非常简单,只触发了两个方法:
hostCreateComment
:内部的代码在packages/runtime-dom/src/nodeOps.ts
中的createComment
方法:jscreateComment: text => doc.createComment(text),
创建
Comment
节点hostInsert
:这个在Text
节点时,刚看过,这里就不在查看了
至此,Comment
节点渲染成功。
由以上代码可知:
- 对于
Comment
而言,只存在 挂载 操作 - 整体的处理非常简单:
- 通过
doc.createComment
创建Comment
节点 - 通过
hostInsert
完成挂载
- 通过
23:框架实现:renderer 渲染器下,Comment 节点的挂载行为
明确好了 Comment
的挂载逻辑之后,下面我们就进行对应的实现。
在
packages/runtime-core/src/renderer.ts
中:jsexport interface RendererOptions { ... /** * 设置 text */ createComment(text: string) } const { ... createComment: hostCreateComment } = options /** * Comment 的打补丁操作 */ const processCommentNode = (oldVNode, newVNode, container, anchor) => { if (oldVNode == null) { // 生成节点 newVNode.el = hostCreateComment((newVNode.children as string) || '') // 挂载 hostInsert(newVNode.el, container, anchor) } else { // 无更新 newVNode.el = oldVNode.el } } // patch 方法中 switch 逻辑 case Comment: // Comment processCommentNode(oldVNode, newVNode, container, anchor) break
在
packages/runtime-dom/src/nodeOps.ts
中,增加createComment
方法:js/** * 创建 Comment 节点 */ createComment: (text) => doc.createComment(text)
代码完成。
创建测试实例 packages/vue/examples/runtime/render-comment.html
:
<script>
const { h, render, Comment } = Vue
const vnode = h(Comment, 'hello world')
// 挂载
render(vnode, document.querySelector('#app'))
</script>
测试成功
24:源码阅读:renderer渲染器下, Fragment 节点的挂载、更新行为
最后我们来看一下 Fragment
节点的挂载和更新行为。
对于 Fragment
它是一个包裹性质的容器,本身并不渲染,只渲染子节点。
那么基于此,我们创建对应的测试实例 packages/vue/examples/imooc/runtime/render-fragment.html
:
<script>
const { h, render, Fragment } = Vue
const vnode = h(Fragment, 'hello world')
// 挂载
render(vnode, document.querySelector('#app'))
setTimeout(() => {
const vnode2 = h(Fragment, '你好,世界')
// 挂载
render(vnode2, document.querySelector('#app'))
}, 2000);
</script>
根据之前的经验,我们直接在 patch
方法中进行 debugger
, 运行代码进入 debugger
:
第一次进入
patch
方法,执行 挂载 操作:触发
switch
,执行processFragment
方法进入
processFragment
方法,该方法的代码相比较多,但是我们忽略掉无用的之后,逻辑和其他的渲染并无其别,同样是直接mountXXX
方法进行挂载只不过需要注意:因为
Fragment
本身并不渲染,所以它的渲染 仅渲染子节点所以此时触发的渲染方法为:
mountChildren
进入
mountChildren
,此时的参数为该方法内部的逻辑就比较简单了
- 循环
children
,分别触发patch
方法进行挂载 - 因为此时的
children
是一个字符串,所以循环之后的每一个child
都是一个字符 - 把字符通过
normalizeVNode
加工之后,将得到一个Text 节点
类型的VNode
- 交给
patch
进行渲染即可
- 循环
至此,挂载 完成
第二次进入
patch
,执行 更新操作:- 同样通过
switch
,触发processFragment
方法- 进入
processFragment
- 因为是 更新,所以会触发
patchChildren
方法- 进入
patchChildren
,此时我们知道:- 挂载时:渲染的是
hello world
文本 - 更新时:渲染的是
你好,世界
文本
- 挂载时:渲染的是
- 所以这次的更新本质上是一个 文本替换文本 的操作。
- 所以执行代码会发现,代码进入
hostSetElementText(container, c2 as string)
方法- 对于该方法我们是熟悉的,它本质上就是一个
el.textContent = text
的方法执行
- 对于该方法我们是熟悉的,它本质上就是一个
- 进入
- 进入
- 同样通过
至此,更新 操作完成
由以上代码可知:
Fragment
只是一个包裹的容器,挂载和更新时,都只会渲染children
- 根据
children
的不同,Fragment
的渲染也会存在很多的不同
25:框架实现:renderer渲染器下, Fragment 节点的挂载、更新行为
明确好了 Fragment
的渲染逻辑之后,那么下面我们对此进行下实现。
在
packages/runtime-core/src/renderer.ts
中,为patch
新增Fragment
的方法触发:jscase Fragment: // Fragment processFragment(oldVNode, newVNode, container, anchor) break
创建
processFragment
方法:js/** * Fragment 的打补丁操作 */ const processFragment = (oldVNode, newVNode, container, anchor) => { if (oldVNode == null) { mountChildren(newVNode.children, container, anchor) } else { patchChildren(oldVNode, newVNode, container, anchor) } }
构建
mountChildren
渲染逻辑:js/** * 挂载子节点 */ const mountChildren = (children, container, anchor) => { // 处理 Cannot assign to read only property '0' of string 'xxx' if (isString(children)) { children = children.split('') } for (let i = 0; i < children.length; i++) { const child = (children[i] = normalizeVNode(children[i])) patch(null, child, container, anchor) } }
在
packages/runtime-core/src/componentRenderUtils.ts
中,为normalizeVNode
增加处理Text
节点逻辑:js/** * 标准化 VNode */ export function normalizeVNode(child) { if (typeof child === 'object') { return cloneIfMounted(child) } else { return createVNode(Text, null, String(child)) } } /** * clone VNode */ export function cloneIfMounted(child) { return child }
更新逻辑之前已经完成过,不需要额外处理
至此,Fragment
的渲染处理完成。
我们可以创建对应测试实例 packages/vue/examples/runtime/render-fragment.html
:
<script>
const { h, render, Fragment } = Vue
const vnode = h(Fragment, 'hello world')
// 挂载
render(vnode, document.querySelector('#app'))
setTimeout(() => {
const vnode2 = h(Fragment, '你好,世界')
// 挂载
render(vnode2, document.querySelector('#app'))
}, 2000);
</script>
挂载、更新成功
26:总结
在本章中,我们主要讲解了 render
函数的渲染机制。
我们首先讲解了渲染器 renderer
的概念,并且知道 render
函数是渲染器对象中的一个函数。但是这个渲染函数我们是不可以直接被用户访问的。所以我们额外在 packages/runtime-dom/src/index.ts
中构建了一个 render
函数,用来给用户使用 render 渲染器
。
在整个的 render
渲染过程中,我们把所有的核心逻辑放入到了 runtime-core
文件夹内,把所有和浏览器相关的 API
操作,放到了 runtime-dom
文件夹下。以此来达到兼容性的目的。
针对不同的节点,具体的渲染逻辑会有所不同。我们分别从:
ELEMENT
TEXT
COMMENT
这三种节点类型入手,讲解了 挂载、更新、卸载 的对应操作逻辑。
对于这三种节点而言,ELEMENT
毫无因为是最复杂的。
对于 ELEMENT
而言,它的渲染除了本身的 挂载、更新、卸载 之外,还包含了 props 属性
的挂载、更新、卸载操作。
对于 props
属性而言,因为 HTML Attributes 和 DOM Properties
的原因,所以我们需要针对不同的 props
分别进行处理。
而对于 event
事件而言,这里 vue
通过了 vei
的方式进行了更新逻辑,这个设计是非常精妙的。
但是哪怕我们做了这么多事情之后,针对于 render
的渲染逻辑,我们依然只是处理了冰山一角而已,比如:
diff
:在旧节点和新节点的子节点都为Array
时,我们需要就行diff
运算,已完成高效的更新。component
组件:组件的处理也是vue
中非常重要的一块内容,这个也需要我们进行单独的讲解。