2018 总结


写在前面

2018 马上就过去了,今年对于我来说是很特别的一年,因为去年的现在,我曾经十分迷茫,因为工作这几年,写了很多代码,参与了很多项目,但总有一种碌碌无为的感觉,而且似乎离成为一名合格的全栈工程师越来越远了。因此,去年给自己定下的目标就是,在新的一年中,一定要远离自己的舒适区,开拓自己的视野,尽可能的 forget the label,不再以前端或者后端的角色限制自己,到头来虽然还是感觉碌碌无为,但有些事情的过程难道不是比结果更重要吗?

今年一整年来,陆陆续续看了很多书,翻译了很多文章,写了很多笔记,还折腾了很多新技术、新框架,每日也坚持学习英语,基本上也算是比较充实了,今日看到 SF 中的“总结你的 2018” 活动,因此凑个热闹,写这么一篇,简单分享下这一整年来,对于自己经历和看到的一些事情的感悟和看法。

业精于术,立于道

由于今年陆陆续续接触并上手了若干种技术栈和框架,前端后端都用,整体的感觉就是我之前一定对全栈这个概念有一些误解。之前我对于全栈的理解,过分地将关注点放在了字上面,但实际上并非如此。同时,“学不动了”这个关键词估计可以算是今年比较火的词汇了,因为前端的技术栈更新换代实在是太快了,这也让我非常理解那些说“学不动了”的人的心态。

想要打破这种困境,必须要明白业精于术,立于道这个道理。举一些例子,像前端中的 MVVM 框架,React、Vue、Angular 这些东西是术,而组件化的开发思想、底层渲染机制、代码复用方案是道,各种编程语言是术,而面向对象、函数式、响应式的开发思想是道。当然了,我这样说的意思不是说这些术就不重要了,请注意,这句话中关于术和道的先后顺序,只有先精于术,才有可能立于道。

因此,如果你不熟悉某一类技术栈的话,先挑一个较成熟的框架或者库,学会它,之后再深入了解它的原理,然后再利用这些原理举一反三的横向扩充其他技术栈,会产生”学不动了“的想法,只是因为学习新东西的方式不正确而已,正确的学习方式应当是利用已有的知识来举一反三,从而达到事半功倍的效果,从而打破“学不动了”的困境。

关于面试

由于我一直比较喜欢有挑战性的工作,每当自己进入舒适区的时候,就会产生危机感,这也是我 10 月份以来,陆陆续续进行了一大波面试的直接原因。

老实讲,面试的过程并不顺利。像各种大厂,比如阿里、腾讯、京东的面试我都参加了几次,我一直认为自己的技术水平还是不错的,但在实际面试中,还是会对一些问题有所疏漏,以至于答的不能让面试官满意,以至于最终基本都挂掉了。还有一次印象比较深刻的经历就是,在内推流程中直接简历评估就被 PASS 掉了,这让我一度很是蛋疼,备受打击。但后来也明白了,面试这个事情,最忌讳的就是妄自菲薄,因为挂掉不一定意味着你的能力不行,也可能是不合适,而且,面试也不是一个可以量化的考核过程,达标并不意味着通过,面试者肯定会选择最好的那一个,况且,千里马常有,而伯乐不常有,所以也没必要太计较结果。

匠心精神

关于这一点,是我在最近接手公司的一个项目时感悟到的。关于接手的项目,只能用混乱来形容,代码毫无规范、缺乏单元测试、设计缺乏逻辑,其实对于接手这种类型的项目,抱有一定的负面情绪也是正常的,但我想说的是,一定不要让这些客观因素影响了自己的工作态度。

一开始,我是很反感接手这种遗留项目的,况且代码还如此混乱,因此对于一些需求的实现,总是抱有得过且过,应付了之心态去完成,结果当然不尽人意,bug 很多,每次发布更新时都很忙乱,因此后来觉得不能再这样下去了,就耐下心来,好好地将项目的一些关键模块的代码看了一次,并进行了重构和调整,并补充了单元测试,之后每次发布更新都很顺利,bug 也少了很多。

所以少抱怨,多做事,耐心地解决问题,这可能才是一个合格的工程师该有的工作态度吧。

要有包容之心

程序员可能是杠精最密集的职业之一,毕竟每天 true/false 见多了,说什么事情都要争个对错。比如在技术社区中,有各种版本的“驳”学,如果你自己看的话,会发现最后你也不知道他们在讨论什么了,基本是为了驳倒他人而驳。我想说的是,作为一个心智成熟的人,一定要有包容之心,理智的探讨技术问题,而非抬杠。

还有就是对待你的同事,不要因为一些显而易见的错误而抱怨或者说一些不适当的话,谁能保证不犯错误呢,有可能下一个犯错误的就是你自己。

总结

大概就这些,下面我将 2018 年内翻译和编写的具有专题性的文章做一些汇总和分类,以供参考,如果还能在一定程度上帮助到他人,那简直太荣幸了。

30 分钟系列

高级 Vue 组件模式系列

高级 Angular 组件模式系列

SOLID 原则

接口设计

其他

more

13 个设计 REST API 的最佳实践


原文

写在前面

之所以翻译这篇文章,是因为自从成为一名前端码农之后,调接口这件事情就成为了家常便饭,并且,还伴随着无数的争论与无奈。编写友好的 restful api 不论对于你的同事,还是将来作为第三方服务调用接口的用户来说,都显得至关重要。关于 restful api 本身以及设计原则,我陆陆续续也看过很多的文章和书籍,在读过原文后,感觉文中指出的 13 点最佳实践还是比较全面的且具有参考意义的,因此翻译出来分享给大家。如有错误,还望指正。

由于我一般倾向于意译,关于原文中的开头语或者一些与之无关的内容,我就省略掉了,毕竟时间是金钱,英语好并且能科学上网的朋友我建议还是看原文,以免造成理解上的误差。

1. 了解应用于 REST 之上的 HTTP 知识

如果你想要构建设计优良的 REST API,了解一些关于 HTTP 协议的基础知识是很有帮助的,毕竟磨刀不误砍材工。

在 MDN 上有很多质量不错的文档介绍 HTTP。但是,就 REST API 设计本身而言,所涉及到的 HTTP 知识要点大概包含以下几条:

  • HTTP 中包含动词(或方法): GETPOSTPUTPATCH 还有 DELETE 是最常用的。
  • REST 是面向资源的,一个资源被一个 URI 所标识,比如 /articles/
  • 端点(endpoint),一般指动词与 URI 的组合,比如 GET: /articles/
  • 一个端点可以被解释为对某种资源进行的某个动作。比如, POST: /articles 可能代表“创建一个新的 article”。
  • 在业务领域,我们常常可以将动词CRUD(增删查改)关联起来:GET 代表查,POST代表增,PUTPATCH 代表改(注: PUT 通常代表整体更新,而 PATCH 代表局部更新),而 DELETE 代表删。

当然了,你可以将 HTTP 协议中所提供的任何东西应用于 REST API 的设计之中,但以上这些是比较基础的,因此时刻将它们记在脑海中是很有必要的。

2. 不要返回纯文本

虽然返回 JSON 数据格式的数据不是 REST 架构规范强制限定的,但大多 REST API 都遵循这条准则。

但是,仅仅返回 JSON 数据格式的数据还是不够的,你还需要指定返回 body 的头部,比如 Content-Type,它的值必须指定为 application/json。这一点对于程序化客户端尤为重要(比如通过 python 的 requests 模块来与 api 进行交互)—— 这些程序是否对返回数据进行正确解码取决于这个头部。

注:通常而言,对于浏览器来说,这似乎不是问题,因为浏览器一般都自带内容嗅探机制,但为了保持一致性,还是在响应中设置这个头部比较妥当。

3. 避免在 URI 中使用动词

如果你理解了第 1 条最佳实践所传达的意思,那么你现在就会明白不要将动词放入 REST API 的 URI 中。这是因为 HTTP 的动词已经足以描述执行于资源的业务逻辑操作了。

举个例子,当你想要提供一个针对某个 article 提供 banner 图片并返回的接口时,可能会实现如下格式的接口:

1
GET: /articles/:slug/generateBanner/

这里 GET 已经说明了这个接口是在做的操作,因此,可以简化为:

1
GET: /articles/:slug/banner/

类似的,如果这个端口是要创建一个 article:

1
2
3
4
5
// 不要这么做
POST: /articles/createNewArticle/

// 这才是最佳实践
POST: /articles/

尝试用 HTTP 的动词来描述所涉及的业务逻辑操作。

4. 使用复数的名词来描述资源

一些时候,使用资源的复数形式还是单数形式确实存在一定的困扰,比如使用 /article/:id/ 更好还是使用 /articles/:id/ 更好呢?

这里我推荐使用后者。为什么呢?因为复数形式可以满足所有类型端点的需求。

单数形式的 GET /article/2/ 看起来还是不错的,但是如果是 GET /article/ 呢?你能够仅通过字面信息来区分这个接口是返回某个 article 还是多个呢?

因此,为了避免有单数命名造成的歧义性,并尽可能的保持一致性,使用复数形式,比如:

1
2
3
GET: /articles/2/
POST: /articles/
...

5. 在响应中返回错误详情

当 API 服务器处理错误时,如果能够在返回的 JSON body 中包含错误信息,对于接口调用者来说,会一定程度上帮助他们完成调试。比如对于常见的提交表单,当遇到如下错误信息时:

1
2
3
4
5
6
{
"error": "Invalid payoad.",
"detail": {
"surname": "This field is required."
}
}

接口调用者很快就是明白发生错误的原因。

6. 小心 status code

这一点可能是最重要、最重要、最重要的一点,可能也是这篇文章中,唯一你需要记住的那一点。

你可能知道,HTTP 中你可以返回带有 200 状态码的错误响应,但这是十分糟糕的。不要这么做,你应当返回与返回错误类型相一致的具有一定含义的状态码

聪明的读者可能会说,我按照第 5 点最佳实践来提供足够详细的信息,难道不行吗?当然可以,不过让我讲一个故事:

我曾经使用过一个 API,对于它返回的所有响应的状态码均是 200 OK,同时通过响应数据中的 status 字段来表示当前的请求是否成功,比如:

1
2
3
4
{
"status": "success",
"data": {}
}

所以,虽然状态码是 200 OK,但我却不能绝对确定请求是否成功,事实上,当错误发生时,这个 API 会按如下代码片段返回响应:

1
2
3
4
5
6
7
8
9
HTTP/1.1 200 OK
Content-Type: text/html

{
"status": "failure",
"data": {
"error": "Expected at least two items in list."
}
}

头部还是 text/html,因为它同时返回了一些 HTML 片段。

正因为这样,我不得不在检查响应状态码正确的同时,还需校验这个具有特殊含义的 status 字段的值,才可以放心的处理响应返回的 data

这种设计的一个真正坏处在于,它打破了接口与调用者之间的“信任”,因为你可能会担心这个接口对你撒谎(注:言外之意就是,由于特设的字段可能会改变,因此增加了不可靠性)。

所以,使用正确的状态码,同时仅在响应的 body 中返回错误信息,并设置正确的头部,比如:

1
2
3
4
5
6
HTTP/1.1 400 Bad Request
Content-Type: application/json

{
"error": "Expected at least two items in list."
}

7. 保持 status code 的一致性

当你掌握了正确使用状态码之后,就应该努力使它们具有一致性。

比如,如果一个 POST 类型的端点返回 201 Created,那么所有的 POST 端点都应返回同样的状态码。这样做的好处在于,调用者无需在意端点返回的状态码取决于某种特殊条件,也就形成了一致性。如果有特殊情况,请在文档中显著地说明它们。

下面是我推荐的与动词相对应的状态码:

1
2
3
4
5
GET: 200 OK
POST: 201 Created
PUT: 200 OK
PATCH: 200 OK
DELETE: 204 No Content

https://blog.florimondmanca.com/restful-api-design-13-best-practices-to-make-your-users-happy

8. 不要嵌套资源

使用 REST API 获取资源数据,通常情况下会直接获取多个或者单个,但当我们需要获取相关联的资源时,该怎么做呢?

比如说,我们期望获取作者为某个 author 的 article 列表 —— 假设 authro 的 id=12。这里提供两种方案:

第一种方案通过在 URI 中,将嵌套的资源放在所关联的资源后边来进行描述,比如:

1
GET: /authors/12/articles/

一些人推荐这种方案的理由是,这种形式的 URI 一定程度上描述了 author 与 article 之间的一对多关系。但与此同时,结合第 4 点最佳实践,我们就不太能够分清当前端点返回的数据到底是 author 类型还是 article 类型。

这里有一篇文章,详细阐述了扁平化形式优于嵌套形式,因此一定有更好的方法,这就是下面的第二种方案:

1
GET: /articles/?author_id=12

直接将筛选 article 的逻辑抽离为 querystring 即可,这样的 URI 相比之前,更加清晰地描述了“获取所有 author(id=12) 的 article”的意思。

9. 优雅地处理尾部斜杠

一个好的 URI 中是否应当包含尾部斜杠,并不具有探讨价值,选择一种更倾向的风格并保持一致性即可,同时当客户端误用尾部斜杠时,提供重定向响应。

我再来讲我自己的一个故事。某天,我在将某个 API 端点集成到项目中,但是我总是收到 500 Internal Error 的错误,我调用的端点差不多看起来这样:

1
2
3
POST: /entities
```
调试一段时间之后,我几乎崩溃了,因为我根本不知道我哪里做错了,直到我发现服务器之所以报 500 的错误,是因为我粗心丢掉了尾部斜杠(注:这种经历人人都会遇到,我在 SF 上遇过无数次类似的问题),当我把 URI 改成:

POST: /entities/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
之后,一切正常运转。

当然,大多数的 web 框架都针对 URL 是否包含尾部斜杠,进行了优雅地处理并提供定制选项,如果可以的话,找到它并开启这项功能。

## 10. 使用 querystring 来完成筛选和分页功能
大部分情况下,一个简单的端点没有办法满足负责业务场景。

你的用户可能想要获取满足一定条件下的某些数据集合 ,同时为了保证性能,仅仅获取这个集合下的一个子集。换言之,这通常叫作**筛选**功能和**分页**功能:
* 筛选:用户可以提供额外的属性来控制返回的数据集合
* 分页:获取数据集合的子集,最简单的分页是基于分页个数的分页,它由 `page` 和 `page_size` 来决定

那么问题来了,我们如何将这两项功能与 RESTful API 结合在一起呢?

答案当然是通过 **querystring**。对于分页,很显然使用这种方式再合适不过了,比如:

GET: /articles/?page=1&page_size=10

1
但对于筛选,你可能会犯第 8 点最佳实践中所指出的问题,比如获取处于 published 状态的 article 列表:

GET: /articles/published/

1
2

除了之前提出的问题外,这里还涉及一个设计上的问题,就是 **published** 本身不是资源,它仅仅是资源的特征,类似这种特征字段,应该将它们放到 querystring 中:

GET: /articles/?published=true&page=2&page_size=20
`
更加优雅、清晰,不是吗?

11. 分清 401 和 403

当我们遇到 API 中关于安全的错误提示时,很容易混淆这两个不同类型的错误,认证授权(比如权限相关)—— 老实讲,我自己也经常搞混。

这里是我自己总结的备忘录,它阐述了我如何在实际情况下,区分它们:

  • 用户是否未提供身份验证凭据?认证是否还有效?这种类型的错误一般是未认证(401 Unauthorized)。
  • 用户经过了正常的身份验证,但没有访问资源所需的权限?这种一般是未授权(403 Forbidden

12. 巧用 202 Accepted

我发现 202 Accepted 在某些场合是 201 Created 的一个非常便捷的替代方案,这个状态码的含义是:

服务器已经接受了你的请求,但是到目前为止还未创建新的资源,但一切仍处于正常状态。

我分享两种特别适合使用 202 Accepted 状态码的业务场景:

  • 如果资源是经过位于将来一系列处理流程之后才创建的,比如当某项作业完成时
  • 如果资源已经存在,但这是理想状态,因此不应该被识别为一个错误时

13. 采用 REST API 定制化的框架

作为最后一个最佳实践,让我们来探讨这样一个问题:你如何在 API 的实施中,实践最佳实践呢?

通常的情况是这样的,你想要快速创建一个 API 以便一些服务可以互相访问彼此。Python 开发者可能马上掏出了 Flask,而 JS 开发者也不甘示弱,祭出了 Express,他们会使用实现一些简单的 routes 来处理 HTTP 请求。

但这样做的问题是,通常,web 框架并不是针对构建 REST API 服务而专门存在的,换言之,Flask 和 Express 是两个十分通用的框架,但它们并非特别适合用于构建 REST API 服务。因此,你必须采取额外的步骤来实施 API 中的最佳实践,但大多数情况下,由于懒惰或者时间紧张等因素,意味着你不会投入过多精力在这些方面 —— 然后给你的用户提供了一个古怪的 API 端点。

解决方案十分简单:工欲善其事,必先利其器,掌握并使用正确的工作才是最好的方案。在各种语言中,许多专门用于构建 REST API 服务的新框架已经出现了,它们可以帮助你在不牺牲生产力的情况下,轻松地完成工作,同时遵循最佳实践。在 Python 中,我发现的最好的 API 框架之一是 Falcon。它与 Flask 一样简单,非常高效,十分适合构建 REST API 服务。如果你更喜欢 Django 的话,使用 Django REST Framework就足够了,虽然框架不是那么直观(注:按我的理解应该是说不太容易上手,但是我不这么认为),但功能非常强大。在 NodeJS 中,Restify 似乎也是一个不错的选择,尽管我还没有尝试过。我强烈建议你给这些框架一个机会!它们将帮助你构建规范,优雅且设计良好的 REST API 服务。

总结

我们都应致力于让调用 API 这件事成为一种乐趣。希望本文能使你了解到在构建更好的 REST API 服务的过程中,涉及到的一些建议和技巧。对我而言,应该把这些最佳实践归结为三点,分别是良好的语义,简洁和合理性。

more

高级 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