一、组合式API
1. 什么是组合式API?
通过创建 Vue 组件,我们可以将界面中重复的部分连同其功能一起提取为可重用的代码段。仅此一项就可以使我们的应用在可维护性和灵活性方面走得相当远。然而,我们的经验已经证明,光靠这一点可能并不够,尤其是当你的应用变得非常大的时候——想想几百个组件。处理这样的大型应用时,共享和重用代码变得尤为重要。
假设我们的应用中有一个显示某个用户的仓库列表的视图。此外,我们还希望有搜索和筛选功能。实现此视图组件的代码可能如下所示:
1 | // src/components/UserRepositories.vue |
该组件有以下几个职责:
- 从假定的外部 API 获取该用户的仓库,并在用户有任何更改时进行刷新
- 使用
searchQuery
字符串搜索仓库 - 使用
filters
对象筛选仓库
使用 (data
、computed
、methods
、watch
) 组件选项来组织逻辑通常都很有效。然而,当我们的组件开始变得更大时,逻辑关注点的列表也会增长。尤其对于那些一开始没有编写这些组件的人来说,这会导致组件难以阅读和理解。
这是一个大型组件的示例,其中逻辑关注点按颜色进行分组。
这种碎片化使得理解和维护复杂组件变得困难。选项的分离掩盖了潜在的逻辑问题。此外,在处理单个逻辑关注点时,我们必须不断地“跳转”相关代码的选项块。
如果能够将同一个逻辑关注点相关代码收集在一起会更好。而这正是组合式 API 使我们能够做到的。
2. 组合式API基础
为了开始使用组合式 API,我们首先需要一个可以实际使用它的地方。在 Vue 组件中,我们将此位置称为 setup
。
2.1 setup
组件选项
新的 setup
选项在组件被创建之前执行,一旦 props
被解析完成,它就将被作为组合式 API 的入口。
在 setup
中你应该避免使用 this
,因为它不会找到组件实例。setup
的调用发生在 data
property、computed
property 或 methods
被解析之前,所以它们无法在 setup
中被获取。
setup
选项是一个接收 props
和 context
的函数。此外,我们将 setup
返回的所有内容都暴露给组件的其余部分 (计算属性、方法、生命周期钩子等等) 以及组件的模板。
把 setup
添加到组件中:
1 | // src/components/UserRepositories.vue |
现在让我们从提取第一个逻辑关注点开始 (在原始代码段中标记为“1”)。
- 从假定的外部 API 获取该用户的仓库,并在用户有任何更改时进行刷新
我们将从最明显的部分开始:
- 仓库列表
- 更新仓库列表的函数
- 返回列表和函数,以便其他组件选项可以对它们进行访问
2.2 带 ref
的响应式变量
在 Vue 3.0 中,我们可以通过一个新的 ref
函数使任何响应式变量在任何地方起作用,如下所示:
1 | import { ref } from 'vue' |
ref
接收参数并将其包裹在一个带有 value
property 的对象中返回,然后可以使用该 property 访问或更改响应式变量的值:
1 | import { ref } from 'vue' |
将值封装在一个对象中,看似没有必要,但为了保持 JavaScript 中不同数据类型的行为统一,这是必须的。这是因为在 JavaScript 中,Number
或 String
等基本类型是通过值而非引用传递的:
在任何值周围都有一个封装对象,这样我们就可以在整个应用中安全地传递它,而不必担心在某个地方失去它的响应性。
换句话说,ref
为我们的值创建了一个响应式引用。在整个组合式 API 中会经常使用引用的概念。
现在,每当我们调用 getUserRepositories
时,repositories
都将发生变化,视图也会更新以反映变化。我们的组件现在应该如下所示:
1 | // src/components/UserRepositories.vue |
我们已经将第一个逻辑关注点中的几个部分移到了 setup
方法中,它们彼此非常接近。剩下的就是在 mounted
钩子中调用 getUserRepositories
,并设置一个监听器,以便在 user
prop 发生变化时执行此操作。
我们将从生命周期钩子开始。
2.3 在 setup
内注册生命周期钩子
为了使组合式 API 的功能和选项式 API 一样完整,我们还需要一种在 setup
中注册生命周期钩子的方法。这要归功于 Vue 导出的几个新函数。组合式 API 上的生命周期钩子与选项式 API 的名称相同,但前缀为 on
:即 mounted
看起来会像 onMounted
。
这些函数接受一个回调,当钩子被组件调用时,该回调将被执行。
让我们将其添加到 setup
函数中:
1 | // src/components/UserRepositories.vue `setup` function |
现在我们需要对 user
prop 的变化做出反应。为此,我们将使用独立的 watch
函数。
2.4 watch
响应式更改
就像我们在组件中使用 watch
选项并在 user
property 上设置侦听器一样,我们也可以使用从 Vue 导入的 watch
函数执行相同的操作。它接受 3 个参数:
- 一个想要侦听的响应式引用或 getter 函数
- 一个回调
- 可选的配置选项
下面让我们快速了解一下它是如何工作的
1 | import { ref, watch } from 'vue' |
每当 counter
被修改时,例如 counter.value=5
,侦听将触发并执行回调 (第二个参数),在本例中,它将把 'The new counter value is:5'
记录到控制台中
以下是等效的选项式 API:
1 | export default { |
有关 watch
的详细信息,请参阅我们的深入指南。
现在我们将其应用到我们的示例中:
1 | // src/components/UserRepositories.vue `setup` function |
你可能已经注意到在我们的 setup
的顶部使用了 toRefs
。这是为了确保我们的侦听器能够根据 user
prop 的变化做出反应。
有了这些变化,我们就把第一个逻辑关注点移到了一个地方。我们现在可以对第二个关注点执行相同的操作——基于 searchQuery
进行过滤,这次是使用计算属性。
2.5 独立的 computed
属性
与 ref
和 watch
类似,也可以使用从 Vue 导入的 computed
函数在 Vue 组件外部创建计算属性。让我们回到 counter 的例子:
1 | import { ref, computed } from 'vue' |
这里我们给 computed
函数传递了第一个参数,它是一个类似 getter 的回调函数,输出的是一个只读的响应式引用。为了访问新创建的计算变量的 value,我们需要像 ref
一样使用 .value
property。
让我们将搜索功能移到 setup
中:
1 | // src/components/UserRepositories.vue `setup` function |
对于其他的逻辑关注点我们也可以这样做,但是你可能已经在问这个问题了——这不就是把代码移到 setup
选项并使它变得非常大吗?嗯,确实是这样的。这就是为什么我们要在继续其他任务之前,我们首先要将上述代码提取到一个独立的组合式函数中。让我们从创建 useUserRepositories
函数开始:
1 | // src/composables/useUserRepositories.js |
然后是搜索功能:
1 | // src/composables/useRepositoryNameSearch.js |
现在我们有了两个单独的功能模块,接下来就可以开始在组件中使用它们了。以下是如何做到这一点:
1 | // src/components/UserRepositories.vue |
此时,你可能已经知道了其中的奥妙,所以让我们跳到最后,迁移剩余的过滤功能。我们不需要深入了解实现细节,因为这并不是本指南的重点。
1 | // src/components/UserRepositories.vue |
我们只触及了组合式 API 的表面以及它允许我们做什么。要了解更多信息,请参阅深入指南。
2. Setup
2.1 参数
使用 setup
函数时,它将接收两个参数:
props
context
让我们更深入地研究如何使用每个参数。
2.1.1 Props
setup
函数中的第一个参数是 props
。正如在一个标准组件中所期望的那样,setup
函数中的 props
是响应式的,当传入新的 prop 时,它将被更新。
1 | // MyBook.vue |
因为 props
是响应式的,你不能使用 ES6 解构,它会消除 prop 的响应性。
如果需要解构 prop,可以在 setup
函数中使用 toRefs
函数来完成此操作:
1 | // MyBook.vue |
如果 title
是可选的 prop,则传入的 props
中可能没有 title
。在这种情况下,toRefs
将不会为 title
创建一个 ref 。你需要使用 toRef
替代它:
1 | // MyBook.vue |
2.1.2 Context
传递给 setup
函数的第二个参数是 context
。context
是一个普通 JavaScript 对象,暴露了其它可能在 setup
中有用的值:
1 | // MyBook.vue |
context
是一个普通的 JavaScript 对象,也就是说,它不是响应式的,这意味着你可以安全地对 context
使用 ES6 解构。
1 | // MyBook.vue |
attrs
和 slots
是有状态的对象,它们总是会随组件本身的更新而更新。这意味着你应该避免对它们进行解构,并始终以 attrs.x
或 slots.x
的方式引用 property。请注意,与 props
不同,attrs
和 slots
的 property 是非响应式的。如果你打算根据 attrs
或 slots
的更改应用副作用,那么应该在 onBeforeUpdate
生命周期钩子中执行此操作。
我们将在稍后解释 expose
所扮演的角色。
2.2 访问组件的 property
执行 setup
时,你只能访问以下 property:
props
attrs
slots
emit
换句话说,你将无法访问以下组件选项:
data
computed
methods
refs
(模板 ref)
2.3 结合模板使用
如果 setup
返回一个对象,那么该对象的 property 以及传递给 setup
的 props
参数中的 property 就都可以在模板中访问到:
1 | <!-- MyBook.vue --> |
注意,从 setup
返回的 refs 在模板中访问时是被自动浅解包的,因此不应在模板中使用 .value
。
2.4 使用渲染函数
setup
还可以返回一个渲染函数,该函数可以直接使用在同一作用域中声明的响应式状态:
1 | // MyBook.vue |
返回一个渲染函数将阻止我们返回任何其它的东西。从内部来说这不应该成为一个问题,但当我们想要将这个组件的方法通过模板 ref 暴露给父组件时就不一样了。
我们可以通过调用 expose
来解决这个问题,给它传递一个对象,其中定义的 property 将可以被外部组件实例访问:
1 | import { h, ref } from 'vue' |
这个 increment
方法现在将可以通过父组件的模板 ref 访问。
2.5 使用 this
在 setup()
内部,this
不是该活跃实例的引用,因为 setup()
是在解析其它组件选项之前被调用的,所以 setup()
内部的 this
的行为与其它选项中的 this
完全不同。这使得 setup()
在和其它选项式 API 一起使用时可能会导致混淆。
3. 生命周期钩子
你可以通过在生命周期钩子前面加上 “on” 来访问组件的生命周期钩子。
下表包含如何在 setup () 内部调用生命周期钩子:
选项式 API | Hook inside setup |
---|---|
beforeCreate |
Not needed* |
created |
Not needed* |
beforeMount |
onBeforeMount |
mounted |
onMounted |
beforeUpdate |
onBeforeUpdate |
updated |
onUpdated |
beforeUnmount |
onBeforeUnmount |
unmounted |
onUnmounted |
errorCaptured |
onErrorCaptured |
renderTracked |
onRenderTracked |
renderTriggered |
onRenderTriggered |
activated |
onActivated |
deactivated |
onDeactivated |
因为 setup
是围绕 beforeCreate
和 created
生命周期钩子运行的,所以不需要显式地定义它们。换句话说,在这些钩子中编写的任何代码都应该直接在 setup
函数中编写。
这些函数接受一个回调函数,当钩子被组件调用时将会被执行:
1 | // MyBook.vue |
4. Provide/Inject
我们也可以在组合式 API 中使用 provide/inject。两者都只能在当前活动实例的 setup()
期间调用。
4.1 设想场景
假设我们要重写以下代码,其中包含一个 MyMap
组件,该组件使用组合式 API 为 MyMarker
组件提供用户的位置。
1 | <!-- src/components/MyMap.vue --> |
1 | <!-- src/components/MyMarker.vue --> |
4.2 使用Provide
在 setup()
中使用 provide
时,我们首先从 vue
显式导入 provide
方法。这使我们能够调用 provide
来定义每个 property。
provide
函数允许你通过两个参数定义 property:
- name (
<String>
类型) - value
使用 MyMap
组件后,provide 的值可以按如下方式重构:
1 | <!-- src/components/MyMap.vue --> |
4.3 使用inject
在 setup()
中使用 inject
时,也需要从 vue
显式导入。导入以后,我们就可以调用它来定义暴露给我们的组件方式。
inject
函数有两个参数:
- 要 inject 的 property 的 name
- 默认值 (可选)
使用 MyMarker
组件,可以使用以下代码对其进行重构:
1 | <!-- src/components/MyMarker.vue --> |
4.4 响应性
4.4.1 添加响应性
为了增加 provide 值和 inject 值之间的响应性,我们可以在 provide 值时使用 ref 或 reactive。
使用 MyMap
组件,我们的代码可以更新如下:
1 | <!-- src/components/MyMap.vue --> |
现在,如果这两个 property 中有任何更改,MyMarker
组件也将自动更新!
4.4.2 修改响应式property
当使用响应式 provide / inject 值时,建议尽可能将对响应式 property 的所有修改限制在定义 provide 的组件内部。
例如,在需要更改用户位置的情况下,我们最好在 MyMap
组件中执行此操作。
1 | <!-- src/components/MyMap.vue --> |
然而,有时我们需要在注入数据的组件内部更新 inject 的数据。在这种情况下,我们建议 provide 一个方法来负责改变响应式 property。
1 | <!-- src/components/MyMap.vue --> |
1 | <!-- src/components/MyMarker.vue --> |
最后,如果要确保通过 provide
传递的数据不会被 inject 的组件更改,我们建议对提供者的 property 使用 readonly
。
1 | <!-- src/components/MyMap.vue --> |
5. 模板引用
在使用组合式 API 时,响应式引用和模板引用的概念是统一的。为了获得对模板内元素或组件实例的引用,我们可以像往常一样声明 ref 并从 setup() 返回:
1 | <template> |
这里我们在渲染上下文中暴露 root
,并通过 ref="root"
,将其绑定到 div 作为其 ref。在虚拟 DOM 补丁算法中,如果 VNode 的 ref
键对应于渲染上下文中的 ref,则 VNode 的相应元素或组件实例将被分配给该 ref 的值。这是在虚拟 DOM 挂载/打补丁过程中执行的,因此模板引用只会在初始渲染之后获得赋值。
作为模板使用的 ref 的行为与任何其他 ref 一样:它们是响应式的,可以传递到 (或从中返回) 复合函数中。
5.1 JSX中的用法
1 | export default { |
5.2 v-for
中的用法
组合式 API 模板引用在 v-for
内部使用时没有特殊处理。相反,请使用函数引用执行自定义处理:
1 | <template> |
5.3 侦听模板引用
侦听模板引用的变更可以替代前面例子中演示使用的生命周期钩子。
但与生命周期钩子的一个关键区别是,watch()
和 watchEffect()
在 DOM 挂载或更新之前运行副作用,所以当侦听器运行时,模板引用还未被更新。
1 | <template> |
因此,使用模板引用的侦听器应该用 flush: 'post'
选项来定义,这将在 DOM 更新后运行副作用,确保模板引用与 DOM 保持同步,并引用正确的元素。
1 | <template> |
二、Mixin
1. 基础
Mixin 提供了一种非常灵活的方式,来分发 Vue 组件中的可复用功能。一个 mixin 对象可以包含任意组件选项。当组件使用 mixin 对象时,所有 mixin 对象的选项将被“混合”进入该组件本身的选项。
例子:
1 | // 定义一个 mixin 对象 |
2. 选项合并
当组件和 mixin 对象含有同名选项时,这些选项将以恰当的方式进行“合并”。
比如,每个 mixin 可以拥有自己的 data
函数。每个 data
函数都会被调用,并将返回结果合并。在数据的 property 发生冲突时,会以组件自身的数据为优先。
1 | const myMixin = { |
同名钩子函数将合并为一个数组,因此都将被调用。另外,mixin 对象的钩子将在组件自身钩子之前调用。
1 | const myMixin = { |
值为对象的选项,例如 methods
、components
和 directives
,将被合并为同一个对象。两个对象键名冲突时,取组件对象的键值对。
1 | const myMixin = { |
3. 全局mixin
你还可以为 Vue 应用程序全局应用 mixin:
1 | const app = Vue.createApp({ |
Mixin 也可以进行全局注册。使用时格外小心!一旦使用全局 mixin,它将影响每一个之后创建的组件 (例如,每个子组件)。
1 | const app = Vue.createApp({ |
大多数情况下,只应当应用于自定义选项,就像上面示例一样。推荐将其作为插件发布,以避免重复应用 mixin。
4. 自定义选项合并策略
自定义选项在合并时,默认策略为简单地覆盖已有值。如果想让某个自定义选项以自定义逻辑进行合并,可以在 app.config.optionMergeStrategies
中添加一个函数:
1 | const app = Vue.createApp({}) |
合并策略接收在父实例和子实例上定义的该选项的值,分别作为第一个和第二个参数。让我们来检查一下使用 mixin 时,这些参数有哪些:
1 | const app = Vue.createApp({ |
如你所见,在控制台中,我们先从 mixin 打印 toVal
和 fromVal
,然后从 app
打印。如果存在,我们总是返回 fromVal
,这就是为什么 this.$options.custom
设置为 hello!
。最后,让我们尝试将策略更改为优先返回子实例的值:
1 | const app = Vue.createApp({ |
5. 不足
在 Vue 2 中,mixin 是将部分组件逻辑抽象成可重用块的主要工具。但是,他们有几个问题:
Mixin 很容易发生冲突:因为每个 mixin 的 property 都被合并到同一个组件中,所以为了避免 property 名冲突,你仍然需要了解其他每个特性。
可重用性是有限的:我们不能向 mixin 传递任何参数来改变它的逻辑,这降低了它们在抽象逻辑方面的灵活性。
为了解决这些问题,我们添加了一种通过逻辑关注点组织代码的新方法:组合式 API。
三、自定义指令
1. 简介
除了核心功能默认内置的指令 (例如 v-model
和 v-show
),Vue 也允许注册自定义指令。注意,在 Vue 中,代码复用和抽象的主要形式是组件。然而,有的情况下,你仍然需要对普通 DOM 元素进行底层操作,这时候就会用到自定义指令。举个聚焦输入框的例子,如下:
当页面加载时,该元素将获得焦点 (注意:autofocus
在移动版 Safari 上不工作)。事实上,如果你在打开这个页面后还没有点击过任何内容,那么此时这个输入框就应当处于聚焦状态。此外,你可以单击 Rerun
按钮,输入框将被聚焦。
现在让我们用指令来实现这个功能:
1 | const app = Vue.createApp({}) |
如果想注册局部指令,组件中也接受一个 directives
的选项:
1 | directives: { |
然后你可以在模板中任何元素上使用新的 v-focus
attribute,如下:
1 | <input v-focus /> |
2. 钩子函数
一个指令定义对象可以提供如下几个钩子函数 (均为可选):
created
:在绑定元素的 attribute 或事件监听器被应用之前调用。在指令需要附加在普通的v-on
事件监听器调用前的事件监听器中时,这很有用。beforeMount
:当指令第一次绑定到元素并且在挂载父组件之前调用。mounted
:在绑定元素的父组件被挂载后调用。beforeUpdate
:在更新包含组件的 VNode 之前调用。我们会在稍后讨论渲染函数时介绍更多 VNodes 的细节。updated
:在包含组件的 VNode 及其子组件的 VNode 更新后调用。beforeUnmount
:在卸载绑定元素的父组件之前调用unmounted
:当指令与元素解除绑定且父组件已卸载时,只调用一次。
接下来我们来看一下在自定义指令 API 钩子函数的参数 (即 el
、binding
、vnode
和 prevVnode
)
2.1 动态指令函数
指令的参数可以是动态的。例如,在 v-mydirective:[argument]="value"
中,argument
参数可以根据组件实例数据进行更新!这使得自定义指令可以在应用中被灵活使用。
例如你想要创建一个自定义指令,用来通过固定布局将元素固定在页面上。我们可以创建一个自定义指令,它的值以像素为单位更新被固定元素的垂直位置,如下所示:
1 | <div id="dynamic-arguments-example" class="demo"> |
1 | const app = Vue.createApp({}) |
这会把该元素固定在距离页面顶部 200 像素的位置。但如果场景是我们需要把元素固定在左侧而不是顶部又该怎么办呢?这时使用动态参数就可以非常方便地根据每个组件实例来进行更新。
1 | <div id="dynamicexample"> |
1 | const app = Vue.createApp({ |
我们的自定义指令现在已经足够灵活,可以支持一些不同的用例。为了使其更具动态性,我们还可以允许修改绑定值。让我们创建一个附加属性 pinPadding
,并将其绑定到 <input type="range">
。
1 | <div id="dynamicexample"> |
1 | const app = Vue.createApp({ |
让我们扩展指令逻辑以在组件更新后重新计算固定的距离。
1 | app.directive('pin', { |
3. 函数简写
在前面的例子中,你可能想在 mounted
和 updated
时触发相同行为,而不关心其他的钩子函数。那么你可以通过将这个回调函数传递给指令来实现:
1 | app.directive('pin', (el, binding) => { |
4. 对象字面量
如果指令需要多个值,可以传入一个 JavaScript 对象字面量。记住,指令函数能够接受所有合法的 JavaScript 表达式。
1 | <div v-demo="{ color: 'white', text: 'hello!' }"></div> |
1 | app.directive('demo', (el, binding) => { |
5. 在组件中使用
和非 prop 的 attribute 类似,当在组件中使用时,自定义指令总是会被应用在组件的根节点上。
1 | <my-component v-demo="test"></my-component> |
1 | app.component('my-component', { |
和 attribute 不同,指令不会通过 v-bind="$attrs"
被传入另一个元素。
有了片段支持以后,组件可能会有多个根节点。当被应用在一个多根节点的组件上时,指令会被忽略,并且会抛出一个警告。
四、Teleport
Vue 鼓励我们通过将 UI 和相关行为封装到组件中来构建 UI。我们可以将它们嵌套在另一个内部,以构建一个组成应用程序 UI 的树。
然而,有时组件模板的一部分逻辑上属于该组件,而从技术角度来看,最好将模板的这一部分移动到 DOM 中 Vue app 之外的其他位置。
一个常见的场景是创建一个包含全屏模式的组件。在大多数情况下,你希望模态框的逻辑存在于组件中,但是模态框的快速定位就很难通过 CSS 来解决,或者需要更改组件组合。
考虑下面的 HTML 结构。
1 | <body> |
让我们来看看 modal-button
组件:
该组件将有一个 button
元素来触发模态框的打开,以及一个带有 class .modal
的 div
元素,它将包含模态框的内容和一个用于自关闭的按钮。
1 | const app = Vue.createApp({}); |
当在初始的 HTML 结构中使用这个组件时,我们可以看到一个问题——模态框是在深度嵌套的 div
中渲染的,而模态框的 position:absolute
以父级相对定位的 div
作为引用。
Teleport 提供了一种干净的方法,允许我们控制在 DOM 中哪个父节点下渲染了 HTML,而不必求助于全局状态或将其拆分为两个组件。
让我们修改 modal-button
以使用 <teleport>
,并告诉 Vue “将这个 HTML 传送到‘body’标签下”。
1 | app.component('modal-button', { |
因此,一旦我们单击按钮打开模态框,Vue 将正确地将模态框内容渲染为 body
标签的子级。
1. 与 Vue components 一起使用
如果 <teleport>
包含 Vue 组件,则它仍将是 <teleport>
父组件的逻辑子组件:
1 | const app = Vue.createApp({ |
在这种情况下,即使在不同的地方渲染 child-component
,它仍将是 parent-component
的子级,并将从中接收 name
prop。
这也意味着来自父组件的注入会正常工作,在 Vue Devtools 中你会看到子组件嵌套在父组件之下,而不是出现在他会被实际移动到的位置。
2. 在同一目标上使用多个 teleport
一个常见的用例场景是一个可重用的 <Modal>
组件,它可能同时有多个实例处于活动状态。对于这种情况,多个 <teleport>
组件可以将其内容挂载到同一个目标元素。顺序将是一个简单的追加——稍后挂载将位于目标元素中较早的挂载之后。
1 | <teleport to="#modals"> |
你可以在 API 参考 查看 teleport
组件。
五、渲染函数
Vue 推荐在绝大多数情况下使用模板来创建你的 HTML。然而在一些场景中,你真的需要 JavaScript 的完全编程的能力。这时你可以用渲染函数,它比模板更接近编译器。
让我们深入一个简单的例子,这个例子里 render
函数很实用。假设我们要生成一些带锚点的标题:
1 | <h1> |
锚点标题的使用非常频繁,我们应该创建一个组件:
1 | <anchored-heading :level="1">Hello world!</anchored-heading> |
当开始写一个只能通过 level
prop 动态生成标题 (heading) 的组件时,我们很快就可以得出这样的结论:
1 | const { createApp } = Vue |
这个模板感觉不太好。它不仅冗长,而且我们为每个级别标题重复书写了 <slot></slot>
。当我们添加锚元素时,我们必须在每个 v-if/v-else-if
分支中再次重复它。
虽然模板在大多数组件中都非常好用,但是显然在这里它就不合适了。那么,我们来尝试使用 render
函数重写上面的例子:
1 | const { createApp, h } = Vue |
render()
函数的实现要精简得多,但是需要非常熟悉组件的实例 property。在这个例子中,你需要知道,向组件中传递不带 v-slot
指令的子节点时,比如 anchored-heading
中的 Hello world!
,这些子节点被存储在组件实例中的 $slots.default
中。如果你还不了解,在深入渲染函数之前推荐阅读实例 property API。
1. DOM树
在深入渲染函数之前,了解一些浏览器的工作原理是很重要的。以下面这段 HTML 为例:
1 | <div> |
当浏览器读到这些代码时,它会建立一个 ”DOM 节点“ 树 来保持追踪所有内容,如同你会画一张家谱树来追踪家庭成员的发展一样。
上述 HTML 对应的 DOM 节点树如下图所示
每个元素都是一个节点。每段文字也是一个节点。甚至注释也都是节点。一个节点就是页面的一个部分。就像家谱树一样,每个节点都可以有孩子节点 (也就是说每个部分可以包含其它的一些部分)。
高效地更新所有这些节点会是比较困难的,不过所幸你不必手动完成这个工作。你只需要告诉 Vue 你希望页面上的 HTML 是什么,这可以是在一个模板里:
1 | <h1>{{ blogTitle }}</h1> |
或者一个渲染函数里:
1 | render() { |
在这两种情况下,Vue 都会自动保持页面的更新,即便 blogTitle
发生了改变。
2. 虚拟DOM树
Vue 通过建立一个虚拟 DOM 来追踪自己要如何改变真实 DOM。请仔细看这行代码:
1 | return h('h1', {}, this.blogTitle) |
h()
到底会返回什么呢?其实不是一个实际的 DOM 元素。它更准确的名字可能是 createNodeDescription,因为它所包含的信息会告诉 Vue 页面上需要渲染什么样的节点,及其子节点的描述信息。我们把这样的节点描述为“虚拟节点 (virtual node)”,也常简写它为 VNode。“虚拟 DOM”是我们对由 Vue 组件树建立起来的整个 VNode 树的称呼。
3. h()
参数
h()
函数是一个用于创建 VNode 的实用程序。也许可以更准确地将其命名为 createVNode()
,但由于频繁使用和简洁,它被称为 h()
。它接受三个参数:
1 | // @returns {VNode} |
如果没有 prop,那么通常可以将 children 作为第二个参数传入。如果会产生歧义,可以将 null
作为第二个参数传入,将 children 作为第三个参数传入。
4. 完整实例
有了这些知识,我们现在可以完成我们最开始想实现的组件:
1 | const { createApp, h } = Vue |
5. 约束
5.1 VNodes必须唯一
组件树中的所有 VNode 必须是唯一的。这意味着,下面的渲染函数是不合法的:
1 | render() { |
如果你真的需要重复很多次的元素/组件,你可以使用工厂函数来实现。例如,下面这渲染函数用完全合法的方式渲染了 20 个相同的段落:
1 | render() { |
6. 创建组件VNode
要为某个组件创建一个 VNode,传递给 h
的第一个参数应该是组件本身。
1 | render() { |
如果我们需要通过名称来解析一个组件,那么我们可以调用 resolveComponent
:
1 | const { h, resolveComponent } = Vue |
resolveComponent
是模板内部用来解析组件名称的同一个函数。
render
函数通常只需要对全局注册的组件使用 resolveComponent
。而对于局部注册的却可以跳过,请看下面的例子:
1 | // 此写法可以简化 |
我们可以直接使用它,而不是通过名称注册一个组件,然后再查找:
1 | render() { |
7. 使用JavaScript代替模板功能
7.1 v-if
和 v-for
只要在原生的 JavaScript 中可以轻松完成的操作,Vue 的渲染函数就不会提供专有的替代方法。比如,在模板中使用的 v-if
和 v-for
:
1 | <ul v-if="items.length"> |
这些都可以在渲染函数中用 JavaScript 的 if
/else
和 map()
来重写:
1 | props: ['items'], |
7.2 v-model
v-model
指令扩展为 modelValue
和 onUpdate:modelValue
在模板编译过程中,我们必须自己提供这些 prop:
1 | props: ['modelValue'], |
7.3 v-on
我们必须为事件处理程序提供一个正确的 prop 名称,例如,要处理 click
事件,prop 名称应该是 onClick
。
1 | render() { |
7.3.1 事件修饰符
对于 .passive
、.capture
和 .once
事件修饰符,可以使用驼峰写法将他们拼接在事件名后面:
实例:
1 | render() { |
对于所有其它的修饰符,私有前缀都不是必须的,因为你可以在事件处理函数中使用事件方法:
修饰符 | 处理函数中的等价操作 |
---|---|
.stop |
event.stopPropagation() |
.prevent |
event.preventDefault() |
.self |
if (event.target !== event.currentTarget) return |
按键:.enter , .13 |
if (event.keyCode !== 13) return (对于别的按键修饰符来说,可将 13 改为另一个按键码 |
修饰键:.ctrl , .alt , .shift , .meta |
if (!event.ctrlKey) return (将 ctrlKey 分别修改为 altKey , shiftKey , 或 metaKey ) |
这里是一个使用所有修饰符的例子:
1 | render() { |
7.4 插槽
你可以通过 this.$slots
访问静态插槽的内容,每个插槽都是一个 VNode 数组:
1 | render() { |
1 | props: ['message'], |
要使用渲染函数将插槽传递给子组件,请执行以下操作:
1 | const { h, resolveComponent } = Vue |
插槽以函数的形式传递,允许子组件控制每个插槽内容的创建。任何响应式数据都应该在插槽函数内访问,以确保它被注册为子组件的依赖关系,而不是父组件。相反,对 resolveComponent
的调用应该在插槽函数之外进行,否则它们会相对于错误的组件进行解析。
1 | // `<MyButton><MyIcon :name="icon" />{{ text }}</MyButton>` |
如果一个组件从它的父组件中接收到插槽,它们可以直接传递给子组件。
1 | render() { |
也可以根据情况单独传递或包裹住。
1 | render() { |
7.5 <component>
和 is
在底层实现里,模板使用 resolveDynamicComponent
来实现 is
attribute。如果我们在 render
函数中需要 is
提供的所有灵活性,我们可以使用同样的函数:
1 | const { h, resolveDynamicComponent } = Vue |
就像 is
, resolveDynamicComponent
支持传递一个组件名称、一个 HTML 元素名称或一个组件选项对象。
通常这种程度的灵活性是不需要的。通常 resolveDynamicComponent
可以被换做一个更直接的替代方案。
例如,如果我们只需要支持组件名称,那么可以使用 resolveComponent
来代替。
如果 VNode 始终是一个 HTML 元素,那么我们可以直接把它的名字传递给 h
:
1 | // `<component :is="bold ? 'strong' : 'em'"></component>` |
同样,如果传递给 is
的值是一个组件选项对象,那么不需要解析什么,可以直接作为 h
的第一个参数传递。
与 <template>
标签一样,<component>
标签仅在模板中作为语法占位符需要,当迁移到 render
函数时,应被丢弃。
7.6 自定义指令
可以使用 withDirectives
将自定义指令应用于 VNode:
1 | const { h, resolveDirective, withDirectives } = Vue |
resolveDirective
是模板内部用来解析指令名称的同一个函数。只有当你还没有直接访问指令的定义对象时,才需要这样做。
7.7 内置组件
诸如 <keep-alive>
、<transition>
、<transition-group>
和 <teleport>
等内置组件默认并没有被全局注册。这使得打包工具可以 tree-shake,因此这些组件只会在被用到的时候被引入构建。不过这也意味着我们无法通过 resolveComponent
或 resolveDynamicComponent
访问它们。
在模板中这些组件会被特殊处理,即在它们被用到的时候自动导入。当我们编写自己的 render
函数时,需要自行导入它们:
1 | const { h, KeepAlive, Teleport, Transition, TransitionGroup } = Vue |
8. 渲染函数的返回值
在我们目前看过的所有示例中,render
函数返回的是单个根 VNode。但其实也有别的选项。
返回一个字符串时会创建一个文本 VNode,而不被包裹任何元素:
1 | render() { |
我们也可以返回一个子元素数组,而不把它们包裹在一个根结点里。这会创建一个片段 (fragment):
1 | // 相当于模板 `Hello<br>world!` |
可能是因为数据依然在加载中的关系,组件不需要渲染,这时它可以返回 null
。这样我们在 DOM 中会渲染一个注释节点。
9. JSX
如果你写了很多渲染函数,可能会觉得下面这样的代码写起来很痛苦:
1 | h( |
特别是对应的模板如此简单的情况下:
1 | <anchored-heading :level="1"> <span>Hello</span> world! </anchored-heading> |
这就是为什么会有一个 Babel 插件,用于在 Vue 中使用 JSX 语法,它可以让我们回到更接近于模板的语法上。
1 | import AnchoredHeading from './AnchoredHeading.vue' |
有关 JSX 如何映射到 JavaScript 的更多信息,请参阅使用文档 。
10. 函数式组件
函数式组件是自身没有任何状态的组件的另一种形式。它们在渲染过程中不会创建组件实例,并跳过常规的组件生命周期。
我们使用的是一个简单函数,而不是一个选项对象,来创建函数式组件。该函数实际上就是该组件的 render
函数。而因为函数式组件里没有 this
引用,Vue 会把 props
当作第一个参数传入:
1 | const FunctionalComponent = (props, context) => { |
第二个参数 context
包含三个 property:attrs
、emit
和 slots
。它们分别相当于实例的 $attrs
、$emit
和 $slots
这几个 property。
大多数常规组件的配置选项在函数式组件中都不可用。然而我们还是可以把 props
和 emits
作为 property 加入,以达到定义它们的目的:
1 | FunctionalComponent.props = ['value'] |
如果这个 props
选项没有被定义,那么被传入函数的 props
对象就会像 attrs
一样会包含所有 attribute。除非指定了 props
选项,否则每个 prop 的名字将不会基于驼峰命名法被一般化处理。
函数式组件可以像普通组件一样被注册和消费。如果你将一个函数作为第一个参数传入 h
,它将会被当作一个函数式组件来对待。
11. 模板编译
你可能会有兴趣知道,Vue 的模板实际上被编译成了渲染函数。这是一个实现细节,通常不需要关心。但如果你想看看模板的功能具体是怎样被编译的,可能会发现会非常有意思。下面是一个使用 Vue.compile
来实时编译模板字符串的简单示例:
六、插件
插件是自包含的代码,通常向 Vue 添加全局级功能。它可以是公开 install()
方法的 object
,也可以是 function
插件的功能范围没有严格的限制——一般有下面几种:
添加全局方法或者 property。如:vue-custom-element
添加全局资源:指令/过渡等。如:vue-touch)
通过全局 mixin 来添加一些组件选项。(如vue-router)
添加全局实例方法,通过把它们添加到
config.globalProperties
上实现。一个库,提供自己的 API,同时提供上面提到的一个或多个功能。如 vue-router
1. 编写插件
为了更好地理解如何创建自己的 Vue.js 版插件,我们将创建一个非常简化的插件版本,它显示 i18n
准备好的字符串。
每当这个插件被添加到应用程序中时,如果它是一个对象,就会调用 install
方法。如果它是一个 function
,则函数本身将被调用。在这两种情况下——它都会收到两个参数:由 Vue 的 createApp
生成的 app
对象和用户传入的选项。
让我们从设置插件对象开始。建议在单独的文件中创建它并将其导出,如下所示,以保持包含的逻辑和分离的逻辑。
1 | // plugins/i18n.js |
我们想要一个函数来翻译整个应用程序可用的键,因此我们将使用 app.config.globalProperties
暴露它。
该函数将接收一个 key
字符串,我们将使用它在用户提供的选项中查找转换后的字符串。
1 | // plugins/i18n.js |
我们假设用户使用插件时,将在 options
参数中传递一个包含翻译后的键的对象。我们的 $translate
函数将使用诸如 greetings.hello
之类的字符串,查看用户提供的配置内部并返回转换后的值-在这种情况下为 Bonjour!
。
例如:
1 | greetings: { |
插件还允许我们使用 inject
为插件的用户提供功能或 attribute。例如,我们可以允许应用程序访问 options
参数以能够使用翻译对象。
1 | // plugins/i18n.js |
插件用户现在可以将 inject[i18n]
注入到他们的组件并访问该对象。
另外,由于我们可以访问 app
对象,因此插件可以使用所有其他功能,例如使用 mixin
和 directive
。要了解有关 createApp
和应用程序实例的更多信息,请查看 Application API 文档。
1 | // plugins/i18n.js |
2. 使用插件
在使用 createApp()
初始化 Vue 应用程序后,你可以通过调用 use()
方法将插件添加到你的应用程序中。
我们将使用在编写插件部分中创建的 i18nPlugin
进行演示。
use()
方法有两个参数。第一个是要安装的插件,在这种情况下为 i18nPlugin
。
它还会自动阻止你多次使用同一插件,因此在同一插件上多次调用只会安装一次该插件。
第二个参数是可选的,并且取决于每个特定的插件。在演示 i18nPlugin
的情况下,它是带有转换后的字符串的对象。
如果你使用的是第三方插件 (例如 Vuex
或 Vue Router
),请始终查看文档以了解特定插件期望作为第二个参数接收的内容。
1 | import { createApp } from 'vue' |
awesome-vue 集合了大量由社区贡献的插件和库。