Vue 开发技巧

这篇文档的主要目的是介绍一些 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 的函数,实际上最常见的只有两种:

  1. 插槽(曾经是 Scoped Slots)
  2. 函数式组件

插槽

如果把一个组件理解为一个函数的话,props 是传统意义上函数的参数,而插槽就意味着参数可以是 VNode。比方说

实际上就好像是这样的函数:

function MyComponent(content = <p>Hello, world!</p>) {}

插槽有一些可能不那么容易注意到的特性,例如:

  • 你可以给插槽传递 Props,在外面通过指令接收。这实际上就是以前的 Scoped Slots。例如

由于插槽 Props 的存在,你可以一定程度上把插槽理解为一个回调函数。理论上你甚至可以实现这样的代码:

  • 插槽名其实可以是动态的。例如

  • 插槽本身也可以嵌套。例如

动态组件

如同你可以在 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 来创建一个组件,毕竟组件是一定会有副作用的,比如挂载操作。

如果我们真的需要将组件包装为一个函数,或者说动态创建一个组件,则需要完成以下步骤:

  1. 实例化一个组件,并给他传递 Props。我们可以通过 Vue.extends 将一个对象类型的组件选项变成一个类,然后再实例化它。另外 Vue 还提供了 propsData 选项,允许在实例化时传递 Props
  2. 挂载组件。如果希望挂载到一个已有的 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;
}

Powered by Sairin