高级 Vue 组件模式 (9)


09 使用 Functional 组件

目标

到此为止,我们的 toggle 组件已经足够强大以及好用了,因此这篇文章不会再为它增加新的特性。如果你是从第一篇文章一直读到这里的读者,你一定会发现,整篇文章中,我几乎没有对 toggle-ontoggle-off 做出任何更改和重构,因此这篇文章着重来重构一下它们。

之前一直没有对它们进行任何更改,一个很重要的原因是因为它们足够简单,组件内部没有任何 data 状态,仅靠外部传入的 prop 属性 on 来决定内部渲染逻辑。这听起来似乎有些耳熟啊,没错,它们就是在上一篇文章中所提及的木偶组件(Dump Component)。在 Vue 中,这种类型的组件也可以叫做函数式组件(Functional Component)。

仔细观察 app 组件的模板代码,会发现存在一定的冗余性的,比如:

1
2
<toggle-on :on="status.on">{{firstTimes}}</toggle-on>
<toggle-off :on="status.on">{{firstTimes}}</toggle-off>

这里两行代码的逻辑几乎一模一样,但我们却要写两次。同时你还会发现一个问题,由于其内部的渲染逻辑是通过 v-if 来描述的,实际上在 Vue 渲染完成后,会渲染两个 dom 节点,在切换时的状态从 devtool 中观察的效果大概是这样子的:

未显示的节点是一个注释节点,而显示的节点是一个 div 节点。

这篇文章将着重解决这两个问题:

  • toggle-ontoggle-off 合二为一,减少代码冗余性
  • 重构以 v-if 实现的渲染逻辑,改为更好的动态渲染逻辑(仅使用一个 dom 节点)

实现

转化为函数式组件

首先,先将已经存在的 toggle-ontoggle-off 组件转化为函数式组件,很简单,只需保留 template 代码块即可,同时在左边的标签上声明 functional 属性,或者在 script 代码块中声明 functional: true 也是可以的。唯一要注意的是,由于函数式组件没有 data 也没有 this,因此所有模板中用到的与 prop 相关的渲染逻辑,都要作出相应更改,比如原先的 on 要改为 props.on的形式,由于这里我们要移除 v-if 的渲染逻辑,因此直接移除即可,详细代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ToggleOn.vue
<template functional>
<div class="toggle-on"><slot></slot></div>
</template>

<style>
.toggle-on {
color: #86d993;
}
</style>

// ToggleOff.vue
<template functional>
<div class="toggle-off"><slot></slot></div>
</template>

<style>
.toggle-off {
color: red;
}
</style>

除此之外,还可以发现,我为两个组件增加不同的颜色样式以便于区分当前的开关状态。

实现 ToggleStatus 组件

接下来实现今天的主角,ToggleStatus 组件,由于我们的目标是将原先的二个函数式组件合二为一,因此这个组件本身应当也是一个函数式组件,不过我们需要使用另外一种写法,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script>
import ToggleOn from './ToggleOn'
import ToggleOff from './ToggleOff'

export default {
functional: true,
render(createElement, {props, data, children}) {
let Comp = ToggleOff

if(props.on) Comp = ToggleOn

return createElement(Comp, data, children)
}
}
</script>

关于这种写法中,rendercreateElement 方法的参数就不赘述了,不熟悉的读者请参考官方文档。可以发现,这里将 toggle-ontoggle-off 以模块的形式导入,之后由 props.on 的值进行判定,从而决定哪一个被作为 createElement 方法的第一个参数进行渲染。

诸如 datachildren 参数我们原封不动的传入 createElement 即可,因为这里对于 toggle-status 组件的定位是一个代理组件,对于其他参数以及子元素应当原封不动的传递给被代理的组件中。

之后在 app 组件中更改响应的渲染逻辑即可,如下:

1
2
3
4
5
// controlled toggle
<toggle-status :on="status.on">{{firstTimes}}</toggle-status>

// uncontrolled toggle
<toggle-status :on="status.on">{{secondTimes}}</toggle-status>

成果

一切如原先一样,只不过这次我们可以少写一行冗余的代码了。同时打开 devtool 可以发现,两种状态的组件会复用同一个 dom 节点,如下:

你可以通过下面的链接来看看这个组件的实现代码以及演示:

总结

关于函数式组件,我是在 React 中第一次接触,其形式和它的名字一样,就是一个函数。这种组件和普通组件相比的优势主要在于,它是无状态的,这意味着它的可测试性和可读性更好,同时一些情况下,渲染开销也更小。

我们在日常工作中,可能会经常遇到动态渲染的需求,一般情况下,我们均会通过 v-if 来解决,在比较简单的情况下,v-if 确实一种很简单且高效的方式,但是随着组件复杂度的上升,很可能会出现面条式的代码,可读性和可测试性都大打折扣,这是不妨换一个角度从渲染机制本身将组件重构为更小的颗粒,并用一个函数式组件动态的代理它们,可能会得到更好的效果,举一个比较常见的例子,比如表单系统中的表单项,一般都具有多种渲染状态,如编辑状态、浏览状态、禁用状态等等,这时利用该模式来抽离不同状态的渲染逻辑就非常不错。

more

高级 Vue 组件模式 (8)


08 使用 Control Props

目标

在第七篇文章中,我们对 toggle 组件进行了重构,使父组件能够传入开关状态的初始值,同时还可以传入自定义的状态重置逻辑。虽然父组件拥有了改变 toggle 组件内部状态的途径,但是如果进一步思考的话,父组件并没有绝对的控制权。在一些业务场景,我们期望父组件对于子组件的状态,拥有绝对的控制权。

熟悉 React 的读者一定不会对智能组件(Smart Component)和木偶组件(Dump Component)感到陌生。对于后者,其父组件一定对其拥有绝对控制权,因为它内部没有状态,渲染逻辑完全取决于父组件所传 props 的值。而对于前者则相反,由于组件内部会有自己的状态,它内部的渲染逻辑由父组件所传 props 与其内部状态共同决定。

这篇文章将着重解决这个问题,如果能够使一个智能组件的状态变得可控,即:

  • toggle 组件的开关状态应该完全由 prop 属性 on 的值决定
  • 当没有 on 属性时,toggle 组件的开关状态降级为内部管理

额外地,我们还将实现一个小需求,toggle 组件的开关状态至多切换四次,如果超过四次,则需点击重置后,才能够重新对开关切换状态进行切换。

实现

判定组件是否受控

由于 toggle 组件为一个智能组件,我们需要提供一个判定它是否受控的方式。很简单,由目标中的第一点可知,当父组件传入了 on 属性后,toggle 处于被控制的状态,否则则没有,于是可以利用 Vue 组件的 computed 特性,声明一个 isOnControlled 计算属性,如下:

1
2
3
4
5
computed: {
isOnControlled() {
return this.on !== undefined;
}
}

其内部逻辑很简单,就是判定 prop 属性 on 的值是否为 undefined,如果是,则未被父组件控制,反之,则被父组件控制。

更改 on 的声明方式

由于要满足目标中提及的第二点,关于 prop 属性 on 的声明,我们要做出一些调整,如下:

1
2
3
4
on: {
type: Boolean,
default: undefined
},

就是简单地将默认值,由 false 改为了 undefined,这么做的原因是因为,按照之前的写法,如果 on 未由父组件传入,则默认值为 false,那么 toggle 组件会认为父组件实际传入了一个值为 falseon 属性,因此会将其内部的开关状态控制为,而非降级为内部管理开关状态。

实现状态解析逻辑

之前的实现中,通过 scope-slot 注入插槽的状态完全取决于组件内部 status 的值,我们需要改变状态的注入逻辑。当组件受控时,其开关状态应该与 prop 属性保持一致,反之,则和原来一样。因此编写一个叫做 controlledStatus 的计算属性:

1
2
3
controlledStatus() {
return this.isOnControlled ? { on: this.on } : this.status;
}

这里利用了之前声明的 isOnControlled 属性来判断当前组件是否处于受控状态。之后相应地把模板中开关状态的注入逻辑也进行更改:

1
<slot :status="controlledStatus" :toggle="toggle" :reset="reset"></slot>

相应地,除了开关状态的注入逻辑,toggle 方法和 reset 方法的注入逻辑也需要更改,至于为什么,就交由读者自行思考得出答案吧,这里简单罗列实现代码,以供参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// toggle 方法
toggle() {
if (this.isOnControlled) {
this.$emit("toggle", !this.on);
} else {
this.status.on = !this.status.on;
this.$emit("toggle", this.status.on);
}
}

// reset 方法
reset() {
if (this.isOnControlled) {
Promise.resolve(this.onReset(!this.on)).then(on => {
this.$emit("reset", on);
});
} else {
Promise.resolve(this.onReset(this.status.on)).then(on => {
this.status.on = on || false;
this.$emit("reset", this.status.on);
});
}
}

总体上的思路是,如果组件受控,则传入回调方法中的开关状态参数,是在触发相应事件后,由 prop 属性 on 得出的组件在下一时刻,应当处于的状态。

这么说可能有点绕,换句话说就是,当组件状态发生更改时,如果当前的 on 属性为 true(开关状态为开),则组件本该处于关的状态,但由于组件受控,则它内部不能直接将开关状态更改为关,而是依旧保持为开,但是它会将 false(开关状态为关)作为参数传入触发事件,这将告知父组件,当前组件的下一个状态为关,至于父组件是否同意将其状态更改为关则有父组件决定。

如果组件不受控,开关状态由组件内部自行管理,那和之前的实现逻辑是一模一样的,保留之前的代码即可。

成果

toggle 组件被改造后,实现这个需求就很容易了。关于实现的代码,这里就不进行罗列了,有兴趣可以通过在线代码链接进行查看,十分简单,这里仅简单附上一个最终的动态效果图:

你可以通过下面的链接来看看这个组件的实现代码以及演示:

总结

关于 Controlled Component 和 Uncontrolled Component 的概念,我第一次是在 React 中关于表单的介绍中接触到的。实际工作中,大部分对于状态可控的需求也都存在于表单组件中,之所以存在这样的需求,是因为表单系统往往是复杂的,将其实现为智能组件,往往内部状态过于复杂,而如果实现为木偶组件,代码结构或者实现逻辑又过于繁琐,这时如果可以借鉴这种模式的话,往往可以达到事半功倍的效果。

more

高级 Vue 组件模式 (7)


07 使用 State Initializers

目标

到目前为止,仅从 toggle 组件自身的角度来看,它已经可以满足大多数的业务场景了。但我们会发现一个问题,就是当前 toggle 组件的状态对于调用者来说,完全是黑盒状态,即调用者无法初始化,也无法更改组件的开关状态,这在一些场景无法满足需求。

对于无法初始化开关状态的问题,倒是很好解决,我们可以在 toggle 组件声明一个 prop 属性 on 来代表组件的默认开关状态,同时在 mounted 生命周期函数中将这个默认值同步到组件 data 相应的属性中去。

对于无法更改开关状态的问题,似乎无法简单通过声明一个 prop 属性的方式来解决,并且如果我们期望的更改逻辑是异步的话,同样无法满足。

因此这篇文章着重来解决这两个问题:

  • toggle 组件能够支持开关状态的初始化功能
  • toggle 组件能够提供一个 reset 方法以供重置开关状态
  • 重置开关状态可以以异步的方式进行

实现

初始化开关状态

为了使 toggle 组件能够支持默认状态的传入,我们采用声明 prop 属性的方式,如下:

1
2
3
4
on: {
type: Boolean,
default: false
}

之后在其 mounted 生命周期对开关状态进行同步,如下:

1
2
3
mounted() {
this.status.on = this.on;
}

这样当我们期望 toggle的状态进行渲染时,可以这样调用组件:

1
2
3
<toggle :on="true" @toggle="onToggle">
...
</toggle>

重置开关状态

为了能够从外部更改 toggle 组件的开关状态,我们可以在组件内部声明一个观测 on prop 属性的监听器,比如:

1
2
3
4
5
watch: {
on(val){
// do something...
}
}

但如果这么做,会存在一个问题,即目标中关于开关状态的更改逻辑的编写者是组件调用者,而 watch 函数的编写者是组件实现者,由于实现者无法预知调用者更改状态的逻辑,所以使用 watch 是无法满足条件的。

让我们换一个角度来思考问题,既然实现者无法预知调用者的逻辑,何不把重置开关状态的逻辑全部交由调用者来实现?别忘了 Vue 组件也是可以传入 Function 类型的 prop 属性的,如下:

1
2
3
4
onReset: {
type: Function,
default: () => this.on
},

这样就将提供重置状态的逻辑暴露给了组件调用者,当然,如果调用者没有提供相关重置逻辑,组件内部会自动降级为使用 on 属性来作为重置的状态值。

组件内部额外声明一个 reset 方法,在其内部重置当前的开关状态,如下:

1
2
3
4
reset(){
this.status.on = this.onReset(this.status.on)
this.$emit("reset", this.status.on)
}

这里会首先以当前开关状态为参数,调用 onReset 方法,再将返回值赋值给当前状态,并触发一个 reset 事件以供父组件订阅。

之后在 app 组件中,可以按如下方式传入 onReset 函数,并编写具体的重置逻辑:

1
2
3
4
5
6
7
8
9
10
11
// template
<toggle :on="false" @toggle="onToggle" :on-reset="resetToTrue">
...
</toggle>

// script
...
resetToTrue(on) {
return true;
},
...

运行效果如下:

支持异步重置

在实现同步重置的基础上,实现异步重置十分简单,通常情况下,处理异步较好的方式是使用 Promise,使用 callback 也可以,使用 Observable 也是不错的选择,这里我们选择 Promise。

由于要同时处理同步和异步两种情况,只需把同步情况视为异步情况即可,比如以下两种情况在效果上是等价的:

1
2
3
4
5
6
7
8
// sync
this.status.on = this.onReset(this.status.on)

// async
Promise.resolve(this.onReset(this.status.on))
.then(on => {
this.status.on = on
})

onReset 函数如果返回的是一个 Promise 实例的话,Promise.resolve 也会正确解析到当它为 fullfill 状态的值,这样关于 reset 方法我们改版如下:

1
2
3
4
5
6
7
reset(){
Promise.resolve(this.onReset(this.status.on))
.then(on => {
this.status.on = on
this.$emit("reset", this.status.on)
})
}

在 app 组件中,可以传入一个异步的重置逻辑,这里就不贴代码了,直接上一个运行截图,组件会在点击重置按钮后 1 秒后,重置为状态:

成果

你可以通过下面的链接来看看这个组件的实现代码以及演示:

总结

Function 类型的 prop 属性在一些情况下非常有用,比如文章中提及的状态初始化,这其实是工厂模式的一种体现,在其他的框架中也有体现,比如 React 中,HOC 中提及的 render props 就是一种比较具体的应用,Angular 在声明具有循环依赖的 Module 时,可以通过 () => Module 的方式进行声明等等。

more

高级 Vue 组件模式 (6)


06 通过 Directive 增强组件内容

目标

之前的五篇文章中,switch 组件一直是被视为内部组件存在的,细心的读者应该会发现,这个组件除了帮我们提供开关的交互以外,还会根据当前 toggle 的开关状态,为 button 元素增加 aria-expanded 属性,以 aira 开头的属性叫作内容增强属性,它用于描述当前元素的某种特殊状态,帮助残障人士更好地浏览网站内容。

但是,作为组件调用者,未必会对使用这种相关属性对网站内容进行增强,那么如何更好地解决这个问题呢?答案就是使用 directive。

我们期望能够显示地声明当前的元素是一个 toggler 职能的组件或者元素,这个组件或者元素,可以根据当前 toggle 组件的开关状态,动态地更新它本身的 aria-expanded 属性,以便针对无障碍访问提供适配。

实现

简单实现

首先创建一个 toggler 指令函数,如下:

1
2
3
4
5
6
7
8
9
export default function(el, binding, vnode) {
const on = binding.value

if (on) {
el.setAttribute(`aria-expanded`, true);
} else {
el.removeAttribute(`aria-expanded`, false);
}
}

这个指令函数很简单,就是通过传入指令的表达式的值来判定,是否在当前元素上增加一个 aria-expanded 属性。之后再 app 引入该指令,如下:

1
2
3
directives: {
toggler
}

之后就可以在 app 组件的模板中使用该指令了,比如:

1
<custom-button v-toggler="status.on" ref="customButton" :on="status.on" :toggle="toggle"></custom-button>

一切都将按预期中运行,当 toggle 组件的状态为开时,custom-button 组件的根元素会增加一个 aria-expanded="true" 的内容增强属性。

Note: 这里关于指令的引入,使用的函数简写的方式,会在指令的 bind 和 update 钩子函数中触发相同的逻辑,vue 中的指令包含 5 个不同的钩子函数,这里就不赘述了,不熟悉的读者可以通过阅读官方文档来了解。

注入当前组件实例

上文中的指令会通过 binding.value 来获取 toggle 组件的开关状态,这样虽然可行,但在使用该指令时,custom-button 本身的 prop 属性 on 已经代表了当前的开关状态,能否直接在指令中获取当前所绑定的组件实例呢?答案是可以的。指令函数的第三个参数即为当前所绑定组件的虚拟 dom 节点实例,其 componentInstance 属性指向当前组件实例,所以可以将之前的指令改版如下:

1
2
3
4
5
6
7
8
9
10
export default function(el, binding, vnode) {
const comp = vnode.componentInstance;
const on = binding.value || comp.on;

if (on) {
el.setAttribute(`aria-expanded`, true);
} else {
el.removeAttribute(`aria-expanded`, false);
}
}

这样,即使不向指令传入表达式,它也可以自动去注入当前修饰组件所拥有的 prop 属性 on 的值,如下:

1
<custom-button v-toggler ref="customButton" :on="status.on" :toggle="toggle"></custom-button>

提供更多灵活性

指令函数的第二个参数除了可以获取传入指令内部的表达式的值以外,还有其他若干属性,比如 name、arg、modifiers等,详细说明可以去参考官方文档。

为了尽可能地使指令保证灵活性,我们期望可以自定义无障碍属性 aria 的后缀名称,比如叫做 aria-on,这里我们可以通过 arg 这个参数轻松实现,改版如下:

1
2
3
4
5
6
7
8
9
10
11
export default function(el, binding, vnode) {
const comp = vnode.componentInstance;
const suffix = binding.arg || "expanded";
const on = binding.value || comp.on;

if (on) {
el.setAttribute(`aria-${suffix}`, true);
} else {
el.removeAttribute(`aria-${suffix}`, false);
}
}

可以发现,这里通过 binding.arg 来获取无障碍属性的后缀名称,并当没有传递该参数时,降级至 expanded。这里仅仅是为了演示,读者有兴趣的话,还可以利用 binding 对象的其他属性提供更多的灵活性。

成果

你可以通过下面的链接来看看这个组件的实现代码以及演示:

总结

关于指令的概念,我自身还是在 angularjs 中第一次见到,当时其实不兴组件化开发这个概念,指令本身的设计理念也是基于增强这个概念的,即增强某个 html 标签。到后来兴起了组件化开发的开发思想,指令似乎是随着 angularjs 的没落而消失了踪影。

但仔细想想的话,web 开发流程中,并不是所有的场景都可以拿组件来抽象和描述的,比如说,你想提供一个类似高亮边框的公用功能,到底如何来按组件化的思想抽象它呢?这时候使用指令往往是一个很好的切入点。

因此,当你面临解决的问题,颗粒度小于组件化抽象的粒度,同时又具备复用性,那就大胆的使用指令来解决它吧。

more

高级 Vue 组件模式 (5)


05 使用 $refs 访问子组件引用

目标

在之前的文章中,详细阐述了子组件获取父组件所提供属性及方法的一些解决方案,如果我们想在父组件之中访问子组件的一些方法和属性怎么办呢?设想以下一个场景:

  • 当前的 custom-button 组件中,有一个 input 元素
  • 我们期望当 toggle 的开关状态为时,显示 input 元素并自动获得焦点

这里要想完成目标,需要获取某个组件或者每个元素的引用,在不同的 mvvm 框架中,都提供了相关特性来完成这一点:

  • angularjs: 可以使用依赖注入的 $element 服务
  • Angular: 可以使用 ViewChild、ContentChild 或者 template ref 来获取引用
  • react: 使用 ref 属性声明获取引用的逻辑

在 vue 中,获取引用的方法与 react 类似,通过声明 ref 属性来完成。

实现

首先,在 custom-button 组件中增加一个 input 元素,如下:

1
<input v-if="on" ref="input" type="text" placeholder="addtional messages">

注意这里的 ref="input",这样在组件内部,可以通过 this.$refs.input 获得该元素的引用,为了实现目标中提及的需求,再添加一个新的方法 focus 来使 input 元素获取焦点,如下:

1
2
3
4
5
focus() {
this.$nextTick(function() {
this.$refs.input.focus();
});
},

注意这里的 this.$nextTick,正常情况下,直接调用 input 的 focus 方法是没有问题的,然而却不行。因为 input 的渲染逻辑取决于 prop 属性 on 的状态,如果直接调用 focus 方法,这时 input 元素的渲染工作很可能还未结束,这时 this.$refs.input 所指向的引用值为 undefined,继续调用方法则会抛出异常,因此我们利用 this.$nextTick 方法,将调用的逻辑延迟至下次 DOM 更新循环之后执行。

同理,在 app 组件中,为 custom-button 添加一个 ref 属性,如下:

1
<custom-button ref="customButton" :on="status.on" :toggle="toggle"></custom-button>

之后修改 onToggle 方法中的逻辑以满足目标中的需求,当 toggle 组件状态为开时,调用 custom-button 组件的 focus 方法,如下:

1
2
3
4
onToggle(on) {
if (on) this.$refs.customButton.focus();
console.log("toggle", on);
}

成果

点击按钮会发现,每当开关为开时,input 元素都会显示,并会自动获得焦点。

你可以通过下面的链接来看看这个组件的实现代码以及演示:

总结

文章中所举例子的交互,在实际场景中很常见,比如:

  • 当通过一个 icon 触发搜索框时,期望自动获得焦点
  • 当表单校验失败时,期望自动获得发生错误的表单项的焦点
  • 当复杂列表的筛选器展开时,期望第一个筛选单元获得焦点

这几种情况下,都可以使用该模式来高效地解决问题,而不是通过使用 DOM 中的 api 或者引入 jquery 获取相关元素再进行操作。

more

高级 Vue 组件模式 (4)


04 使用 slot 替换 mixin

目标

在第三篇文章中,我们使用 mixin 来抽离了注入 toggle 依赖项的公共逻辑。在 react 中,类似的需求是通过 HOC 的方式来解决的,但是仔细想想的话,react 在早些的版本也是支持 mixin 特性的,只不过后来将它标注为了 deprecated。

mixin 虽然作为分发可复用功能的常用手段,但是它是一把双刃剑,除了它所带来的便利性之外,它还有以下缺点:

  • 混入的 mixin 可能包含隐式的依赖项,这在某些情况下可能不是调用者所期望的
  • 多个 mixin 可能会造成命名冲突问题,且混入结果取决于混入顺序
  • 使用不当容易使项目的复杂度呈现滚雪球式的增长

所以是否有除了 mixin 以外的替代方案呢?答案当时也是有的,那就是使用 vue 中提供的作用域插槽特性。

实现

这里关于作用域插槽的知识同样不赘述了,不熟悉的读者可以去官方文档了解。我们可以在 toggle 组件模板中的 slot 标签上将所有与其上下文相关的方法及属性传递给它,如下:

1
2
3
<div class="toggle">
<slot :status="status" :toggle="toggle"></slot>
</div>

这样,我们可以通过 slot-scope 特性将这些方法和属性取出来,如下:

1
2
3
4
<template slot-scope="{status, toggle}">
<custom-button :on="status.on" :toggle="toggle"></custom-button>
<custom-status-indicator :on="status.on"></custom-status-indicator>
</template>

当然,相比上一篇文章,我们需要对 custom-buttoncustom-status-indicator 组件做一些简单的更改,只需要将混入 mixin 的逻辑去掉,并分别声明相应的 props 属性即可。

成果

通过作用域插槽,我们有效地避免了第三方组件由于混入 toggleMixin 而可能造成的命名冲突以及隐式依赖等问题。

你可以通过下面的链接来看看这个组件的实现代码以及演示:

总结

mixin 虽好,但是一定不要滥用,作为组件开发者,可以享受它带来的便利性,但是它对于组件调用者来说,可能会造成一些不可预料的问题,通过作用域插槽,我们可以将这种问题发生的程度降到最小,同时解决 mixin 需要解决的问题。

more

高级 Vue 组件模式 (3)


03 使用 mixin 来增强 Vue 组件

目标

之前一篇文章中,我们虽然将 toggle 组件划分为了 toggle-buttontoggle-ontoggle-off 三个子组件,且一切运行良好,但是这里面其实是存在一些问题的:

  • toggle 组件的内部状态和方法只能和这三个子组件共享,我们期望第三方的组件也可以共享这些状态和方法
  • inject 的注入逻辑我们重复编写了三次,如果可以的话,我们更希望只声明一次(DRY原则)
  • inject 的注入逻辑当前为硬编码,某些情况下,我们可能期望进行动态地配置

如果熟悉 react 的读者这里可能马上就会想到 HOC(高阶组件) 的概念,而且这也是 react 中一个很常见的模式,该模式能够提高 react 组件的复用程度和灵活性。在 vue 中,我们是否也有一些手段或特性来提高组件的复用程度和灵活性呢?答案当然是有的,那就是 mixin。

实现

关于 mixin 本身的知识,这里就不做过多赘述了,不熟悉的读者可以去官方文档了解。我们通过声明一个叫作 toggleMixin 的 mixin 来抽离公共的注入逻辑,如下:

1
2
3
4
5
export const withToggleMixin = {
inject: {
toggleComp: "toggleComp"
}
};

之后,每当需要注入 toggle 组件提供的依赖项时,就混入当前 mixin,如下:

1
mixins: [withToggleMixin]

如果关于注入的逻辑,我们增加一些灵活性,比如期望自由地声明注入依赖项的 key 时,我们可以借由 HOC 的概念,声明一个高阶 mixin(可以简称 HOM ?? 皮一下,很开心),如下:

1
2
3
4
5
6
7
export function withToggle(parentCompName = "toggleComp") {
return {
inject: {
[parentCompName]: "toggleComp"
}
};
}

这个 HOC mixin 可以按如下的方式使用:

1
mixins: [withToggle("toggle")]

这样在当前的组件中,调用 toggle 组件相关状态和方法时,就不再是 this.toggleComp,而是 this.toggle

成果

通过实现 toggleMixin,我们成功将注入的逻辑抽离了出来,这样每次需要共享 toggle 组件的状态和方法时,混入该 mixin 即可。这样就解决了第三方组件无法共享其状态和方法的问题,在在线实例代码中,我实现了两个第三方组件,分别是 custom-buttoncustom-status-indicator,前者是自定义开关,使用 withToggleMixin 来混入注入逻辑,后者是自定义的状态指示器,使用 withToggle 高阶函数来混入注入逻辑。

你可以通过下面的链接来看看这个组件的实现代码以及演示:

总结

mixin 作为一种分发 Vue 组件中可复用功能的非常灵活的方式,可以在很多场景下大展身手,尤其在一些处理公共逻辑的组件,比如通知、表单错误提示等,使用这种模式尤其有用。

more

高级 Vue 组件模式 (2)


02 编写复合组件

目标

我们需要实现的需求是能够使使用者通过 <toggle> 组件动态地改变包含在它内部的内容。

熟悉 vue 的童鞋可能马上会想到不同的解决方案,比如使用 slot 并配合 v-if,我们这里采用另外一种方法,利用 vue 提供的 provide/inject 属性按照复合组件的思想来实现。

这里简单介绍下 provide/inject 的功能,它允许某个父组件向子组件注入一个依赖项(这里的父子关系可以跨域多个层级,也就是祖先与后代),如果我们在其他 mvvm 框架对比来看的话,你可以发现其他框架也具有相同的特性,比如:

  • angularjs: directive 中的 require 属性来声明注入逻辑
  • Angular: 依赖注入中组件级别的注入器
  • React: context 上下文对象

想进一步了解的话,可以参考官方文档

实现

在 vue 中,这里我们会分别实现三个组件,依次为:

  • toggle-button: 代表开关,用来渲染父组件的开关状态
  • toggle-on: 根据父组件 toggle 的开关状态,渲染当状态为时的内容
  • toggle-off: 根据父组件 toggle 的开关状态,渲染当状态为时的内容

在上一篇文章中,我们已经实现了 toggle 组件,这里我们要做一些更改。首先,需要使用 provide 属性增加一个提供依赖的逻辑,如下:

1
2
3
4
5
6
7
8
provide() {
return {
toggleComp: {
status: this.status,
toggle: this.toggle
}
}
}

这里的 status 是该组件 data 中的声明的一个可监听对象,这个对象包含一个 on 属性来代表组件的开关状态,而 toggle 则是 methods 中的一个组件方法。

关于为什么这里不直接使用 on 属性来代表开关状态,而使用一个可监听对象,是因为 provideinject 绑定并不是可响应的,同时官方文档也指出,这是刻意而为,所以为了享受到 vue 响应性带来的便利性,我们这里传入 status 这个可监听对象。

对于其他三个组件,其内部实现逻辑十分简单,相信读者通过参考在线代码实例马上就能看懂,这里只提一下关于 inject 声明注入依赖的逻辑,如下:

1
inject: { toggleComp: "toggleComp" }

这里的 "toggleComp" 与之前的 provide 对象中声明的 key 值所对应,而 inject 对象的 key 值当前组件注入依赖项的变量名称,之后,子组件即可以通过 this.toggleComp 来访问父组件的属性与方法。

成果

通过复合组件的方式,我们将 toggle 组件划分为了三个更小的、职责更加单一的子组件。同时由于 toggle-ontoggle-off 都使用 slot 来动态地注入组件调用者在其内部包含的自定义渲染逻辑,其灵活性得到了进一步的提升,只要这三个组件是作为 toggle 组件的子组件来调用,一切都将正常运行。

你可以通过下面的链接来看看这个组件的实现代码以及演示:

总结

通常情况下,在设计和实现职能分明的组件时,可以使用这种模式,比如 tabs 与 tab 组件,tabs 只负责 tab 的滚动、导航等逻辑,而 tab 本身仅负责内容的渲染,就如同这里的 toggle 和 toggle-button、toggle-on、toggle-off 一样。

more

高级 Vue 组件模式 (1)


写在前头

去年,曾经阅读过一系列关于高级 react 组件模式的文章,今年上半年,又抽空陆陆续续地翻译了一系列关于高级 angular 组件模式的文章,碰巧最近接手了一个公司项目,前端这块的技术栈是 vue。我对于 vue 本身还是比较熟悉的,不过大多都是一些很简单的个人项目,在构建相对比较复杂的应用中缺乏实践经验,就想着也搜搜类似题材的文章,涨涨知识。结果似乎没有找到(其实也是有一些的,只不过不是和 react 和 angular 对比来写的),不如就按照 react 和 angular 这两个系列文章的思路,使用 vue 来亲自实现一次吧。

由于三个框架的设计思想、语法都有比较大的区别,所以在实现过程中,均使用更符合 vue 风格的方式去解决问题,同时也提供一些对比,供读者参考,如果观点有误,还望指正。

01 实现一个 toggle 组件

这个系列的文章的第一篇,都会从实现一个最简单的 toggle 组件开始。

在 Vue 中,我们通过 data 来声明一个 checked 属性,这个属性所控制的状态代表组件本身的开关状态,这个状态会传递给负责渲染开关变换逻辑的 switch 组件中,关于 switch 组件,这里不做过多介绍,你把它当作一个私有组件即可,其内部实现与该篇文章没有太大的关联。同时这个组件还拥有一个 on 属性,用来初始化 checked 的状态值。

通过在 switch 组件注册原生 click 事件,toggle 组件还会触发一个 toggled 事件,在 App 组件中,我们会监听这个事件,并将其回传的值打印到控制台中。

你可以通过下面的链接来看看这个组件的实现代码以及演示:

总结

toggle组件的实现是一个很典型的利用单向数据流作为数据源的简单组件:

  • on 是单向数据源,checked 代表组件内部的开关状态
  • 通过触发 toggle 事件,将 checked 状态的变化传递给父组件

more

30 分钟 HTTP 查漏补缺之 Vary


写在前面

最近抽空参加了几场大厂的面试,突然发现一个现象,就是不论面试偏服务端的职位还是偏客户端的职位,不论面试的 5 年以上的高级职位,还是 3 年左右的中级职位,面试官开头所问问题必然是关于 HTTP 的。

我记得之前找工作的时候,似乎都是先考察一些职位所需技能领域的基础知识,之后再考察关于 HTTP 的东西,现在大家都将 HTTP 的问题放到面试的开头来问,我觉的应该是越来越多的招聘者意识到,作为一个 Web 开发者,HTTP 真的是太重要了,必须要先考察。

回想起来,这几年我自己对于 HTTP 的学习大多是碎片化的,很多东西无法系统地在脑海中组织起来。虽然感觉 HTTP 整体的学习难度是比较低的,但是各个知识点交杂在一起又变得很复杂很难,相信大家都会有同感。同时有些知识点,如果在实际工作中没有采坑或者刻意深挖的话,很自然地就被忽略了。

由于在之前一次面试中,被狠狠地问了若干关于 Vary 的问题,所以想抽一些时间整理一下那些比较容易让人忽略的知识点,算是查漏补缺吧。

内容协商

首先需要了解的是内容协商这个术语。当我们通过某个 URI 来访问其指向的资源时,HTTP 协议可以通过内容协商机制提供资源的不同的展示形式。

如果缺少服务端开发经验话,对于这个概念可能会感到陌生,但其实我们在工作中几乎都会遇到它,比如在调用接口时,经常会用到 Accept: application/json 这个头部,有时可能会用到 Accept: application/xml,这就是内容协商,前者期望接口返回 json 格式的数据,而后者期望返回 xml 格式的数据。

一般客户端涉及的常见头部有以下几个:

  • Accept: 声明客户端可以处理的资源格式
  • Accept-Charset: 声明客户端可以处理的字符集类型
  • Accept-Language: 声明客户端可以理解的自然语言
  • Accept-Encoding: 声明客户端支持的编码格式

而服务端涉及的常见头部包括:

  • Content-Type: 指示资源的 MIME 类型
  • Content-Language: 指示该资源所期望的自然语言
  • Content-Encoding: 指示资源使用该编码格式进行内容转换

仔细观察的话,会发现它们其实存在着一定程度的对应关系。原因也很简单,既然是协商,那必然就会和两个人在进行说话一样,如果两者之间的对话内容没有关联,他们还怎么沟通呢?客户端和服务端进行沟通同理。

如果想详细了解该机制,可以参考MDN的文档,很详细,这里就不多说了。

这里顺带说明一下,对于内容协商机制中涉及的头部,从 web 发展历史上来看已经没有什么实质的用途了,原因如下(有兴趣的话可以阅读这篇wiki):

  • Accept-Charset: 由于 utf-8 成为主流的字符集类型,所以使用其他字符集类型的服务可以将其转换为 utf-8 类型
  • Accept-Language: 大体包含以下几点
    • 提供多种语言服务的网站往往是基于某种特定语言构建,再提供其他语言支持的,这样每种语言类型的内容在质量上层次不齐,而访问者可能会更倾向于内容质量更高的那一种语言,而内容协商机制无法替代用户的主观判断
    • 实践中,对于切换网站语言的功能,切换方式往往更倾向于主动切换(比如提供一个切换的按钮)而非自动切换
    • 浏览器在用户不提供语言相关配置的情况下,很难猜测用户的自然语言倾向(一般可能会根据地理定位、ip等因素猜测),打个比方,比如我会经常出差去日本,但这不代表我会说日语,同时虽然我挂了加拿大的 vps,但是提供中文内容的网站,我还是倾向于看中文
  • Accept: 与 Accept-Language 类似,同样因为内容的格式会因用户的主观意识而不同,还有诸多其他因素制约内容协商机制,所以最终失败了。

唯一有些用途的是 Accept-Encoding,但鉴于如今大部分现代浏览器都已支持多种压缩方式(常见的如 gzip、br),因此一定程度上已经不需要额外声明这个头部了,虽然大部分浏览器都会自动发送这个头部,但其实这会造成额外 23 字节的浪费。

Vary 头部

在理解(或者巩固)了内容协商的概念后,就可以介绍 Vary 这个头部了。直接引用 MDN 对于它的描述:

The Vary HTTP response header determines how to match future request headers to decide whether a cached response can be used rather than requesting a fresh one from the origin server.

Vary 是一个HTTP响应头部信息,它决定了对于未来的一个请求头,应该使用一个缓存作为响应还是向源服务器请求一个新的响应。

单纯靠文档对于 Vary 的描述来理解它其实是有些困难的,最起码我会有这种感觉。

这个头部的语法和其他的 HTTP 头部类似,如下:

1
Vary: <header-name>, <header-name>, ...

不同的头部之间使用逗号进行分割,同时可以指定 * 为它的值,这样等价于将资源视为唯一,并不进行缓存,但这并不是最佳实践,因此不建议这么做。

Vary 的工作原理

一句话概括它的工作原理就是,就是它表示某个响应因某个响应头部而不同。举个例子,比如 Vary: Accept 的意思即为,响应因请求资源格式头部而不同,那么通过相同 URI 访问的资源就可以根据这个头上知道其内容格式不同。

但我们已经知道,对于大部分内容协商机制中涉及的头部,已经被看作是失败的,那么 Vary 和这些头部搭配使用还有什么意义呢?话虽如此,但 Vary 还可以与 HTTP 中其他的头部来搭配使用,从而满足很多应用场景下的特殊需求,比如动态服务、防止缓存错乱等。

Vary 的应用场景

以下简单罗列一些常用的应用场景以及采坑指南。

Vary 与 动态服务

关于动态服务,最常见的莫过于 Vary: User-Agent。众所周知,UA 是一段特征字符串,通常包含区分客户端类型、操作系统、版本号等信息,随着移动 web 应用变得越流行,一个应用网站同时提供桌面和移动两种版本的应用是很常见的事情。通过设置 Vary: User-Agent 头部,对于搜索引擎,对于关键字的搜索结果可以提供更加准确的应用版本,对于客户端,可以使其从缓存服务器获取到相应应用类型的缓存版本,而不是错误地将桌面版缓存传递给移动版应用。

web 应用的性能在加载速度这一指标上,很大程度上取决于加载资源的大小,而图片资源是所占比例最大的一块。为了减少图片的大小,除了对常见的图片格式进行压缩以外,chrome 推出的 WebP 格式也是不错的选择。但是这里的问题是,不是所有的浏览器都支持 WebP 图片格式的,所以这里使用 Vary: Accept 来针对浏览器的支持情况返回相应的缓存副本,支持则返回 WebP 格式,不支持则返回缩略图或者原图。

还有其他关于动态服务的场景,比如要针对不同分辨率的屏幕加载不同质量的图片(Client Hints 相关的头部)、针对不同用户身份提供不同的资源(Cookie头部)等等。

Vary 与 缓存错乱

有时候我们会发现响应中存在 Vary: Accept-Encoding 头部信息,我原先按照内容协商机制中所描述的内容来理解,但到后来才发现,其实很大程度上是为了防止缓存错乱的问题。

设想一下,如果没有这个头部,当两个分别支持 gzip 和 不支持 gzip 的客户端对同一份资源进行获取时,结果会变得十分微妙。如果不支持 gzip 的客户端先访问,缓存代理会缓存未压缩的版本,那么当支持 gzip 的客户端再访问时,由于命中缓存,虽然它支持 gzip 但也只能加载未压缩的资源。反过来同样如此,支持 gzip 客户端先访问,则缓存代理会缓存压缩版本,当不支持 gzip 的客户端再访问时,缓存同样命中,但是由于它无法对压缩资源解码,所以会呈现乱码。

通过 Vary: Accept-Encoding 我们可以防止这种情况的发生,因为 Vary 在这里其实是扮演着校验器的角色,它会进一步对命中缓存的资源进行再校验,如果发现头部信息不同,则会将缓存资源视为无效,从而将请求继续转发至源服务器。这对于缓存代理服务器也有一定的益处,因为可以有有依据地针对不同的 Accept-Encoding 缓存不同的资源副本。

Vary 与 缓存命中率

Vary 虽然可以防止缓存错乱,但并不代表可以滥用,盲目的使用会适得其反,比如之前提及的 Vary: *,这样等价于将每个请求视为唯一,并且不缓存其响应资源,除非有意为之,不然没有人会牺牲缓存带来的性能提升。

同时对于一些 Header 的值是开放性的,比如之前提及的 User-Agent,如果单纯从字面量来匹配的话,众多桌面浏览器的值会因各种因素而不同的,如果仅是简单地将 UA 作为区分桌面端和移动端的依据,那么缓存命中率会达到一个很低的水平。如何解决这个问题呢?可以将这些 UA 头部的值进行标准化,比如可以通过正则匹配所有桌面浏览器的 UA 并重新更改为 Desktop,之后再转发至缓存代理和源服务器,这样有利于提高缓存命中率,关于这部分的内容,可以参考这篇文章,其中有很细致的讲解。

所以我们要时刻留意,在使用 Vary 时,一定要根据缓存命中率作出调整,在不发生缓存错乱的情况之下,尽可能的提高资源的缓存命中率。

Vary 与 CORS

对于跨域的有情况,Vary 也包含一些内容。HTTP 协议规定,当服务端响应包含 Access-Control-Allow-Origin 头部,且它的值是一个具体的域名而不是通配符 *,那么这时必须要包含 Vary: Origin 这个头部。

为什么要包含这个头部,因为请求头中的 Origin 头部代表了该请求来源的具体域名信息,那么对于不同域名网站所发起的请求,会使用仅属于它本身的缓存。一般而言,我们很少会遇到这种问题,因为一般都将 Access-Control-Allow-Origin 设置为了 *,至少我自己是这样的。如果想进一步了解 Vary 和 CORS 的内容,可以参考这篇文章

最后

差不多就这么多内容了,如有错误,还望指正。

参考链接

广告

我的博客即将同步至腾讯云+社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=ynlt15k3lmbx

more