3. 正确认识并运用组件化

2,822 阅读16分钟

React 知命境」第 3 篇

对于初学者来说,直接阅读官方文档学习 React 是一个糟糕的方式。

你能够快速掌握碎片化的语法,然而当你试图用 React 完成一个完整的项目时,你可能会无所适从,不知道这些碎片知识应该如何运用在项目中。

你折腾很久,终于能够熟练使用 React 了,可是你依然无法产生得心应手的感觉。因此很多人会误以为 React 心智负担很重。

我从 2015 年底就开始学习并使用 React,并从 2017 开始教别人如何使用,在随后与其他前端团队沟通与交流的过程中发现,少有团队能够正确认识并运用组件化,这里面也包括许多大厂团队,这是造成 React 心智负担重的核心原因之一

因此,本篇文章主要目的就是在学习 React 之前,帮助大家一起准确认识组件化。

OK,让我们开启 React 学习的魔幻之旅吧。

前言

React 并非一个前端框架,而是一个 UI 库。他的优秀之处在于引导我们思考如何构建一个应用。因此,如果能够在学习之前脱离基础语法的束缚,站在更全面的角度去搞懂它的设计哲学,我们对于 React 的把控度将会更高,我们达到的高度也会更高。

组件化的基础理解

组件化是前端独有的开发思维,是在模块化基础之上发展而来的更高效的开发方式,是实现所见即所得的优秀技术方案。

我们把页面上任何一个能够独立出来的部分称之为组件,组件的最小单位,是单个 HTML 元素。

DOM 元素也属于组件化的范畴

例如一个完整的页面,我们可以称之为页面组件。通常一个项目能够拆分出来多个页面。

项目拆分

一个页面能够简单拆分为头部、内容、底部,这三部分也可以被看做是三个组件。

页面组件拆分

一个头部组件的拆分要视情况而定,例如下图,头部组件可以简单拆分为 logo 与 搜索框,这两个部分也可以称之为组件。

头部组件拆分

头部组件拆分

因此在前端开发中,组件化是一种先拆分,后合并的思维方式。我们能够利用这种思维,将一个项目的大问题,一层层拆分为单个组件的小问题。这是嵌套思维的运用。

上面这一段巨重要,一定要仔细体会

HTML 标签其实是组件化思维的最早实践。例如,我们只需要在页面中,写入下面这段简单的代码,就能够在页面中渲染出一个按钮。只不过由于默认样式比较丑而极少单独运用于实践中。

<button>按钮</button>

可惜在 HTML 中,我们并没有任何一种方式能够如此轻松的得到一个自定义样式的组件。在最早的实现中,前端开发无法单独为按钮提供自定义样式,而必须与其他 CSS 代码写在一起。因此优秀的程序员会将组件化思路分别运用于 HTML 标签与 CSS 样式的编写,但这不是真正的组件化。

我们知道,一个按钮,通常由如下几部分共同组成

+ DOM 结构 `<button class="btn">按钮</button>`
+ 按钮样式 `.btn {}`
+ 按钮逻辑 `click` 等回调事件
+ 可能包括按钮前置图片
+ 可能包括背景图片
+ 可能包括一些音频资源
...

这些资源共同构成了一个完整的按钮。

组件

最理想的方式,是把按钮当成一个独立的个体引入一次,即可完整呈现。

import Button from './components/Button'

但是在普通的开发结构中,我们做不到这样的便利。我们必须分别处理 HTML 标签、JavaScript 逻辑、CSS 样式,漏掉一个,就无法呈现完整的按钮。

得益于 webpack 的横空出世,组件化思维终于有了落地实现的技术基础。webpack 能够将任何资源文件处理成为 JavaScript 能够识别的模块,参与到 JavaScript 的逻辑中去

例如,当我们要实现一个按钮组件时,我们可以这样去编写 JavaScript 逻辑

import logo from './logo.png'
import './index.css'

function Button() {
  return (
    <div className="container">
      <img src={logo} className="App-logo" alt="logo" />
      <button class="btn">自定义样式的组件</button>
    </div>
  )
}

这段代码比较诡异的地方在于,我们将图片资源与 CSS 文件当成了模块引入,并且还能够参与 JS 表达式的运算中去。webpack 能够帮助我们识别这样的语法,它让按钮的所有组成部分聚合在一起成为了一个整体。当我们在别的地方使用时,就只需要引入按钮组件即可。

在现有的客户端解决方案中,HTML 标签是组件化最理想的代码表达方式。因此,在 React 中,我们依然使用标签的形式去表达一个组件。

例如,一个项目,由多个页面组件组成,我们可以这样表达

<!--通常,自定义组件,首字母大写-->
<div className="root">
  <Page1 />
  <Page2 />
  <Page3 />
</div>

一个页面,由头部、内容、底部组成

<div className="page1_container">
  <Header />
  <Content />
  <Footer />
</div>

一个头部组件,由 Logo 与 搜索框组成

<header>
  <img src={logo} alt="logo"/>
  <Search />
</header>

需要注意的是,在代码实现中,组件化主要体现在聚合上,我们会先编写一个个单独的组件,然后在更大的组件中将他们聚合在一起。但是,正确解决问题的思路是先思考拆分。我们需要先有一个大局观,首先着眼于整体页面,然后思考如何拆分并得到更小的组件。

组件化在文件结构上给我们的指引

这一部分知识非常关键。特别是对于有一定开发经验的朋友来说,它可能会彻底颠覆你的认知,并大幅度提高你的开发效率。

组件化的思维体现在组成部分的聚合。因此在文件结构上,组件化思维与后端项目常规 MVC 等分层思维差异很大,它的另一层意思,其实表达的是:凡是属于我的部分,都应该跟我放在一起。

在分层思维的影响下,大多数团队在前端项目中,组织代码结构的方式沿用了分层思维。

例如,我们的项目有 100 个页面,我们先不细分到组件,那么每个页面基本上都具备以下内容

+ View 视图部分,用于 UI 的展示
+ 数据访问,各种接口请求
+ 状态管理、逻辑处理,model 或者别的名称,用于处理当前页面的状态与数据
+ css,该页面自己的样式

在做项目组织的时候,我们一般都会如下这样处理

首先创建一个 pages 目录,将所有页面 UI 或者组件放到该文件夹中

// + 号表示文件夹,- 号表示文件
+ pages
  - Home
  - Profile
  - Detail
  - Show
  ...

然后创建一个 apis 目录,将所有的接口请求代码放在该目录中

+ apis
  - common.ts
  - home.ts
  - profile.ts
  - detail.ts
  - show.ts
  ...

再然后创建一个 models 目录,将所有的状态管理逻辑处理等模块放在该目录中

+ models
  - home.ts
  - profile.ts
  - detail.ts
  - show.ts

最后创建一个 styles 目录,将所有的 css 文件放在该目录

+ styles
  - common.css
  - home.css
  - profile.css
  - detail.css
  - show.css
  ...

于是,我们整个项目的代码层面,可能就会长这样

// 其他文件结构与脚手架有关,这里不做扩展
+ src
  + assets
  + commonets
  + pages
  + models
  + apis
  + styles

这是传统分层代码结构组织形式。那么它运用到前端,有什么问题呢?

在开发时,其实有一个痛点很多人都有,只是没有明确提出来。那就是当我们仅仅只是实现一个页面功能时,我们需要使用编辑器同时打开大量的代码文件,可能是 4、5 个,多一点的 10 多个都有。

因此前端的编辑器大多数都支持分屏功能。

那么在这种情况之下,当项目变得很大之后,文件与文件之间的位置就隔得很远。例如 pages/Homeapis/home.ts 他们虽然属于同一页面,但是在代码结构上却相隔得非常远。

当项目页面越多,我们要找到对应页面的组成部分,就越困难。在我们专注「摸鱼」的情况下,甚至会找错页面。

因此对于前端开发来说,这种分层的代码组织效率非常低下。但大多数团队依然采用了这种效率低下的模式。

在组件化思维的指导下,我们应该采用新的组织方式。

组件化思维强调整体,强调聚合,也就是说,只要是一个组件的组件部分,我们应该尽量把他们放在一起。

例如,我们有一个 Home 页面,那么就应该这样去组织代码

+ Home
  - index.tsx
  - index.css
  - api.ts
  - model.ts

当然,如果页面比较复杂,可能还有更多的组成部分

+ Home
  + components  // 子组件
  + images      // 图片资源
  - index.tsx
  - api.ts
  - model.ts
  - interface.d.ts
  - config.ts
  - entry.ts  // 存放一些默认值、常量、隐射关系等
  - data.cvs  // 某种别的数据格式
  ...

在代码结构组织上,我们也把一个组件当成一个整体,只要是属于该组件的模块,基本上都放在一个文件夹里,对外来说,Home 文件夹,就代表了 Home 组件的全部。

这就是组件化思维指导下的代码组织方式。无论是做项目迁移,还是类似的页面复制功能,这都是最简单最高效的方式。

因此代码文件结构最终可能如下

+ src
  + components // 项目公共组件
  + pages // 所有的页面,将页面当成整体来看待
  + hooks // hooks 类工具方法
  + utils // 普通工具方法
  + api.ts // 少量的共有请求
  + router.ts // 处理路由配置

组件化的进一步思考:封装

所有高大上的开发思维,都是封装的运用。

封装思维是所有程序员最底层的基本功。他决定了一个人水平的上限。

在我的另一本书《JavaScript 核心进阶》中专门介绍了封装的本质。组件化的进一步思考,就是对封装的运用。

我们知道,HTML 标签其实就是组件化最早的运用,可是我们并不能利用起来,因为我们无法自定义组件。

React 提供了自定义组件的支持,这为我们组织代码带来了极大的想象空间。自定义组件,即为对标签的封装。

例如,一个列表可以用如下结构来实现

<ol>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
  <li></li>
</ol>

但是,当差不多一样格式的列表,在项目中出现多次时,如果不加以封装,我们就需要重复写多次这样的结构,明显这不是最理想的方案。

当有自定义组件的技术支持,我们就可以将该列表封装为一个自定义组件

<List />

这样,当他要出现在许多地方时,代码结构上,就会非常简单。

与其他封装思路一样,封装的核心应用,在于我们是否能够准确的区分出来共性与差异。例如对于函数来说,共性的逻辑我们封装到函数代码里,差异的内容,通过参数传入。

组件中对于封装思维的运用也是一样,例如有这样的一个案例,如下图:

案例

该图呈现出来的是一个列表。

因此,我最终要将其封装为一个 <List /> 这样的格式参与运行。该列表有 4 个子项,经过分析我们知道,每一个子项的结构,样式等都是一样的,而图标、标题等文字内容不同。因此,我们可以将这四个子项封装为一个组件,在运用时,通过不同的参数传入来分别表示这四个子项。

于是 List 组件可以由如下方式组成

<ul className="list-wrapper">
  <Item icon={item1.icon} title={item1.title} numer={item1.number} size={item1.size} />
  <Item icon={item2.icon} title={item2.title} numer={item2.number} size={item2.size} />
  <Item icon={item3.icon} title={item3.title} numer={item3.number} size={item3.size} />
  <Item icon={item4.icon} title={item4.title} numer={item4.number} size={item4.size} />
</ul>

再次利用封装思维,上面的代码可简化如下:

// 首先抽取不同的部分组合成为一个数组,数据不同,标签相同
const data = [item1, item2, item3, item4]

// 然后利用遍历的方式,将数组的值一次赋值给每一个 Item 组件
<ul className="list-wrapper">
  {data.map(item => (
    <Item icon={item.icon} title={item.title} numer={item.number} size={item.size} />
  ))}
</ul>

每一个字段,都通过标签的属性 props 传入。我们在声明自定义组件 Item 时,只需要能够读取到这些从外部传入的属性值,就可以完整的渲染出来 4 个不同的子项。

React 提供了读取 props 的语法。

因为在 React 中,是将标签语言作为 JavaScript 的逻辑表达式在使用,因此为了避免与声明类的关键字冲突,标签的 class 名都需要修改为 className

子组件 Item 的封装代码如下:

// 函数的第一个参数,即为所有传入的 prop 组成的对象
function Item(props) {
  const {icon, title, number, size} = props

  return (
    <div className="item-wapper">
      <img className="left" src={icon} alt="icon" />
      <div className="middle">
        <div className="title">{title}</div>
        <div className="number">{number}</div>
      </div>
      <div className="right size">{`${size}GB`}</div>
    </div>
  )
}

于是,列表这一部分功能,我们就可以单独拿出来开发。

// 引入 Item 组件
import Item from './Item'
import icon1 from 'images/icon1.png'
import icon2 from 'images/icon2.png'
import icon3 from 'images/icon3.png'
import icon4 from 'images/icon4.png'
import './index.css'

export default function List() {
  const data = [{
    icon: icon1,
    title: 'Documents Files',
    number: 1328,
    size: 1.3
  }, {
    icon: icon2,
    title: 'Media Files',
    number: 1328,
    size: 15.1
  }, {
    icon: icon3,
    title: 'Other Files',
    number: 1328,
    size: 12.7
  }, {
    icon: icon4,
    title: 'Unknown',
    number: 428,
    size: 1.3
  }]

  return (
    <ul className="list-wrapper">
      {data.map(item => (
        <Item icon={item.icon} title={item.title} numer={item.number} size={item.size} />
      ))}
    </ul>
  )
}

值得注意的是,组件化封装思维的核心依然在找出相同的逻辑部分,与有差异的部分,在某些情况下,这很难做到,需要大家在实践中多思考,多积累,多交流。

在封装思维的驱动下,一部分数据被单独抽离出来。如果标签表达的是 UI,那么这个时候,我们就把传统的组件,在内部拆分成为了数据层与 UI 层。我们发现,数据层与 UI 层在某种意义上是一一对应的关系。理解这一点,对未来我们去探索最佳实践时,有极大的帮助。

因此,追求 UI 层的所见即所得,与追求数据层的所见即所得,成为组件化的最高评判标准。

组件化的高阶思考:数据驱动

交互是前端开发无法避免的研究课题。

一个网页的运行是由多个线程共同协作完成,当页面通过 GUI 线程首次渲染完成之后,页面并非永远一成不变。不同的线程活动,需要页面给予相应的逻辑反馈。

点击按钮出现弹窗

例如,当我们点击按钮,页面出现一个新的弹窗,弹窗上通常会有关闭按钮,点击该按钮,弹窗关闭。这是最常见的交互之一。

点击行为通过 I/O 线程告知页面,假如弹窗组件的标签已经提前写在了 HTML 中,使用 display: none 隐藏,传统方式下,要实现这样的效果,页面逻辑大概会有如下步骤

  • 获取弹窗组件的对象
  • 在该对象上添加弹窗显示的 className
var dialog = document.getElementById('dialog')
dialog.className = 'show'

传统方式下,当交互逐渐变得复杂,我们需要获取大量的元素对象,然后通过对应的 api 去操作他们。这会让项目变得越来越难以维护。React 提供了一种新的方式:数据驱动视图,去解决这样的复杂度。

在上面封装自定义组件的案例中,我们区分了数据层与 UI 层。如果数据发生了变化,数据会驱动 UI 组件重新渲染,从而让 UI 也发生一致的变化。这就是数据驱动视图

数据驱动的开发思维,引导我们关注数据的所见即所得,仅仅通过操作数据的思维去改变 UI 渲染结果。React 内部组件提供了 state 的方式,用于存放能够驱动视图的数据

弹窗案例中,我们可以将弹窗的显示与隐藏的行为,在数据层抽象为一个具体的值:show。当我们改变 show = true 时,弹窗显示,当我们改变数据 show = false 时,弹窗隐藏。

因此,这个逻辑我们可以封装如下即可:

const App = () => {
  // 默认值为 false, 弹窗隐藏
  const [show, setShow] = useState(false)

  return (
    <div className="root">
      <button onClick={() => setShow(true)}>显示对话弹窗</button>
      <Dialog show={show} onClose={() => setShow(false)} />
    </div>
  )
}

我们将数据 show 通过 React 的语法 useState 存放在 state 中,这些数据的变化会驱动视图发生变化。因此,当我们使用 setShow(true)setShow(false) 改变 show 的值时,弹窗组件能够相应反馈显示和隐藏。

当 state 发生改变时,组件函数 App 会重新执行

结合上文中我们提到的对属性 props 的处理,自定义组件 Dialog 的逻辑可以很好实现,代码大概如下:

function Dialog({show, onClose}) {
  return (
    <div className={`dialog ${show ? 'show' : 'hide'}`}>
      <button onClick={onclose}>关闭按钮</button>
      其他内部内容
    </div>
  )
}

show 的变化,对应控制容 div className 的变化让弹窗显示或者隐藏。关闭按钮 onClick 执行的回调函数由传入的属性 onClose 来决定。结合上例我们知道,onClose 的逻辑是 setShow(false),因此,点击该按钮时,show 改变为 false,弹窗隐藏。

数据驱动视图的开发思维下,我们就不再去思考如何获得 DOM 元素的引用来完成对应的逻辑,而是学会将 UI 行为抽象成为数据,并通过改变数据的方式完成交互需求。我们只需要把最多的精力放在数据如何发生变化即可。通常情况下,通过 http 线程请求服务端数据,通过 I/O 线程监听用户输入信息,通过定时器线程执行对应的逻辑等方式都会导致数据发生变化,这也是我们后续学习,需要关注的重点。

这里需要注意的是,使用 state 存放的数据有自身的特性,那就是能够驱动对应的 UI 发生变化,我们在开发中,还会用到许多跟 UI 变化没有关系的数据,这个时候,就建议不要使用 state 来存放。总之,state 数据尽量与 UI 一一对应。

而掌握「数据驱动 UI 」 的核心哲学,需要反过来解读。也就是说,什么时候我们需要从 UI 中抽离数据出来。

那就是:会在交互中变化的 UI,需要抽离出数据,并用 state 存放。

例如列表组件,初始化是列表为空,请求一次接口之后,列表有了数据,那么列表的内容是变化,抽离出来的数据就是一个数组

function App() { 
  const [list, setList] = useState([])

  return (
    <List data={list} renderItem={(item) => <Item ={item} />} />
  )
}

建议大家可以专门做一做这一方面的训练,去更多的思考一下不同组件的交互提炼出来的数据应该长什么样。例如单选,多选,tab 切换等等,也可以在群里互相交流。

拆分组件最小颗粒度原则

如何拆分组件,最小颗粒度到什么程度,是许多同学使用 React 的难点。

然而这实际上是封装思维的进阶运用,因此考究的是你封装能力的火候。单独的文章无法迅速提高你的封装底层能力,它需要大量的思考与练习,但是我可以提供三个原则帮助大家尽可能的快速提高自己的封装能力。

明确什么时候需要封装

封装的目的是为了简化代码。因此这两种情况下,我们都应该考虑封装代码。

首先,当代码量过大,造成阅读困难时。

其次,当相同或者类似的代码逻辑,出现多次时。

基于这两个前提条件,我们就可以做出如下决策。

人为约定组件的代码规模

为了让代码保持简洁性,每一个组件的代码行数,应该尽可能的保持在一定的数量之内。我们可以约定为 200 行,或者 150 行,这根据团队的具体情况来定。一旦超出我们约定的行数,那么就表示我们需要将组件进行拆分了。这是一个非常棒的判断标准。

实际情况是,在没有正确的引导下,许多同学悲剧的将一个组件写出了几千行,甚至几万行代码,这维护成本杠杆的,别说没有,没准就是你

关注数据

数据作为影响组件的重要组成部分,是最容易被忽略的存在。也是最考验开发者思维的部分。许多项目为了避免数据对组件造成干扰,就使用了全局的状态管理工具 redux、mobx等等,将所有的数据都存放在全局的 store 中。

这是一种解决方案。但是过于简单粗暴,限制了开发者的创作能力。在大型项目中,甚至存在内存占用过多的隐患。因此,精准分析数据是 React 知命境的追求目标。

找准数据归属于谁,在实际开发中,最麻烦的地方在于许多数据,是由组件之间相互共享的。因此,解决这部分问题,关键词就在于:数据被谁共享。

当组件之间共享数据,我们就应该把数据存放在他们最近的父组件之中。

反过来说,如果数据不存在共享,那么数据就应该属于对应组件私有。

避免过度封装

中国古代哲学告诉我们:过犹不及。

在开发中许多同学存在为了封装而封装,为了增加代码阅读难度而封装,对一行代码进行封装等过度封装的现象。在思考封装时我们应该避免这些现象的出现。

最后

理解了以上内容,你的组件化造诣就已经非常深厚了。

最后,学会 React 语法并非难事,理解 React 组件化,才是重中之重,这篇文章的内容能够让你在 React 的学习过程中少走许多弯路,所以建议大家反复阅读直到彻底掌握为止。


「React 知命境」 是一本从知识体系顶层出发,理论结合实践,通俗易懂,覆盖面广的精品小册,欢迎关注我的公众号,我会持续更新

点击关注我点击添加我
这波能反杀icanmeetu