前言
在读这篇文章之前,我想先安利大家一个东西:
看到这副黑框眼镜,你是不是想到了什么?
对,就是它:Vue.js 组件编码规范。读过的同学忽略,没读过的同学有时间的话请花 20 分钟认真看看,文章的内容都是在认可这篇规范的基础上展开的。
另外,本文中的“最佳实践”(注意引号),全都是一家之言,不一定对,欢迎各路大佬讨论拍砖。
实践一:如何分类组件
组件(component)是 vue 最核心的概念之一,但是正因为这一概念太过宽泛,我们会在实际开发中看到各种各样的组件,对开发和维护的同学带来了很大的困惑和混乱。这里我把组件分成四类:
view
顾名思义,view 指的是页面,你也可以把它叫做 page。它的定义是:和具体的某一条路由对应,在 vue-router 配置中指定。view 是页面的容器,是其他组件的入口。它可以和 vuex store 通信,再把数据分发给普通组件。
global component
全局组件,作为小工具而存在。例如 toast、alert 等。他的特点是具备全局性,直接嵌套在 root 下,而不从属于哪个 view。global component 也和 vuex store 通信,它单独地使用 state 中的一个 module,这个 state 中的数据专门用来控制 gloabl component 的显隐和展示,不和其他业务实体用到的 state 混淆。
其他组件想修改它,可以直接派发相应的 mutation。而要监听它的变化(比如一个全局的confirm,确认之后在不同的组件中触发不同的操作),则使用全局事件总线(event bus)。
simple component
简单组件。这种组件对应的是 vue 中最传统的组件概念。它的交互和数据都不多,基本上就是起到一个简单展示,拆分父组件的作用。这种组件和父组件之间通过最传统的方式进行通讯:父组件将 props 传入它,而它通过 $emit 触发事件到父组件。
简单组件内部是不写什么业务逻辑的,它可以说是生活不能自理,要展示什么就等着父组件传入,要干什么就 $emit 事件出去让父组件干,父组件够操心的。
complex component
复杂组件。这种组件的特点是,内部包含有很多交互逻辑,常常需要访问接口。另外,展示的数据也往往比较多。如下图。
图中红框内部的就是一个复杂组件的实例。它是一个大列表的列表项,展示的数据很多,而且点击左下角的几个 button,还会弹出相应的弹窗,弹窗内有复杂的表单需要填写提交···逻辑可以说是相当复杂了。如果这时我们还拘泥于简单组件的那种通信方式,衣来伸手饭来张口,啥事儿不干,那么:1.所有的 props 都由父组件一一传入,如果有十几个乃至几十个要展示的数据,那么父组件
内的代码可不得上天了?
2.所有的业务流程都要 $emit 出去要父组件处理,那么父组件
内的代码可不得上天了?
所以,对于这种复杂组件,我们应该允许它有一定的“自主权”。可以跳过父组件,自行和 vuex 通信,获取一下 state,派发一下 mutation 和 action,不是很开心么。
我画了一张图来说明上面这四种 component 的关系,希望能帮助大家更好理解。
在区分了这四种 component 后,我们在编码时就能做到心里有数,现在在写的组件,到底属于哪一类?每一类以特定的方式编写和交互,逻辑上就会清晰很多。 使用 vue-cli 构建的项目中都会有一个目录叫做 component,以前是一股脑往里塞,现在可以在此基础上再设置几个子目录,放置不同类型的组件。
实践二:如何优雅地修改 props
先来看一个栗子?
假设有一个模态对话框的组件。父组件为了能够打开模态框,给模态框传入了一个控制其显隐的 props,命名为 visible,type 为 Boolean,绑定模态框外层的 v-if 指令。那么,问题来了,如果我们点击了模态框内部的关闭按钮,关闭自身,应该怎么写?
当然,最传统的方式自然还是模态框抛出事件,父组件中设置监听,然后修改值。但这种方式无疑有很强的侵入性,无端增加了很多的代码量。关闭按钮在模态框内部,关闭自己是我自己的事儿,能不能不让父组件管这些?
有同学说了,直接在模态框内部修改 visible 啊。this.visible = false
,不行吗?
还真不行。如果这么干,你会看到以下一堆报错:
[Vue warn]: Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. Instead, use a data or computed property based on the prop's value.
vue 很明确地告诉你了,作为子组件,你要安分守己,不许随便修改老爹传给你的 props。
那么我们应该怎么办?
方法一
我们思考一下,如果不允许修改 props 的值,那我们修改 porps 的······属性如何?
事实证明,是可以的。
我们可以把上面 visible 的 type 设为 Object,模态框的显隐决定于 visible.value。当模态框想要关闭自身时,只需 this.visible.value = false
即可。 这种方式看起来相当方便,但实际是一种投机取巧的方法。上面安利的 Vue.js 组件编码规范中明确有一条规范,就是 props 原子化,也就是说,props 里的字段必须是简单的 String,Number 或 Boolean。这么做的原因是:
- 使得组件 API 清晰直观。
- 只使用原始类型和函数作为 props 使得组件的 API 更接近于 HTML(5) 原生元素。
- 其它开发者更好的理解每一个 prop 的含义、作用。
- 传递过于复杂的对象使得我们不能够清楚的知道哪些属性或方法被自定义组件使用,这使得代码难以重构和维护。
所以,我们把 visible 改为 Object,本来就是违反规范的。
方法二
vue 中有种已经存在的机制,和现有需求很像,这就是 v-model。在表单中,每一个 input,就像一个子组件。在外层通过 v-model 绑定的值可以在 input 中回显,而 input 本身的值也能改变。
事实上,v-model 仅仅是一个语法糖,v-model="xxx"
,就相当于 :value="xxx" @input="val=>xxx=val"
。那么,我们就可以利用 v-model 的这种特性来实现我们的需求。我们只需要在模态框内部抛出一个 input 事件 this.$emit('input', false)
,就能关闭自身了。
这种方式比较简洁,也不违反规范,但是容易让人困惑,以为这里是要进行什么表单操作。
我们还有没有什么更好的方式呢?
方法三
如果你是从大版本为 1 时就开始接触 vue,那你可以知道一个修饰符,叫做.sync。如果你是从 2.0 开始接触的,则很可能不熟悉它。这是因为,vue 在 2.0 版本时把它删除了,不过好在, 2.3 版本之后,它又回来了。
这个修饰符简直就是为我们这个需求量身定制的。它本身是一个和 v-model 类似的语法糖,我们要做的,仅仅是在组件内部需要改动值的地方,抛出一个 update 事件。this.$emit('update:foo', newValue)
。既不违反规范,也足够清晰,可以说是最佳的解决方案了。唯一的不足之处,就是对版本有一点要求。
实践三:如何封装请求接口
数据是 SPA 的核心,而数据的来源都是接口。如何优雅、高效地通过接口请求数据,是开发者必须要关心的问题。在实践中,我是这样封装接口的:
从高层到底层,依次说明。第一层就是组件。
第二层则是 vuex 中的 action,我们在组件中调用 action,基本操作。
第三层是 api。在这里,我们预先定义了每一个接口。包括接口的 url、type、content-type,以及写死的请求参数。在 action 中,我们调用 api 请求接口。
第四层是 request,这是我们请求的公共方法,作用就是对特定的 http client。 进行封装,实现一套统一的接口请求——处理流程。
第五层则是以 axios 为代表的各种 http client。
我们主要进行编码的是第三层和第四层,也就是 api 和 request。api 的编写没有什么难点,主要谈谈 request 的代码。这部分代码,我们要关心以下几个方面。
- loading 处理。当请求时间比较长时,要跳出全局的 loading 让用户知晓。
- 错误处理。有两种错误,第一种是 http 请求直接返回错误码。第二种,虽然请求的返回值是 200,但是返回结果中提示错误。比如返回的 json 中
success: false
。对于这两种错误,我们都要捕获并处理。 - api 一致性处理。http client 接受的参数是有讲究的,以 axios为例,get 请求的请求参数为 params,而 post 请求的参数则为 data。对于这种差异,request 这层需要将其抹平,api 层不需要在定义接口时关心这些。
下面是示例代码,可供参考。
if (opt.method === 'post') {
axiosOpt.data = opt.payload
} else if (opt.method === 'get') {
axiosOpt.params = opt.payload
}
if (opt.withFile) {
Object.assign(axiosOpt, { headers: {
'Content-Type': 'multipart/form-data'
}})
}
// 全局请求的 loading,当请求 300 ms 后还没返回,才会出现 loading
const timer = setTimeout(() => {
store.dispatch('showLoading', {
text: '加载数据中'
})
}, 300)
try {
// 开始请求
const result = await axios(axiosOpt)
// 如果 300 ms 还没到,就取消定时器
clearTimeout(timer)
store.dispatch('closeLoading')
if (result.status === 200 && result.statusText === 'OK') {
if (result.data.success) {
return result.data.results || true
} else {
// 请求失败的 toast
store.dispatch('showAlert', {
type: 'error',
text: `请求失败${result.data.message ? `,信息:${result.data.message}`: ''}`
})
return false
}
} else {
return false
}
} catch(e) {
clearInterval(timer)
// 请求失败的 toast
store.dispatch('closeLoading')
store.dispatch('showAlert', {
type: 'error',
text: '请求失败'
})
return false
}
复制代码
实践四:如何决定请求数据的时机
SPA中,每一个 view 中的都有很多数据是需要通过接口请求获得的,如果没有获得,页面中就会有很多空白。上面,我们讨论了如何封装好接口请求,下一步就是决定什么时候请求初始化数据,即,代码在哪里写的问题。实践下来,有两个时机是比较理想的。
beforeRouteEnter/Update
vue-router 提供了以上两个生命周期钩子,分别会在进入路由和路由改变时触发。这两个钩子是写的 view 中的。
router.beforeEach
vue-router还提供了一个全局性的 beforeEach 方法,任何一个路由改变时,都会被这个方法拦截,我们可以在这个方法中加入我们自己的代码,做统一处理。比如,对于所有 view 初始化请求的 action,我们可以以特定的名称命名,如以 _init 作为后缀等。在 beforeEach 方法内,我们对当前 view 对应的 store 进行监听,查找到其中以 _init 命名的 action 并派发。
以上两种方式各有特点。
对于前者,优点是数据获取的代码和具体的 view 是绑定在一起的,我们可以在 view 内部就清晰地看到数据获取的流程。缺点是,每增加一个页面,都要在其内部写一堆初始化代码,增加了代码量。 对于后者。优点是,代码统一且规整,使用了配置的方式,写一次即可,不需要每次增加额外的代码。缺点是比较隐晦,且初始化代码和 view 本身割裂了。
对于以上两种方式如何取舍的问题,我倾向于,大型项目用后者,小型项目用前者。
Other Tips
- 多使用 mixing,能够在组件级别抽离公共部分,减少冗余,极好的机制。
- 多使用常量,这点和 vue 本身没有关系,但是能极大地提升代码的健壮性。
- 链接如果是在项目内部跳转,多使用 ,而不是去拼 a 标签的 href。
- 不要用 dom 操作。但如果迫不得已,比如你要获得某个 dom 的 scrollTop 属性,用 $ref,而不是用选择器去取。
- 能想到的就这些,欢迎大佬们讨论补充。
作者:丁香园前端团队-㍿社长