这篇文档的主要目的是介绍一些 Vue 和其他框架不完全相同的抽象方法,但其中并非所有方法都常见/常用,多数情况还是需要根据场景来选择更合适的方法。
属性合并
Vue 模板的 class 属性声明是支持合并的,举个例子就很容易理解了:
上面的例子里子组件的 className
实际上是 'cls-a cls-b cls-c'
与此相似的,style 属性的表现也和 class 一致,支持如上的合并规则。
除了 class 和 style,属性的绑定也支持合并,例如:
上面的例子里,子组件接收的 props 最终会是 { propA: 'abc', propB: 'xyz' }
注意,这里尽可能地避免在合并时出现同名属性的情况。在 Vue 2.6 中这个逻辑有修改过,并且根据成员的反应来看这更像是一个 UB。目前的规则是,合并与顺序无关,单独声明的属性总是会覆盖 v-bind 的组合声明
model 和 :prop.sync
相关的官方文档
概念
简单来说,model
和 :prop.sync
都是一个属性和一个事件的组合,有点类似于数学里面群的概念;或者从数据流的角度思考,对于子组件来说,相关数据的 getter 等价于 model 或者是 sync 对应的属性,setter 等价于需要传递给子组件的事件。
在 Vue 中,如果遇到了子组件需要修改 props 的场景,使用这两个通常都是最直观的答案。 因为其双向的特性,v-model 和 prop.sync 绑定的属性必须明确地指向一个可赋值的地址,也就是说,不支持将字面量,或者是函数的返回值作为其绑定的属性。
区别
实际上 model 和 :prop.sync 能够实现的功能是完全一致的(除了 model 只能声明一个属性),事实上,社区内有很多关于二者功能合并的讨论(例如 https://github.com/vuejs/rfcs/blob/master/active-rfcs/0005-replace-v-bind-sync-with-v-model-argument.md)。比较容易发现的一个区别是,model 需要在组件选项内额外进行声明(配置式),而 :prop.sync 是不需要额外的声明的(约定式)
一般来说,如果一个数据被认为是子组件需要处理的目标数据,例如表单元素的 value,通常更习惯使用 v-model;如果一个数据父组件和子组件都可能进行修改,只是一个共享的数据的话,通常更习惯使用 :prop.sync
绑定继承
React JSX 支持一个特性是 Spread Props,通常是如下形式的:
在 Vue 中,也可以通过类似的方式实现:
注意这里 Vue 的模板是不支持写作 :="props"
的,因为单个冒号 : 不是合法的 HTML 属性名。
实际上 React 中的 props 在 Vue 中既可能是 :prop
也可能是 @event
,所以事件也可以使用类似的写法:
利用上面的两种写法,我们很容易写出两个组件在模板级别的继承逻辑
这里 $listeners 是所有绑定在组件调用上的事件,但 $props
实际上只包含了组件声明的 props。如果在继承时不希望把所有的子组件 props 全都声明一遍,可以利用禁用默认继承的方式使用 $attrs,参见:禁用 Attribute 继承。【事实上这才是更多时候我们需要使用的模式】。
有些时候我们希望事件也和属性一样,分为我们想自己使用的($props)和希望继承的($attrs)两类,则可以利用 $listener 的 observable 通过如下方式实现:
{
template: '<MyComponent v-on="listeners"/>',
computed: {
listeners() {
const { myEventA, myEventB, ...listeners } = this.$listeners;
return listeners;
},
},
}
其中 myEventA 和 myEventB 就是只希望组件本身使用的事件
provide/inject
Vue 中有一个类似于 React Context 的机制,即 provide/inject,可以从 provide 这个名字上窥见一斑。官方文档参见 provide / inject。
然而这个机制和 React Context 实际上有很大的差别,最重要的一点就是,provide 的值/返回值会在后代组件 inject 的初始化时确定,并且与 data 不同,Vue 不会观测 provide 的值/返回值。这也就意味着,当 provide 的内容发生变化时,inject 的值并不会更新。
实际上 provide 更多被设计为一个静态共享的方式,如果你希望在 Vue 中完成一个 Consumer 的工作,你需要 provide 一个函数,在后代组件里调用这个函数,完成数据获取的操作。 如果你想要更简单的实现动态的 provide/inject,可以使用 https://www.npmjs.com/package/vue-reactive-context 这个插件。
指令
Vue 的官方文档中有专门的一篇用来讲述如何使用自定义指令。指令是模板声明特有的一个特性,由于模板造成了 DOM/Virtual DOM 对 JS 代码不可见,指令实际上是提供了操作 DOM/Virtual DOM 的一种方式。
一个指令可以由下面的几个部分组成:
v-directive:arg.modifier1.modifier2="value"
其中 arg 的类型是字符串,而 modifier 的类型是布尔值。value 是一个任意类型的值,并且实际上可以获取到原始表达式(expression),但非常不建议这样使用,因为指令的 value 语义上是一个合法的绑定值,如果不这样使用可能会造成静态分析工具/语法高亮不识别,以及语义上的混乱。
在指令的各个时机的钩子函数中都可以通过参数获取当前的 vnode(以及之前的 oldVnode)。Vue 通过 flow 定义了 VNode API,在这其中最重要的量个属性实际上是:
data
:获取当前 VNode 的绑定数据。 这个数据对象和函数式组件的参数一致。通过 data,你实际上可以通过其他的绑定值来给指令传递参数,比如:
就可以通过 vnode.data.props
来获取所有绑定在这个元素上的特性。像 https://github.com/ElemeFE/vue-infinite-scroll 类似的特性就可以通过这种方式实现。
context
:获取当前模板从属的实例。 通过 context,则可以和模板的其他声明进行交互,比如:
可以通过 vnode.context.$refs.myRef
来获取到外部 DOM/组件实例的引用。像 https://github.com/freeze-component/vue-popper 类似的特性就可以通过这种方式实现。
在 Vue 2.0 中,指令是唯三的跨生命周期抽象的方式之一(另外两种是 mixin 和 hook events),所以可以看到一些具有副作用的插件(由于需要在销毁时消除副作用)很多都是通过指令的方式实现的,比如外部事件的绑定(诸如 scroll、click outside 等)
值得注意的是,在一个 Virtual DOM 上可以绑定多个同名的指令,所以你可以利用这一特性,通过不同的 arg 或者 modifier 来使一个指令支持多个不同的功能。
基于 render 的抽象
所谓“基于 render 的抽象”,实际上就是将 VirtualDOM 作为普通对象来进行抽象。在 Vue 中你当然可以同 React 一样,将 VirtualDOM 写成 JSX 的形式;但此处我们不讨论这种情况(因为在我看来这是一种偷懒的、且低内聚的抽象方式)。
这里的 render 实际上泛指了可以返回 VNode 的函数,实际上最常见的只有两种:
- 插槽(曾经是 Scoped Slots)
- 函数式组件
插槽
如果把一个组件理解为一个函数的话,props 是传统意义上函数的参数,而插槽就意味着参数可以是 VNode。比方说
Hello, World!
实际上就好像是这样的函数:
function MyComponent(content = <p>Hello, world!</p>) {}
插槽有一些可能不那么容易注意到的特性,例如:
- 你可以给插槽传递 Props,在外面通过指令接收。这实际上就是以前的 Scoped Slots。例如
{{ item.key }}
由于插槽 Props 的存在,你可以一定程度上把插槽理解为一个回调函数。理论上你甚至可以实现这样的代码:
{{ data.text }}
{{ err.message }}
- 插槽名其实可以是动态的。例如
Hello
World
- 插槽本身也可以嵌套。例如
Hello
Fallback: {{ item.key }}
动态组件
如同你可以在 React 中将一个变量作为组件名,在 Vue 中也可以这样做。参考:动态组件,只需要
{
computed: {
myComponent() {
return this.enabled ? MyInput : 'input';
},
}
}
可以注意到,用这种方式你甚至不需要在组件的 components 里声明这个组件。
另一种声明动态组件的方式是函数式组件。你可以:
export default {
render(h, { props, data, children }) {
return h(props.enabled ? MyInput : 'input', data, children);
}
}
如果你使用 vue-loader 和单文件组件,也可以使用模板的方式来控制逻辑:
(props
是作为特性注入在模板中的,就好像你使用了 with (context) {}
一样)
动态生命周期
通常,声明一个生命周期的方式是指定一个生命周期方法,比如:
{
mounted() {
this.componentDidMount();
}
}
但有时你可能需要动态声明生命周期,这时你可以使用 hook events:
{
beforeMounted() {
this.$once('hook:mounted', () => {
this.componentDidMount();
});
}
}
通常来说,如果涉及到跨生命周期的副作用,通常都需要使用这个方式。可以参考程序化的事件侦听器
Mixin 和自定义选项
一个在 Vue 中通常不被推荐的功能是 Mixin。和 PHP 的 Trait 不被推荐的逻辑一样,使用 Mixin 会导致你可能完全不清楚某个属性到底来自于哪里,甚至还要为此解决冲突问题。比如:
{
mixins: [President, Trump, Devil],
created() {
// Who has declared it ?!!
this.makeAmericaGreatAgain();
},
}
extends
实际上也有类似的问题,所以通常不是很推荐使用这种方式。当然有些情况下使用 Mixin/Extends 是很清晰的,比如作为 interface
声明一个组件必须传递的属性,或者必须实现的 methods 等。
这里要说的是另一种用法。有时候你可能会想,能不能让 Vue 组件支持自定义的选项。比如,我想给每个组件都指定一个 Path,然后每当这样的组件被创建时,我都能记录下来:
{
myPath: 'xxx',
data() {},
computed: {},
methods: {},
}
这种情况下,使用一个全局的 Mixin 是最佳选择。你可以:
Vue.mixins({
beforeCreated() {
const { myPath } = this.$options;
if (typeof myPath === 'string') {
this.$once('hook:created', () => {
console.log(`Created: ${myPath}`);
});
this.$once('hook:destroyed', () => {
console.log(`Destroyed: ${myPath}`);
});
}
},
});
(这个例子里,事实上也可以在 mixin 里直接声明 created 和 destroyed,但这样有时会让你遗漏一些副作用的处理)
Keep-alive
<keep-alive>
是 Vue 少见的内置组件之一,官方文档可以参考在动态组件上使用 keep-alive。不过如果看官方的这个例子,似乎并不太能 get 到为什么一定要使用这个东西(毕竟这个例子使用 Vuex 就很香了)……
我们可以考虑这样一个场景:假设我们使用了一个依赖 DOM 的第三方库,将它用在了一个可切换的 tab 上,比如:
{
mounted() {
new MyLibrary(this.$el, {});
}
}
当我们切换 tab 时,mounted 不会重新触发,$el 也不会改变,这就意味着组件和 DOM 都是复用的。假设我们是在不同的 tab 上的 canvas 画图,这样的复用显然就会有问题了。
也许你可以很自然地想到,那我把所有的 tab 都渲染出来,但是只展示一个,这样就行了吧?
如果这样操作的话,假如你希望在 tab 切换走的时候做一些操作就会异常麻烦,最有可能的情况是,你还得将 activeTab 传进组件来 watch,或者是将是否展示的状态传递给子组件。这样就一定程度上破坏了组件功能的边界。
更好的处理方式实际上是:
这样实际上页面每个时刻只有一个渲染出来的组件,而其他访问过的组件也不会被销毁,而是在内部被保存了下来,当 key 下一次匹配时仍然会被复用。并且在组件内部,你也可以通过 deactived 等钩子来处理组件离屏时的操作等。
特别要注意,如无必要,不要使用这个特性,由于所有渲染过的组件都会被缓存,keep-alive 是最有可能造成内存泄露的操作之一。如果要使用,确保指定了 max
或者是动态的 include
来限制缓存的条件
将组件包装为函数
一个组件实际意义上就是一个函数,Props 就是这个函数的参数。但我们并不能通过构造函数直接传递 Props 来创建一个组件,毕竟组件是一定会有副作用的,比如挂载操作。
如果我们真的需要将组件包装为一个函数,或者说动态创建一个组件,则需要完成以下步骤:
- 实例化一个组件,并给他传递 Props。我们可以通过 Vue.extends 将一个对象类型的组件选项变成一个类,然后再实例化它。另外 Vue 还提供了 propsData 选项,允许在实例化时传递 Props
- 挂载组件。如果希望挂载到一个已有的 DOM,$mount 可以完成;否则可以直接调用 $mount 生成一个 DOM,再将它挂载到文档中。
以下代码仅做示意:
function createComponent(componentOptions, props, el) {
const componentClass = Vue.extends(componentOptions);
const componentInstance = new componentClass({
el,
propsData: props
});
if (el) {
componentInstance.$mount(el);
} else {
const el = componentInstance.$mount();
document.body.appendChild(el);
}
return componentInstance;
}