前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
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 热点
旧版

React 设计模式和最佳实践总结,从组件拆分到 Hooks

首页2019-08-10 18:50:12Front-End
JavaScriptReact设计模式Hooks

这篇笔记最早是我 2019 年边读边整理出来的,那时候 React 的正式版还停在 16.x,class 组件是绝对主流,Hooks 才刚从 alpha 走出来。后来我把它翻出来过好几次,发现里面讲的那些东西,组件怎么拆、逻辑怎么复用、状态放在哪、服务端渲染卡在哪一步,跟版本号其实没多大关系,换到 React 19 一样成立。

所以这次我没有把老内容删掉重写,而是原样保留 2019 年的判断和代码,再在每一节后面另起一段,补一句现在通常怎么做。你能看到的不只是一份最佳实践清单,还有 React 这几年是怎么一步步走到今天的。

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

  • 组件设计的三条原则,以及为什么 renderXXXX 这种拆法是个坑
  • 高阶组件、render props、提供者模式、组合组件四种经典模式的取舍
  • 为什么 React 单元测试选 Jest 而不是 Mocha,以及 shallow 和 mount 的区别
  • setState 为什么不同步,函数式 setState 到底解决了什么
  • Mobx 和 Redux 在数据处理哲学上的根本分歧
  • react-router v4 的「动态路由」到底动在哪里
  • 服务端渲染的脱水和注水,以及 Next.js 的 getInitialProps 为什么巧妙
  • Fiber 异步渲染怎么把生命周期切成两个阶段,哪些老生命周期因此被判了死刑
  • Suspense 用同步代码写异步操作的原理,以及 Hooks 带来的模式转变
  • 每一节对应的现代写法补注,从 React 16 到 React 19

# 一、组件实践

# 1.1 设计原则

先说结论,组件设计上真正管用的就三条:

  • 保持接口小,props 数量要少
  • 根据数据边界来划分组件,充分利用组合
  • 把 state 往上层组件提取,让下层组件只需要实现为纯函数

这三条看着很虚,但每一条背后都对应一类具体的痛。

props 数量多了,第一个受伤的是调用方,每次用这个组件都要回去翻一遍它到底要哪些参数;第二个受伤的是重构,props 就是组件的对外接口,接口越宽,将来想改就越难。按数据边界拆,说的是别按视觉区块拆,而是看这块 UI 依赖哪几个字段,依赖同一份数据的放一起。至于把 state 往上提,好处是下层组件退化成纯函数之后,它的行为完全由入参决定,测试好写,shouldComponentUpdate 也好判断。

这块我自己踩过的坑是第三条用力过猛,把所有 state 一股脑提到根组件,结果任何一个输入框敲一下字整棵树都要 re-render。state 提到「所有需要它的组件的最近公共父节点」就够了,再往上就是负担。

# 1.2 组件划分

任何一个复杂组件都是从简单组件开始的,一开始我们在 render 函数里写的代码不多,但是随着逻辑的复杂,JSX 代码越来越多,于是,就需要拆分函数中的内容

组件长大到什么程度该拆?我的经验是 render 里出现第二层视觉分组的时候就该动手了。但怎么拆很关键,下面这种拆法是最常见的错误示范。

  • 在 React 中,有一个误区,就是把 render 中的代码分拆到多个 renderXXXX 函数中去,比如下面这样
class StopWatch extends React.Component {
  render() {
    const majorClock = this.renderMajorClock();
    const controlButtons = this.renderControlButtons();
    const splitTimes = this.renderSplitTimes();
    
    return (
       <div>
          {majorClock}
          {controlButtons}
          {splitTimes}
       </div>
    );
  }
  
  renderMajorClock() {
     //TODO: 返回数字时钟的JSX
  }
  
  renderControlButtons() {
     //TODO: 返回两个按钮的JSX
  }
  
  renderSplitTimes() {
     //TODO: 返回所有计次时间的JSX
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

用上面的方法组织代码,当然比写一个巨大的 render 函数要强,但是,实现这么多 renderXXXX 函数并不是一个明智之举,因为这些 renderXXXX 函数访问的是同样的 props 和 state,这样代码依然耦合在了一起。更好的方法,是把这些 renderXXXX 重构成各自独立的 React 组件,像下面这样

class StopWatch extends React.Component {
  render() {
    return (
       <div>
          <MajorClock>
          <ControlButtons>
          <SplitTimes>
       </div>
    );
  }
}

const MajorClock = (props) => {
  //TODO: 返回数字时钟的JSX
};

const ControlButtons = (props) => {
  //TODO: 返回两个按钮的JSX
};
  
const SplitTimes = (props) => {
  //TODO: 返回所有计次时间的JSX
}
@前端进阶之旅: 代码已经复制到剪贴板

我们创造了 MajorClock、ControlButtons 和 SplitTimes 这三个组件,目前,我们并不知道它们是否应该有自己的 state,但是从简单开始,首先假设它们没有自己的 state,定义为函数形式的无状态组件

拆成独立组件之后,多出来的那层价值是「边界」。原来 renderMajorClock 想读什么就读什么,现在 MajorClock 只能拿到你显式传给它的 props,依赖关系一眼可见。顺带还多了两个好处,一是这三个组件可以单独写单元测试,二是它们各自有了独立的重渲染判断点。

这里有个坑要注意,上面这段示例代码里 <MajorClock> 这些标签没有闭合,是原文顺手写的伪代码。实际项目里要写成 <MajorClock /> 这种自闭合形式,不然 JSX 会把后面的内容当成 children 一路吃进去。

组件 props 的设计

props 定下来了,还得让别人知道每个 props 是什么类型、哪些是必填。老项目里这件事交给 propTypes。

使用 propTypes 来定义组件的 props

const ControlButtons = (props) => {
  //TODO: 返回两个按钮的JSX
};

ControlButtons.propTypes = {
  activated: PropTypes.bool,
  onStart: PropTypes.func.isRequired,
  onPause: PropTypes.func.isRequired,
  onSplit: PropTypes.func.isRequired,
  onReset: PropTypes.func.isRequired,
  splits: PropTypes.arrayOf(PropTypes.number)
};

@前端进阶之旅: 代码已经复制到剪贴板

这份 propTypes 写出来其实就是一份接口文档。activated 是可选的布尔量,四个 on 开头的回调都是必填,splits 是数字数组。开发环境下一旦传错类型,控制台会直接给出警告,能挡掉相当一部分低级错误。

顺着上面聊,propTypes 只在开发环境生效,生产构建会被剥掉,所以它不是运行时校验,指望它挡住后端返回的脏数据是不行的。

现在的做法:prop-types 这个包还能用,但主流已经换成 TypeScript,直接给组件的 props 定义 interface,在编译期就把类型问题拦掉,编辑器里还能自动补全。React 官方文档现在也把类型检查这一节的重心放在了 TypeScript 上。如果项目还没上 TS,propTypes 依然是有价值的兜底。

# 1.3 组件内部实现

组件拆好、接口定好之后,剩下的是组件文件内部怎么组织。这三条是老项目里最容易被忽略的细节。

  • 尽量每个组件都有自己专属的源代码文件
  • 用解构赋值(destructuring assignment)的方法获取参数 props 的每个属性值
  • 利用属性初始化(property initializer)来定义 state 和成员函数

一个文件一个组件,好处是文件名就是组件名,找代码不用全局搜索;解构赋值则是把这个组件用到哪些 props 摆在函数第一行,读代码的人不用往下扫二十行才拼出依赖列表。

属性初始化方法

尽量不要在 JSX 中写内联函数(inline function),比如这样写,是很不恰当的

<ControlButtons
  activated={this.state.isStarted}
  onStart={() => { /* TODO */}}
  onPause={() => { /* TODO */}}
  onReset={() => { /* TODO */}}
  onSplit={() => { /* TODO */}}
/>
@前端进阶之旅: 代码已经复制到剪贴板

当然,按照上面那种写法,也可以完成程序的功能,但是,会带来性能的代价。首先,每一次渲染这段 JSX,都会产生全新的函数对象,这是一种浪费;其次,因为每一次传给 ControlButtons 的都是新的 props,这样 ControlButtons 也无法通过 shouldComponentUpdate 对 props 的检查来避免重复渲染

正确的写法是用类的属性初始化语法,把这些回调定义成组件的成员:

class StopWatch extends React.Component {
  state = {
    isStarted: false
  };

  onStart = () => {
    this.setState({ isStarted: true });
  };

  render() {
    return (
      <ControlButtons
        activated={this.state.isStarted}
        onStart={this.onStart}
      />
    );
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

用箭头函数配合属性初始化定义成员方法,还顺手解决了 this 绑定问题,不用再在构造函数里写一堆 this.onStart = this.onStart.bind(this)。整个组件生命周期内 this.onStart 都是同一个函数对象,传给子组件的 props 就稳定了。

那内联函数是不是一律不能写?也不是。如果子组件本来就不做 props 比较,或者这个子组件是个原生 <button>,内联函数带来的开销小到可以忽略,为了这点开销把代码写得七零八落反而得不偿失。真正要在意的是列表渲染里给上百个子项各挂一个新函数这种场景。

现在的做法:函数组件里对应的手段是 useCallback 把回调缓存住,配合 React.memo 做 props 浅比较。React 19 之后官方推的 React Compiler 会自动做这类记忆化,理论上不用再手写 useCallback,不过它的启用方式和适用范围还在演进,具体以官方文档为准。我自己目前的做法还是老老实实手写。

# 二、组件设计模式

这一章是整篇笔记里最值钱的部分。高阶组件、render props、提供者模式、组合组件这四个东西,回答的都是同一个问题,怎么在不同组件之间复用逻辑而不是复用 UI。理解了这四种模式的取舍,再去看 Hooks 为什么长成那个样子,会顺畅很多。

# 2.1 高阶组件

fe
  • 一、组件实践
    • 1.1 设计原则
    • 1.2 组件划分
    • 1.3 组件内部实现
  • 二、组件设计模式
    • 2.1 高阶组件
    • 2.2 render props 模式
    • 2.3 提供者模式
    • 2.4 组合组件
  • 三、React 单元测试
    • 3.1 测试的目的
  • 四、单元测试
  • 五、React 状态管理
    • 5.1 组件状态
    • 5.2 Mobx 使用模式
    • 5.3 不同方式对比
  • 六、React Router
  • 七、服务器端渲染
    • 7.1 基本套路
    • 7.2 理解 Next.js
  • 八、React 的未来(1): 拥抱异步渲染
  • 九、React 的未来(2):Suspense 带来的异步操作革命
  • 十、函数化的 Hooks
  • 总结
  • 参考

← 小程序蓝牙开发实践 BLE 连接流程与踩坑记录Ionic3 升级 Ionic4 变更对比与迁移避坑记录 →