JSX、数据流通、虚拟 DOM、调和与 Diff、setState、Fiber 架构、React 合成事件、性能优化、设计模式等
# React 进阶
# JSX
三个问题:
- JSX 的本质是什么,他和 JS 之间到底是什么关系?
- 为什么要用 JSX?不用会有什么后果?
- JSX 背后的功能模块是什么,这个功能模块都做了哪些事情?
JSX 是 JS 的一种语法扩展,他和模板语言很接近,但充分具备 JS 的能力。
JSX 本质上是一种语法糖,允许开发者使用类 HTML 标签语法来创建虚拟 DOM
通过 Babel:JSX — 编译 —> React.createElement (),如果不用 JSX,也可以使用 React.createElement ()
JSX 的编译执行流程大致如下:
# 从 React15 到 React16 + 的生命周期变化
组件的初始化渲染流程:
组件的更新流程:
Question:
- 为什么要用 getDerivedStateFromProps 代替 componentWillReceiveProps?
- 消失的 componentWillUpdate 与新增的 getSnapshotBeforeUpdate
在 getDerivedStateFromProps 内,能且仅能做一件事:实现基于 props 派生 state
React16 实际上是在强制推行:只用 getDerivedStateFromProps 来完成 props 到 state 的映射这一最佳实践
getSnapshotBeforeUpdate 的返回值会作为第三个参数给到 componentDidUpdate,它的执行时机是在 render 方法之后,真实 DOM 更新之前,同时获取到更新前的真实 DOM 和更新前后的 state&props 信息
getSnapshotBeforeUpdate 与 componentDidUpdate 一起涵盖过时的 componentWillUpdate 的所有用例,本质上还是 componentWillUpdate 阻碍了 Fiber 架构
# Fiber 架构
为什么要更换为 Fiber 架构:我认为主要是因为原本的同步渲染过程可能会有大计算量的工作导致渲染阻塞,从而造成不好的用户体验
为什么异步能提高用户体验:其实无论是同步还是异步,总计算量是不变的,关键在于宏任务、微任务、事件循环的相关概念
Fiber 是 React16 对 React 核心算法的一次重写,使得原本同步的渲染过程变为异步的。
Fiber 会将一个大的更新任务拆解为许多小任务,使得渲染过程可以被打断,执行优先级更高的任务。
生命周期在 Render 阶段是可以被打断的,而在 Commit 阶段则总是同步执行的,详见下图:
从 React15 到 React16,废弃了如下 API:
- componentWillMount
- componentWilUpdate
- componentWillReceiveProps
因为这些 API 都处于 Render 阶段,可能会被重复执行,而且很多情况下会被滥用,做一些副作用操作(setState、异步 Fetch 请求、操作 DOM 等),而这些操作都有一些共同特点:
- 完全可以转移到其他生命周期(尤其是 componentDidXXX)中做
- 在 Fiber 带来的异步渲染机制下,可能会导致非常严重的 Bug
# 数据流通
基本数据通信:
-
父 - 子组件通信:父组件通过 props 将数据传递给子组件
-
子 - 父组件通信:子组件调用父组件传递的回调函数,通过函数入参将数据传递给父组件
-
兄弟组件通信:化简为子父组件通信 + 父子组件通信
基本数据通信方式虽然可以解决绝大多数问题,但遇到多层嵌套组件的通信时,就显得不那么优雅。
进阶数据通信:
-
“发布 - 订阅模式”:添加订阅 on (),发布事件 emit (),删除订阅 off ()
// 简单实例
class PubSub {
private listeners: { [type: string]: Function[] } = {};
on(type: string, listener: Function) {
let list: Function[] = this.listeners[type] && [];
list.push(listener);
this.listeners[type] = list;
}
emit(type: string, ...message: unknown[]) {
let list: Function[] = this.listeners[type] && [];
list.forEach(fun => fun(...message));
}
off(type: string, listener: Function) {
let list: Function[] = this.listeners[type];
this.listeners[type] = list?.filter(fun => fun !== listener);
}
}
-
React Context API:通过 Context.Provider 和 Context.Consumer,数据可以穿透多层组件,让所有包裹在 Context 下的组件都能同步生产者和消费者之间的数据
-
第三方数据流框架 Redux:解决应用复杂度越来越高、需要维护的状态越来越多、组件间的关系越来越难处理的问题。Redux 是 JavaScript 状态容器,提供可预测的状态管理。在 Redux 中,store 是一个单一的数据源,而且是只读的,action 是对变化的描述,reducer 负责接收 action,对变化处理并更新 & 分发新的状态。✨在 Redux 的整个工作流程中,数据流是严格单向的。
# React-Hooks
Why React-Hooks:
- 告别难以理解的 ES6 Class 语法(主要是 this 的问题)
- 解决业务逻辑难以拆分的问题(类组件中逻辑会与生命周期耦合,难以复用,逻辑像是被打散了一样融进生命周期中)
- 使状态逻辑复用变得简单可行
- 函数组件从设计思想上来看更加契合 React 的理念
Hooks 能够帮助实现业务逻辑的聚合,避免复杂的组件和冗余的代码(HOC 和 Render Props 也可以解决,但却会造成简单问题复杂化,嵌套地狱等问题)
要注意的是,Hooks 也有其局限性,例如:
- Hooks 暂时还不能完全为函数组件补齐类组件的能力
- 函数组件轻量,但这可能使它不能很好消化复杂
- Hooks 在使用层面有着严格的规则约束(不能嵌套在条件判断、循环中等)
# 为什么不能将 Hooks 嵌套在条件判断等逻辑中?
以 useState 为例,Hooks 的底层实现为链表,在组件初始化时,调用的 Hooks 会形成一个单向链表,之后的更新渲染时,底层 api 会根据 useState 的调用顺序来确定应该返回哪个对应的 state,所以当初始化调用的 state 顺序和更新渲染时调用顺序不一致,useState 就会返回错误的 state,产生严重 bug
# 虚拟 DOM
虚拟 DOM 本质上是 JS 和 DOM 之间的一个映射缓存,在形态上表现为:一个能够描述 DOM 结构及其属性信息的 JS 对象
为什么会有虚拟 DOM:主要源于对 DOM 操作的解决方案
- 因为原生 API 难用,所以最早期使用 jQuery 来操作 DOM,降低研发成本
- 但因为 jQuery 本质上还是一个工具,并不能从根本上解决 DOM 操作量过大情况下前端侧的压力,所以进一步的,出现了早期模板引擎,让开发者不用关心 DOM 操作,而只需关系数据和数据的变化
- 而早期模板引擎却有一个致命的问题:不能做太复杂的事情,性能表现不尽人意,数据变化时,单纯是全部销毁之前的 DOM 节点然后生成新的,而最后出现的虚拟 DOM 可以完美解决这个问题(JS 算法的计算量和 DOM 操作的计算量不在一个层级上)
选用虚拟 DOM 技术,本质上还是在于研发体验 / 研发效率上,虚拟 DOM 不一定会带来更高的性能,但它能够在提供更爽、更高效的研发模式的同时仍然保持一个还不错的性能,虚拟 DOM 的价值不在性能,而在于别处
虚拟 DOM 解决了哪些关键问题:
- 研发体验 / 研发效率的问题
- 跨平台
# 调和(Reconciler)与 Diff 算法
调和(协调):将虚拟 DOM 与真实 DOM 的状态进行同步,是一个使一致的过程
Diff:判断要删除、新建、移动的节点,是一个找不同的过程
Reconciler != Diff,但是一般说调和(协调)就是指的 Diff 算法,因为 Diff 算法确实是调和过程最具代表性的一环
# Diff
Diff 算法的设计思想:
- 若两个组件属于同一个类型,它们将拥有相同的 DOM 树形结构
- 处于同一层级的一组子节点,可用通过设置 key 作为唯一标识从而维持各个节点在不同渲染过程中的稳定性
Diff 逻辑:
- Diff 算法性能突破的关键点在于 “分层对比”
- 类型一致的节点才有继续 Diff 的必要性
- key 属性的设置,可以帮我们尽可能重用同一层级内的节点
比较过程大致如下:
key 属性帮助 React “记住” 节点,以尽可能重用同一层级内的节点:
React15 的栈调和大致如上,主要特征为同步的 “树递归”,其本质还是递归算法,而 React16 + 则采用了 Fiber 调和
# setState 是同步 / 异步?
这里指的是 React15,React16 + 之后,setState 也被 Fiber 化,处理逻辑又有所不同
本质上来说:setState 并不单纯是同步 / 异步的,setState 的表现会因调用场景的不同而不同,表现为异步主要与 React 的批量更新(BatchUpdate)和事务(Transaction)机制有关
当 setState 在组件内部的方法被调用时,React 会在调用该方法前手动开启事务,在方法结束后手动关闭事务,而当事务处于开启状态时,setState 的所有变更会被存入批量更新队列中( pendingStateQueue.push(state)
-> dirtyComponents.push(component)
),当事务处于关闭状态时,所有的操作都会即时被应用(视图也会被即时更新)
所以当 setState 在 ReactComponent 中被调用时,表现就是 “异步”(其实也不是真正的异步)的,而在 setTimeOut () 中被调用时,表现则为同步
# 栈调和与 Fiber 调和
在 React15 的栈调和机制下,由于本质上还是树结构的深度优先遍历算法,因此避免不了使用递归,当树节点较多,应用较复杂时,就难免会导致 Diff 长时间霸占 JavaScript 主线程,使得用户界面无响应。
React16 + 采用的 Fiber:
- 从架构角度来看,是对 React 核心算法的重写
- 从编码角度来看,是 React 内部所定义的一种数据结构
- 从工作流的角度来看,节点保存了组件需要更新的状态和副作用
Fiber 架构是为了实现 “增量渲染”,即渲染任务的可中断、可恢复,并给不同的任务赋予不同的优先级
Fiber 下的渲染流程:
Fiber 架构对生命周期的影响:
# ReactDOM.render 调用栈
调用栈大致分为三个阶段:
- 初始化阶段:render 调用到 scheduleUpdateOnFiber
- render 阶段:scheduleUpdateOnFiber 到 commitRoot
- commit 阶段:commitRoot 到结束
初始化阶段只负责一件事:完成 Fiber 树基本实体(fiberRoot)的创建。大体流程如下:
- 请求当前 Fiber 节点的 lane(优先级)
- 结合 lane(优先级)创建当前 Fiber 节点的 update 对象,并将其入队
- 调度当前节点(rootFiber)
在 ReactDOM.render 中,perfromSyncWorkOnRoot 是 render 阶段的起点,render 阶段的任务就是完成 Fiber 树的构建,它是整个渲染链路中最核心的一环(虽然 Fiber 架构下,render 阶段是可打断的,然而 perfromSyncWorkOnRoot 却让 render 阶段是同步执行的)
那么为什么 ReactDOM.render 触发的首次渲染是个同步过程呢?
原因在于 React 在 16 + 后都有 3 种启动方式:
- legacy 模式:ReactDOM.render (<App/>, rootNode),不支持 Fiber 架构带来的新功能,触发的仍然是同步的渲染链路
- blocking 模式:ReactDOM.createBlockingRoot (rootNode).render (<App/>),目前作为向第三种模式迁移的中间态
- concurrent 模式:ReactDOM.createRoot (rootNode).render (<App/>),这种模式开启了所有的新功能,最终的稳定模式
其实不同的渲染模式在挂载阶段的差异,本质上来说是 mode 属性的差异,mode 属性决定这个工作流是同步的还是异步的
Fiber 架构一定是异步渲染吗?React16 + 如果没有开启 Concurrent 模式还能叫 Fiber 架构吗?
从动机上来看,Fiber 架构的设计确实是为了 Concurrent 而存在。但是 Fiber 架构在 React 中并不能够和异步渲染画严格的等号,因为它是一种同时兼容了同步渲染与异步渲染的设计
# DOM 原生事件与 React 合成事件
一个页面往往会被绑定许许多多的事件,而页面接收事件的顺序,就是事件流
一个事件的传播过程以此经历 3 个阶段:事件捕获阶段、目标阶段、事件冒泡阶段
通过 event.target 可以拿到实际触发事件的那个元素,因而可以实现事件委托:把多个子元素的同一类型的监听逻辑合并到父元素上,通过一个监听函数来管理的行为
当事件在具体的 DOM 节点上被触发后,最终都会冒泡到 document 上,因此 React 事件系统就可以依赖事件委托,在 document 上绑定统一的事件处理程序,将事件分发到具体的组件实例
React 合成事件在底层抹平了不同浏览器的差异,在上层面向开发者暴露统一的、稳定的、与 DOM 原生事件相同的事件接口,虽然合成事件并不是原生 DOM 事件,但也存了原生 DOM 事件的引用(e.nativeEvent)
React 通过 path 数组模拟事件的捕获阶段和冒泡阶段传播顺序,然后再分别执行按照顺序的事件处理回调函数
# React 应用性能优化
前端项目普适的性能优化手段对 React 应用也适用(如资源加载过程优化、减少重绘与回流、服务端渲染、启用 CDN 等),不过 React 还有一些特色的优化手段:
-
使用 shouldComponentUpdate 规避冗余的更新逻辑
shouldComponentUpdate 的默认返回值为 true,也就是无条件 re-render,我们可以手动增加逻辑,实现有条件的重绘,减少不必要的重绘
-
PureComponent+Immutable.js
PureComponent 能提前安排好更新判定逻辑(内置了 shouldComponentUpdate 的 props 和 state 的浅比较逻辑),然而 PureComponent 自带的浅比较逻辑会有两个问题:
- 若数据内容没变,但是引用变了,会认为数据发生了变化,从而导致触发不必要的渲染
- 若数据内容变了,但是引用没变,会认为数据没有发生变化,从而导致不渲染
PureComponent 浅比较带来的问题本质上是对 “变化” 的判断不够精准导致的,而 Immutable 则可以让变化无处遁形
-
React.memo 与 useMemo
在函数组件中,也有类似 shouldComponentUpdate/PureComponent 的工具可以使用:React.memo,通过它包装的函数组件会记住前一次的渲染结果,当入参不变时,渲染结果会直接复用前一次的结果
useMemo 与 React.memo 类似:
React.memo 控制是否需要重渲染一个组件
useMemo 控制的则是是否需要重复执行某一段逻辑
# React 应用设计模式
# 高阶组件(HOC)
作为 React 中最经典的组件逻辑复用方式,HOC 在概念上沿袭了 HOF(高阶函数),高阶组件本质是一个函数,接收一个组件作为参数,返回值为一个新的组件,通过 HOC 可以复用同样的逻辑
# Render Props
Render Props 本身作为一个函数组件,它可以接受一个函数作为入参,这个函数可以处理自己的逻辑并返回一个新的组件,相对于 HOC 而言会更加灵活
# 单一职责、有状态组件、无状态组件
单一职责指的是:一个类或者模块有且只有一个改变的原因
当一个组件内部不维护 state 时,它就是一个无状态组件,无状态组件也有一些别名,如 “容器组件”、“展示组件” 等,它最核心的的特点就是:把数据处理和页面渲染这两个工作剥离开来
说到底,React 组件做的事无非两件:处理数据(数据的获取、格式化、分发等)和渲染界面
按照单一职责的原则,我们应该将数据处理和渲染界面的逻辑分离到不同的组件中,这样功能模块的组合将会更加灵活,也会更加有利于逻辑的复用
# 设计模式解决不了所有的问题
就 React 来说,无论是高阶组件,还是 Render Props,两者的出现都是为了弥补类组件在 “逻辑复用” 这个层面的不灵活性,然而两者都有一些无法解决的问题,如:嵌套地狱、较高的学习成本、props 属性命名冲突等
当 React-Hooks 出现后,现在我们想去复用一段逻辑时,首选应该是 “自定义 Hook”
# React17:承上启下的基石
React17 没有增加任何的新特性,但是这个版本会使 React 自身的升级变得更容易,而且让不同版本的 React 互相嵌套变得更加容易
React17 开启了渐进式升级的新篇章,将项目从 React17 迁移至 18、19 等更新的版本时,可以部分升级
React17 带来的新变化:
- 新的 JSX 转换逻辑
- 事件系统重构
- Lane 模型的引入
在 React17 + 中:
-
编写 JSX 代码将不再需要手动导入 React 包,编译器会针对 JSX 代码进行自动导入(react/jsx-runtime)和优化
-
事件系统将放弃利用 document 来做事件的中心化管控,管控相关的逻辑被转移到了每个 React 组件自己的容器 DOM 节点中
放弃了事件池的设计,现在随时都可以拿到合成事件的 target 对象
# 参考
- React 高级进阶教程_2021
- React 官网
- 现代 JavaScript 教程