渲染器(二):挂载与更新
前面介绍了渲染器的基本概念和整体框架,接下来就可以介绍渲染器的核心功能:挂载与更新。
1.挂载子节点和元素的属性:
vnode.children
的值为字符串类型时,会把它设置为元素的文本内容。一个元素除了有文本子节点外,还可以包含其他元素子节点,并且子节点可以是多个。为了描述元素的子节点,我们将vnode.children
定义为数组。
下面代码描述的是 “一个div具有一个子节点,子节点是p标签”。
const vnode = {
type: 'h1',
children: [
{
type: 'p',
children: 'hello'
}
]
}
可以看到,vnode.children
是一个数组,它的每一个元素都是一个独立的vnode对象,这样就形成了树形结构,即虚拟DOM树。
修改mountElement
完成子节点的渲染:
function mountElement(vnode, container) {
const el = createElement(vnode.type);
if (typeof vnode.children === 'string') {
setElementText(el, vnode.children);
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach(child => {
patch(null, child, el);
})
}
insert(el, container);
}
增加新的判断分支,判断vnode.children
是否是数组,如果是数组则循环遍历它,并调用patch函数挂载数组中的虚拟节点。在挂载子节点时,注意两点:
- 传递给patch函数的第一个参数是null,因为是挂载阶段,没有旧vnode。
- 传递给patch函数的第三个参数是挂载点。由于我们正在挂载的子元素是div标签的子节点,所以需要把刚刚创建的div元素作为挂载点,这样才能保证这些子节点挂载到正确位置。
完成子节点的挂载后,来看看如何用vnode描述一个标签的属性,以及如何渲染这些属性。
HTML标签中有很多属性,有些属性是通用的,如id、class等,而有些属性是特定元素才有,有form元素的action属性。我们先来看看最基本的属性处理,定义vnode.props
字段描述元素的属性。
const vnode = {
type: 'h1',
// 使用 props描述一个元素的属性
props: {
id: 'foo'
},
children: [
{
type: 'p',
children: 'hello'
}
]
}
vnode.props
是一个对象,它的键代表元素的属性名称,值代表对应属性的值。这样就可以通过遍历props对象的方式,把这些属性渲染到对应的元素上,如下代码:
function mountElement(vnode, container) {
const el = createElement(vnode.type);
if (typeof vnode.children === 'string') {
setElementText(el, vnode.children);
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach(child => {
patch(null, child, el);
})
}
if (vnode.props) {
for (const key in vnode.props) {
el.setAttribute(key, vnode.props[key]);
// 直接设置
el[key] = vnode.props[key];
}
}
insert(el, container);
}
检查vnode.props
字段是否存在,存在则遍历它,并调用setAttribute
函数将属性设置到元素上,或者通过DOM对象直接设置。
这两种方式都存在缺陷,其实为元素设置属性比想象中要复杂。不过在讨论具体缺陷以前,有必要先搞清楚两个重要的概念: HTML Attributes
和 DOM Properties
。
2.HTML Attributes 和 DOM Properties:
理解HTML Attributes
和 DOM Properties
之间的差异和关联非常重要,这能帮我们合理地设计虚拟节点的结构,更是正确为元素设置属性的关键。
<input id="my-input" type="text" value="foo" />
HTML Attributes
指的就是定义在 HTML标签上的属性,这里指的是 id="my-input" type="text" value="foo"
。当浏览器解析这段HTML代码,会创建一个与之对应的DOM元素对象,我们可以通过JS来获取该DOM对象:
const el = document.querySelector('#my-input');
这个DOM对象包含很多属性,如下图所示:
DOM对象下的属性就是所谓的 DOM Properties
。很多 HTML Attributes
在DOM对象上有与之同名的 DOM Properties
,例如 id=“my-input” 对应 el.id ,type="text"对应 el.type ,value=“foo” 对应 el.value等。
但是它们之间的名字不全是一模一样的,例如:
<div class="foo"></div>
class="foo"
对应的 DOM Properties
则是 el.className
。另外不是所有的HTML Attributes
都有与之对应的 DOM Properties
。例如:
<div aira-valuenow="75"></div>
aira-*
类的 HTML Attributes
就没有与之对应的 DOM Properties
。
类似地,不是所有 DOM Properties
都有与之对应的 HTML Attributes
,例如可以用 el.textContent
来设置元素的文本内容,但没有与之对应的 HTML Attributes
来完成同样的工作。
HTML Attributes
的值与 DOM Properties
的值之间是有关联的,例如:
<div id="foo"></div>
上面代码描述了一个具有 id属性的div标签。其中 id="foo"
对应 DOM Properties
是 el.id,并且值为字符串 "foo"
。
这种HTML Attributes
和 DOM Properties
具有相同名称(即id)的属性看作是直接映射。
但不是所有都是直接映射关系。例如:<input value="foo" />
- 当我们没有修改文本框内容时,通过
el.value
读取对应的DOM Properties
的值就是字符串foo
。 - 如果修改了文本框的值,那么
el.value
的值就是当前文本框的值。
修改文本框的内容为’bar’,这时:
console.log(el.getAttribute('value')); // 仍然是foo
console.log(el.value); // bar
用户对文本框内容的修改不会影响 el.getAttribute('value')
的返回值,这个现象蕴含HTML Attributes
所代表的意义。即HTML Attributes
的作用是设置与之对应的DOM Properties
的初始值。一旦值改变,DOM Properties
始终存储当前值,而getAttribute()
得到的仍然是初始值。
我们也可以通过 el.defaultValue
来访问初始值,如:
console.log(el.getAttribute('value')); // 仍然是foo
console.log(el.value); // bar
console.log(el.defaultValue); // foo
这说明一个 HTML Attributes
可能关联多个 DOM Properties
,例如上面的 value="foo"与 el.value 和 el.defaultValue都有关联。
虽然我们可以认为 HTML Attributes
是用来设置与之对应的 DOM Properties
的初始值的,但是有些值是受限制的,就好像浏览器内部做了默认值校验。
// 通过HTML Attributes提供的默认值不合法,
// 浏览器使用内建的和法治作为对应的 DOM Properties默认值
<input type="foo" />
console.log(el.type); // 'text'
它们两者之间的关系很复杂,但记住一个核心原则即可:HTML Attributes
是用来设置与之对应的 DOM Properties
的初始值的。
3.正确地设置元素属性:
前面详细讨论了HTML Attributes 和 DOM Properties
相关的内容,因为它们会影响DOM属性的添加方式。
- 对于普通的HTML文件来说,当浏览器解析HTML代码后,会自动分析
HTML Attributes
并设置合适的DOM Properties
。 - 但是用户编写在Vue.js的单文件组件中的模板不会被浏览器解析,这意味着原来需要浏览器来完成的工作,现在需要框架来完成。
举例说明,如下禁用按钮:
<button disabled>Button</button>
浏览器解析这段HTML代码时,发现这个按钮存在一个叫做 disabled
的HTML Attributes
,于是将该按钮设置为禁用状态,并将它的 el.disabled
这个 DOM Properties
的值设置为true,这一切都是浏览器帮我们完成的。
但是同样的代码出现在 Vue.js模板中,这情况有所不同。首先这个html模板会被编译成vnode,等价于:
const button = {
type: 'button',
props: {
disabled: ''
}
}
这里的 props.disabled
值为空字符串,如果在渲染器中调用 setAttribute
函数设置属性,则相当于: el.setAttribute('disabled', '')
。这样做没问题,浏览器会将按钮禁用。
但考虑如下模板:
<button :disabled="false">Button</button>
它对应的vnode为:
const button = {
type: 'button',
props: {
disabled: false
}
}
用户的本意是"不禁用"按钮,但如果渲染器仍然使用 setAttribute()
设置属性值,则会产生意外效果,即按钮被禁用了:
el.setAttribute('disabled', false)
在浏览器上面运行这句代码,可以发现浏览器仍然将按钮禁用了。这是因为使用 setAttribute()
设置的值总会被字符串化,所以它等价于:
el.setAttribute('disabled', 'false')
对于按钮来说,它的 el.disabled
属性值是布尔类型的,并且它不关心具体的 HTML Attributes
的值是什么。只要disabled属性存在,按钮就会被禁用。
所以我们发现,渲染器不应该总是使用 setAttribute()
将 vnode.props
对象中的属性设置到元素上。那么应该怎么解决呢?
思路:优先设置 Dom Properties
,例如:el.disabled = false
这样是可以正确工作,但是又有新的问题出现了。以一开始的模板为例:
<button disabled>Button</button>
// 对应的vnode:
const button = {
type: 'button',
props: {
disabled: ''
}
}
观察可以发现,模板经过编译后得到的 vnode对象中,props.disabled
的值是一个空字符串。如果直接用它设置元素的 DOM Properties
,就相当于:el.disabled = ''
,此时浏览器会将它的值矫正为布尔类型的值,即false。等价于: el.disabled = false
。
这违背了用户本意,用户希望禁用按钮,但是值为false则是不禁用的意思。
这么看下来,其实无论是使用 setAttribute()
,还是直接设置元素的 DOM Properties
都存在缺陷。
想彻底解决这个问题就要做特殊处理,即优先设置元素的 DOM Properties
,但当值为空字符串时,要手动将值矫正为true。只有这样才能保证代码行为符合预期。如下代码:
function mountElement(vnode, container) {
const el = createElement(vnode.type);
if (typeof vnode.children === 'string') {
setElementText(el, vnode.children);
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach(child => {
patch(null, child, el);
})
}
if (vnode.props) {
for (const key in vnode.props) {
// 用 in 判断key是否存在对应的 DOM Properties
if (key in el) {
// 获取该 DOM Properties 的类型
const type = typeof el[key];
const value = vnode.props[key];
// 如果是布尔类型,并且value为空字符串,矫正为true
if (type === 'boolean' && value === '') {
el[key] = true;
} else {
el[key] = value;
}
} else {
// 如果要设置的属性没有对应的DOM Properties,
// 则使用 setAttribute()设置属性
el.setAttribute(key, vnode.props[key]);
}
}
}
insert(el, container);
}
我们检查每一个 vnode.props
中的属性。
- 是否存在对应的
DOM Properties
,如果存在则优先设置,同时对布尔类型的DOM Properties
做了值的矫正,当要设置的值为空字符串时,将其矫正为布尔值true。 - 如果不存在对应的
DOM Properties
,则仍然使用setAttribute()
完成属性的设置。
但是其实上面的代码还不够完善,因为有一些DOM Properties
是只读的(如el.form),修改现有逻辑:
function shouldSetAsProps(el, key, value) {
// 特殊处理
if (key === 'form' && el.tagName === 'INPUT') return false;
// 兜底
return key in el;
}
function mountElement(vnode, container) {
const el = createElement(vnode.type);
if (typeof vnode.children === 'string') {
setElementText(el, vnode.children);
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach(child => {
patch(null, child, el);
})
}
if (vnode.props) {
for (const key in vnode.props) {
const value = vnode.props[key];
// 使用 shouldSetAsProps()判断是否应该作为 DOM Properties设置
if (shouldSetAsProps(el, key, value)) {
const type = typeof el[key];
if (type === 'boolean' && value === '') {
el[key] = true;
} else {
el[key] = value;
}
} else {
// 如果要设置的属性没有对应的DOM Properties,
// 则使用 setAttribute()设置属性
el.setAttribute(key, vnode.props[key]);
}
}
}
insert(el, container);
}
如上代码所示,提取一个 shouldSetAsProps()
,该函数返回一个布尔值,代表属性是否应该作为 DOM Properties
被设置。
- 如果返回true,则代表应该作为
DOM Properties
被设置,否则使用setAttribute
来设置。
其实 shouldSetAsProps
内的特殊处理 不止对 <input form="xxx">
这一种,无论是 <input/>
标签,所有表单元素都具有 form
属性,它们都应该作为 HTML Attributes
被设置。当然我们不可能列举出所有情况,只需要掌握处理问题的思路就可以了。
最后将属性的设置变成与平台无关,将相关代码提取到渲染器选项中:
// 在创建renderer时传入配置项
const renderer = createRenderer({
createElement(tag) {
return document.createElement(tag);
},
setElementText(el, text) {
el.textContent = text;
},
insert(el, parent, anchor = null) {
parent.insertBefore(el, anchor);
},
// 将属性设置相关操作封装到这里,并作为渲染器选项传递
patchProps(el, key, prevValue, nextValue) {
if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key];
if (type === 'boolean' && nextValue === '') {
el[key] = true;
} else {
el[key] = nextValue;
}
} else {
el.setAttribute(key, nextValue);
}
}
})
此时的 mountElement
函数:
function mountElement(vnode, container) {
const el = createElement(vnode.type);
if (typeof vnode.children === 'string') {
setElementText(el, vnode.children);
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach(child => {
patch(null, child, el);
})
}
if (vnode.props) {
for (const key in vnode.props) {
// 调用 pathchProps即可
patchProps(el, key, null, vnode.props[key]);
}
}
insert(el, container);
}
这样就把属性相关的渲染逻辑从渲染器的核心中抽离出来了。
4.class的处理:
在前面中,我们讲解了如何正确地把 vnode.props
中定义的属性设置到DOM元素上。
但是有一些属性需要特殊处理,比如class。在 Vue.js
中对class进行了增强,所以我们需要对它特殊处理。在 Vue.js
中为元素设置类型有以下几种方式:
方式一:指定class为一个字符串值。
<div class="foo bar "></div>
// 模板对应的vnode:
const vnode = {
type: 'p',
props: {
class: 'foo bar'
}
}
方式二:指定class为一个对象值。
<p :class="cls"></p>
- 假设对象cls的内容如下:
const cls = { foo: true, bar: flase }
对应的vnode则为:
const vnode = {
type: 'p',
props: {
class: { foo: true, bar: false },
}
}
方式三:class是包含上述两种类型的数组: <p :class="arr"></p>
这个数组可以是字符串值与对象值的组合:
const arr = [
// 字符串
'foo bar',
// 对象
{
baz: true
}
]
对应的vnode则为:
const vnode = {
type: 'p',
props: {
class: [
'foo bar',
{ baz: true }
],
}
}
可以看到,class的值可以是多种类型,所以我们必须在设置元素的class之前将值归一化为统一的字符串类型,再用该字符串作为元素的class值取设置。
因此我们封装 normalizeClass
函数,用它来将不同类型的class值正常化为字符串,如:
const vnode = {
type: 'p',
props: {
class: normalizeClass([
'foo bar',
{ baz: true }
])
}
}
// 等价于
const vnode = {
type: 'p',
props: {
class: 'foo bar baz'
}
}
normalizeClass
的实现:
function normalizeClass(value) {
let res = '';
if (isString(value)) {
res = value;
}
else if (isArray(value)) {
for (let i = 0; i < value.length; i++) {
const normalized = normalizeClass(value[i]);
if (normalized) {
res += normalized + ' ';
}
}
}
else if (isObject(value)) {
for (const name in value) {
if (value[name]) {
res += name + ' ';
}
}
}
return res.trim();
}
接下来就开始将正常化后的 class
值设置到元素上。在浏览器中为一个元素设置class有三种方式,即使用 setAttribute、el.className 或 el.classlist
,接下来对比它们的性能发现 el.className
的性能最优。调整 patchProps
函数的实现,如:
const renderer = createRenderer({
createElement(tag) {
return document.createElement(tag);
},
setElementText(el, text) {
el.textContent = text;
},
insert(el, parent, anchor = null) {
parent.insertBefore(el, anchor);
},
patchProps(el, key, prevValue, nextValue) {
// 对class进行特殊处理
if (key === 'class') {
el.className = nextValue || ''
} else if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key];
if (type === 'boolean' && nextValue === '') {
el[key] = true;
} else {
el[key] = nextValue;
}
}
else {
el.setAttribute(key, nextValue);
}
}
})
通过对 class的处理,能发现 vnode.props
对象中定义的属性值类型并不总是与 DOM元素属性的数据结构保持一致,这取决于上层API的设计。
Vue.js
中允许对象类型的值作为 class是为了方便开发者,在底层实现上,必然需要对值进行正常化后再使用。如果需要进行大量的正常化操作,则会消耗更多性能。
5.卸载操作:
前面讨论的都是挂载操作,接下来就来看卸载操作,卸载操作发生在更新阶段。
- 更新:初次挂载完成后,后续渲染触发更新。
更新的情况有几种,我们逐个来看。当后续调用 render()
渲染空内容(即null):
// 初次挂载
renderer.render(vnode, document.querySelector('#app'))
// 新vnode为null,意味着卸载之前渲染的内容
renderer.render(null, document.querySelector('#app'))
传递null作为新vnode,意味着什么都不渲染,这时就需要卸载之前渲染的内容。回顾前面实现的render函数:
function render(vnode, container) {
if (vnode) {
// 新vnode存在,将其与旧vnode一起传递给patch函数,进行更新
patch(container._vnode, vnode, container);
} else {
if (container._vnode) {
// 旧vnode存在,且新vnode不存在,说明是unmount(卸载)操作
// 只需要将container内的DOM清空即可
container.innerHtml = '';
}
}
// 把 vnode存储到 container._vnode下,即后续渲染中的旧vnode
container._vnode = vnode;
}
可以看到,当vnode为null,且container._vnode
属性存在,直接通过 innerHtml
清空容器是不严谨的,原因有三:
- 容器的内容可能是由某个或多个组件渲染的,当卸载操作发生时,应该正确调用这些组件的
beforeUnmount
、unmounted
等生命周期函数。 - 即使内容不是由组件渲染的,有的元素存在自定义指令,我们应该在卸载操作发生时正确执行对应的指令钩子函数。
- 使用
innerHTML
清空容器元素内容的另一个缺陷是:它不会移除绑定在DOM元素上的事件处理函数。
正如上述三点原因,我们不能简单地使用 innerHTML
来完成卸载操作。
正确的卸载方式是:根据vnode对象获取与其相关联的真实DOM元素,然后使用原生DOM操作方法将该DOM元素移除。
修改 mountElement
函数,在vnode与真实DOM元素之间建立联系:
function mountElement(vnode, container) {
// 让 vnode.el引用真实 DOM元素
const el = vnode.el = createElement(vnode.type);
if (typeof vnode.children === 'string') {
setElementText(el, vnode.children);
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach(child => {
patch(null, child, el);
})
}
if (vnode.props) {
for (const key in vnode.props) {
patchProps(el, key, null, vnode.props[key]);
}
}
insert(el, container);
}
当调用 createElement
函数创建真实DOM元素时,会把真实DOM元素赋值给 vnode.el
属性。这样在vnode与真实DOM元素之间就建立了联系,就可以通过 vnode.el
来获取该虚拟节点对应的真实DOM元素。
有了这些,当卸载操作发生时,只需要根据 vnode.el
取得真实DOM元素,再将其从父元素中移除即可:
function render(vnode, container) {
if (vnode) {
patch(container._vnode, vnode, container);
} else {
if (container._vnode) {
// 根据 vnode获取要卸载的真实DOM元素
const el = container._vnode.el;
// 获取 el的父元素
const parent = el.parentNode;
// 调用 removeChild 移除元素
if (parent) parent.removeChild(el);
}
}
container._vnode = vnode;
}
其中 container._vnode
代表旧 vnode,即要被卸载的vnode。然后通过 container._vnode.el
取得真实DOM元素,并调用 removeChild
函数将其从父元素中移除即可。
由于卸载操作是比较常见且基本的操作,所以我们将它封装到 unmount
函数中,以便后续代码可以复用它,unmount
实现:
function unmount(vnode) {
const parent = vnode.el.parentNode;
if (parent) parent.removeChild(vnode.el);
}
unmount
函数接收一个虚拟节点作为参数,并将该虚拟节点对应的 真实DOM元素从父元素中移除。现在这个代码非常简单,后续再慢慢完善它。
此时的render函数:
function render(vnode, container) {
if (vnode) {
patch(container._vnode, vnode, container);
} else {
if (container._vnode) {
// 调用unmount函数卸载 vnode
unmount(container._vnode);
}
}
container._vnode = vnode;
}
通过将卸载操作封装的unmount
中,有两点额外好处:
- 在unmount函数内,有机会调用绑定在DOM元素上的指令钩子函数,例如
beforeUnmount
、unmounted
等。 - 当
unmount
函数执行时,有机会检测虚拟节点 vnode的类型。如果该虚拟节点描述的是组件,则有机会调用组件相关的生命周期函数。
6.区分vnode的类型:
在前面中,当调用 render
渲染空内容null时,会执行卸载操作。
如果在后续渲染时,为render
函数传递了新的vnode
,则不会进行卸载操作,而是会把新旧vnode
都传递给patch
函数进行打补丁。回顾前面的patch函数:
function patch(n1, n2, container) {
// 如果n1不存在,意味着挂载,调用 mountElement()完成挂载
if (!n1) {
mountElement(n2, container);
} else {
// n1存在,意味着更新,暂时省略
}
}
n1:旧vnode,n2:新vnode。如果旧vnode存在,则需要在新旧vnode之间打补丁。
但在具体执行打补丁操作之前,需要保证新旧vnode所描述的内容相同。来看个例子,如果初次渲染的vnode是一个p元素:
const vnode = {
type: 'p'
}
renderer.render(vnode, document.querySelector('#app'))
后续渲染:
const vnode = {
type: 'input'
}
renderer.render(vnode, document.querySelector('#app'))
这就造成了新旧vnode所描述的内容不同,即 vnode.type
属性的值不同。对于上例来说,p元素和input元素之间不存在打补丁的意义。因为对于不同元素,每个元素都有特有的属性,例如:
<p id="foo"></p>
<!-- type属性是input标签特有的,p标签没有该属性 -->
<input type="submit" />
在这种情况下,正确的更新操作是:先将p元素卸载,再将input元素挂载到容器中。调整patch函数:
function patch(n1, n2, container) {
// 如果n1存在,则对比 n1 和n2的类型
if (n1 && n1.type !== n2.type) {
// 如果新旧vnode的类型不同,则直接将旧vnode卸载
unmount(n1);
n1 = null;
}
if (!n1) {
mountElement(n2, container);
} else {
// 更新
}
}
如上面代码所示,在真正执行更新操作之前优先检查新旧 vnode所描述的内容是否相同。如果不同则直接调用 unmount
函数将 旧vnode
卸载,卸载完后将 n1的值重置为null,这样就能确保后续挂载操作正确执行了。
即使新旧vnode描述的内容相同,仍然需要进一步确认它们的类型是否相同。
一个vnode可以用来描述普通标签,也可以用来描述组件,还可以用来描述 Fragment
等。对于不同类型的vnode,我们需要提供不同的挂载或打补丁的处理方式。
所以继续修改patch函数的代码以满足需求:
function patch(n1, n2, container) {
if (n1 && n1.type !== n2.type) {
unmount(n1);
n1 = null;
}
// 代码运行到这里,证明 n1 和 n2所描述的内容相同
const { type } = n2;
// 如果 n2.type的值是字符串类型,则它描述的是普通标签元素
if (typeof type === 'string') {
if (!n1) {
mountElement(n2, container);
} else {
patchElement(n1, n2);
}
} else if (typeof type === 'object') {
// 如果 n2.type的值类型是对象,则它描述的是组件
} else if (type === 'xxx') {
// 处理其他类型的vnode
}
}
实际上,在前面我们一直假设 vnode
类型是普通标签元素,但严谨做法是根据 vnode.type
进一步确认它们的类型是什么,从而使用相应的处理函数进行处理。例如:
typeof type === 'string'
,则它描述的是普通标签元素,调用mountElement
和patchElement
完成挂载和更新操作;typeof type === 'object'
,则它描述的是组件,则调用与组件相关的挂载和更新方法。
7.事件的处理:
这节开始讨论如何处理事件,包括如何在虚拟节点中描述事件,如何事件添加DOM元素上,以及如何更新事件。
先来解决第一个问题,即如何在虚拟节点中描述事件。
- 事件可以视作一种特殊的属性,因此我们可以约定,在
vnode.props
对象中,凡是以字符串 on 开头的属性都视作事件。如:
const vnode = {
type: 'p',
props: {
onClick: () => {
alert('clicked')
}
},
children: 'text'
}
解决了事件在虚拟节点层面的描述问题后,再来看看如何将事件添加到DOM元素上。
这不难,只需要在 patchProps
中调用 addEventListener()
来绑定事件即可,如:
patchProps(el, key, prevValue, nextValue) {
// 匹配以on开头的属性,视其为事件
if (/^on/.test(key)) {
// 根据属性名得到对应事件名,如 onClick -> click
const name = key.slice(2).toLowerCase();
// 绑定事件,nextValue为事件处理函数
el.addEventListener(name, nextValue);
} else if (key === 'class') {
el.className = nextValue || ''
} else if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key];
if (type === 'boolean' && nextValue === '') {
el[key] = true;
} else {
el[key] = nextValue;
}
}
else {
el.setAttribute(key, nextValue);
}
}
那么更新事件该如何处理呢?我们选择一种性能更优的方式来完成事件更新。
在绑定事件时,绑定一个伪造的事件处理函数 invoker
,然后把真正的事件处理函数设置为 invoker.value
属性的值。这样当更新事件时,就不需要调用 removeEventListener
函数来移除上一次绑定的事件,只需要更新 invoker.value
的值即可,如下代码:
patchProps(el, key, prevValue, nextValue) {
// 匹配以on开头的属性,视其为事件
if (/^on/.test(key)) {
let invoker = el._vei;
const name = key.slice(2).toLowerCase();
if (nextValue) {
if (!invoker) {
// 如果没有 invoker,则伪造invoker缓存到 el._vei中
// vei是vue event invoker的首字母缩写
invoker = el._vei = (e) => {
// 当伪造的事件处理函数执行时,会执行真正的事件处理函数
invoker.value(e);
}
// 将真正的事件处理函数赋值给 invoker.value
invoker.value = nextValue;
// 绑定 invoker作为事件处理函数
el.addEventListener(name, invoker);
} else {
// 如果invoker存在,直接更新 invoker.value的值即可
invoker.value = nextValue;
}
} else if (invoker) {
// 新的事件绑定函数不存在,且之前绑定的invoker存在,移除绑定
el.removeEventListener(name, invoker);
}
} else if (key === 'class') {
el.className = nextValue || ''
} else if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key];
if (type === 'boolean' && nextValue === '') {
el[key] = true;
} else {
el[key] = nextValue;
}
}
else {
el.setAttribute(key, nextValue);
}
}
事件绑定主要分为两个步骤:
- 先从
el._vei
中读取对应的invoker
,如果它不存在则将伪造的invoker
作为事件处理函数,并将它缓存到el._vei
属性中。 - 把真正的事件处理函数赋值给
invoker.value
,然后把伪造的invoker
函数作为事件处理函数绑定到元素上。可以看到,当事件触发时,实际上执行的是伪造的事件处理函数,在其内部间接执行了真正的事件处理函数invoker.value(e)
。
当更新事件时,由于 el._vei
已经存在,所以我们只用将 invoker.value
的值修改为新的事件处理函数就行。这样在更新时间时就避免一次 removeEventListener()
的调用,从而提升了性能。它的作用其实很挺多的,还可以用它解决事件冒泡与事件更新之间相互影响的问题,后文详解。
但目前的实现仍然存在问题,现在只需要将事件处理函数缓存在 el._vei
属性中,问题是同一时刻只能缓存一个事件处理函数。这意味着如果一个元素同时绑定了多种事件,将会出现事件覆盖的现象。为了解决这个现象,我们需要重新设计 el._vei
的数据结构,我们将它设计为一个对象,键是事件名称,值是对应的事件处理函数,这样就不会发生事件覆盖的现象。如下代码:
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
// 定义 el.vei为一个对象,存在事件名称到事件处理函数的映射
let invokers = el._vei || (el._vei = {});
// 根据事件名获取 invoker
let invoker = invokers[key];
const name = key.slice(2).toLowerCase();
if (nextValue) {
if (!invoker) {
// 将事件处理函数缓存到 el._vel[key]下,避免覆盖
invoker = el._vei[key] = (e) => {
// 当伪造的事件处理函数执行时,会执行真正的事件处理函数
invoker.value(e);
}
invoker.value = nextValue;
el.addEventListener(name, invoker);
} else {
invoker.value = nextValue;
}
} else if (invoker) {
el.removeEventListener(name, invoker);
}
} else if (key === 'class') {
el.className = nextValue || ''
} else if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key];
if (type === 'boolean' && nextValue === '') {
el[key] = true;
} else {
el[key] = nextValue;
}
}
else {
el.setAttribute(key, nextValue);
}
}
另外一个元素不仅可以绑定多种类型的事件,对于同一事件而已,还可以绑定多个处理函数。为了描述同一个事件的多个事件处理函数,我们需要调整 vnode.props
对象中事件的数据结构:
const vnode = {
type: 'p',
props: {
onClick: [
() => { alert('clicked 1') },
() => { alert('clicked 2') }
]
},
children: 'text'
}
用一个数组来描述事件,数组中的每个元素都是一个独立的事件处理函数,并且这些事件处理函数都能够正确地绑定到对应元素上,修改 patchProps
:
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
let invokers = el._vei || (el._vei = {});
let invoker = invokers[key];
const name = key.slice(2).toLowerCase();
if (nextValue) {
if (!invoker) {
invoker = el._vei[key] = (e) => {
// 如果 invoker.value是数组,则遍历它并逐个调用事件处理函数
if (Array.isArray(invoker.value)) {
invoker.value.forEach(fn => fn(e));
} else {
// 否则直接作为函数调用
invoker.value(e);
}
}
invoker.value = nextValue;
el.addEventListener(name, invoker);
} else {
invoker.value = nextValue;
}
} else if (invoker) {
el.removeEventListener(name, invoker);
}
} else if (key === 'class') {
el.className = nextValue || ''
} else if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key];
if (type === 'boolean' && nextValue === '') {
el[key] = true;
} else {
el[key] = nextValue;
}
}
else {
el.setAttribute(key, nextValue);
}
}
在这段代码中,修改了 invoker
函数的实现。当它执行时,在调用真正事件处理函数之前,要先检查 invoker.value
是否是数组,如果是则遍历它,并逐个调用定义在数组中的事件处理函数。
8.事件冒泡与更新时机问题:
在前面,介绍了基本的事件处理。这节将讨论事件冒泡与更新时机相结合所导致的问题。先来看一个小例子:
import { ref } from '../reactivity/ref'
import { effect } from '../reactivity/effect';
const bol = ref(false);
effect(() => {
// 创建vnode
const vnode = {
type: 'div',
props: bol.value ?
{ onClick: () => { alert('父元素 clicked') } }
: {},
children: [
{
type: 'p',
props: {
onClick: () => { bol.value = true }
},
children: 'text'
}
]
}
// 渲染vnode
renderer.render(vnode, document.querySelector('#app'));
})
创建一个响应式数据bol,接着创建一个副作用函数,并在内部调用渲染函数来渲染vnode。
这里重点在于该vnode对象,它描述了一个 div元素,并且该div元素具有一个p元素作为子节点,来看一下它们的特点:
- div元素:它的props对象的值是由一个三元表达式决定的。在首次渲染时,由于
bol.value
的值为false,所以它的props的值是一个空对象。 - p元素:它具有click点击事件,并且点击它时,事件处理函数会将
bol.value
的值设置为true。
来思考一个问题:当首次渲染完成后,点击p元素,会触发父级的click事件的事件处理函数执行吗?
- 答案其实很明显,在首次渲染完成之后,由于
bol.value
的值为false,所以渲染器并不会为div元素绑定点击事件。当用鼠标点击p元素时,即使 click事件可以从p元素冒泡到父级div元素,但由于div元素没有绑定click事件的事件处理函数,所以什么都不会发生。 - 但是事实是,当你尝试运行上面这段代码并点击p元素时,会发现父级div元素的click事件的事件处理函数居然执行了。这和更新机制有关。
来分析一下,当点击p元素时,发生了什么?
-
当点击p元素时,绑定到它身上的click函数会执行,于是
bol.value = true
。 -
接下来由于bol是一个响应式数据,当它的值发生变化时,会触发副作用函数重新执行。由于此时
bol.value = true
,所以在更新阶段,渲染器会为父级div元素绑定click事件处理函数。 -
当更新完成后,点击事件才从p元素冒泡到父级div元素。由于此时div元素已经绑定了 click事件的处理函数,就发生了上述奇怪的现象。可看下图:
观察上图可以发现,之所以出现前面的现象,是因为更新操作发生在事件冒泡之前,即:为div元素绑定事件处理函数发生在事件冒泡之前。并且可以发现,触发事件的时间与绑定事件的时间之间是有联系的,如下图:
看上图可以发现,事件触发的时间要早于事件处理函数被绑定的时间。
这意味着当一个事件触发时,目标元素上还没有绑定相关的事件处理函数,我们可以根据这个特点来解决问题:屏蔽所有绑定时间晚于事件触发时间的事件处理函数的执行。调整 patchProps()
中关于事件的代码:
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
let invokers = el._vei || (el._vei = {});
let invoker = invokers[key];
const name = key.slice(2).toLowerCase();
if (nextValue) {
if (!invoker) {
invoker = el._vei[key] = (e) => {
// e.timeStamp:事件发生的时间
// 如果事件发生的时间早于事件处理函数绑定的时间,则不执行事件处理函数
if (e.timeStamp < invoker.attached) return;
if (Array.isArray(invoker.value)) {
invoker.value.forEach(fn => fn(e));
} else {
invoker.value(e);
}
}
invoker.value = nextValue;
// 添加 invoker.attached属性,存储事件处理函数被绑定的时间
invoker.attached = performance.now();
el.addEventListener(name, invoker);
} else {
invoker.value = nextValue;
}
} else if (invoker) {
el.removeEventListener(name, invoker);
}
} else if (key === 'class') {
el.className = nextValue || ''
} else if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key];
if (type === 'boolean' && nextValue === '') {
el[key] = true;
} else {
el[key] = nextValue;
}
}
else {
el.setAttribute(key, nextValue);
}
}
如上代码,我们只是在原来的基础上添加了两行代码。添加 invoker.attached
属性用来存储事件处理函数被绑定的时间。然后在 invoker
执行的时候,通过事件对象的 e.timeStamp
获取事件发生的时间,然后再进行比较。
9.更新子节点:
前几节中讲解了元素属性的更新,包括普通标签属性和事件。接下来讨论如何更新元素的子节点。先回顾以下子节点是如何被挂载的:
function mountElement(vnode, container) {
// 让 vnode.el引用真实 DOM元素
const el = vnode.el = createElement(vnode.type);
if (typeof vnode.children === 'string') {
setElementText(el, vnode.children);
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach(child => {
patch(null, child, el);
})
}
if (vnode.props) {
for (const key in vnode.props) {
patchProps(el, key, null, vnode.props[key]);
}
}
insert(el, container);
}
在挂载子节点时,首先要区分其类型:
- 如果
vnode.children
是字符串,则说明元素具有文本子节点; - 如果
vnode.children
是数组,则说明元素具有多个子节点。
这里为什么要区分子节点的类型呢?其实这是一个规范性的问题,因为只有子节点的类型是规范化的,才有利于我们编写更新逻辑。那应该设定怎么样的规范呢?
其实对一个元素来说,它的子节点无非有以下三种情况:
- 没有子节点,此时
vnode.children
的值为null
; - 具有文本子节点,此时
vnode.children
的值为字符串,代表文本的内容; - 其他情况无论是单个元素子节点,还是多个子节点(可能是文本和元素的混合),都可以用数组来表示。
如下代码:
vnode = { type: 'div', children: null };
vnode = { type: 'div', children: 'Text' };
vnode = {
type: 'div',
children: [
{ type: 'p' },
'Text'
]
};
现在就规范化了 vnode.children
的类型。既然一个vnode子节点可能有三种情况,那么当渲染器执行更新时,新旧子节点都分别是三种情况之一。那么可以总结出更新子节点时全部九种情况,如下图:
但落实到代码中可以发现,其实不需要完全覆盖这九种可能。代码实现:
function patchElement(n1, n2) {
const el = n2.el = n1.el;
const oldProps = n1.props;
const newProps = n2.props;
// 第一步:更新props
for (const key in newProps) {
if (newProps[key] !== oldProps[key]) {
patchProps(el, key, oldProps[key], newProps[key]);
}
}
for (const key in oldProps) {
if (!(key in newProps)) {
patchProps(el, key, oldProps[key], null);
}
}
// 第二步:更新children
patchChildren(n1, n2, el);
}
如上面代码所示,更新子节点是对一个元素进行打补丁的最后一步操作。将它封装到 patchChildren
函数中:
function patchChildren(n1, n2, container) {
// 判断新子节点的类型是否是文本节点
if (typeof n2.children === 'string') {
// 旧子节点的类型的三种可能
// 只有当旧子节点为一组子节点时,才需要逐个卸载
if (Array.isArray(n1.children)) {
n1.children.forEach((c) => unmount(c));
}
// 最后将新的文本节点内容设置给容器元素
setElementText(container, n2.children);
} else if (Array.isArray(n2.children)) {
// 说明新子节点是一组子节点
if (Array.isArray(n1.children)) {
// 代码运行到这,说明新旧子节点都是一组子节点,这里涉及到核心Diff算法
} else {
// 此时旧子节点不是一组子节点,清空容器,并逐个挂载新子节点
setElementText(container, '');
n2.children.forEach(c => patch(null, c, container));
}
} else {
// 代码运行到这里,说明新子节点不存在
if (Array.isArray(n1.children)) {
// 旧子节点是一组子节点,逐个卸载
n1.children.forEach(c => unmount(c));
} else if (typeof n1.children === 'string') {
// 旧子节点是文本子节点,清空内容即可
setElementText(container, '');
}
// 如果也没有旧子节点,什么都不做
}
}
分别用三个分支来表示上面提到的三种情况,这样做能实现需求,但不是最优解,最优解在后面讲解如何使用Diff算法高效更新两组子节点。
10.文本节点和注释节点:
在前面,只说了描述普通标签的vnode,接下来将讨论如何使用vnode描述注释节点和文本节点。
我们知道,vnode.type
属性能够代表一个vnode的类型。
- 如果它是字符串类型,则它描述的是普通标签,并且该值就代表标签的名称。
- 但注释节点与文本节点不同于普通标签节点,它们不具有标签名称,所以我们需要人为创造一些唯一标识,这时自然而然就想到了使用
Symbol
。
const Text = Symbol();
const newVnode = {
type: Text,
children: '我是文本内容'
}
const Comment = Symbol();
const newVnode = {
type: Comment,
children: '我是注释内容'
}
vnode.type
属性的值我们使用文本节点和注释节点创建,这样就能够用vnode来描述文本节点和注释节点了。有了用于描述文本节点和注释节点的vnode对象后,就可以使用渲染器来渲染它们了,如下代码:
function patch(n1, n2, container) {
if (n1 && n1.type !== n2.type) {
unmount(n1);
n1 = null;
}
// 代码运行到这里,证明 n1 和 n2所描述的内容相同
const { type } = n2;
// 如果 n2.type的值是字符串类型,则它描述的是普通标签元素
if (typeof type === 'string') {
if (!n1) {
mountElement(n2, container);
} else {
patchElement(n1, n2);
}
} else if (typeof type === 'Text') {
// 如果新vnode的类型是Text,则它描述的是文本节点
// 如果没有旧节点,则进行挂载
if (!n1) {
//使用 createTextNode 创建文本节点
const el = n2.el = document.createTextNode(n2.children);
// 将文本节点插入到容器中
insert(el, container);
} else {
// 如果旧vnode存在,只需要用新文本节点的文本内容更新旧文本节点即可
const el = n2.el = n1.el;
if (n2.children !== n1.children) {
el.nodeValue = n2.children;
}
}
} else if (type === 'xxx') {
// 处理其他类型的vnode
}
}
可以看到 patch函数依赖浏览器平台特有的API,所以需要将 createText
和 setText
抽离封装到渲染器选项中:
const renderer = createRenderer({
createElement(tag) {
return document.createElement(tag);
},
setElementText(el, text) {
el.textContent = text;
},
insert(el, parent, anchor = null) {
parent.insertBefore(el, anchor);
},
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
let invokers = el._vei || (el._vei = {});
let invoker = invokers[key];
const name = key.slice(2).toLowerCase();
if (nextValue) {
if (!invoker) {
invoker = el._vei[key] = (e) => {
// e.timeStamp:事件发生的时间
// 如果事件发生的时间早于事件处理函数绑定的时间,则不执行事件处理函数
if (e.timeStamp < invoker.attached) return;
if (Array.isArray(invoker.value)) {
invoker.value.forEach(fn => fn(e));
} else {
invoker.value(e);
}
}
invoker.value = nextValue;
// 添加 invoker.attached属性,存储事件处理函数被绑定的时间
invoker.attached = performance.now();
el.addEventListener(name, invoker);
} else {
invoker.value = nextValue;
}
} else if (invoker) {
el.removeEventListener(name, invoker);
}
} else if (key === 'class') {
el.className = nextValue || ''
} else if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key];
if (type === 'boolean' && nextValue === '') {
el[key] = true;
} else {
el[key] = nextValue;
}
}
else {
el.setAttribute(key, nextValue);
}
},
createText(text) {
return document.createTextNode(text);
},
setText(el, text) {
el.nodeValue = text;
}
})
此时的patch函数:
function patch(n1, n2, container) {
if (n1 && n1.type !== n2.type) {
unmount(n1);
n1 = null;
}
const { type } = n2;
if (typeof type === 'string') {
if (!n1) {
mountElement(n2, container);
} else {
patchElement(n1, n2);
}
} else if (typeof type === 'Text') {
if (!n1) {
const el = n2.el = createTextNode(n2.children);
insert(el, container);
} else {
const el = n2.el = n1.el;
if (n2.children !== n1.children) {
setText(el, n2.children);
}
}
} else if (type === 'xxx') {
// 处理其他类型的vnode
}
}
注释节点的处理方式与文本节点的处理方式类似,不同在于注释节点是使用 document.createComment
函数创建注释节点元素。
11.Fragment:
Fragment(片断)是Vue.js3
中新增的一个vnode类型。在讨论实现之前,我们先来了解一下为什么需要它?来看场景,我们需要封装一组列表组件:
<List>
<Items />
</List>
整体由两个组件构成,List组件渲染一个ul标签作为包裹层:
<!-- List.vue -->
<template>
<ul>
<slot />
</ul>
</template>
而Items组件负责渲染一组li列表:
<!-- Items.vue -->
<template>
<li>1</li>
<li>2</li>
<li>3</li>
</template>
这在 Vue.js2中是无法实现的。在Vue.js2中,组件的模板不允许存在多个根节点,这意味着:一个items组件最多只能渲染一个li标签。因此在Vue.js2中,我们通常需要配合 v-for指令来达到目的:
<List>
<Items v-for="item in list" />
</List>
类似的组合还有 select标签和 option标签。
而Vue3中支持多根节点模板,所以不存在上述问题。那么Vue3如何用 vnode来描述多根节点模板?答案是使用 Fragment
,如下代码:
const Fragment = Symbol();
const vnode = {
type: Fragment,
children: [
{ type: 'li', children: '1' },
{ type: 'li', children: '2' },
{ type: 'li', children: '3' }
]
}
与文本节点和注释节点类似,片段也没有所谓的标签名称,因此需要用到Symbol来创建唯一标识,即Fragment。有了它之后,就可以用它来描述 Items.vue组件的模板了。
对于如下模板对应的vnode则为:
<List>
<Items />
</List>
const vnode = {
type: 'ul',
children: [
{
type: Fragment,
children: [
{ type: 'li', children: '1' },
{ type: 'li', children: '2' },
{ type: 'li', children: '3' }
]
}
]
}
当渲染器渲染 Fragment
类型的虚拟节点时,由于它本身不会渲染任何内容,所以渲染器只会渲染它的子节点,如下代码:
function patch(n1, n2, container) {
if (n1 && n1.type !== n2.type) {
unmount(n1);
n1 = null;
}
const { type } = n2;
if (typeof type === 'string') {
if (!n1) {
mountElement(n2, container);
} else {
patchElement(n1, n2);
}
} else if (typeof type === 'Text') {
if (!n1) {
const el = n2.el = createTextNode(n2.children);
insert(el, container);
} else {
const el = n2.el = n1.el;
if (n2.children !== n1.children) {
setText(el, n2.children);
}
}
} else if (type === 'Fragment') {
if (!n1) {
// 如果旧vnode不存在,只需要将 Fragment的children逐个挂载即可
n2.children.forEach(c => patch(null, c, container));
} else {
// 如果旧vnode存在,更新Fragment的children即可
patchChildren(n1, n2, container);
}
}
}
其实观察上面代码,可以发现渲染Fragment很简单。本质原因就是 渲染 Fragment与渲染普通元素的区别:它本身并不渲染任何内容,所以只需要处理它的子节点即可。
unmount
也支持 Fragment
类型的虚拟节点的卸载,故需要进行处理:
function unmount(vnode) {
// 卸载的vnode类型为Fragment时,则需要卸载其children
if (vnode.type === Fragment) {
vnode.children.forEach(c => unmount(c));
return;
}
const parent = vnode.el.parentNode;
if (parent) parent.removeChild(vnode.el);
}
12.总结:
在前面,首先讨论了如何挂载子节点,以及节点的属性。节点的属性其实挺复杂的,涉及到的两个概念,HTML Attributes 和 DOM Properties
,记住一个核心原则即可:HTML Attributes
是用来设置与之对应的 DOM Properties
的初始值的。
该如何正确设置元素属性,取决于被设置属性的特点,以及对特殊属性class的处理。
又该如何正确卸载,避免直接使用 innerHTML
来清空容器元素。
还有vnode的类型,该如何区分,什么时候才有打补丁的必要。即使它们所描述的内容相同,也需要进一步检查它们的类型(即vnode.type),根据这个来判断它描述的具体内容是什么。(字符串/对象/文本/注释/片段)
后面就是如何处理事件以及更新时即的问题。
至此,这一章就结束了,后面将介绍渲染器的核心Diff算法。