渲染机制
拥有虚拟 DOM 层有一些好处,最重要的是它让组件的渲染逻辑完全从真实 DOM 中解耦,并让它更直接地在其他环境中重用框架的 runtime。
例如,Vue 允许第三方开发人员创建自定义渲染解决方案目标,不仅仅是浏览器,也包括 IOS 和 Android 等原生环境,也可以使用 API 创建自定义渲染器直接渲染到 WebGL 而不是 DOM节点。
在 Vue 3 中,我们让自定义渲染器 API 成为一等公民。因此开发人员可以直接拉取 Vue runtime 核心作为依赖项,然后利用自定义渲染器 API 构建自己的自定义渲染器。事实上,我们已经有了早期用户报告他们已经成功地构建了一个使用 Vue 3 API关于虚拟 DOM 的 WebGL 渲染器。
虚拟 DOM 的另一个重要的地方,是它提供了一种能力,能在实际返回渲染引擎之前,以编程方式构造、检查、克隆以及操作所需的 DOM 结构。你可以完全利用 JavaScript 的能力做到这些。这个能力很重要,因为总会有某些情况在 UI 编程中使用模板语法会有一些限制,你只需要一种有充分灵活性的合适的编程语言来表达潜在的逻辑。
这种情况更常见于当你在创作一个库、或 UI 组件套件,这些打算上传供第三方开发者使用的代码的时候。实际在日常 UI 开发中是相当罕见的。
让我们想象一下一个像复杂类型的顶部框或者一个与一堆文本相关联的输入框,这些类型的组件通常包含很少的标签,但它们将包含很多交互逻辑。在这些情况下,模板语法有时候会限制你更容易地表达潜在的逻辑,或者有时你在模板中加入了很多逻辑,同时还是很多逻辑在 JavaScript 中,而 render 函数允许你把这些逻辑组合在一个地方。你通常不需要想太多关于这些情况下的标签。
所以我的理解是模板会完成你要做的事,99%的情况下你只需要写出 HTML 就好了,但偶尔可能想做些更可控的事情,这时候你就需要编写一个渲染函数了。
渲染函数
Vue 2 中的渲染函数如下所示:
render(h) {
return h(
'div', {
attrs: { id: foo },
on: { click: this.onClick },
'hello'
})
}
render
是选项 API 中的一个选项,相对于提供一个template
option,你可以为组件提供一个渲染函数,函数内能得到h
参数,直接用它来创造我们的虚拟 DOM 节点,简称vnode
。
vnode
接受三个参数:
-
tag
:(必须) 第一个参数是节点的类型,在这里我们创建一个 div。
{String | Object | Function}
一个 HTML 标签名、一个组件、一个异步组件、或一个函数式组件。 -
props
:(可选) 第二个参数是一个对象,包含vnode
上的所有数据或属性。
{Object}
与 attribute、prop 和事件 相对应的对象。
我们会在模板中使用。
从某种意义上说,你必须指明传递给节点的绑定类型。例如,如果要绑定属性你必须把它嵌套在attrs
对象下;如果要绑定事件侦听器必须把它列在on
下。 -
children
:(可选) 第三个参数是这个vnode
的子节点。
{String | Array | Object}
使用h()
构建的子 VNodes 数组,或使用字符串获取 "文本 Vnode" 或者 有插槽的对象。
直接传递一个字符串表明此节点只包含文本子节点,它也可以是包含更多子节点的数组。所以你可以在这里有一个数组里面并且嵌套更多的嵌套 h 调用。
如果没有传prop
,那么通常可以将children
作为第二个参数传入。如果会产生歧义,可以将null
作为第二个参数传入,
在 Vue 3 中我们改变了API,目标是简化它。
import { h } from 'vue'
render () {
return h(
'div',
{
id: 'foo',
onClick: this.onClick
},
'hello'
})
}
第一个显著的变化是我们现在有了一个扁平的props
结构。当你调用h
时,第二个参数现在总是一个扁平的对象。你可以直接给它传递一个属性,这里我们给了它一个 ID。按惯例监听器以on
开头,所以任何带on
的都会自动绑定为一个监听器。所以你不必考虑太多嵌套的问题。
在大多数情况下,你也不需要思考是应将其作为attribute
绑定还是 DOM 属性绑定,因为 Vue 将智能地找出为你做这件事的最好方法。我们检查这个key
是否作为属性存在在原生 DOM 中。如果存在,我们会将其设置为property
;如果它不存在,我们将它设置为一个attribute
。
render API 的另一项改动是h
helper 现在是直接从 Vue 本身全局导入的。一些用户在 Vue 2 中很困扰因为h
只在这里面传递,而h
绑定到当前组件实例。当你想拆分一个大的渲染函数时,你必须把这个h
函数一路传递给这些分割函数。当有了全局引入的h
你导入一次就可以分割你的渲染函数,在同一个文件里分割多少个都行。
渲染函数不再有h
参数了,在内部它确实接收参数,但这只是编译器使用的,用来生成代码。当用户直接使用时,他们不需要这个参数。所以,如果你用 TypeScript 使用定义的组件 API 你也会得到 this 的完整类型推断。
Q&A
1.我知道原始的那种虚拟 DOM 的实现得到了启发来自其他项目对吗?
是的有一个库叫
snabbdom
,Vue 2 基本上就是从这个库中分离出来的。
2.好的然后是 Vue 3,你在这里的编码方式只是改进了 Vue 2 的模式吗?
好吧,Vue 3 是一个彻底的重写,几乎从头开始一切都是定制的。显然,有些现有的算法看起来像没有变化,因为这些是我们看到社区在做广泛研究的领域所以这是建立在所有这些以前的实现的基础上的,但代码本身现在是从头开始。
3.都是用TypeScript写的,对吧?
是的,都是 TypeScript 写的。
何时/如何使用 render 函数
在 Vue 2 中,一个传统的 Vue 组件,有一个template
选项。但为了重用渲染函数我们可以用一个render
函数来代替它,我们会通过参数得到h(hyperscript)
。但这里只示范一下我们如何在 Vue 3 中使用它。
import { h } from 'vue'
const App = {
render () {
return h('div') // 等效模板中的普通 div
}
}
- 所以它返回 div 的 JavaScript 对象表示?
完全正确。
- 那么,你的虚拟 DOM 就像…编译器?是编译器接收它吗?
是渲染器,渲染器接收它。
- 然后它实际上进行 DOM 调用将其带入浏览器?
完全正确。
所以我们可以给这个虚拟节点一些props
,
import { h } from 'vue'
const App = {
render () {
return h(
'div',
{ id: 'hello' },
[ h('span', 'world') ]
)
}
}
// world
现在,我们知道了如何生成静态结构。但是当人们第一次使用 render 函数会问 “我该怎么写比如v-if
或者v-for
”?我们没有像v-if
或者类似的东西。相反,你可以直接使用 JavaScript。
import { h } from 'vue'
const App = {
render () {
return this.ok
? h('div', { id: 'hello' }, [h('span', 'world')]
: h('p', 'other branch')
)
}
}
如果 ok 的值为true
,它将呈现div
;反之,它将呈现p
。同样,如果你想做v-else-if
你需要嵌套这个三元表达式:
import { h } from 'vue'
const App = {
render () {
return this.ok
? h('div',{ id: 'hello' }, [h('span', 'world')]
: this.otherCondition
? h('p', 'other branch')
: h('span')
)
}
}
我想你可能会喜欢创建一个变量,将不同的节点添加到该变量。所以当你不得不将这些整个嵌套在一个表达式中,这会很有用。
import { h } from 'vue'
let nodeToReturn
if(this.ok) {
nodeToReturn = ...
} else if (xxx) {
}
const App = {
render () {
return nodeToReturn
}
}
这就是 JavaScript 灵活的地方,这看起来更像普通的 JavaScript。当你的代码变得更加复杂时您可以使用普通的 JavaScript 重构技巧使它们更容易理解。
我们讨论了v-if
, 接下来看v-for
。 你也可以给它们加上key
,这是渲染函数中的渲染列表。
import { h } from 'vue'
const App = {
render () {
return this.list.map(item => {
return h('div', {key: item.id}, item.text)
}))
}
}
在渲染函数中,你很可能总是要处理插槽。
当你写一个重标记组件(markup heavy component
),或者我更喜欢称之为特性组件(feature component
),它与你的应用程序的外观有关,布局结构、实际的显示给用户的 HTML 。对于这些类型的组件,我更喜欢始终使用模板。
只有在必须使用渲染函数的时候,比如写一些功能型的组件,有时会期望获取一些插槽内容,将其打包或者以某种方式操纵他们。在 Vue 3 里默认插槽将暴露在this.$slot.default
。如果对组件什么都没有提供,这将是undefined
,所以你得先检查一下它是否存在。如果它存在,它将永远是一个数组。有了作用域槽,我们可以将props
传递给作用域槽,所以把数据传递到作用域槽只是通过传递一个参数到这个函数调用中。因为这是一个数组,你可以将它直接放在children
位置。
import { h } from 'vue'
const App = {
render () {
const slot = this.$slot.default ? this.$slot.default() : []
return h('div', slot)
}
}
你可以在render
函数中用插槽做一件很强大的事,比如以某种方式操纵插槽,因为它是一个 JavaScript 对象数组,你可以用filter
过滤,也可以用map
遍历它。
import { h } from 'vue'
const App = {
render () {
const slot = this.$slot.default ? this.$slot.default() : []
slot.map(vnode => {
return h('div', [vnode])
})
}
}
这里有一个拦截并更改插槽数据的例子。
假设我们有一个堆栈组件(tack component
),在一些用户界面库(UI libraries
)中很常见。你可以传递很多属性给它,得到嵌套的堆栈渲染结果,有点像 HTML 中ul
和ol
的默认样式。
hello
hello
hello
渲染结果会是这样:
hello
hello
它只是创造了一堆包裹元素,唯一的目的就是给其中一些元素加上额外的margin
或padding
用于布局。
普通的基于模板的语法,在同一个插槽内它们都是默认插槽,你能做的只有渲染这个部分,因此这在模板很难实现。但是用渲染函数则能够程序化地遍历插槽内的每个项目,然后把它们变成别的东西。
import { h } from 'vue'
const Stack = {
render () {
const slot = this.$slots.default ? this.$slots.default() : []
return h(
'div',
{class: 'stack'},
slot.map(child => {
return h('div', {class: `mt-${this.$props.size}`}, [child])
})
)
}
}
我们用slot.map
生成新的 vnode 列表,原来的子插槽被包装在里面。我们把它放到一个stack.html
文件里 :
// stack.html
当创作类似这些底层的公用设施组件,有时真的会遇到麻烦,这时渲染函数会更有效。但话说回来,也需要了解每种方法的利弊,这些是为了让你更好地理解在什么情况下应该使用模板或使用渲染函数。
基本上是当你用一个模板时遇到限制时,比如就像我们刚才看到的那样,可能改为使用渲染函数会更有效。当你意识到想表达的逻辑用 JavaScript 更容易而不是使用模板语法时就使用它。
从我的经验来看,这种情况在您创作可重用的功能组件,要跨多个应用程序共享或者在组织内部共享时更常见。在日常开发中你主要是在编写特性组件,模板通常是有效的方式,模板的好处是更简单,当你有很多标记的时候会通过编译器优化,它的另一个好处是它更容易让设计师接管组件并用 CSS 设计样式。因此,Vue 提供了这两个选项,当情况出现的时候以便您可以选择合适的方式。