渲染器(二):挂载与更新
前面介绍了渲染器的基本概念和整体框架,接下来就可以介绍渲染器的核心功能:挂载与更新。
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 === '') {