Vue 3 Deep Dive with Evan You

什么是 DOM?

如果我们把这个 HTML 加载到浏览器中,浏览器创建这些节点,用来显示网页。所以这个 HTML 映射到一系列 DOM 节点,然后我们可以使用 JavaScript 进行操作。例如:

let item = document.getElementByTagName('h1')[0]
item.textContent = "New Heading"

VDOM

网页可以有很多 DOM 节点,这意味着 DOM 树可以有数千个节点。这就是为什么我们有像 Vue 这样的框架,帮我们干这些重活儿,并进行大量的 JavaScript 调用。

然而,搜索和更新数千个 DOM 节点很明显会变慢。这就是 Vue 和其他类似框架有一种叫做虚拟 DOM 的东西。虚拟 DOM 是表示 DOM 的一种方式。例如,这个 HTML 也可以通过一个虚拟节点来表示,看起来像这样。如您所见,它只是一个 JavaScript 对象。

<div>Hello</div>
{
    tag: 'div',
    children: [
        {
            text: 'Hello'
        }
    ]
}

Vue 知道如何使用此虚拟节点并挂载到 DOM 上,它会更新我们在浏览器中看到的内容。实际上还有一个步骤其中,Vue 基于我们的模板创建一个渲染函数,返回一个虚拟 DOM 节点。

渲染函数可以是这样的:

render(h) {
    return h('div', 'hello')
}

当组件更改时,Render 函数将重新运行,它将创建另一个虚拟节点。然后发送旧的 VNode 和新的 VNode 到 Vue 中进行比较并以最高效的方式在我们的网页上更新。

我们可以将虚拟 DOM 和实际 DOM 的关系类比为蓝图和实际建筑的关系。假设我更改了 29 楼的一些数据。我改变了家具的布局还加了一些橱柜。我有两种方法可以改变。首先,我可以拆除 29 楼的一切从头开始重建。或者我可以创造新的蓝图,比较新旧蓝图并进行更新以尽可能减少工作量。这就是虚拟 DOM 的工作原理。Vue 3 让这些更新更快并且更高效。

核心模块

Vue 的三个核心模块:

  • Reactivity Module 响应式模块
  • Compiler Module 编译器模块
  • Renderer Module 渲染模块

响应式模块允许我们创建 JavaScript 响应对象并可以观察其变化。当使用这些对象的代码运行时,它们会被跟踪,因此,它们可以在响应对象发生变化后运行。

编译器模块获取 HTML 模板并将它们编译成渲染函数。这可能在运行时在浏览器中发生,但在构建 Vue 项目时更常见。这样浏览器就可以只接收渲染函数。

渲染模块的代码包含在网页上渲染组件的三个不同阶段:

  • 渲染阶段
  • 挂载阶段
  • 补丁阶段

在渲染阶段,将调用 render 函数,它返回一个虚拟 DOM 节点。
在挂载阶段,使用虚拟 DOM 节点并调用 DOM API 来创建网页。
在补丁阶段,渲染器将旧的虚拟节点和新的虚拟节点进行比较并只更新网页变化的部分。

现在让我们来看一个例子,一个简单组件的执行。它有一个模板,以及在模板内部使用的响应对象。首先,模板编译器将 HTML 转换为一个渲染函数。然后初始化响应对象,使用响应式模块。接下来,在渲染模块中,我们进入渲染阶段。这将调用 render 函数,它引用了响应对象。我们现在监听这个响应对象的变化,render 函数返回一个虚拟 DOM 节点。接下来,在挂载阶段,调用 mount 函数使用虚拟 DOM 节点创建 web 页面。最后,如果我们的响应对象发生任何变化,正在被监视,渲染器再次调用 render 函数,创建一个新的虚拟 DOM 节点。新的和旧的虚拟 DOM 节点,发送到补丁函数中,然后根据需要更新我们的网页。

渲染器机制

拥有虚拟 DOM 层有一些好处,最重要的是它让组件的渲染逻辑完全从真实 DOM 中解耦,并让它更直接地重用框架的运行时在其他环境中。例如,Vue 允许第三方开发人员创建自定义渲染解决方案目标,不仅仅是浏览器也包括 IOS 和 Android 等原生环境,也可以使用 API 创建自定义渲染器直接渲染到 WebGL 而不是 DOM 节点。在 Vue 2 中我们实际上已经有了这种能力但是,我们在 Vue 2 中提供的 API 没有正式记录并且需要分叉源代码。所以这给维护带来了很大的负担,对开发这些定制解决方案的开发人员在 Vue 3 中,我们让自定义渲染器 API 成为一等公民。因此开发人员可以直接拉取 Vue 运行时核心作为依赖项,然后利用自定义渲染器 API 构建自己的自定义渲染器。事实上,我们已经有了早期用户报告他们已经成功地构建了一个使用 Vue 3 API 关于虚拟 DOM 的 WebGL 渲染器。

另一个重要方面,它提供了能力以编程方式构造、检查、克隆以及操作所需的 DOM 结构,在实际返回渲染引擎之前你可以利用 JavaScript 的全部能力做到这些。这个能力很重要,因为总会有某些情况在 UI 编程中使用模板语法会有一些限制,你只需要一种有充分灵活性的合适的编程语言来表达潜在的逻辑。现在,这种情况实际上是相当罕见的在日常 UI 开发中。但当你在创作一个库的时候,这种情况更常见或编写 UI 组件套件,你打算上传供第三方开发者使用。让我们想象一下一个,像复杂类型的顶部框这样的组件或者一个与一堆文本相关联的输入框,这些类型的组件通常包含很少的标记,但它们将包含很多交互逻辑在这些情况下,模板语法有时候会限制你更容易地表达潜在的逻辑,或者有时候你会发现自己在模板中加入了很多逻辑,但你还是有很多逻辑在 JavaScript 中而 render 函数允许你把这些逻辑组合在一个地方你通常不需要想太多关于这些情况下的标记。

所以我理解是模板会完成你要做的事在 99% 的情况下你只需要写出 HTML 就好了,但偶尔可能想做些更可控的事情在,你需要编写一个渲染函数。Vue 2 中的渲染函数如下所示,

render(h) {
    return h (
        'div', {
        attrs: {
            id: foo
        },
        on: {
            click: this.onClick
        },
        'hello'
    })
}

所以这是组件定义中的一个选项,相对于提供一个 template 选项,在 Vue 2 中你可以为组件提供一个渲染函数,你会得到 h 参数,直接作为渲染函数的参数。你可以用它来创造我们称之为虚拟 DOM 节点,简称 vnode。

vnode 接受三个参数:

  • 第一个参数是类型,所以我们在这里创建一个 div。
  • 第二个参数是一个对象包含 vnode 上的所有数据或属性,API 有点冗长从某种意义上说,你必须指明传递给节点的绑定类型。例如,如果要绑定属性你必须把它嵌套在 attrs 对象下如果要绑定事件侦听器你得把它列在 on 下面。
  • 第三个参数是这个 vnode 的子节点。所以直接传递一个字符串是一个方便的 API,表明此节点只包含文本子节点,但它也可以是包含更多子节点的数组。所以你可以在这里有一个数组并且嵌套了更多的嵌套 h 调用。

在 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 的实现得到了启发来自其他项目对吗?

是的有一个库叫 snabbdomVue 2 基本上就是从这个库中分离出来的。

2. 好的然后是 Vue 3,你在这里的编码方式只是改进了 Vue 2 的模式吗?

好吧,Vue 3 是一个彻底的重写,几乎从头开始一切都是定制的显然,有现有的算法看起来像没有变化,因为这些是我们看到社区在做广泛研究的领域所以这是建立在所有这些以前的实现的基础上的但代码本身现在是从头开始。

3. 都是用 TypeScript 写的,对吧?

是的,都是 TypeScript 写的。

何时 / 如何使用 render 函数

看看渲染函数在 Vue 中是什么样子。在 Vue 2 中,一个传统的 Vue 组件,有一个 template 选项,但是为了重用渲染函数我们可以用一个名为 render 的函数来代替它,我们会通过参数得到这个称为 h(hyperscript)。但在这里,我们只是示范一下我们如何在 Vue 3 中使用它。我们会从 vue 导入 h,我们可以用它来返回 h。

import { h } from 'vue'

const App = {
render () {
return h('div')
}
}

// 等效模板中的普通 div

1. 所以它返回 div 的 JavaScript 对象表示?

完全正确。

2. 那么,你的虚拟 dom 就像…编译器?是编译器接收它吗?

是渲染器,渲染器接收它。

3. 然后它实际上进行 dom 调用将其带入浏览器?

完全正确。

所以我们可以给这个虚拟节点一些 props,

import { h } from 'vue'

const App = {
render () {
return h(
'div',
{
id: 'hello'
},
[
h('span','world')
]
)
}
}

// <div id="hello"><span>world</span></div>

现在,我们知道如何生成静态结构。但是当人们第一次使用 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 () {

}

const App = {
render () {
return this.ok
? h('div',{ id: 'hello' },[h('span','world')]
: this.otherCondition
? h('p', 'other branch')
: h('span')
)
}
}

这就是 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()
: []

    <span class="token keyword">return</span> <span class="token function">h</span><span class="token punctuation">(</span><span class="token string">'div'</span><span class="token punctuation">,</span> slot<span class="token punctuation">)</span>
<span class="token punctuation">}</span>

}

你可以在 render 函数中用插槽做一件很强大的事,比如以某种方式操纵插槽,因为它只是一个 JavaScript 对象数组,你可以用 map 遍历它。

import { h } from 'vue'

const App = {
render () {
const slot = this.$slot.default
? this.$slot.default()
: []

    slot<span class="token punctuation">.</span><span class="token function">map</span><span class="token punctuation">(</span><span class="token parameter">vnode</span> <span class="token operator">=&gt;</span> <span class="token punctuation">{</span>
        <span class="token keyword">return</span> <span class="token function">h</span><span class="token punctuation">(</span><span class="token string">'div'</span><span class="token punctuation">,</span> <span class="token punctuation">[</span>vnode<span class="token punctuation">]</span><span class="token punctuation">)</span>
    <span class="token punctuation">}</span><span class="token punctuation">)</span>
<span class="token punctuation">}</span>

}

这里有一个例子,截住并更改插槽数据。假设我们有一个堆栈组件(tack component),在一些用户界面库 (UI libraries) 中很常见。你可以传递很多属性给它,得到嵌套的堆栈渲染结果,有点像 HTML 中 ulol 的默认样式。

<Stack size="4">
    <div>hello</div>
    <Stack size="4">
        <div>hello</div>
        <div>hello</div>
    </Stack>
</Stack>

渲染成这样:

<div class="stack">
    <div class="mt-4">
         <div>hello</div>
    </div>
    <div class="mt-4">
         <div class="stack">
            <div class="mt-4">
                <div>hello</div>
            </div>
         </div>
    </div>
</div>

这里有一个普通的基于模板的语法,在同一个插槽内它们都是默认插槽,你能做的只有渲染这个部分,在模板很难实现。但是你可以用渲染函数来实现,程序化的遍历插槽内的每个项目然后把它们变成别的东西。

import { h } from 'vue'

const Stack = {
render () {
const slot = this.$slots.default
? this.$slots.default()
: []

    <span class="token keyword">return</span> <span class="token function">h</span><span class="token punctuation">(</span>
        <span class="token string">'div'</span><span class="token punctuation">,</span>
        <span class="token punctuation">{</span><span class="token keyword">class</span><span class="token operator">:</span> <span class="token string">'stack'</span><span class="token punctuation">}</span><span class="token punctuation">,</span>
        slot<span class="token punctuation">.</span><span class="token function">map</span><span class="token punctuation">(</span><span class="token parameter">child</span> <span class="token operator">=&gt;</span> <span class="token punctuation">{</span>
            <span class="token keyword">return</span> <span class="token function">h</span><span class="token punctuation">(</span>
                    <span class="token string">'div'</span><span class="token punctuation">,</span> 
                    <span class="token punctuation">{</span><span class="token keyword">class</span><span class="token operator">:</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">mt-</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span><span class="token keyword">this</span><span class="token punctuation">.</span>$props<span class="token punctuation">.</span>size<span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">`</span></span><span class="token punctuation">}</span><span class="token punctuation">,</span>
                    <span class="token punctuation">[</span>child<span class="token punctuation">]</span>
                <span class="token punctuation">)</span>
        <span class="token punctuation">}</span><span class="token punctuation">)</span>
    <span class="token punctuation">)</span>
<span class="token punctuation">}</span>

}

我们用 slot.map 生成新的 vnode 列表,原来的子插槽被包装在里面。有了这个,我们把它放到一个 stack.html 文件里。

stack.html

<script src="https://unpkg.com/vue@next"></script>
<style>
    .mt-4 {
        margin: 10px
    }
</style>

<div id="app"></div>

<script>
const { h, createApp } = Vue

<span class="token keyword">const</span> Stack <span class="token operator">=</span> <span class="token punctuation">{</span>
    <span class="token function">render</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
        <span class="token keyword">const</span> slot <span class="token operator">=</span> <span class="token keyword">this</span><span class="token punctuation">.</span>$slots<span class="token punctuation">.</span>default
            <span class="token operator">?</span> <span class="token keyword">this</span><span class="token punctuation">.</span>$slots<span class="token punctuation">.</span><span class="token function">default</span><span class="token punctuation">(</span><span class="token punctuation">)</span>
            <span class="token operator">:</span> <span class="token punctuation">[</span><span class="token punctuation">]</span>

        <span class="token keyword">return</span> <span class="token function">h</span><span class="token punctuation">(</span>
            <span class="token string">'div'</span><span class="token punctuation">,</span>
            <span class="token punctuation">{</span> <span class="token keyword">class</span><span class="token operator">:</span> <span class="token string">'stack'</span> <span class="token punctuation">}</span><span class="token punctuation">,</span>
            slot<span class="token punctuation">.</span><span class="token function">map</span><span class="token punctuation">(</span><span class="token parameter">child</span> <span class="token operator">=&gt;</span> <span class="token punctuation">{</span>
                <span class="token keyword">return</span> <span class="token function">h</span><span class="token punctuation">(</span><span class="token string">'div'</span><span class="token punctuation">,</span> <span class="token punctuation">{</span> <span class="token keyword">class</span><span class="token operator">:</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">mt-</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span><span class="token keyword">this</span><span class="token punctuation">.</span>$attrs<span class="token punctuation">.</span>size<span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">`</span></span> <span class="token punctuation">}</span><span class="token punctuation">,</span> <span class="token punctuation">[</span>child<span class="token punctuation">]</span><span class="token punctuation">)</span>
                <span class="token comment">// this.$props.size ?</span>
            <span class="token punctuation">}</span><span class="token punctuation">)</span>
        <span class="token punctuation">)</span>
    <span class="token punctuation">}</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span>

<span class="token keyword">const</span> App <span class="token operator">=</span> <span class="token punctuation">{</span>
    <span class="token literal-property property">components</span><span class="token operator">:</span> <span class="token punctuation">{</span>
        Stack
    <span class="token punctuation">}</span><span class="token punctuation">,</span>
    <span class="token literal-property property">template</span><span class="token operator">:</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">
    &lt;Stack size="4"&gt;
        &lt;div&gt;hello&lt;/div&gt;
        &lt;Stack size="4"&gt;
            &lt;div&gt;hello&lt;/div&gt;
            &lt;div&gt;hello&lt;/div&gt;
        &lt;/Stack&gt;
    &lt;/Stack&gt;
    </span><span class="token template-punctuation string">`</span></span>
<span class="token punctuation">}</span>

<span class="token function">createApp</span><span class="token punctuation">(</span>App<span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token function">mount</span><span class="token punctuation">(</span><span class="token string">'#app'</span><span class="token punctuation">)</span>

</script>

当你创作这些底层的公用设施组件,有时真的会遇到麻烦,这时渲染函数更有效。但话说回来,也需要了解每种方法的利弊,这些是为了让你更好地理解在什么情况下应该使用模板或使用渲染函数。基本上是当你用一个模板时遇到限制时,比如你就像我们刚才看到的那样,可能改为使用渲染函数会更有效。当你意识到想表达的逻辑用 JavaScript 更容易而不是使用模板语法时就使用它。从我的经验来看,这种情况在您创作可重用的功能组件,要跨多个应用程序共享或者在组织内部共享时更常见。在日常开发中你主要是在编写特性组件,模板通常是有效的方式,模板的好处是更简单,当你有很多标记的时候会通过编译器优化,它的另一个好处是它更容易让设计师接管组件并用 CSS 设计样式。因此,Vue 提供了这两个选项,当情况出现的时候以便您可以选择合适的方式。


更新中...