一个页面写着写着变成一千多行的 .vue 文件,接口请求、状态判断、埋点、弹窗控制全糊在一起,这种代码基本每个人都接手过。想拆,第一个问题就是按什么标准拆。按功能拆?按页面区块拆?拆完之后数据放哪、请求由谁发?这篇讲一种用了很多年、到现在依然好使的划分标准:把组件分成容器组件和展示组件,前者管数据和逻辑,后者管渲染。下面用一个博客首页把这套划分从代码到层次结构完整走一遍,也说说它在今天该怎么调整。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 容器组件和展示组件各自的职能边界
- 用一个博客首页的完整代码演示怎么拆
- 这么拆到底换来了什么,代价又是什么
- 组件层次为什么不建议超过三层,超了怎么办
- 这套源自 Redux 的划分,在组合式 API 时代还剩多少价值
一、容器组件和展示组件分别在管什么
按职能给 Vue 组件分类,可以分成两种:容器组件和展示组件。
这组概念最早来自 Redux 的文档。容器组件顾名思义是容器性质的组件,可以把它理解成一个页面最外层的父组件,一般放在 views 文件夹下。它的活是做数据提取和实现公共逻辑,然后把结果渲染给对应的子组件。
另一类是展示组件,字面意思,主要用于展示。它负责接收容器组件传来的数据并在页面上渲染,实现自己内部独有的功能逻辑。
判断一个组件属于哪类,我的经验是问三个问题:它自己发接口请求吗?它读全局状态吗?它知道数据是从哪来的吗?三个都是「否」的,就是展示组件。三个里有任何一个是「是」,它就是容器组件。
按这个标准,一个博客首页的结构是:最外层那个父组件是容器组件,里面的导航栏、文章列表、页脚是展示组件。容器组件在 mounted 里把首页数据拉回来,通过 props 分发给三个子组件,同时接住子组件抛上来的事件。
二、一个博客首页的拆法
容器组件
先看首页容器组件的代码:
<template> |
这段和原文有三处不一样,都是修掉的问题。第一处,原文 computed 那个对象的右花括号后面漏了逗号,直接跟 methods,语法上过不去。第二处,原文的组件标签写的是 <article>,这个名字是 HTML 的原生标签,Vue 会优先当成原生元素处理,你的组件根本不会被渲染出来,控制台还会给一条不要用内置或保留标签名做组件名的警告,所以改成了 <article-list>。第三处,<foot> 同理,虽然它不是标准 HTML 元素,但单个单词的组件名容易和未来的原生标签撞车,官方风格指南也建议组件名始终用多个单词,改成了 <site-foot>。
组件名这条规则很多人不当回事,直到某天用了 <header> 或者 <footer> 当组件名,发现页面上什么都没渲染出来,才想起来这回事。
容器组件在这里就干了两件事:数据的传递和回调的处理。除此之外,那些不属于任何一个展示组件的公共逻辑也归它,比如校验登录状态、页面级的埋点上报。
展示组件 Navigation
一个容器组件里可以有多个展示组件。先看导航:
<template> |
注意 goNav 里没有 this.$router.push,只有一个 $emit。这是整段代码里最关键的一行。
导航组件知道用户点了哪一项,但它不该知道点完之后要干什么。存点击量、跳路由这些是业务逻辑,属于容器组件的地盘。展示组件只负责把「用户点了 article 这一项」这个事实抛上去,剩下的交给上面决定。
这个约束带来的好处很直接:同一个导航组件,在首页里点击是跳路由,在移动端抽屉里点击可以是关闭抽屉再跳,在预览模式下点击可以什么都不做。组件本身一行都不用改。
这里还有个可以优化的点,:key="index" 在这个场景下能跑,因为导航数组是写死的、不增删。但形成习惯之后迟早会用在动态列表上出事,这里的 item.type 本身就是唯一的,直接拿它当 key 更稳妥。
展示组件 Article
再看文章列表:
<template> |
原文这里的 default 直接写的是 []。这个写法在 Vue 2 里会触发警告,因为对象和数组类型的 prop 默认值必须用工厂函数返回,否则这个数组会被该组件的所有实例共享,一个实例改了别的实例跟着变。这类 bug 平时很难复现,只有当页面上同时渲染了两个以上同类组件时才暴露,所以更得在写的时候就改对。
顺手把 :key 也从 index 换成了 item.id,文章列表是会变的,用 index 当 key 在插入和删除时会导致大面积重建。
Article 组件里动态的数据全部从 props 来,它内部只处理列表的渲染工作。UI 层面和应用层面就这样分开了,今后想在别的页面复用这个列表,传一份数据进去就能用。
纯静态组件 Foot
页脚是纯静态组件,不接收外部数据也不抛回调,属于展示组件里最简单的一种。这类组件的价值在于它几乎不可能出 bug,也几乎不需要维护,能划到这一类的东西越多越好。
三、这么拆到底换来了什么
拆完之后代码行数没少,文件数还变多了,那图什么?
图的是变更的影响范围可控。接口字段改了,只动容器组件;样式和交互改了,只动展示组件。两类变更来自完全不同的两拨需求,把它们隔开之后,改一个不用担心碰坏另一个。
还有一个不太被提的好处:展示组件极其好测。它没有副作用,输入是 props,输出是渲染结果和抛出的事件,一个纯函数式的东西,写测试用例不用 mock 任何接口。容器组件确实难测一些,但一个页面里容器组件就那么一两个。
代价也得说清楚。拆得太细之后,一份数据从容器组件传到最里层的展示组件,中间可能要经过两三层组件的转发,每一层都得声明一遍 props、再往下传一遍。这种「透传」写多了非常烦,也是下一节要讲的层级问题的根源。至于怎么绕开逐层透传,provide 和 inject 是正经方案,我在 Vue组件化实践详解 里把九种通信方式各自的适用场景和代价都列过一遍,配合这篇看会更完整。
四、组件的层次结构
职能划分之外,还有个维度是层次深度。一般建议一个页面里组件嵌套不要超过三层。
原因就是上面说的,层次越深,数据传递的链路越长。父传子的 props 要一层层往下声明,子传父的事件要一层层往上转发,中间那些什么都不干、只负责传话的组件是纯粹的成本。而且一旦出问题,你得顺着这条链路一层层排查数据在哪断了。
那超过三层怎么办?有几条路。
一是换个划分方式。原本一个容器组件包着一堆展示组件,可以改成页面里存在多个平级的容器组件,每个容器组件各自管一片区域的数据和逻辑。这样自然就把深度压下来了,因为每个容器组件到它的展示组件之间只有一到两层。
二是让展示组件内部再包一个容器组件。听起来有点绕,实际很常见:一个展示组件 B 里面嵌了容器组件 C,C 自己去取自己那份数据,不依赖最外层容器组件 A 传下来。这样 A 就不用关心 C 需要什么,也就不用替它做透传。
三是用跨层级的通信手段。provide 和 inject 让祖先组件直接把数据放出来、任意深度的后代直接拿,中间层完全不参与。全局状态管理(Vuex 或者现在的 Pinia)也是同一类解法,把状态提到组件树之外,谁要谁自己取。
第三条听着最省事,但它有隐性成本:数据流向从「顺着树往下」变成了「凭空出现」,后代组件光看自己的代码不知道数据从哪来,脱离那个祖先就跑不起来。所以我的建议是,前两条能解决的优先用前两条,第三条留给真正跨越多层的场景。
「不超过三层」也不是什么硬性规定,是个提醒。真到了三层还不够用的时候,先回头看看是不是划分方式本身有问题,而不是急着上更重的通信方案。
五、这套划分在今天还成立吗
得说个背景。容器组件和展示组件这组概念是 Dan Abramov 在 React 早期提出来的,后来他自己在那篇文章上加了一段说明,大意是这套划分是他很多年前写的,随着 Hooks 出现,他不再建议把它当成必须遵守的规则,硬拆反而会增加没必要的组件层级。
放到 Vue 这边是同样的道理。Vue 2 时代之所以需要容器组件,很大程度是因为 Options API 下没有别的地方能放「可复用的有状态逻辑」,mixin 又有命名冲突和来源不清的老问题,于是只能靠组件层级来承载。
Vue 3 的组合式 API 改变了这个前提。一段带状态的逻辑现在可以抽成组合式函数(useXxx),谁需要谁调用,不需要为它专门套一层组件。数据获取、分页、表单校验这些原本必须挂在容器组件上的东西,现在可以是一个 useArticleList()。组合式函数的写法和它相比 mixin 的优势,我在 Vue3之Composition API详解 里写过。
所以我现在的做法是:不再刻意去区分「这个文件是容器还是展示」,但保留背后那条原则,也就是有副作用的逻辑和纯渲染的逻辑要分开。区别只是分离的手段从「拆成两层组件」变成了「抽成组合式函数」。判断标准还是那三个问题:它自己发请求吗?它读全局状态吗?它知道数据从哪来吗?
另外原文示例用的是 Vuex 的 mapGetters 和 mapActions。Vue 3 生态里官方推荐的状态管理已经是 Pinia,去掉了 mutation 那一层,对 TypeScript 也友好得多。Vuex 的写法本身没错,存量项目继续用没问题,新项目直接上 Pinia。
总结
回到最开始那个一千多行的页面文件。按容器和展示来拆,标准其实只有一条:这个组件知不知道数据是从哪来的。知道的是容器,不知道的是展示。
拆完的收益是变更影响范围可控,接口改动只碰容器组件,交互和样式改动只碰展示组件,两类需求彼此隔离。展示组件因为没有副作用还额外白送一个好处,测试特别好写。
代价是层级会变深,props 逐层透传写起来很烦。解法优先考虑重新划分,比如让一个页面里存在多个平级的容器组件,或者让展示组件内部自己包一个小容器组件去取数据;实在跨越多层再上 provide 和 inject 或者全局状态。三层这个数字不是硬指标,是超了就该回头审视划分方式的信号。
最后是这套方法今天的位置。它的提出者本人都已经不建议把它当规则,因为 Hooks 和组合式 API 提供了更轻的逻辑复用方式。但底下那条「副作用和渲染分开」的原则没有过期,变的只是实现手段。写代码的时候记住这条原则就够了,不必为了套模式而多加一层组件。