一、Vue与Web Components
Web Components 是一组 Web 原生 API 的总称,允许开发人员创建可重用的自定义组件。
Vue 和 Web Components 大体上是互补的技术。Vue 能很好地解析和创建自定义元素。不论是在将自定义元素整合到已有的 Vue 应用中,还是使用 Vue 构建和分发自定义元素,你都能获得很好的支持。
1. 在Vue中使用自定义元素
Vue 应用程序中解析出的自定义元素大体上和原生 HTML 元素相同,但需要牢记以下几点:
1.1 跳过组件的解析
默认情况下,Vue 会优先尝试将一个非原生的 HTML 标签解析为一个注册的 Vue 组件,如果失败则会将其渲染为自定义元素。这种行为会导致在开发模式下的 Vue 发出“failed to resolve component”的警告。如果你希望 Vue 能将某些确切的元素作为自定义元素处理并跳过组件解析,请指定 compilerOptions.isCustomElement
选项。
如果你正在构建步骤中使用 Vue,则此选项需要通过构建配置传递,因为这是一个编译时选项。
1.1.1 浏览器内配置示例
1 | // 仅当使用浏览器内编译时有效 |
1.1.2 Vite配置示例
1 | // vite.config.js |
1.1.3 Vue CLI配置示例
1 | // vue.config.js |
1.2 传递 DOM Property
由于 DOM attribute 只能是字符串,因此我们得将复杂数据作为 DOM property 传递给自定义元素。在自定义元素上配置 prop 时,Vue 3 会自动使用 in
操作符检查是否存在 DOM-property,如果此键存在则会优先将值配置为一个 DOM property。也就是说大多数情况下,如果自定义元素遵守推荐的最佳实践,则无需考虑这一点。
但是,在极少数情况下,数据必须作为 DOM property 传递,但自定义元素没有正确定义/反映 property (导致 in
检查失败)。此时,可以使用 .prop
修饰符强制将一个 v-bind
绑定设置为一个 DOM property:
1 | <my-element :user.prop="{ name: 'jack' }"></my-element> |
2. 使用 Vue 构建自定义元素
自定义元素的一大好处就是它们可以与任何框架一起使用,甚至可以在没有框架的情况下使用。当你需要向可能使用不同前端技术栈的终端用户分发组件时,或者希望向最终应用程序隐藏其所用组件的实现细节时,使用自定义元素非常适合。
2.1 defineCustomElement
Vue 支持使用 defineCustomElement
方法创建自定义元素,并且使用与 Vue 组件完全一致的 API。该方法接受与 defineComponent
相同的参数,但是会返回一个扩展自 HTMLElement
的自定义元素构造函数:
1 | <my-vue-element></my-vue-element> |
1 | import { defineCustomElement } from 'vue' |
2.1.1 生命周期
当元素的
connectedCallback
被首次调用时,Vue 自定义元素会在其隐式根部挂载一个内部的 Vue 组件实例。当元素的
disconnectedCallback
被调用时,Vue 会在很短的时间后检查此元素是否已被移出页面。如果元素仍在文档中,说明是移动,组件实例将被保留;
如果元素已被移出文档,说明是移除,组件实例将被卸载。
2.1.2 Props
所有使用
props
选项声明的 prop 都将在自定义元素上定义为 property。Vue 将在合适的时候自动处理 attribute / property 之间的映射。Attribute 总是映射为相应的 property。
基础类型 (
string
、boolean
或number
) 的 property 会被映射为 attribute。
Vue 也会自动将声明为
Boolean
或Number
类型的 attribute prop (始终为字符串)转换为所需的类型。例如给出以下 prop 声明:1
2
3
4props: {
selected: Boolean,
index: Number
}以及自定义元素用法:
1
<my-element selected index="1"></my-element>
在组件中,
selected
会被转换为true
(boolean),index
会被转换为1
(number)。
2.1.3 事件
在自定义元素中,通过 this.$emit
或在 setup 中的 emit
发出的事件会被调度为原生 CustomEvents。附加的事件参数 (payload) 会作为数组暴露在 CustomEvent 对象的 details
property 上。
2.1.4 插槽
在组件内部,可以像往常一样使用 <slot/>
渲染插槽。但是在解析最终生成的元素时,它只接受原生插槽语法:
不支持作用域插槽。
传递命名插槽时,请使用
slot
attribute 而非v-slot
指令:
1 | <my-element> |
2.1.5 Provide/Inject
Provide / Inject API 和组合式 API 中的 Provide / Inject 在 Vue 定义的自定义元素之间可以正常工作。但是请注意这仅适用于自定义元素之间,即 Vue 定义的自定义元素将无法注入非自定义元素的 Vue 组件提供的属性。
2.2 将 SFC 作为自定义元素
defineCustomElement
也适用于 Vue 单文件组件 (SFC)。但是,在默认工具链配置下,生产构建时 SFC 内部的 <style>
会被提取并合并到单独的 CSS 文件中。当使用 SFC 作为自定义元素时,通常需要将 <style>
标签注入自定义元素的隐式根。
官方 SFC 工具支持以“自定义元素模式”(需要 @vitejs/plugin-vue@^1.4.0
或 vue-loader@^16.5.0
)导入 SFC。以自定义元素模式加载的 SFC 将其 <style>
标签作为 CSS 字符串内联,并在组件的 styles
选项中暴露出来,然后会被 defineCustomElement
获取并在实例化时注入隐式根。
要选用此模式,只需使用 .ce.vue
作为文件拓展名即可:
1 | import { defineCustomElement } from 'vue' |
如果你希望指定应在自定义元素模式下导入的文件(例如将 所有 SFC 视为自定义元素),你可以将 customElement
选项传递给相应的构建插件:
2.3 Vue自定义元素库的提示
如果使用 Vue 构建自定义元素,则此元素将依赖于 Vue 的运行时。这会导致一个 16kb 左右的基础大小开销 (具体取决于使用了多少特性)。这意味着如果你准备发布单个自定义元素,使用 Vue 可能不是最佳方案——你可能想要使用纯 JavaScript,petite-vue,或是其他专注于轻量化运行时的框架。但是,如果你要发布具有复杂逻辑的自定义元素集合,那么这点基础大小就会显得合理了,因为 Vue 可以使用非常精简的代码耦合每个组件。你准备发布的元素越多,开销权衡就越好。
如果自定义元素会在同样使用 Vue 的项目中使用,你可以选择从构建的包中外部化 Vue,这样元素就会使用与宿主应用程序相同的 Vue 副本。
我们推荐你提供一个导出独立元素的构造函数,这样你的用户就可以灵活地按需导入它们并使用他们所需的标签名注册自定义元素。你还可以导出一个能自动注册所有元素的函数以便于使用。这是一个 Vue 自定义元素库示例的入口点:
1 | import { defineCustomElement } from 'vue' |
如果你有许多组件,你还可以利用构建工具提供的功能,例如 Vite 的 glob 导入或是 webpack 的 require.context
。
3. 对比 Web Components 与 Vue 组件
一些开发人员认为应该避免使用框架专有的组件模型,并且仅使用自定义元素以便应用程序“面向未来”。我们将在此处尝试解释为什么我们认为这种看法过于简单化了问题。
自定义元素和 Vue 组件之间确实存在一定程度的功能重叠:它们都允许我们定义具有数据传递、事件发出和生命周期管理功能的可重用组件。然而,Web Components API 是相对低级和简单的。为了构建一个实际可用的应用程序,我们需要很多平台没有涵盖的附加功能:
一个声明式的、高效的模板系统;
一个有助于跨组件逻辑提取和重用的响应式状态管理系统;
一个能在服务器端渲染组件并在客户端集成的高效方法(SSR),这对于 SEO 和 Web 关键指标 (例如 LCP) 来说很重要。原生自定义元素 SSR 通常涉及在 Node.js 中模拟 DOM,然后序列化被改变的 DOM,而 Vue SSR 会尽可能编译为字符串连接,后者的效率更高。
作为一个考虑周到的系统,Vue 的组件模型在设计时就考虑到了这些需求。
如果你拥有一支称职的工程团队,或许可以基于原生自定义元素构建出近似效果的产品——但这也意味着你需要承担对内部框架的长期维护负担,同时失去了像 Vue 这样拥有生态系统和社区贡献的成熟的框架。
也有使用自定义元素作为其组件模型基础构建的框架,但它们都不可避免地要针对上面列出的问题引入自己的专有解决方案。使用这些框架需要学习或是购买他们对这些问题的技术决策——尽管他们可能会打广告宣传——这依旧无法使你免除后顾之忧。
我们还找到了一些自定义元素无法胜任的应用场景:
激进的插槽定值会阻碍组件的整合。Vue 的作用域插槽提供了非常强大的组件整合机制,这是原生插槽所没有的,因为原生插槽的激进特性。激进特性插槽同样意味着接收组件无法控制何时或是否需要渲染一段插槽内容。
目前,发布带有隐式 DOM scoped CSS 的自定义元素需要将 CSS 嵌入到 JavaScript 中,以便它们可以在运行时注入到隐式根中。在 SSR 场景中,它们还会导致重复定义样式。该领域有一些平台特性正在开发中——但截至目前,它们尚未得到普遍支持,并且仍有生产环境性能/ SSR 问题需要解决。而与此同时,Vue SFC 已经提供了 CSS 作用域机制,支持将样式提取到纯 CSS 文件中。
Vue 将始终与 Web 平台中的最新标准保持同步,如果平台提供的任何内容能使我们的工作更轻松,我们将很乐意利用它。但是,我们的目标是提供运行良好且开箱即用的解决方案。这意味着我们必须以批判的心态整合新的平台功能——这会涉及到在遵循现有标准的前提下弥补标准的不足。
二、响应性原理
1. 深入响应性原理
Vue 最独特的特性之一,是其非侵入性的响应性系统。数据模型是被代理的 JavaScript 对象。而当你修改它们时,视图会进行更新。这让状态管理非常简单直观,不过理解其工作原理同样重要,这样你可以避开一些常见的问题。在这个章节,我们将研究一下 Vue 响应性系统的底层的细节。
1.1 什么是响应性
响应性是一种允许我们以声明式的方式去适应变化的编程范例。人们通常展示的典型例子,是一份 excel 电子表格 (一个非常好的例子)。
如果将数字 2 放在第一个单元格中,将数字 3 放在第二个单元格中并要求提供 SUM,则电子表格会将其计算出来给你。不要惊奇,同时,如果你更新第一个数字,SUM 也会自动更新。
JavaScript 通常不是这样工作的。
1 | let val1 = 2 |
如果我们更新第一个值,sum 不会被修改。
那么我们如何用 JavaScript 实现这一点呢?
作为一个高阶的概述,我们需要做到以下几点:
- 当一个值被读取时进行追踪,例如
val1 + val2
会同时读取val1
和val2
。 - 当某个值改变时进行检测,例如,当我们赋值
val1 = 3
。 - 重新运行代码来读取原始值,例如,再次运行
sum = val1 + val2
来更新sum
的值。
我们不能直接用前面的例子中的代码来继续,但是我们后面会再来看看这个例子,以及如何调整它来兼容 Vue 的响应性系统。
首先,让我们深入了解一下 Vue 是如何实现上述核心响应性要求的。
1.2 Vue如何知道哪些代码在执行
为了能够在数值变化时,随时运行我们的总和,我们首先要做的是将其包裹在一个函数中。
1 | const updateSum = () => { |
但我们如何告知 Vue 这个函数呢?
Vue 通过一个副作用 (effect) 来跟踪当前正在运行的函数。副作用是一个函数的包裹器,在函数被调用之前就启动跟踪。Vue 知道哪个副作用在何时运行,并能在需要时再次执行它。
为了更好地理解这一点,让我们尝试脱离 Vue 实现类似的东西,以看看它如何工作。
我们需要的是能够包裹总和的东西,像这样:
1 | createEffect(() => { |
我们需要 createEffect
来跟踪和执行。我们的实现如下:
1 | // 维持一个执行副作用的栈 |
当我们的副作用被调用时,在调用 fn
之前,它会把自己推到 runningEffects
数组中。这个数组可以用来检查当前正在运行的副作用。
副作用是许多关键功能的起点。例如,组件的渲染和计算属性都在内部使用副作用。任何时候,只要有东西对数据变化做出奇妙的回应,你就可以肯定它已经被包裹在一个副作用中了。
虽然 Vue 的公开 API 不包括任何直接创建副作用的方法,但它确实暴露了一个叫做 watchEffect
的函数,它的行为很像我们例子中的 createEffect
函数。我们会在该指南后面的部分详细讨论这个问题。
但知道什么代码在执行只是难题的一部分。Vue 如何知道副作用使用了什么值,以及如何知道它们何时发生变化?
1.3 Vue如何跟踪变化
我们不能像前面的例子中那样跟踪局部变量的重新分配,在 JavaScript 中没有这样的机制。我们可以跟踪的是对象 property 的变化。
当我们从一个组件的 data
函数中返回一个普通的 JavaScript 对象时,Vue 会将该对象包裹在一个带有 get
和 set
处理程序的 Proxy 中。Proxy 是在 ES6 中引入的,它使 Vue 3 避免了 Vue 早期版本中存在的一些响应性问题。
那看起来灵敏,不过,需要一些 Proxy 的知识才能理解!所以让我们深入了解一下。有很多关于 Proxy 的文档,但你真正需要知道的是,Proxy 是一个对象,它包装了另一个对象,并允许你拦截对该对象的任何交互。
我们这样使用它:new Proxy(target, handler)
1 | const dinner = { |
这里我们截获了读取目标对象 property 的举动。像这样的处理函数也称为一个*捕捉器 (trap)*。有许多可用的不同类型的捕捉器,每个都处理不同类型的交互。
除了控制台日志,我们可以在这里做任何我们想做的事情。如果我们愿意,我们甚至可以不返回实际值。这就是为什么 Proxy 对于创建 API 如此强大。
使用 Proxy 的一个难点是 this
绑定。我们希望任何方法都绑定到这个 Proxy,而不是目标对象,这样我们也可以拦截它们。值得庆幸的是,ES6 引入了另一个名为 Reflect
的新特性,它允许我们以最小的代价消除了这个问题:
1 | const dinner = { |
使用 Proxy 实现响应性的第一步就是跟踪一个 property 何时被读取。我们在一个名为 track
的处理器函数中执行此操作,该函数可以传入 target
和 property
两个参数。
1 | const dinner = { |
这里没有展示 track
的实现。它将检查当前运行的是哪个副作用,并将其与 target
和 property
记录在一起。这就是 Vue 如何知道这个 property 是该副作用的依赖项。
最后,我们需要在 property 值更改时重新运行这个副作用。为此,我们需要在代理上使用一个 set
处理函数:
1 | const dinner = { |
还记得前面的表格吗?现在,我们对 Vue 如何实现这些关键步骤有了答案:
- 当一个值被读取时进行追踪:proxy 的
get
处理函数中track
函数记录了该 property 和当前副作用。 - 当某个值改变时进行检测:在 proxy 上调用
set
处理函数。 - 重新运行代码来读取原始值:
trigger
函数查找哪些副作用依赖于该 property 并执行它们。
该被代理的对象对于用户来说是不可见的,但是在内部,它们使 Vue 能够在 property 的值被访问或修改的情况下进行依赖跟踪和变更通知。有一点需要注意,控制台日志会以不同的方式对 proxy 对象进行格式化,因此你可能需要安装 vue-devtools,以提供一种更易于检查的界面。
如果我们要用一个组件重写我们原来的例子,我们可以这样做:
1 | const vm = createApp({ |
data
返回的对象将被包裹在响应式代理中,并存储为 this.$data
。Property this.val1
和 this.val2
分别是 this.$data.val1
和 this.$data.val2
的别名,因此它们通过相同的代理。
Vue 将把 sum
的函数包裹在一个副作用中。当我们试图访问 this.sum
时,它将运行该副作用来计算数值。包裹 $data
的响应式代理将会追踪到,当副作用运行时,property val1
和 val2
被读取了。
从 Vue 3 开始,我们的响应性现在可以在一个独立包中使用。将 $data
包裹在一个代理中的函数被称为 reactive
。我们可以自己直接调用这个函数,允许我们在不需要使用组件的情况下将一个对象包裹在一个响应式代理中。
1 | const proxy = reactive({ |
在指南接下来的几页中,我们将探索响应性包所暴露的功能。这包括我们已经见过的 reactive
和 watchEffect
等函数,以及使用其他响应性特性的方法,如不需要创建组件的 computed
和 watch
。
1.3.1 被代理的对象
Vue 在内部跟踪所有已经被转成响应式的对象,所以它总是为同一个对象返回相同的代理。
当从一个响应式代理中访问一个嵌套对象时,该对象在被返回之前也被转换为一个代理:
1 | const handler = { |
1.3.2 Proxy vs 原始标识
Proxy 的使用确实引入了一个需要注意的新警告:在身份比较方面,被代理对象与原始对象不相等 (===
)。例如:
1 | const obj = {} |
其他依赖严格等于比较的操作也会受到影响,例如 .includes()
或 .indexOf()
。
这里的最佳实践是永远不要持有对原始对象的引用,而只使用响应式版本。
1 | const obj = reactive({ |
这确保了等值的比较和响应性的行为都符合预期。
请注意,Vue 不会在 Proxy 中包裹数字或字符串等原始值,所以你仍然可以对这些值直接使用 ===
来比较:
1 | const obj = reactive({ |
1.4 如何让渲染响应变化
一个组件的模板被编译成一个 render
函数。渲染函数创建 VNodes,描述该组件应该如何被渲染。它被包裹在一个副作用中,允许 Vue 在运行时跟踪被“触达”的 property。
一个 render
函数在概念上与一个 computed
property 非常相似。Vue 并不确切地追踪依赖关系是如何被使用的,它只知道在函数运行的某个时间点上使用了这些依赖关系。如果这些 property 中的任何一个随后发生了变化,它将触发副作用再次运行,重新运行 render
函数以生成新的 VNodes。然后这些举动被用来对 DOM 进行必要的修改。
2. 响应性基础
2.1 声明响应式状态
要为 JavaScript 对象创建响应式状态,可以使用 reactive
方法:
1 | import { reactive } from 'vue' |
reactive
相当于 Vue 2.x 中的 Vue.observable()
API,为避免与 RxJS 中的 observables 混淆因此对其重命名。该 API 返回一个响应式的对象状态。该响应式转换是“深度转换”——它会影响传递对象的所有嵌套 property。
Vue 中响应式状态的基本用例是我们可以在渲染期间使用它。因为依赖跟踪的关系,当响应式状态改变时视图会自动更新。
这就是 Vue 响应性系统的本质。当从组件中的 data()
返回一个对象时,它在内部交由 reactive()
使其成为响应式对象。模板会被编译成能够使用这些响应式 property 的渲染函数。
在响应性基础 API 章节你可以学习更多关于 reactive
的内容。
2.2 创建独立的响应式值作为 refs
想象一下,我们有一个独立的原始值 (例如,一个字符串),我们想让它变成响应式的。当然,我们可以创建一个拥有相同字符串 property 的对象,并将其传递给 reactive
。Vue 为我们提供了一个可以做相同事情的方法——ref
:
1 | import { ref } from 'vue' |
ref
会返回一个可变的响应式对象,该对象作为一个响应式的引用维护着它内部的值,这就是 ref
名称的来源。该对象只包含一个名为 value
的 property:
1 | import { ref } from 'vue' |
2.2.1 Ref解包
当 ref 作为渲染上下文 (从 setup() 中返回的对象) 上的 property 返回并可以在模板中被访问时,它将自动浅层次解包内部值。只有访问嵌套的 ref 时需要在模板中添加 .value
:
1 | <template> |
如果你不想要访问实际的对象实例,可将其用 reactive
包裹:
1 | nested: reactive({ |
2.2.2 访问响应式对象
当 ref
作为响应式对象的 property 被访问或更改时,为使其行为类似于普通 property,它会自动解包内部值:
1 | const count = ref(0) |
如果将新的 ref 赋值给现有 ref 的 property,将会替换旧的 ref:
1 | const otherCount = ref(2) |
Ref 解包仅发生在被响应式 Object
嵌套的时候。当从 Array
或原生集合类型如 Map
访问 ref 时,不会进行解包:
1 | const books = reactive([ref('Vue 3 Guide')]) |
2.3 响应式状态结构
当我们想使用大型响应式对象的一些 property 时,可能很想使用 ES6 解构来获取我们想要的 property:
1 | import { reactive } from 'vue' |
遗憾的是,使用解构的两个 property 的响应性都会丢失。对于这种情况,我们需要将我们的响应式对象转换为一组 ref。这些 ref 将保留与源对象的响应式关联:
1 | import { reactive, toRefs } from 'vue' |
你可以在 Refs API 部分中了解更多有关 refs
的信息
2.4 使用 readonly
防止更改响应式对象
有时我们想跟踪响应式对象 (ref
或 reactive
) 的变化,但我们也希望防止在应用程序的某个位置更改它。例如,当我们有一个被 provide 的响应式对象时,我们不想让它在注入之后被改变。为此,我们可以基于原始对象创建一个只读的 proxy 对象:
1 | import { reactive, readonly } from 'vue' |
3. 响应式计算和侦听
3.1 计算值
有时我们需要依赖于其他状态的状态——在 Vue 中,这是用组件计算属性处理的,以直接创建计算值,我们可以使用 computed
函数:它接受 getter 函数并为 getter 返回的值返回一个不可变的响应式 ref 对象。
1 | const count = ref(1) |
或者,它可以使用一个带有 get
和 set
函数的对象来创建一个可写的 ref 对象。
1 | const count = ref(1) |
3.1.1 调试Computed
computed
可接受一个带有 onTrack
和 onTrigger
选项的对象作为第二个参数:
onTrack
会在某个响应式 property 或 ref 作为依赖被追踪时调用。onTrigger
会在侦听回调被某个依赖的修改触发时调用。
所有回调都会收到一个 debugger 事件,其中包含了一些依赖相关的信息。推荐在这些回调内放置一个 debugger
语句以调试依赖。
1 | const plusOne = computed(() => count.value + 1, { |
onTrack
和 onTrigger
仅在开发模式下生效。
3.2 watchEffect
为了根据响应式状态自动应用和重新应用副作用,我们可以使用 watchEffect
函数。它立即执行传入的一个函数,同时响应式追踪其依赖,并在其依赖变更时重新运行该函数。
1 | const count = ref(0) |
3.2.1 停止侦听
当 watchEffect
在组件的 setup() 函数或生命周期钩子被调用时,侦听器会被链接到该组件的生命周期,并在组件卸载时自动停止。
在一些情况下,也可以显式调用返回值以停止侦听:
1 | const stop = watchEffect(() => { |
3.2.2 清除副作用
有时副作用函数会执行一些异步的副作用,这些响应需要在其失效时清除 (即完成之前状态已改变了) 。所以侦听副作用传入的函数可以接收一个 onInvalidate
函数作入参,用来注册清理失效时的回调。当以下情况发生时,这个失效回调会被触发:
- 副作用即将重新执行时
- 侦听器被停止 (如果在
setup()
或生命周期钩子函数中使用了watchEffect
,则在组件卸载时)
1 | watchEffect(onInvalidate => { |
我们之所以是通过传入一个函数去注册失效回调,而不是从回调返回它,是因为返回值对于异步错误处理很重要。
在执行数据请求时,副作用函数往往是一个异步函数:
1 | const data = ref(null) |
我们知道异步函数都会隐式地返回一个 Promise,但是清理函数必须要在 Promise 被 resolve 之前被注册。另外,Vue 依赖这个返回的 Promise 来自动处理 Promise 链上的潜在错误。