前言

这篇文章开始,我们就要继续学习Vue3中其它包的作用了,在前文也提到过,Vue3的组成,是有编译时运行时的概念。

  • 编译时:其实就是将模板转化为函数的过程,举个例子,就是将我们写的模板代码,如<template>{{ msg }}</template>转化为函数。之所以用模板的方式来写,纯粹是为了减少开发的心智负担,能够根据语义化进行代码书写,而不必用各种函数调用的方式来生成。
  • 运行时:运行时又分为两个部分,那么运行时的核心,也就是runtime-core是不依赖任何平台的,

那么模块之间的依赖就是runtime-dom提供了浏览器运行环境中的DOM API,而runtime-core提供了虚拟dom的核心逻辑,通过runtime-dom提供的API,从而生成真实DOM,而runtime-core中又会引入reactivity包中的内容,所以整体的流程是Vue -> runtime-dom -> runtime-core -> reactivity后者均是前者的子级,由前者导入使用。我们本篇文章主要讲运行时相关的内容。

runtime-core包中,提供了一个方法createRenderer,看着虽然陌生,但是在我们项目中的createApp(在runtime-dom包中实现),其实底层调用的就是这个方法,那么我们便从这个方法开始,一步步学习runtime-domruntime-core这两个包吧!

runtime-dom的实现

首先,我们依旧是要创建文件夹,和之前的套路一样,先看下示例效果,再进行代码书写。和reactivity包位置相同,我们创建runtime-dom文件夹和package.json文件,并且在runtime-dom文件夹下边创建src/index.ts作为入口;创建dist/index.html作为效果展示示例页面。同样,我们把node_modules文件夹中,Vue官方打包好的compiler-dom.esm-browser.js文件,复制进dist目录下,和我们之前reactivity的操作一模一样,先看看人家官方的方法实现效果,再自己实现一遍。最后,别忘了将script/dev.js中的target改为runtime-dom,这样,我们就是从runtime-dom/src/index.ts作为入口进行打包了。万事具备,我们写一下测试代码,看看有没有跑通吧:

1
2
// runtime-dom/src/index.ts
export const testName = '测试runtime-dom'

然后执行npm run dev,对我们runtime-dom模块的代码进行打包,之后修改dist/index.html文件内容,执行npx serve dist,在浏览器控制台观测结果,成功打印了testName,便说明我们已经调通了。

1
2
3
4
5
// runtime-dom/dist/index.html
<script type="module">
import { testName } from './runtime-dom.js'
console.log(testName)
</script>

我们首先看下两个方法:createRendererh的用法。

1
2
3
4
5
<script type="module">
// runtime-dom/dist/index.html 文件
// 引入的文件是我们刚才复制进来官方打报好的runtime-dom文件
import { createRenderer, h } from './runtime-dom.esm-browser.js'
</script>

那么这两个方法,其实是runtime-core中提供的,前文也说过,其实runtime-dom提供的主要是浏览器相关的API,作为参数传入createRenderer中。什么意思呢?我们一步一步来看。

相信h方法,大家都有所耳闻,可以生成一个虚拟DOM,那么调用createRenderer就可以将虚拟DOM,通过我们传入的API,在页面中生成真实的DOM,我们再次修改示例代码,然后查看控制台结果。

1
2
3
4
5
6
7
8
<script type="module">
// runtime-dom/dist/index.html 文件
// 引入的文件是我们刚才复制进来官方打报好的runtime-dom文件
import { createRenderer, h } from './runtime-dom.esm-browser.js'
const renderer = createRenderer()
// 将h1渲染到页面上
renderer.render(h('h1', 'hello world'), document.getElementById('app'))
</script>

发现控制台竟然报错了:

代码非常简单,就是想要将h1标签渲染到页面上,但是为啥报错了呢?我们查看报错的内容,可以发现,提示我们缺少insert方法,这是啥意思呢?没错,前文提到了runtime-dom这个包中,提供了DOM操作的API,将这些API配置项等传入createRenderer,才能够正常的执行代码,所以我们此时要传入一个insert方法,告诉runtime-core在将虚拟DOM转化为真实DOM,进行插入操作,要用我们传入的这个insert方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script type="module">
// runtime-dom/dist/index.html 文件
// 引入的文件是我们刚才复制进来官方打报好的runtime-dom文件
import { createRenderer, h } from './runtime-dom.esm-browser.js'

const renderOptions = {
// 我们自己提供一个insert方法,当做api来调用
insert (el, container, anchor = null) {
container.insertBefore(el, anchor)
}
}
// 传入配置项,里边包含各种操作的api
const renderer = createRenderer(renderOptions)
// 将h1渲染到页面上
renderer.render(h('h1', 'hello world'), document.getElementById('app'))
</script>

此时我们再刷新页面,发现又有了新的报错,很明显,有了前边的经验,我们很容易能明白,原来还缺少一个创建元素的方法,runtime-core不知道用哪个API来进行元素的创建,于是我们又补充了一下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script type="module">
// runtime-dom/dist/index.html 文件
// 引入的文件是我们刚才复制进来官方打报好的runtime-dom文件
import { createRenderer, h } from './runtime-dom.esm-browser.js'

const renderOptions = {
// 我们自己提供一个insert方法,当做api来调用
insert (el, container, anchor = null) {
container.insertBefore(el, anchor)
},
// 注意,报错中的hostCreateElement是对我们配置中的命名做了个映射,所以我们配置这里命名为createElement
createElement (element) {
return document.createElement(element)
}
}
// 传入配置项,里边包含各种操作的api
const renderer = createRenderer(renderOptions)
// 将h1渲染到页面上
renderer.render(h('h1', 'hello world'), document.getElementById('app'))
</script>

我们再运行代码,发现又有了一个报错,还真是没完没了- -,我们不难分析出来,还需要提供一个设置元素值的方法,于是我们再次修改了配置项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<script type="module">
// runtime-dom/dist/index.html 文件
// 引入的文件是我们刚才复制进来官方打报好的runtime-dom文件
import { createRenderer, h } from './runtime-dom.esm-browser.js'

const renderOptions = {
// 我们自己提供一个insert方法,当做api来调用
insert (el, container, anchor = null) {
container.insertBefore(el, anchor)
},
// 注意,报错中的hostCreateElement是对我们配置中的命名做了个映射,所以我们配置这里命名为createElement
createElement (element) {
return document.createElement(element)
},
// 将文字内容赋值给元素
setElementText (el, text) {
el.innerHTML = text
}
}
// 传入配置项,里边包含各种操作的api
const renderer = createRenderer(renderOptions)
// 将h1渲染到页面上
renderer.render(h('h1', 'hello world'), document.getElementById('app'))
</script>

这次我们再刷新页面,可以发现,页面上终于打印出了hello world,也就是说,至少要提供创建元素、插入元素、设置元素内容这3个API,才能够在页面上正常显示一个基本的元素。

说了这么多,大家应该知道runtime-dom的大致作用了吧?没错,就是提供了上述的这些个renderOptionsDOM相关的API。所以,我们有了大致的思路,便开始实现一下吧!

目录结构如下:

1
2
3
4
5
6
runtime-dom
src
module // 存放模块文件
index.ts // 入口文件
nodeOps.ts // 操作节点相关的api
patchProp.ts // 属性相关的api
1
2
3
4
5
6
// src/index.ts
import { nodeOps } from './nodeOps'
import { patchProp } from './patchProp'

// 将渲染时所需要的属性做整理
export const renderOptions = Object.assign({ patchProp }, nodeOps)

nodeOps.ts文件中,我们存放了和节点相关的操作,不止上文中提到的三个,常见的还有如下一些API`:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// src/nodeOps.ts文件
export const nodeOps = {
insert: (child, parent, anchor) => { // 添加节点
parent.insertBefore(child, anchor || null);
},
remove: child => { // 节点删除
const parent = child.parentNode;
if (parent) {
parent.removeChild(child);
}
},
createElement: (tag) => document.createElement(tag),// 创建节点
createText: text => document.createTextNode(text),// 创建文本
setText: (node, text) => node.nodeValue = text, // 设置文本节点内容
setElementText: (el, text) => el.textContent = text, // 设置文本元素中的内容
parentNode: node => node.parentNode, // 父节点
nextSibling: node => node.nextSibling, // 下一个节点
querySelector: selector => document.querySelector(selector) // 查找元素
}

除了节点操作,还涉及到了对比属性的方法,比如处理类,样式的替换,事件的绑定解绑,这些都写在src/patchProp.ts文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// src/patchProp.ts文件

import { patchClass } from './module/class'
import { patchStyle } from './module/style'
import { patchEvent } from './module/event'
import { patchAttr } from './module/attr'

// 比对属性的方法
export const patchProp = (el, key, prevValue, nextValue) => {
if (key === 'class') {
patchClass(el, nextValue)
} else if (key === 'style') {
patchStyle(el, prevValue, nextValue);
} else if (/^on[^a-z]/.test(key)) {
patchEvent(el, key, nextValue)
} else {
patchAttr(el, key, nextValue)
}
}

针对不同情况的处理,把这些文件单独放在module文件夹下

1
2
3
4
5
6
7
8
// src/module/attr.ts 文件
export function patchAttr(el, key, value) { // 更新属性
if (value == null) {
el.removeAttribute(key);
} else {
el.setAttribute(key, value);
}
}
1
2
3
4
5
6
7
8
// src/module/class.ts 文件
export function patchClass(el, value) { // 根据最新值设置类名
if (value == null) {
el.removeAttribute('class');
} else {
el.className = value;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// src/module/event.ts 文件

function createInvoker(initialValue) {
const invoker = (e) => invoker.value(e)
// 真实的方法,是绑定在.value上的
invoker.value = initialValue
return invoker;
}
export function patchEvent(el, rawName, nextValue) {
const invokers = el._vei || (el._vei = {})
const exisitingInvoker = invokers[rawName] // 是否缓存过

if (nextValue && exisitingInvoker) {
// 有新值并且绑定过事件,需要进行换绑操作
exisitingInvoker.value = nextValue;
} else {
// 获取注册事件的名称
const name = rawName.slice(2).toLowerCase()
if (nextValue) {// 缓存函数
const invoker = (invokers[rawName]) = createInvoker(nextValue)
el.addEventListener(name, invoker);
} else if (exisitingInvoker) {
el.removeEventListener(name, exisitingInvoker);
invokers[rawName] = undefined
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/module/style.ts 文件
export function patchStyle(el, prev, next) { // 更新style
const style = el.style;
for (const key in next) { // 用最新的直接覆盖
style[key] = next[key]
}
if (prev) {
for (const key in prev) {// 老的有新的没有删除
if (next[key] == null) {
style[key] = null
}
}
}
}

那么有了这些个APIruntime-core就知道,应该用哪些方法将虚拟DOM转化为真实DOM了。之后,我们引入自己的renderOptions看看能不能正常渲染:

1
2
3
4
5
6
<script type="module">
import { createRenderer, h } from './runtime-dom.esm-browser.js'
import { renderOptions } from './runtime-dom.js'
const renderer = createRenderer(renderOptions)
renderer.render(h('h1', 'hello'), app)
</script>

页面正常渲染了!那么针对上文这种方式,适合针对某个平台(跨平台),自己定义一套渲染API,可以随意进行定制化,如果在浏览器环境下,其实正如上文所说,API都已经在runtime-dom中了,所以在内部又提供了一个方法(render),默认把这一坨renderOptions自动传进去了,不用我们再手动传入:

1
2
3
4
5
6
7
<script type="module">
import { createRenderer, h, render } from './runtime-dom.esm-browser.js'
// import { renderOptions } from './runtime-dom.js'
//const renderer = createRenderer(renderOptions)
//renderer.render(h('h1', 'hello'), app)
render(h('h1', 'hello'), app)
</script>

再次运行代码,发现结果没变,还是能正常运行,说明这两种方式都可行,使用render的话,相当于默认传入浏览器环境下的API,使用createRenderer可以自定义传入API,比较灵活,所以,我们最后还需要改一下入口文件的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/index.ts
import { nodeOps } from './nodeOps'
import { patchProp } from './patchProp'
import { createRenderer as renderer } from '@vue/runtime-core'

// 将渲染时所需要的属性做整理
export const renderOptions = Object.assign({ patchProp }, nodeOps)

export function createRenderer (options) {
// 提供了渲染的api,但实际调用的是runtime-core中的方法
return renderer(options)
}

// 专门给浏览器环境中使用
export function render (vnode, container) {
const renderer = createRenderer(renderOptions)
return renderer.render(vnode, container)
}
// 将runtime-core中的方法都进行导出
export * from '@vue/runtime-core'

诶,这样就没啥问题了,既然从runtime-core包中引入了渲染的方法,那么接下来我们需要的就是来实现runtime-core的核心逻辑了。

runtime-core的实现

老规矩,首先还是创建相应文件夹:

1
2
3
4
5
6
runtime-core
src
index.ts // 入口文件
createVNode.ts // 创建虚拟DOM
renderer.ts // 创建真实DOM进行渲染
h.ts // 封装createVNode,形成h方法

先在入口文件进行导出操作,防止后边忘记掉,我们不难发现,正如我们之前所说,runtime-domDOM相关API传给runtime-coreruntime-core中又使用了reactivity模块,至此,三个模块便互相串通了起来。

1
2
3
4
5
// index.ts 入口文件
export * from './renderer'
export * from './createVNode'
export * from './h'
export * from '@vue/reactivity'

我们还是先紧跟着runtime-dom的逻辑,先写下renderer.ts的大概逻辑,那么这个就是我们runtime-dom中使用的createRenderer方法,实际上调用的还是runtime-core中的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// renderer.ts文件
export function createRenderer(renderOptions) {
// 从renderOptions中解构api,并重命名
const {
insert: hostInsert,
remove: hostRemove,
patchProp: hostPatchProp,
createElement: hostCreateElement,
createText: hostCreateText,
setText: hostSetText,
setElementText: hostSetElementText,
parentNode: hostParentNode,
nextSibling: hostNextSibling,
querySelector: hostQuerySelector
} = renderOptions
const render = (vnode, container) => {
console.log('render')
}
return {
render
}
}

接下来我们写一下createVNode.ts中的逻辑。所谓虚拟DOM,就是用对象的形式,来形容一个节点,标注了各种信息,为了之后转化成真实DOM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { ShapeFlags } from '@vue/shared'
// 判断是不是一个虚拟节点
export function isVNode(value) {
return value ? value.__v_isVNode === true : false
}
export function createVNode(type, props, children = null) {
const shapeFlag = typeof type === 'string' ? ShapeFlags.ELEMENT : 0
// 虚拟节点包含的信息
const vnode = {
__v_isVNode: true, // 判断对象是不是虚拟节点
type,
props,
key: props && props['key'], // 虚拟节点的key,主要用于diff算法
el: null, // 虚拟节点对应的真实节点
children,
shapeFlag
}
if (children) {
let type = 0;
if (Array.isArray(children)) {
type = ShapeFlags.ARRAY_CHILDREN;
} else {
children = String(children);
type = ShapeFlags.TEXT_CHILDREN
}
vnode.shapeFlag |= type
// 如果shapeFlag结果为9 说明元素中包含一个文本
// 如果shapeFlag结果为17 说明元素中有多个子节点
}
// 返回的虚拟节点并且标注了虚拟节点的类型,之后生成真实DOM时,根据shapFlag调用不同的方法。
return vnode
}

我们在@vue/shared包中补充下ShapFlags,并且来详细解释一下,这到底是个什么东西。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// shared/src/index.ts
export const enum ShapeFlags { // Vue3提供的标识
ELEMENT = 1, // 元素
FUNCTIONAL_COMPONENT = 1 << 1, // 函数式组件
STATEFUL_COMPONENT = 1 << 2, // 普通状态组件
TEXT_CHILDREN = 1 << 3, // 组件儿子为文本
ARRAY_CHILDREN = 1 << 4, // 组件的儿子为数组
SLOTS_CHILDREN = 1 << 5, // 组件的插槽
TELEPORT = 1 << 6, // 传送门组件
SUSPENSE = 1 << 7, // 异步加载组件
COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8, // keep-alive相关
COMPONENT_KEPT_ALIVE = 1 << 9, // keep-alive相关
COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT // 按位或操作,相当于包含两种类型
}

很多朋友可能对<< | &移位、按位或、按位与很陌生,就算知道其定义,也不知道有哪些个使用场景。其实在Vue3中,就有很好的例子。比如这个ShapFlags通过名称我们便能大致猜出来,是描述形状的标志,比如一个普通的元素,就用1来代表,函数式组件就用1向左移1位来表示,普通的状态组件,就用1向左移2位来表示。为啥要用移位操作呢?搞几个普通的枚举值不行么,其实,之所以用移位来进行标识,是为了后续进行按位与,按位或操作提供了极大的便利。我们举个例子,我们有如下的3种权限:

1
2
3
测试:1,二进制为001
开发者:1 << 1,二进制为010
超级管理员:1 << 2,二进制为100

那么当A,既是开发者,又是超级管理员的时候,那么只需要将开发者超级管理员的权限进行按位或操作,也就是010100进行按位或操作,得到的结果为110,大于0。那么判断A有没有测试权限,只需要将刚才的结果和测试的权限进行按位与操作,即110001进行按位与操作,得到的结果为000,等于0。从而我们可以发现,在使用移位符操作的枚举值,进行|操作后,相当于权限相加的操作,进行&操作后,如果结果大于0,说明包含相关权限,如果结果等于0,则说明不包括相关权限。

那么再回到我们之前的实例,我们改动下index.html中的代码,来调试下代码有没有生效,先调试下createVNode方法:

1
2
3
4
<script>
import { createVNode } from './runtime-dom.js'
console.log(createVNode('div',null, ['hello', 'world']))
</script>

打印结果可以看到,虚拟节点成功的被创建了:

但是createVNode这个方法,写法是固定的,比如传参的顺序和类型,都不能变,并不灵活,(特别注意,createVNode的第三个参数,只能传字符串和数组类型的数据),所以,我们可以基于createVNode进行封装,那么这个方法就是我们熟悉的h方法了,首先我们先看下h方法能怎么传参:

  • 只传1个参数,就是标签;
  • 传2个参数,可能是传标签和属性:h('div', { style: { color: 'red' } }),也可能是传标签和子元素:h('div', h('span', null, 'hello')) h('div', [h('span', null, 'hello')]),还可能是传标签和内容:h('div', 'hello')
  • 传3个参数,那就是和createVNode的传参一样了,即h('div', { style: {color: 'red'} }, 'hello')
  • 传3个以上的参数,第二个参数必须是属性,之后的参数都作为内容:h('div', null, 'hello', 'world', '!')

那么知道了以上的用法,我们便可以按照传参数量的不同,分别处理相应逻辑,来编写h方法了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// h.ts 文件
import { isArray, isObject } from '@vue/shared'
import { createVNode, isVNode } from './createVNode'

export function h(type, propsOrChildren?, children?) {
const l = arguments.length
if (l === 2) {
// 只有属性,或者只有一个生成的虚拟元素的时候
if (isObject(propsOrChildren) && !isArray(propsOrChildren)) {
// 区分第二个参数是属性还是生成的虚拟元素,比如h('div',h('span'))
if (isVNode(propsOrChildren)) {
// 如果是虚拟元素,根据createVNode的传参要求,就要用数组包起来
return createVNode(type, null, [propsOrChildren])
}
// 如果是h('div',{style:{color:'red'}}),则进行如下传参
return createVNode(type, propsOrChildren)
} else {
// 传递儿子列表h('div',null,[h('span'),h('span')])或者h('div', 'hello')的情况
return createVNode(type, null, propsOrChildren)
}
} else {
// 除了前2个,后边的都是子元素
if (l > 3) {
children = Array.prototype.slice.call(arguments, 2)
} else if (l === 3 && isVNode(children)) {
// 第三个参数传入的是生成的虚拟元素
children = [children]
}
return createVNode(type, propsOrChildren, children)
}
}

到此,我们h方法便写好了,是不是没有想象中那么困难呢?

接下来,就该完善createRenderer方法,也就是渲染方法了,之后二者一结合,就能够在页面中,将虚拟DOM渲染成真实DOM了,刚才我们写到了render方法,那我们继续完善吧!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// renderer.ts 文件
export function createRenderer(renderOptions) {
// 从renderOptions中解构api,并重命名
const {
insert: hostInsert,
remove: hostRemove,
patchProp: hostPatchProp,
createElement: hostCreateElement,
createText: hostCreateText,
setText: hostSetText,
setElementText: hostSetElementText,
parentNode: hostParentNode,
nextSibling: hostNextSibling,
querySelector: hostQuerySelector
} = renderOptions
const render = (vnode, container) => {
// 虚拟节点渲染成真实DOM,挂载到页面上
// 卸载操作 render(null, container)
// 初始化和更新虚拟DOM
if (vnode == null) {
// 卸载逻辑
}else {
// 初始传入一个null作为老的虚拟DOM值;保留之前的vnode,为之后的diff算法做准备
patch(container._vnode || null, vnode, container)
}
container._vnode = vnode
}
return {
render
}
}
const mountElement = (vnode, container) => {
// 将虚拟节点转化为真实DOM
}

// 虚拟节点对比逻辑
const patch = (n1, n2, container) => {
if(n1 == n2) {
return
}
if(n1 == null) {
// 初始化情况
mountElement(n2, container)
}else {
// n1, n2不相等,diff算法逻辑
}
}

那么整个流程的架子我们已经搭好了,接下来就一个个来实现具体的方法,我们先实现将虚拟DOM转化为真实DOMmountElement方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// renderer.ts 文件
export function createRenderer(renderOptions) {
// 从renderOptions中解构api,并重命名
const {
insert: hostInsert,
remove: hostRemove,
patchProp: hostPatchProp,
createElement: hostCreateElement,
createText: hostCreateText,
setText: hostSetText,
setElementText: hostSetElementText,
parentNode: hostParentNode,
nextSibling: hostNextSibling,
querySelector: hostQuerySelector
} = renderOptions
const mountChildren = (children, container) => {
for(let i = 0; i < children.length; i++) {
// 递归调用patch方法
patch(null,children[i],container)
}
}
const mountElement = (vnode, container) => {
// 将虚拟节点转化为真实DOM
const { type, props, shapeFlag, children } = vnode
// 创建真实元素,挂载到虚拟节点上
let el = vnode.el = hostCreateElement(type)
// 如果有props,则处理属性
if (props) {
for (const key in props) {
hostPatchProp(el, key, null, props[key])
}
}
if(children) {
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 说明是文本
hostSetElementText(el, vnode.children)
}else if(shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 说明有多个儿子
mountChildren(vnode.children, el)
}
}
hostInsert(el,container); // 插入到容器中
}
// 虚拟节点对比逻辑
const patch = (n1, n2, container) => {
if(n1 == n2) {
return
}
if(n1 == null) {
// 初始化情况
mountElement(n2, container)
}else {
// n1, n2不相等,diff算法逻辑
}
}
const render = (vnode, container) => {
// 虚拟节点渲染成真实DOM,挂载到页面上
// 卸载操作 render(null, container)
// 初始化和更新虚拟DOM
if (vnode == null) {
// 卸载逻辑
}else {
// 初始传入一个null作为老的虚拟DOM值;保留之前的vnode,为之后的diff算法做准备
patch(container._vnode || null, vnode, container)
}
container._vnode = vnode
}
return {
render
}
}

我们可以清楚的看到,就是用了runtime-dom中的API,来递归生成真实的DOM元素,我们来验证一下,代码是否有问题吧:

1
2
3
4
<script type="module">
import { h, render } from './runtime-dom.js'
render(h('h1',{style: {color: 'red'}}, 'hello'), app)
</script>

在浏览器中运行完代码,发现,hello已经成功被渲染到页面上了。那么初始化阶段的渲染的逻辑,便写完了!那么初始化逻辑写完后,我们再写一下卸载的逻辑,什么是卸载的逻辑呢?可以理解为render(null, app),也就是传入了null的时候,要把页面中元素清除掉,我们之前已经预留出来卸载逻辑的位置,那我们现在便可以来完善了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// renderer.ts 文件
export function createRenderer(renderOptions) {
// 从renderOptions中解构api,并重命名
const {
insert: hostInsert,
remove: hostRemove,
patchProp: hostPatchProp,
createElement: hostCreateElement,
createText: hostCreateText,
setText: hostSetText,
setElementText: hostSetElementText,
parentNode: hostParentNode,
nextSibling: hostNextSibling,
querySelector: hostQuerySelector
} = renderOptions
const mountChildren = (children, container) => {
for(let i = 0; i < children.length; i++) {
// 递归调用patch方法
patch(null,children[i],container)
}
}
const mountElement = (vnode, container) => {
// 将虚拟节点转化为真实DOM
const { type, props, shapeFlag, children } = vnode
// 创建真实元素,挂载到虚拟节点上
let el = vnode.el = hostCreateElement(type)
// 如果有props,则处理属性
if (props) {
for (const key in props) {
hostPatchProp(el, key, null, props[key])
}
}
if(children) {
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 说明是文本
hostSetElementText(el, vnode.children)
}else if(shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 说明有多个儿子
mountChildren(vnode.children, el)
}
}
hostInsert(el,container); // 插入到容器中
}
// 虚拟节点对比逻辑
const patch = (n1, n2, container) => {
if(n1 == n2) {
return
}
if(n1 == null) {
// 初始化情况
mountElement(n2, container)
}else {
// n1, n2不相等,diff算法逻辑
}
}
// 卸载元素的方法
const unmount = vnode => {
const { shapeFlag } = vnode
if(shapeFlag & ShapeFlags.ELEMENT) {
// 如果是一个元素,那么直接删除DOM即可
hostRemove(vnode.el)
}
}
const render = (vnode, container) => {
// 虚拟节点渲染成真实DOM,挂载到页面上
// 卸载操作 render(null, container)
// 初始化和更新虚拟DOM
if (vnode == null) {
// 卸载逻辑
if(container._vnode) {
// 找到对应的真实节点,将其卸载
unmount(container._vnode)
}
}else {
// 初始传入一个null作为老的虚拟DOM值;保留之前的vnode,为之后的diff算法做准备
patch(container._vnode || null, vnode, container)
}
container._vnode = vnode
}
return {
render
}
}

到这里,就只剩下元素更新的逻辑了,那么元素更新的逻辑,涉及的内容又非常多,我们先讲一些关键性的点,从而为后续文章做好铺垫。我们用几个不同的例子,来表明什么时候触发更新,也就是说,怎么判断两个虚拟节点相同,可以复用:

1
2
3
4
5
6
7
8
9
10
11
12
<script type="module">
import { h, render } from './runtime-dom.js'
// 1、可以看到,标签名不相同,所以就不能够进行DOM复用
render(h('h1',{style: {color: 'red'}}, 'hello'), app)
render(h('div',{style: {color: 'blue'}}, 'world'), app)
// 2、那么如果标签名相同,又不想复用DOM,那么这时候就需要提供key,来进行区分了
render(h('div',{style: {color: 'blue'}, key: 1}, 'world'), app)
render(h('div',{style: {color: 'blue'}, key: 2}, 'world'), app)
// 3、如果标签名相同,也没有key,那么就进行复用
render(h('h1',{style: {color: 'red'}}, 'hello'), app)
render(h('h1',{style: {color: 'red'}}, 'hello'), app)
</script>

所以我们可以得到结论,当两个虚拟节点的标签类型不同时候,或者两个虚拟节点标签类型相同,但是key不同,那么就不会进行复用;如果两个虚拟节点的标签类型相同,并且不传入key,或者key相同,那么就进行复用。

所以,我们需要继续改进下更新下patch方法中的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
// renderer.ts 文件
export function createRenderer(renderOptions) {
// 从renderOptions中解构api,并重命名
const {
insert: hostInsert,
remove: hostRemove,
patchProp: hostPatchProp,
createElement: hostCreateElement,
createText: hostCreateText,
setText: hostSetText,
setElementText: hostSetElementText,
parentNode: hostParentNode,
nextSibling: hostNextSibling,
querySelector: hostQuerySelector
} = renderOptions
const mountChildren = (children, container) => {
for(let i = 0; i < children.length; i++) {
// 递归调用patch方法
patch(null,children[i],container)
}
}
const mountElement = (vnode, container) => {
// 将虚拟节点转化为真实DOM
const { type, props, shapeFlag, children } = vnode
// 创建真实元素,挂载到虚拟节点上
let el = vnode.el = hostCreateElement(type)
// 如果有props,则处理属性
if (props) {
for (const key in props) {
hostPatchProp(el, key, null, props[key])
}
}
if(children) {
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 说明是文本
hostSetElementText(el, vnode.children)
}else if(shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 说明有多个儿子
mountChildren(vnode.children, el)
}
}
hostInsert(el,container); // 插入到容器中
}

// 判断是不是相同的虚拟节点
const isSameVNode = (n1, n2) => {
return n1.type === n2.type && n1.key === n2.key
}
// 处理元素
const processElement = (n1, n2, container) => {
if (n1 == null) {
// 初始化情况
mountElement(n2, container)
} else {
// 元素相同,属性更新了,可以进行复用,进行diff的逻辑
console.log(n1, n2);
}
}
// 虚拟节点对比逻辑
const patch = (n1, n2, container) => {
if(n1 == n2) {
return
}
if(n1 && !isSameVNode(n1, n2)) {
unmount(n1)
n1 = null
}
// 处理元素
processElement(n1, n2, container)
}
// 卸载元素的方法
const unmount = vnode => {
const { shapeFlag } = vnode
if(shapeFlag & ShapeFlags.ELEMENT) {
// 如果是一个元素,那么直接删除DOM即可
hostRemove(vnode.el)
}
}
const render = (vnode, container) => {
// 虚拟节点渲染成真实DOM,挂载到页面上
// 卸载操作 render(null, container)
// 初始化和更新虚拟DOM
if (vnode == null) {
// 卸载逻辑
if(container._vnode) {
// 找到对应的真实节点,将其卸载
unmount(container._vnode)
}
}else {
// 初始传入一个null作为老的虚拟DOM值;保留之前的vnode,为之后的diff算法做准备
patch(container._vnode || null, vnode, container)
}
container._vnode = vnode
}
return {
render
}
}

我们又将当两个虚拟节点不相同时的更新逻辑写完了,我们改下调试代码,在页面上看效果,发现,过了1秒钟后,成功的渲染了新的虚拟节点:

1
2
3
4
5
6
7
<script type="module">
import { h, render } from './runtime-dom.js'
render(h('h1', { style: { color: 'red' } }, 'hello'), app)
setTimeout(() => {
render(h('div', { style: { color: 'blue' } }, 'world'), app)
}, 2000)
</script>

所以,当两个虚拟节点可以复用时的逻辑,我们就放到后续文章中,进行详细的讲解,因为会涉及到我们耳熟能详的diff算法。

作者:柠檬soda水
链接:https://juejin.cn/post/7209906040921489468
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。