前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

Vue之合理划分容器组件与展示组件

首页2019-06-02 00:30:32Front-End
Vue组件化前端架构

一个页面写着写着变成一千多行的 .vue 文件,接口请求、状态判断、埋点、弹窗控制全糊在一起,这种代码基本每个人都接手过。想拆,第一个问题就是按什么标准拆。按功能拆?按页面区块拆?拆完之后数据放哪、请求由谁发?这篇讲一种用了很多年、到现在依然好使的划分标准:把组件分成容器组件和展示组件,前者管数据和逻辑,后者管渲染。下面用一个博客首页把这套划分从代码到层次结构完整走一遍,也说说它在今天该怎么调整。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • 容器组件和展示组件各自的职能边界
  • 用一个博客首页的完整代码演示怎么拆
  • 这么拆到底换来了什么,代价又是什么
  • 组件层次为什么不建议超过三层,超了怎么办
  • 这套源自 Redux 的划分,在组合式 API 时代还剩多少价值

# 一、容器组件和展示组件分别在管什么

按职能给 Vue 组件分类,可以分成两种:容器组件和展示组件。

这组概念最早来自 Redux 的文档。容器组件顾名思义是容器性质的组件,可以把它理解成一个页面最外层的父组件,一般放在 views 文件夹下。它的活是做数据提取和实现公共逻辑,然后把结果渲染给对应的子组件。

另一类是展示组件,字面意思,主要用于展示。它负责接收容器组件传来的数据并在页面上渲染,实现自己内部独有的功能逻辑。

判断一个组件属于哪类,我的经验是问三个问题:它自己发接口请求吗?它读全局状态吗?它知道数据是从哪来的吗?三个都是「否」的,就是展示组件。三个里有任何一个是「是」,它就是容器组件。

按这个标准,一个博客首页的结构是:最外层那个父组件是容器组件,里面的导航栏、文章列表、页脚是展示组件。容器组件在 mounted 里把首页数据拉回来,通过 props 分发给三个子组件,同时接住子组件抛上来的事件。

# 二、一个博客首页的拆法

# 容器组件

先看首页容器组件的代码:

<template>
    <div>
        <navigation @count="countFn"></navigation>
        <article-list :list="articleList"></article-list>
        <site-foot></site-foot>
    </div>
</template>

<script>
    import { mapActions, mapGetters } from 'vuex';
    export default {
        mounted() {
            this.SET_BLOG_DATA(); // 调用接口获取数据
        },
        computed: {
            ...mapGetters(['articleList']), // 监听 state
        },
        methods: {
            ...mapActions(['SET_BLOG_DATA', 'SET_NAV_COUNT']),
            countFn(item) {
            
                // 调用接口存储导航点击次数并跳转,通过派发 action 的形式来发起 state 变化
                this.SET_NAV_COUNT({ type: item.type });
                
                this.$router.push({name: item.route});
            }
        }
    }
</script>
@前端进阶之旅: 代码已经复制到剪贴板

这段和原文有三处不一样,都是修掉的问题。第一处,原文 computed 那个对象的右花括号后面漏了逗号,直接跟 methods,语法上过不去。第二处,原文的组件标签写的是 <article>,这个名字是 HTML 的原生标签,Vue 会优先当成原生元素处理,你的组件根本不会被渲染出来,控制台还会给一条不要用内置或保留标签名做组件名的警告,所以改成了 <article-list>。第三处,<foot> 同理,虽然它不是标准 HTML 元素,但单个单词的组件名容易和未来的原生标签撞车,官方风格指南也建议组件名始终用多个单词,改成了 <site-foot>。

组件名这条规则很多人不当回事,直到某天用了 <header> 或者 <footer> 当组件名,发现页面上什么都没渲染出来,才想起来这回事。

容器组件在这里就干了两件事:数据的传递和回调的处理。除此之外,那些不属于任何一个展示组件的公共逻辑也归它,比如校验登录状态、页面级的埋点上报。

# 展示组件 Navigation

一个容器组件里可以有多个展示组件。先看导航:

<template>
    <ul>
        <li 
            v-for="(item, index) in nav"
            :key="index"
            @click="goNav(item)"
            v-text="item.name"
        ></li>
    </ul>
</template>

<script>
    export default {
        data() {
            return {
                nav: [{
                    name: '首页',
                    route: 'index',
                    type: 'index'
                }, {
                    name: '文章',
                    route: 'article',
                    type: 'article' 
                }, {
                    name: '关于',
                    route: 'about',
                    type: 'about' 
                }]
            }
        },
        methods: {
            goNav(item) {
                this.$emit('count', item); // 触发回调
            }
        }
    }
</script>
@前端进阶之旅: 代码已经复制到剪贴板

注意 goNav 里没有 this.$router.push,只有一个 $emit。这是整段代码里最关键的一行。

导航组件知道用户点了哪一项,但它不该知道点完之后要干什么。存点击量、跳路由这些是业务逻辑,属于容器组件的地盘。展示组件只负责把「用户点了 article 这一项」这个事实抛上去,剩下的交给上面决定。

这个约束带来的好处很直接:同一个导航组件,在首页里点击是跳路由,在移动端抽屉里点击可以是关闭抽屉再跳,在预览模式下点击可以什么都不做。组件本身一行都不用改。

这里还有个可以优化的点,:key="index" 在这个场景下能跑,因为导航数组是写死的、不增删。但形成习惯之后迟早会用在动态列表上出事,这里的 item.type 本身就是唯一的,直接拿它当 key 更稳妥。

# 展示组件 Article

再看文章列表:

<template>
    <ul>
        <li 
            v-for="item in list"
            :key="item.id"
            @click="goPage(item.id)"
            v-text="item.title"
        ></li>
    </ul>
</template>

<script>
    export default {
        props: {
        
            // 接收容器组件数据
            list: {
                type: Array,
                default: () => []
            }
        }
    }
</script>
@前端进阶之旅: 代码已经复制到剪贴板

原文这里的 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详解 里写过。

所以我现在的做法是:不再刻意去区分「这个文件是容器还是展示」,但保留背后那条原则,也就是有副作用的逻辑和纯渲染的逻辑要分开。区别只是分离的手段从「拆成两层组件」变成了「抽成组合式函数」。判断标准还是那三个问题:它自己发请求吗?它读全局状态吗?它知道数据从哪来吗?

fe
  • 一、容器组件和展示组件分别在管什么
  • 二、一个博客首页的拆法
    • 容器组件
    • 展示组件 Navigation
    • 展示组件 Article
    • 纯静态组件 Foot
  • 三、这么拆到底换来了什么
  • 四、组件的层次结构
  • 五、这套划分在今天还成立吗
  • 总结
  • 参考

← Vue API 盲点解析 performance errorHandler nextTick 与 watchVue之学会编写可复用性模块 函数组件与插件三种粒度 →