前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
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 新特性详解,memo、lazy、Suspense 与 Hooks

首页2019-09-02 17:20:12Front-End
Reactmemo|suspense|lazyHooks代码分割

有个显示时间的组件每秒 setState 一次,里面套着的子组件明明什么都没变,控制台里 I am rendering 还是一秒打一次。这类问题在 React 16 之前只能把子组件改写成 class 再继承 PureComponent,一个纯展示的函数组件被迫升级成类,代码一下子胖了一圈。

React.memo 就是来解决这个的。这篇把 React 16 那一批新特性串起来讲一遍,重点在 memo、lazy、Suspense 三个,把它们各自解决什么问题、边界在哪、以及今天在 React 19 上是什么状态说清楚。

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

  • React 16 带来了哪些结构性变化,各自解决什么问题
  • React.memo 怎么用,第二个比较函数的返回值语义为什么容易搞反
  • React.lazy 做代码分割的完整链路,以及模块必须默认导出这个硬要求
  • Suspense 当年的设想是什么,今天真正落地成了什么
  • Hooks 在这套演进里处于什么位置
  • 这些 API 在 React 19 上还有哪些变化

react 16 给我们带来的一系列重要变化:

  • render/纯组件能够 return 任何数据结构,以及 createPortal API
  • 新的 context api,尝试代替一部分 redux 的职责
  • babel 的 <> 操作符,方便用户返回数组
  • 异步渲染/时间切片(time slicing),成倍提高性能
  • componentDidCatch,错误边界,框架层面上提高用户 debug 的能力
  • 未来的 Suspense,优雅处理异步副作用
  • 未来的 hooks

这份清单是当年写的,现在回头看,除了「时间切片」那条的描述偏乐观(它最后落地成了 React 18 的并发渲染,收益取决于场景,不是无脑提速),其余几条基本都兑现了。createPortal 成了 Modal、Tooltip 这类组件的标准做法;React.createContext 确实吃掉了一部分轻量全局状态的场景;错误边界现在还多了一个 static getDerivedStateFromError 配套。

下面挑三个最常被问到的展开。

# 一、memo

React v16.6.0 出了一些新的包装函数(wrapped functions),其中一种是用于函数组件的、相当于 PureComponent / shouldComponentUpdate 形式的 React.memo()。

React.memo() 是一个高阶函数,它与 React.PureComponent 类似,但作用对象是函数组件而不是类。

先看问题本身。现在有一个显示时间的组件,每一秒都会重新渲染一次,对于 Child 组件我们肯定不希望也跟着渲染:

import React  from 'react';

export default class extends React.Component {
    constructor(props){
        super(props);
        this.state = {
            date : new Date()
        }
    }

    componentDidMount(){
        setInterval(()=>{
            this.setState({
                date:new Date()
            })
        },1000)
    }

    render(){
        return (
            <div>
                <Child seconds={1}/>
                <div>{this.state.date.toString()}</div>
            </div>
        )
    }
}
@前端进阶之旅: 代码已经复制到剪贴板

React 的默认行为是父组件重渲染,整棵子树跟着重渲染,不管子组件的 props 有没有变。所以这里 Child 每秒都会被调用一次,哪怕 seconds 一直是 1。

在 16.6 之前,唯一的办法是把它写成类:

class Child extends React.PureComponent {
    render(){
        console.log('I am rendering');
        return (
            <div>I am update every {this.props.seconds} seconds</div>
        )
    }
}
@前端进阶之旅: 代码已经复制到剪贴板

React.memo() 让我们不必为了跳过渲染而放弃函数组件:

function Child({seconds}){
    console.log('I am rendering');
    return (
        <div>I am update every {seconds} seconds</div>
    )
};
export default React.memo(Child)
@前端进阶之旅: 代码已经复制到剪贴板

# 1.1 第二个参数的语义

React.memo() 可接受 2 个参数,第一个是函数组件,第二个用于对比 props 控制是否刷新:

import React from "react";

function Child({seconds}){
    console.log('I am rendering');
    return (
        <div>I am update every {seconds} seconds</div>
    )
};

function areEqual(prevProps, nextProps) {
    if(prevProps.seconds===nextProps.seconds){
        return true
    }else {
        return false
    }

}
export default React.memo(Child,areEqual)
@前端进阶之旅: 代码已经复制到剪贴板

这里有个坑要注意。原文说这个参数「与 shouldComponentUpdate() 功能类似」,功能上确实是一回事,但返回值的语义是反的:areEqual 返回 true 表示两次 props 相等、跳过渲染,而 shouldComponentUpdate 返回 true 表示需要更新、执行渲染。

我一开始也是照着 shouldComponentUpdate 的直觉写的,结果写出来的组件一次都不更新,盯着看了半天才反应过来是把布尔值写反了。判断方法很简单,看函数名,它叫 areEqual 而不是 shouldUpdate。

# 1.2 memo 的默认比较是浅比较

不传第二个参数时,React.memo 对 props 做的是浅比较,也就是逐个 key 用 Object.is 比。那结果就是下面这种写法,memo 完全不生效:

<Child config={{ size: 'large' }} onClick={() => doSomething()} />
@前端进阶之旅: 代码已经复制到剪贴板

对象字面量和箭头函数每次渲染都是新引用,浅比较必然不等。想让 memo 真正起作用,父组件那边得配合 useMemo 稳住对象、useCallback 稳住函数。

所以 memo 不是加上就有效的开关,它是一条需要父子两端配合的约定。

这也是为什么我不建议无脑给所有组件套 memo。比较本身有成本,一个渲染开销很小的叶子组件,套上 memo 之后大概率是负优化,还多了一层心智负担。我的判断标准是:这个组件的渲染成本明显(长列表项、图表、富文本),并且它的 props 在父组件频繁更新时确实是稳定的,两个条件同时成立才加。

顺带提一句,React 团队在推进 React Compiler,目标就是自动完成这类记忆化,落地之后手写 memo 的必要性会下降。具体的版本节奏和适用范围建议直接看官方文档,我这边只在 demo 项目里试过,没在生产上跑过。

# 二、lazy

React.lazy 用于做 Code-Splitting,代码拆分。类似于按需加载,渲染的时候才加载代码:

import React, {lazy} from 'react';
const OtherComponent = lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    <div>
      <OtherComponent />
    </div>
  );
}
@前端进阶之旅: 代码已经复制到剪贴板

lazy(() => import('./OtherComponent')) 使用 es6 的 import() 返回一个 promise,类似于:

lazy(() => new Promise(resolve =>
  setTimeout(() =>
    resolve(
      // 模拟ES Module
      {
        // 模拟export default
        default: function render() {
          return <div>Other Component</div>
        }
      }
    ),
    3000
  )
));
@前端进阶之旅: 代码已经复制到剪贴板

这段模拟代码其实把 React.lazy 的契约写得很清楚了:它要的是一个返回 Promise 的函数,Promise resolve 出来的对象必须有 default 字段。

所以有个硬要求,被 lazy 加载的模块必须有默认导出。只用具名导出的模块直接 lazy 会拿到 undefined,报的错还挺不直观。绕法是包一层:

const OtherComponent = lazy(() =>
  import('./components').then(module => ({ default: module.OtherComponent }))
);
@前端进阶之旅: 代码已经复制到剪贴板

还有一点要说在前面,import() 的路径不能是完全动态拼出来的字符串。打包器需要在构建期静态分析出可能的模块,才能切出对应的 chunk。import('./pages/' + name) 这种写法在 webpack 下会把整个 pages 目录都打进去,在 Vite 下则需要走 import.meta.glob。这个我踩过,页面越加越多之后发现「按需加载」的 chunk 里塞满了根本没用到的路由。

# 2.1 lazy 需要 Suspense 兜底

组件还没加载完的这段时间,页面上得有个东西顶着,这就要引出 Suspense:

const OtherComponent = React.lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <OtherComponent />
      </Suspense>
    </div>
  );
}
@前端进阶之旅: 代码已经复制到剪贴板
  • 在我们的业务场景中,OtherComponent 可以代表多个条件渲染组件,我们全部加载完成才取消 loading
  • 只要 promise 没执行到 resolve,suspense 都会返回 fallback 中的 loading
  • 代码简洁,loading 可提升至祖先组件,易聚合,相当优雅地解决了条件渲染

fallback 的粒度值得琢磨一下。放在整个页面外层,用户看到的是整页 loading,体验最差但代码最简单;放在每个懒加载块外层,页面骨架先出来、内容逐块补齐,体验最好但要处理布局跳动。我一般的做法是按视觉分区放,fallback 用和真实内容等高的骨架屏,避免内容加载完之后页面往下窜。

网络失败的情况 Suspense 是不管的,import() 的 Promise reject 之后需要错误边界来接。所以完整的写法通常是 ErrorBoundary 包 Suspense 包 lazy 组件,三层。部署了新版本导致旧 chunk 404 是线上最常见的触发场景,错误边界里做一次 location.reload() 是很多团队的兜底方案。

fe
  • 一、memo
    • 1.1 第二个参数的语义
    • 1.2 memo 的默认比较是浅比较
  • 二、lazy
    • 2.1 lazy 需要 Suspense 兜底
  • 三、suspense
    • 3.1 这几年它实际落成了什么
  • 四、hooks
  • 总结
  • 参考

← Typescript实践总结 从基础类型到工程化与项目实战React Hooks 详解,useState useEffect useContext 与 useReducer →