fiber 架构的工作原理?
参考答案
React 中的 Fiber 架构是一种新的协调算法,旨在提高 React 的性能和用户体验。它通过引入新的数据结构和机制,使得 React 能够更高效地处理 UI 更新。以下是 Fiber 架构的工作原理:
1. Fiber 数据结构
- Fiber 节点:Fiber 是一个表示组件的内部数据结构,每个 Fiber 节点对应一个 React 组件。它包含了组件的状态、更新信息和子组件的引用等。
- Fiber 树:Fiber 节点形成了一棵 Fiber 树,类似于旧版的虚拟 DOM 树。每个 Fiber 节点指向其父节点、子节点和兄弟节点。
2. 工作单元和增量渲染
- 工作单元:渲染过程被分解为多个工作单元,每个单元代表一个小的渲染任务。这样可以将渲染过程拆分成可中断的任务,以避免长时间的阻塞。
- 增量渲染:Fiber 允许将渲染任务拆分为增量的操作,逐步完成整个渲染过程。每次渲染会处理 Fiber 树的一部分,允许在任务之间插入中断点,从而提高了渲染的响应性。
3. 调度优先级
- 优先级调度:Fiber 引入了任务调度机制,允许根据任务的优先级来决定渲染的顺序。高优先级的任务(如用户输入、动画)会优先处理,而低优先级的任务(如数据加载)会在空闲时间处理。
- 任务中断和恢复:Fiber 支持在渲染过程中中断并恢复任务。当重要任务需要处理时,当前的渲染任务可以被中断,待重要任务完成后再恢复继续。
4. 更新和协调
- 更新队列:每个 Fiber 节点都有一个更新队列,用于存储与组件相关的更新信息。更新队列可以包含多个更新,React 会根据更新的优先级和顺序进行协调。
- 协调过程:Fiber 通过对比新旧 Fiber 树来决定哪些部分需要更新。这一过程称为协调(Reconciliation),它会检查节点的变更,生成更新的补丁。
5. 渲染阶段和提交阶段
- 渲染阶段:在渲染阶段,Fiber 架构会计算出需要更新的部分,但不会立即更新 DOM。这一阶段主要用于计算新的 Fiber 树,并生成更新任务。
- 提交阶段:在提交阶段,Fiber 会将渲染阶段计算出的更新应用到实际的 DOM 上。这个阶段是同步的,确保所有的更改都被正确地应用。
6. 错误处理
- 错误边界:Fiber 提供了更好的错误处理机制,可以局部地处理渲染中的错误。即使在渲染过程中发生错误,也能保证 UI 的部分更新和恢复。
React Reconciler 为何要采用 fiber 架构?
参考答案
React Reconciler 采用 Fiber 架构主要是为了提升性能和用户体验。Fiber 是 React 16 引入的一种新的协调算法,它相对于旧版的 Reconciler 具备以下优势:
1. 增量渲染
- 旧版 Reconciler:一次性计算并更新整个 UI 树,可能会导致性能瓶颈,尤其是在大型应用中。
- Fiber 架构:支持增量渲染,将渲染任务拆分为小的单元,分批执行。这样可以在长时间运行的任务中插入中断点,使得 UI 更响应式。
2. 中断和优先级
- 旧版 Reconciler:一旦开始更新,渲染过程无法中断,可能会阻塞用户交互。
- Fiber 架构:允许中断和恢复工作,可以根据任务的优先级来调整渲染顺序。低优先级的任务可以在高优先级任务完成后再继续执行,提高了用户交互的流畅性。
3. 任务调度
- 旧版 Reconciler:没有任务调度机制,所有更新都按顺序执行。
- Fiber 架构:使用任务调度机制(Scheduler)来管理和调度不同优先级的更新任务,确保重要任务(如用户输入、动画)优先处理。
4. 异常处理
- 旧版 Reconciler:异常处理能力有限,无法优雅地处理渲染过程中的错误。
- Fiber 架构:允许局部错误处理,确保在渲染过程中即使发生异常,也能保证 UI 的部分更新和恢复。
5. 渲染中断与恢复
- 旧版 Reconciler:无法中断和恢复渲染。
- Fiber 架构:支持在渲染过程中中断并恢复,能够平滑处理长时间运行的任务。
6. 事务管理
- 旧版 Reconciler:处理复杂的事务和操作较为困难。
- Fiber 架构:将渲染任务分解为独立的事务,每个事务可以独立地管理和控制,简化了复杂操作的管理。
useState 是如何实现的?
参考答案
useState 是 React 的一个 Hook,用于在函数组件中管理状态。它使函数组件能够拥有类似于类组件中的 this.state 和 this.setState 的功能。useState 的实现涉及到 React 的内部机制,包括状态管理、更新队列和组件的重新渲染。
下面是 useState 的实现原理:
1. 状态的初始化
当你调用 useState 时,可以传递一个初始状态值或一个函数用于计算初始状态。React 会将这个初始状态值存储在一个内部的状态容器中。
const [state, setState] = useState(initialState);
2. 内部数据结构
React 使用一个叫做“Hooks List”的数据结构来管理各个组件的 Hook 状态。在每次组件渲染时,React 会使用这个数据结构来跟踪组件的 Hook 调用顺序和状态。
- Fiber 树:每个组件在 React 的 Fiber 树中都有一个与之对应的 Fiber 节点。Fiber 节点中包含了该组件的状态信息和相关的 Hook 信息。
- Hooks 链表:
useState和其他 Hooks 会在 Fiber 节点中按照调用顺序形成一个链表。每个 Hook 记录了其当前的状态值和更新函数。
3. 状态的更新
当调用 setState 时,React 会将状态更新请求加入到更新队列中。更新队列是 React 用于管理所有状态变更的机制。每当 setState 被调用时,React 会将新的状态值和当前状态值进行比较,决定是否需要触发重新渲染。
function setState(newState) {
// 更新队列中加入新的状态值
updateQueue.push(newState);
// 标记 Fiber 节点需要重新渲染
scheduleUpdate();
}
4. 触发重新渲染
在调用 setState 后,React 会安排重新渲染过程。这包括以下几个步骤:
- 调度更新:将更新请求加入调度队列,React 会在适当的时候处理这些更新。
- 重新渲染组件:React 会调用组件函数,执行
useState和其他 Hook。 - 比较新旧状态:React 会比较新旧状态,计算出哪些组件需要更新。
- 提交更新:将计算好的更新提交到 DOM 中。
5. 状态的持久化
在每次组件渲染时,React 会通过 Hooks 链表来保持状态的一致性。即使组件重新渲染,useState 会从 Fiber 节点中获取之前保存的状态值,确保状态在多次渲染中保持不变。
6. 实现细节
useState 的实现细节包括但不限于以下方面:
- 状态保存:在 Fiber 节点上保存状态值和更新函数。
- 更新机制:通过调度更新机制来处理状态变化。
- 依赖管理:确保 Hooks 的调用顺序和依赖关系正确,避免错误的状态管理。
React Fiber是什么?
参考答案
Fiber 出现的背景
首先要知道的是,JavaScript 引擎和页面渲染引擎两个线程是互斥的,当其中一个线程执行时,另一个线程只能挂起等待。
在这样的机制下,如果 JavaScript 线程长时间地占用了主线程,那么渲染层面的更新就不得不长时间地等待,界面长时间不更新,会导致页面响应度变差,用户可能会感觉到卡顿。
而这正是 React 15 的 Stack Reconciler 所面临的问题,即是 JavaScript 对主线程的超时占用问题。Stack Reconciler 是一个同步的递归过程,使用的是 JavaScript 引擎自身的函数调用栈,它会一直执行到栈空为止,所以当 React 在渲染组件时,从开始到渲染完成整个过程是一气呵成的。如果渲染的组件比较庞大,js 执行会占据主线程较长时间,会导致页面响应度变差。
而且所有的任务都是按照先后顺序,没有区分优先级,这样就会导致优先级比较高的任务无法被优先执行。
Fiber 是什么
Fiber 的中文翻译叫纤程,与进程、线程同为程序执行过程,Fiber 就是比线程还要纤细的一个过程。纤程意在对渲染过程实现进行更加精细的控制。
从架构角度来看,Fiber 是对 React 核心算法(即调和过程)的重写。
从编码角度来看,Fiber 是 React 内部所定义的一种数据结构,它是 Fiber 树结构的节点单位,也就是 React 16 新架构下的"虚拟 DOM"。
一个 fiber 就是一个 JavaScript 对象,Fiber 的数据结构如下:
type Fiber = {
// 用于标记fiber的WorkTag类型,主要表示当前fiber代表的组件类型如FunctionComponent、ClassComponent等
tag: WorkTag,
// ReactElement里面的key
key: null | string,
// ReactElement.type,调用`createElement`的第一个参数
elementType: any,
// The resolved function/class/ associated with this fiber.
// 表示当前代表的节点类型
type: any,
// 表示当前FiberNode对应的element组件实例
stateNode: any,
// 指向他在Fiber节点树中的`parent`,用来在处理完这个节点之后向上返回
return: Fiber | null,
// 指向自己的第一个子节点
child: Fiber | null,
// 指向自己的兄弟结构,兄弟节点的return指向同一个父节点
sibling: Fiber | null,
index: number,
ref: null | (((handle: mixed) => void) & { _stringRef: ?string }) | RefObject,
// 当前处理过程中的组件props对象
pendingProps: any,
// 上一次渲染完成之后的props
memoizedProps: any,
// 该Fiber对应的组件产生的Update会存放在这个队列里面
updateQueue: UpdateQueue<any> | null,
// 上一次渲染的时候的state
memoizedState: any,
// 一个列表,存放这个Fiber依赖的context
firstContextDependency: ContextDependency<mixed> | null,
mode: TypeOfMode,
// Effect
// 用来记录Side Effect
effectTag: SideEffectTag,
// 单链表用来快速查找下一个side effect
nextEffect: Fiber | null,
// 子树中第一个side effect
firstEffect: Fiber | null,
// 子树中最后一个side effect
lastEffect: Fiber | null,
// 代表任务在未来的哪个时间点应该被完成,之后版本改名为 lanes
expirationTime: ExpirationTime,
// 快速确定子树中是否有不在等待的变化
childExpirationTime: ExpirationTime,
// fiber的版本池,即记录fiber更新过程,便于恢复
alternate: Fiber | null,
}
Fiber 如何解决问题的
Fiber 把一个渲染任务分解为多个渲染任务,而不是一次性完成,把每一个分割得很细的任务视作一个"执行单元",React 就会检查现在还剩多少时间,如果没有时间就将控制权让出去,故任务会被分散到多个帧里面,中间可以返回至主进程控制执行其他任务,最终实现更流畅的用户体验。
即是实现了"增量渲染",实现了可中断与恢复,恢复后也可以复用之前的中间状态,并给不同的任务赋予不同的优先级,其中每个任务更新单元为 React Element 对应的 Fiber 节点。
Fiber 实现原理
实现的方式是requestIdleCallback这一 API,但 React 团队 polyfill 了这个 API,使其对比原生的浏览器兼容性更好且拓展了特性。
window.requestIdleCallback()方法将在浏览器的空闲时段内调用的函数排队。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间 timeout,则有可能为了在超时前执行函数而打乱执行顺序。
requestIdleCallback回调的执行的前提条件是当前浏览器处于空闲状态。
即requestIdleCallback的作用是在浏览器一帧的剩余空闲时间内执行优先度相对较低的任务。首先 React 中任务切割为多个步骤,分批完成。在完成一部分任务之后,将控制权交回给浏览器,让浏览器有时间再进行页面的渲染。等浏览器忙完之后有剩余时间,再继续之前 React 未完成的任务,是一种合作式调度。
简而言之,由浏览器给我们分配执行时间片,我们要按照约定在这个时间内执行完毕,并将控制权还给浏览器。
React 16 的Reconciler基于 Fiber 节点实现,被称为 Fiber Reconciler。
作为静态的数据结构来说,每个 Fiber 节点对应一个 React element,保存了该组件的类型(函数组件/类组件/原生组件等等)、对应的 DOM 节点等信息。
作为动态的工作单元来说,每个 Fiber 节点保存了本次更新中该组件改变的状态、要执行的工作。
每个 Fiber 节点有个对应的 React element,多个 Fiber 节点是如何连接形成树呢?靠如下三个属性:
// 指向父级Fiber节点
this.return = null
// 指向子Fiber节点
this.child = null
// 指向右边第一个兄弟Fiber节点
this.sibling = null
简单介绍下React中的 diff 算法
参考答案
diff 算法主要基于三个规律:
- DOM 节点的跨层级移动的操作特别少,可以忽略不计
- 拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结构
- 对于同一层级的一组子节点,可以通过唯一的 id 进行区分
tree diff
因为上面的三个策略中的第一点, DOM 节点的跨级操作比较少,那么 diff 算法只会对相同层级的 DOM 节点进行比较。如果发现节点不存在 那么会将该节点以及其子节点完全删除,不会再继续比较。如果出现了 DOM 节点的跨层级的移动操作,那么会删除改节点以及其所有的子节点,然后再移动后的位置重新创建。
component diff
如果是同一类型的组件,那么会继续对比 VM 数
如果不是同一类型的组件,那么会将其和其子节点完全替换,不会再进行比对
同一类型的组件,有可能 VM 没有任何的变化,如果可以确定的知道这点,那么就可以节省大量的 diff 时间,所以用户可以设置 shouldComponentUpdate() 来判断是否需要进行 diff 算法。
element diff
当节点处于同一层级的时候时,有三种操作:INSERT_MAKEUP插入、 MOVE_EXISTING 移动、 REMOVE_NODE 删除
这里 React 有一个优化策略,对于同一层级的同组子节点,添加唯一的 key 进行区分。这样的话,就可以判断出来是否是移动节点。通过 key 发现新旧集合中的节点都是相同的节点,就只需要进行移动操作就可以。
如何让 useEffect 支持 async/await?
参考答案
大家在使用 useEffect 的时候,假如回调函数中使用 async...await... 的时候,会报错如下。
看报错,我们知道 effect function 应该返回一个销毁函数(return返回的 cleanup 函数),如果 useEffect 第一个参数传入 async,返回值则变成了 Promise,会导致 react 在调用销毁函数的时候报错**。
React 为什么要这么做?
useEffect 作为 Hooks 中一个很重要的 Hooks,可以让你在函数组件中执行副作用操作。
它能够完成之前 Class Component 中的生命周期的职责。它返回的函数的执行时机如下:
- 首次渲染不会进行清理,会在下一次渲染,清除上一次的副作用。
- 卸载阶段也会执行清除操作。
不管是哪个,我们都不希望这个返回值是异步的,这样我们无法预知代码的执行情况,很容易出现难以定位的 Bug。
所以 React 就直接限制了不能 useEffect 回调函数中不能支持 async...await...
useEffect 怎么支持 async...await...
竟然 useEffect 的回调函数不能使用 async...await,那我直接在它内部使用。
做法一:创建一个异步函数(async...await 的方式),然后执行该函数。
useEffect(() => {
const asyncFun = async () => {
setPass(await mockCheck());
};
asyncFun();
}, []);
做法二:也可以使用 IIFE,如下所示:
useEffect(() => {
(async () => {
setPass(await mockCheck());
})();
}, []);
自定义 hooks
既然知道了怎么解决,我们完全可以将其封装成一个 hook,让使用更加的优雅。我们来看下 ahooks 的 useAsyncEffect,它支持所有的异步写法,包括 generator function。
思路跟上面一样,入参跟 useEffect 一样,一个回调函数(不过这个回调函数支持异步),另外一个依赖项 deps。内部还是 useEffect,将异步的逻辑放入到它的回调函数里面。
function useAsyncEffect(
effect: () => AsyncGenerator<void, void, void> | Promise<void>,
// 依赖项
deps?: DependencyList,
) {
// 判断是 AsyncGenerator
function isAsyncGenerator(
val: AsyncGenerator<void, void, void> | Promise<void>,
): val is AsyncGenerator<void, void, void> {
// Symbol.asyncIterator: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Symbol/asyncIterator
// Symbol.asyncIterator 符号指定了一个对象的默认异步迭代器。如果一个对象设置了这个属性,它就是异步可迭代对象,可用于for await...of循环。
return isFunction(val[Symbol.asyncIterator]);
}
useEffect(() => {
const e = effect();
// 这个标识可以通过 yield 语句可以增加一些检查点
// 如果发现当前 effect 已经被清理,会停止继续往下执行。
let cancelled = false;
// 执行函数
async function execute() {
// 如果是 Generator 异步函数,则通过 next() 的方式全部执行
if (isAsyncGenerator(e)) {
while (true) {
const result = await e.next();
// Generate function 全部执行完成
// 或者当前的 effect 已经被清理
if (result.done || cancelled) {
break;
}
}
} else {
await e;
}
}
execute();
return () => {
// 当前 effect 已经被清理
cancelled = true;
};
}, deps);
}
async...await 我们之前已经提到了,重点看看实现中变量 cancelled 的实现的功能。 它的作用是中断执行。
通过
yield语句可以增加一些检查点,如果发现当前effect已经被清理,会停止继续往下执行。
试想一下,有一个场景,用户频繁的操作,可能现在这一轮操作 a 执行还没完成,就已经开始开始下一轮操作 b。这个时候,操作 a 的逻辑已经失去了作用了,那么我们就可以停止往后执行,直接进入下一轮操作 b 的逻辑执行。这个 cancelled 就是用来取消当前正在执行的一个标识符。
还可以支持 useEffect 的清除机制么?
可以看到上面的 useAsyncEffect,内部的 useEffect 返回函数只返回了如下:
return () => {
// 当前 effect 已经被清理
cancelled = true;
};
这说明,你通过 useAsyncEffect 没有 useEffect 返回函数中执行清除副作用的功能。
你可能会觉得,我们将 effect(useAsyncEffect 的回调函数)的结果,放入到 useAsyncEffect 中不就可以了?
实现最终类似如下:
function useAsyncEffect(effect: () => Promise<void | (() => void)>, dependencies?: any[]) {
return useEffect(() => {
const cleanupPromise = effect()
return () => { cleanupPromise.then(cleanup => cleanup && cleanup()) }
}, dependencies)
}
这种做法在github上也有讨论,上面有个大神的说法我表示很赞同:
他认为这种延迟清除机制是不对的,应该是一种取消机制。否则,在钩子已经被取消之后,回调函数仍然有机会对外部状态产生影响。他的实现和例子我也贴一下,跟 useAsyncEffect 其实思路是一样的,如下:
实现:
function useAsyncEffect(effect: (isCanceled: () => boolean) => Promise<void>, dependencies?: any[]) {
return useEffect(() => {
let canceled = false;
effect(() => canceled);
return () => { canceled = true; }
}, dependencies)
}
Demo:
useAsyncEffect(async (isCanceled) => {
const result = await doSomeAsyncStuff(stuffId);
if (!isCanceled()) {
// TODO: Still OK to do some effect, useEffect hasn't been canceled yet.
}
}, [stuffId]);
其实归根结底,我们的清除机制不应该依赖于异步函数,否则很容易出现难以定位的 bug。
总结与思考
由于 useEffect 是在函数式组件中承担执行副作用操作的职责,它的返回值的执行操作应该是可以预期的,而不能是一个异步函数,所以不支持回调函数 async...await 的写法。
我们可以将 async...await 的逻辑封装在 useEffect 回调函数的内部,这就是 ahooks useAsyncEffect 的实现思路,而且它的范围更加广,它支持的是所有的异步函数,包括 generator function。
React 中怎么实现状态自动保存(KeepAlive)?
参考答案
什么是状态保存?
假设有下述场景:
移动端中,用户访问了一个列表页,上拉浏览列表页的过程中,随着滚动高度逐渐增加,数据也将采用触底分页加载的形式逐步增加,列表页浏览到某个位置,用户看到了感兴趣的项目,点击查看其详情,进入详情页,从详情页退回列表页时,需要停留在离开列表页时的浏览位置上
类似的数据或场景还有已填写但未提交的表单、管理系统中可切换和可关闭的功能标签等,这类数据随着用户交互逐渐变化或增长,这里理解为状态,在交互过程中,因为某些原因需要临时离开交互场景,则需要对状态进行保存
在 React 中,我们通常会使用路由去管理不同的页面,而在切换页面时,路由将会卸载掉未匹配的页面组件,所以上述列表页例子中,当用户从详情页退回列表页时,会回到列表页顶部,因为列表页组件被路由卸载后重建了,状态被丢失。
如何实现 React 中的状态保存
在 Vue 中,我们可以非常便捷地通过 标签实现状态的保存,该标签会缓存不活动的组件实例,而不是销毁它们
而在 React 中并没有这个功能,曾经有人在官方提过相关 issue ,但官方认为这个功能容易造成内存泄露,表示暂时不考虑支持,所以我们需要自己想办法了。
常见的解决方式:手动保存状态
手动保存状态,是比较常见的解决方式,可以配合 React 组件的 componentWillUnmount 生命周期通过 redux 之类的状态管理层对数据进行保存,通过 componentDidMount 周期进行数据恢复
在需要保存的状态较少时,这种方式可以比较快地实现我们所需功能,但在数据量大或者情况多变时,手动保存状态就会变成一件麻烦事了
作为程序员,当然是尽可能懒啦,为了不需要每次都关心如何对数据进行保存恢复,我们需要研究如何自动保存状态
通过路由实现自动状态保存(通常使用 react-router)
既然 React 中状态的丢失是由于路由切换时卸载了组件引起的,那可以尝试从路由机制上去入手,改变路由对组件的渲染行为
我们有以下的方式去实现这个功能:
- 重写 组件,可参考 react-live-route。重写可以实现我们想要的功能,但成本也比较高,需要注意对原始 功能的保存,以及多个 react-router 版本的兼容
- 重写路由库,可参考 react-keeper 。重写路由库成本是一般开发者无法承受的,且完全替换掉路由方案是一个风险较大的事情,需要较为慎重地考虑。
- 基于 组件现有行为做拓展,可参考 react-router-cache-route 。在阅读了 的源码后发现,如果使用 component 或者 render 属性,都无法避免路由在不匹配时被卸载掉的命运。但将 children 属性当作方法来使用,我们就有手动控制渲染的行为的可能。
上面几种方案,主要通过路由入手实现自动状态保存的可能,但终究不是真实的、纯粹的 KeepAlive 功能。
模拟真实的 功能
以下是期望的使用方式
function App() {
const [show, setShow] = useState(true)
return (
<div>
<button onClick={() => setShow(show => !show)}>Toggle</button>
{show && (
<KeepAlive>
<Test />
</KeepAlive>
)}
</div>
)
}
下面简单介绍下 react-activation 的实现原理:由于 React 会卸载掉处于固有组件层级内的组件,所以我们需要将 中的组件,也就是其 children 属性抽取出来,渲染到一个不会被卸载的组件 内,再使用 DOM 操作将 内的真实内容移入对应 ,就可以实现此功能。
Redux 状态管理器和变量挂载到 window 中有什么区别?
参考答案
Redux 状态管理器和将变量挂载到 window 对象上是两种不同的状态管理方法,它们各有优缺点。
Redux 状态管理器
优点:
- 集中管理:所有的状态都存储在 Redux store 中,方便管理和调试。
- 可预测性:状态变化是通过纯函数(reducers)和明确的 actions 来处理,使得状态变化可预测。
- 中间件支持:Redux 支持中间件,比如
redux-thunk或redux-saga,来处理异步操作和副作用。 - 工具支持:Redux 有强大的开发工具(如 Redux DevTools)来帮助调试和查看状态的变化。
- 组件解耦:通过
connect或useSelector和useDispatch等 API,组件可以不直接依赖于具体的状态结构,增强了组件的解耦性和可测试性。
缺点:
- 学习曲线:对于新手来说,Redux 的概念和使用方式可能会比较复杂。
- 样板代码:Redux 的使用通常需要大量的样板代码,比如 actions、reducers 和 action creators。
变量挂载到 window
优点:
- 简单直接:直接在
window对象上挂载变量可以快速实现简单的状态共享。 - 易于访问:全局变量可以在应用的任何地方直接访问,方便使用。
缺点:
- 全局污染:将变量挂载到
window对象上可能会导致全局命名空间污染,容易引发命名冲突。 - 不易维护:随着应用的增长,全局变量可能会变得难以管理和维护。
- 缺乏结构:没有像 Redux 那样的结构化和规范化,状态管理变得不够一致和可预测。
- 调试困难:全局状态的变化不容易追踪,缺乏系统化的调试工具和机制。
总的来说,Redux 适合于需要复杂状态管理和维护的大型应用,而挂载到 window 可能适用于小型项目或简单的全局状态需求。
react 中,数组用useState做状态管理的时候,使用push,pop,splice等直接更改数组对象,会引起页面渲染吗?
参考答案
在 React 中,直接使用 push、pop、splice 等方法修改数组不会触发页面重新渲染。React 的状态更新机制依赖于状态的不可变性(immutability),即通过创建新的状态对象来更新状态。直接修改原始状态对象(如数组)不会创建新的对象引用,因此 React 不会检测到状态的变化,也不会触发重新渲染。
为什么直接修改数组不触发渲染
React 使用 Object.is(或其变体)来检查状态是否发生了变化。直接对数组进行 push、pop、splice 等操作,修改了原始数组的内容,但数组的引用(内存地址)没有改变。React 仅通过引用变化来判断状态是否更新,因此直接修改原始数组不会触发更新。
正确的做法
为了触发渲染,应该遵循不可变数据模式,即通过创建新数组来更新状态。以下是使用 useState 管理数组状态的推荐方法:
示例:使用 concat、slice、map 等方法
import React, { useState } from 'react';
function MyComponent() {
const [items, setItems] = useState([1, 2, 3]);
// 添加新项
const addItem = (item) => {
setItems(prevItems => [...prevItems, item]);
};
// 移除最后一项
const removeLastItem = () => {
setItems(prevItems => prevItems.slice(0, -1));
};
// 更新特定项
const updateItem = (index, newItem) => {
setItems(prevItems => prevItems.map((item, i) => i === index ? newItem : item));
};
return (
<div>
<button onClick={() => addItem(4)}>Add Item</button>
<button onClick={removeLastItem}>Remove Last Item</button>
<ul>
{items.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
);
}
export default MyComponent;
解释
-
添加新项:
- 使用展开运算符
[...prevItems, item]创建一个新数组,并将新项添加到末尾。
- 使用展开运算符
-
移除最后一项:
- 使用
slice(0, -1)创建一个新数组,去除最后一项。
- 使用
-
更新特定项:
- 使用
map方法创建一个新数组,并根据条件更新特定项。
- 使用
如何在React中应用样式?
参考答案
将样式应用于React组件有三种方法。
外部样式表
在此方法中,你可以将外部样式表导入到组件使用类中。 但是你应该使用className而不是class来为React元素应用样式, 这里有一个例子。
import React from 'react';
import './App.css';
import { Header } from './header/header';
import { Footer } from './footer/footer';
import { Dashboard } from './dashboard/dashboard';
import { UserDisplay } from './userdisplay';
function App() {
return (
<div className="App">
<Header />
<Dashboard />
<UserDisplay />
<Footer />
</div>
);
}
export default App;
内联样式
在这个方法中,我们可以直接将 props 传递给HTML元素,属性为style。这里有一个例子。这里需要注意的重要一点是,我们将javascript对象传递给style,这就是为什么我们使用 backgroundColor 而不是CSS方法backbackground-color。
import React from 'react';
export const Header = () => {
const heading = 'TODO App'
return(
<div style={{backgroundColor:'orange'}}>
<h1>{heading}</h1>
</div>
)
}
定义样式对象并使用它
因为我们将javascript对象传递给style属性,所以我们可以在组件中定义一个style对象并使用它。下面是一个示例,你也可以将此对象作为 props 传递到组件树中。
import React from 'react';
const footerStyle = {
width: '100%',
backgroundColor: 'green',
padding: '50px',
font: '30px',
color: 'white',
fontWeight: 'bold'
}
export const Footer = () => {
return(
<div style={footerStyle}>
All Rights Reserved 2019
</div>
)
}
taro 2.x 和 taro 3 最大区别是什么?
参考答案
Taro 2.x 和 Taro 3 的最大区别可以总结为以下几个方面:
- 编译方式:Taro 2.x 使用 Gulp 构建工具进行编译,而 Taro 3 改为使用 Webpack 进行构建。这使得 Taro 3 在编译速度、可扩展性、构建配置等方面有了更好的表现。
- React 版本升级:Taro 2.x 使用的是 React 16 版本,而 Taro 3 升级到了 React 17 版本。React 17 引入了一些新特性,例如以初始渲染器为基础的事件处理、重新设计的事件系统等,从而提高了性能和稳定性。
- API 改进:Taro 3 对 API 进行了改进,并引入了新的特性。例如,在 JSX 中可以使用 class 关键字来定义 CSS 样式;增加 useReady 钩子函数在小程序生命周期 onReady 被触发时执行;引入了快应用和 H5 等新平台的支持等。
- 插件机制:Taro 3 引入了插件机制,使得开发者可以通过插件实现更多的功能和特性,例如对 TypeScript 支持的扩展、国际化支持等。
- 性能优化:Taro 3 在性能方面进行了优化,例如使用虚拟 DOM 进行局部更新,减少对原生 API 的调用等。同时,Taro 3 可以根据平台的不同生成更小的代码包。
Taro 3 引入了一些新特性和优化,并提高了性能、可扩展性和稳定性。
如果需要使用 Taro 框架开发多端应用,建议选择 Taro 3。
react 和 react-dom 是什么关系?
参考答案
react 和 react-dom 是 React 库的两个主要部分,它们分别负责处理不同的事务。它们之间的关系可以理解为:
react: 这是 React 库的核心部分,包含了 React 的核心功能,如组件、状态、生命周期等。它提供了构建用户界面所需的基本构建块。当你编写 React 组件时,你实际上是在使用react包。react-dom: 这是 React 专门为 DOM 环境提供的包,它包含了与浏览器 DOM 相关的功能。react-dom提供了用于在浏览器中渲染 React 组件的方法,包括ReactDOM.render。在 Web 开发中,react-dom被用于将 React 应用渲染到浏览器的 DOM 中。
基本上,react 和 react-dom 是为了分离 React 的核心功能,以便更好地处理不同的环境和平台。这种分离使得 React 更加灵活,可以适应不同的渲染目标,而不仅仅局限于浏览器环境。
在使用 React 开发 Web 应用时,通常会同时安装和引入这两个包:
npm install react react-dom
然后在代码中引入:
import React from 'react';
import ReactDOM from 'react-dom';
const App = () => {
return <h1>Hello, React!</h1>;
};
ReactDOM.render(<App />, document.getElementById('root'));
在上面的例子中,react 库提供了 App 组件的定义,而 react-dom 库提供了 ReactDOM.render 方法,用于将组件渲染到 HTML 页面中。这种分工让 React 在不同平台上能够更灵活地适应各种渲染目标。
React Fiber 是如何实现更新过程可控?
参考答案
更新过程的可控主要体现在下面几个方面:
- 任务拆分
- 任务挂起、恢复、终止
- 任务具备优先级
任务拆分
在 React Fiber 机制中,它采用"化整为零"的思想,将调和阶段(Reconciler)递归遍历 VDOM 这个大任务分成若干小任务,每个任务只负责一个节点的处理。
任务挂起、恢复、终止
- workInProgress tree
workInProgress 代表当前正在执行更新的 Fiber 树。在 render 或者 setState 后,会构建一颗 Fiber 树,也就是 workInProgress tree,这棵树在构建每一个节点的时候会收集当前节点的副作用,整棵树构建完成后,会形成一条完整的副作用链。
- currentFiber tree
currentFiber 表示上次渲染构建的 Filber 树。在每一次更新完成后 workInProgress 会赋值给 currentFiber。在新一轮更新时 workInProgress tree 再重新构建,新 workInProgress 的节点通过 alternate 属性和 currentFiber 的节点建立联系。
在新 workInProgress tree 的创建过程中,会同 currentFiber 的对应节点进行 Diff 比较,收集副作用。同时也会复用和 currentFiber 对应的节点对象,减少新创建对象带来的开销。也就是说无论是创建还是更新、挂起、恢复以及终止操作都是发生在 workInProgress tree 创建过程中的。workInProgress tree 构建过程其实就是循环的执行任务和创建下一个任务。
挂起
当第一个小任务完成后,先判断这一帧是否还有空闲时间,没有就挂起下一个任务的执行,记住当前挂起的节点,让出控制权给浏览器执行更高优先级的任务。
恢复
在浏览器渲染完一帧后,判断当前帧是否有剩余时间,如果有就恢复执行之前挂起的任务。如果没有任务需要处理,代表调和阶段完成,可以开始进入渲染阶段。
- 如何判断一帧是否有空闲时间的呢?
使用前面提到的 RIC (RequestIdleCallback) 浏览器原生 API,React 源码中为了兼容低版本的浏览器,对该方法进行了 Polyfill。
- 恢复执行的时候又是如何知道下一个任务是什么呢?
答案是在前面提到的链表。在 React Fiber 中每个任务其实就是在处理一个 FiberNode 对象,然后又生成下一个任务需要处理的 FiberNode。
终止
其实并不是每次更新都会走到提交阶段。当在调和过程中触发了新的更新,在执行下一个任务的时候,判断是否有优先级更高的执行任务,如果有就终止原来将要执行的任务,开始新的 workInProgressFiber 树构建过程,开始新的更新流程。这样可以避免重复更新操作。这也是在 React 16 以后生命周期函数 componentWillMount 有可能会执行多次的原因。
任务具备优先级
React Fiber 除了通过挂起,恢复和终止来控制更新外,还给每个任务分配了优先级。具体点就是在创建或者更新 FiberNode 的时候,通过算法给每个任务分配一个到期时间(expirationTime)。在每个任务执行的时候除了判断剩余时间,如果当前处理节点已经过期,那么无论现在是否有空闲时间都必须执行该任务。过期时间的大小还代表着任务的优先级。
任务在执行过程中顺便收集了每个 FiberNode 的副作用,将有副作用的节点通过 firstEffect、lastEffect、nextEffect 形成一条副作用单链表 A1(TEXT)-B1(TEXT)-C1(TEXT)-C1-C2(TEXT)-C2-B1-B2(TEXT)-B2-A。
其实最终都是为了收集到这条副作用链表,有了它,在接下来的渲染阶段就通过遍历副作用链完成 DOM 更新。这里需要注意,更新真实 DOM 的这个动作是一气呵成的,不能中断,不然会造成视觉上的不连贯(commit)。
react中懒加载的实现原理是什么?
参考答案
随着前端应用体积的扩大,资源加载的优化是我们必须要面对的问题,动态代码加载就是其中的一个方案,webpack 提供了符合 ECMAScript 提案 的 import() 语法 ,让我们来实现动态地加载模块(注:require.ensure 与 import() 均为 webpack 提供的代码动态加载方案,在 webpack 2.x 中,require.ensure 已被 import 取代)。
在 React 16.6 版本中,新增了 React.lazy 函数,它能让你像渲染常规组件一样处理动态引入的组件,配合 webpack 的 Code Splitting ,只有当组件被加载,对应的资源才会导入 ,从而达到懒加载的效果。
使用 React.lazy
在实际的使用中,首先是引入组件方式的变化:
// 不使用 React.lazy
import OtherComponent from './OtherComponent';
// 使用 React.lazy
const OtherComponent = React.lazy(() => import('./OtherComponent'))
React.lazy 接受一个函数作为参数,这个函数需要调用 import() 。它需要返回一个 Promise,该 Promise 需要 resolve 一个 defalut export 的 React 组件。
import React, { Suspense } from 'react';
const OtherComponent = React.lazy(() => import('./OtherComponent'));
function MyComponent() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<OtherComponent />
</Suspense>
</div>
);
}
如上代码中,通过 import()、React.lazy 和 Suspense 共同一起实现了 React 的懒加载,也就是我们常说了运行时动态加载,即 OtherComponent 组件文件被拆分打包为一个新的包(bundle)文件,并且只会在 OtherComponent 组件渲染时,才会被下载到本地。
需要注意的一点是,React.lazy 需要配合 Suspense 组件一起使用,在 Suspense 组件中渲染 React.lazy 异步加载的组件。如果单独使用 React.lazy,React 会给出错误提示。
Suspense 可以包裹多个动态加载的组件,这也意味着在加载这两个组件的时候只会有一个 loading 层,因为 loading 的实现实际是 Suspense 这个父组件去完成的,当所有的子组件对象都 resolve 后,再去替换所有子组件。这样也就避免了出现多个 loading 的体验问题。所以 loading 一般不会针对某个子组件,而是针对整体的父组件做 loading 处理。
Webpack 动态加载
上面使用了 import() 语法,webpack 检测到这种语法会自动代码分割。使用这种动态导入语法代替以前的静态引入,可以让组件在渲染的时候,再去加载组件对应的资源,这个异步加载流程的实现机制是怎么样呢?
import() 原理
import() 函数是由TS39提出的一种动态加载模块的规范实现,其返回是一个 promise。在浏览器宿主环境中一个import()的参考实现如下:
function import(url) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
const tempGlobal = "__tempModuleLoadingVariable" + Math.random().toString(32).substring(2);
script.type = "module";
script.textContent = `import * as m from "${url}"; window.${tempGlobal} = m;`;
script.onload = () => {
resolve(window[tempGlobal]);
delete window[tempGlobal];
script.remove();
};
script.onerror = () => {
reject(new Error("Failed to load module script with URL " + url));
delete window[tempGlobal];
script.remove();
};
document.documentElement.appendChild(script);
});
}
结合上面的代码来看,webpack 通过创建 script 标签来实现动态加载的,找出依赖对应的 chunk 信息,然后生成 script 标签来动态加载 chunk,每个 chunk 都有对应的状态:未加载 、 加载中、已加载 。
我们可以运行 React.lazy 代码来具体看看 network 的变化,为了方便辨认 chunk。我们可以在 import 里面加入 webpackChunckName 的注释,来指定包文件名称。
Suspense 组件
Suspense 内部主要通过捕获组件的状态去判断如何加载, React.lazy 创建的动态加载组件具有 Pending、Resolved、Rejected 三种状态,当这个组件的状态为 Pending 时显示的是 Suspense 中 fallback 的内容,只有状态变为 resolve 后才显示组件。
Error Boundaries 处理资源加载失败场景
如果遇到网络问题或是组件内部错误,页面的动态资源可能会加载失败,为了优雅降级,可以使用 Error Boundaries 来解决这个问题。
Error Boundaries 是一种组件,如果你在组件中定义了 static getDerivedStateFromError() 或 componentDidCatch() 生命周期函数,它就会成为一个 Error Boundaries 的组件。
总结
React.lazy() 和 React.Suspense 的提出为现代 React 应用的性能优化和工程化提供了便捷之路。
React.lazy 可以让我们像渲染常规组件一样处理动态引入的组件,结合 Suspense 可以更优雅地展现组件懒加载的过渡动画以及处理加载异常的场景。
useEffect 的第二个参数, 传空数组和传依赖数组有什么区别?
参考答案
在 React 中,useEffect 是一个常用的 Hook,它用于处理组件生命周期中的副作用。
useEffect 接收两个参数,第一个是要执行的函数,第二个是依赖数组(可选)。
当传递空数组 [] 时,useEffect 只会在组件挂载和卸载时调用一次。这种情况下,useEffect 不会监听任何变量,并且不会对组件进行重新渲染。
useEffect(() => {
// 只在挂载和卸载时执行
}, []);
当传递依赖数组时,useEffect 会在组件挂载和依赖项更新时调用。当依赖项中的任何一个值发生变化时,useEffect 都将被重新调用。如果依赖数组为空,则每次组件重新渲染时都会调用 useEffect。
useEffect(() => {
// 在挂载、依赖列表变化及卸载时执行
}, [dep1, dep2]);
下面是这两种情况的总结:
- 当传递空数组 [] 时,useEffect 只会在组件挂载和卸载时调用一次,不会对组件进行重新渲染。
- 当传递依赖数组时,useEffect 会在组件挂载和依赖项更新时调用,每次更新时都会检查依赖项列表是否有变化,如果有变化则重新执行。
如果 useEffect 中使用了闭包函数,则应该确保所有引用的变量都在依赖项中被显示声明,否则可能会导致不必要的重新渲染或者无法获取最新的状态。
使用 React hooks 怎么实现类里面的所有生命周期?
参考答案
在 React 16.8 之前,函数组件也称为无状态组件,因为函数组件也不能访问 react 生命周期,也没有自己的状态。react 自 16.8 开始,引入了 Hooks 概念,使得函数组件中也可以拥有自己的状态,并且可以模拟对应的生命周期。
我们应该在什么时候使用 Hooks 呢?
官方并不建议我们把原有的 class 组件,大规模重构成 Hooks,而是有一个渐进过程:
- 首先,原有的函数组件如果需要自己的状态或者需要访问生命周期函数,那么用 Hooks 是再好不过了;
- 另外就是,我们可以先在一些逻辑较简单的组件上尝试 Hooks ,在使用起来相对较熟悉,且组内人员比较能接受的前提下,再扩大 Hooks 的使用范围。
那么相对于传统class, Hooks 有哪些优势?
- State Hook 使得组件内的状态的设置和更新相对独立,这样便于对这些状态单独测试并复用。
- Hook 将组件中相互关联的部分拆分成更小的函数(比如设置订阅或请求数据),而并非强制按照生命周期划分,这样使得各个逻辑相对独立和清晰。
class 生命周期在 Hooks 中的实现
Hooks 组件更接近于实现状态同步,而不是响应生命周期事件。但是,由于我们先熟悉的 class 的生命周期,在写代码时,难免会受此影响,那么 Hooks 中如何模拟 class 的中的生命周期呢:
总结:
| class 组件 | Hooks 组件 |
|---|---|
| constructor | useState |
| getDerivedStateFromProps | useEffect 手动对比 props, 配合 useState 里面 update 函数 |
| shouldComponentUpdate | React.memo |
| render | 函数本身 |
| componentDidMount | useEffect 第二个参数为[] |
| componentDidUpdate | useEffect 配合useRef |
| componentWillUnmount | useEffect 里面返回的函数 |
| componentDidCatch | 无 |
| getDerivedStateFromError | 无 |
代码实现:
import React, { useState, useEffect, useRef, memo } from 'react';
// 使用 React.memo 实现类似 shouldComponentUpdate 的优化, React.memo 只对 props 进行浅比较
const UseEffectExample = memo((props) => {
console.log("===== UseStateExample render=======");
// 声明一个叫 “count” 的 state 变量。
const [count, setCount] = useState(0);
const [count2, setCount2] = useState(0);
const [fatherCount, setFatherCount] = useState(props.fatherCount)
console.log(props);
// 模拟 getDerivedStateFromProps
useEffect(() => {
// props.fatherCount 有更新,才执行对应的修改,没有更新执行另外的逻辑
if(props.fatherCount == fatherCount ){
console.log("======= 模拟 getDerivedStateFromProps=======");
console.log(props.fatherCount, fatherCount);
}else{
setFatherCount(props.fatherCount);
console.log(props.fatherCount, fatherCount);
}
})
// 模拟DidMount
useEffect(() => {
console.log("=======只渲染一次(相当于DidMount)=======");
console.log(count);
}, [])
// 模拟DidUpdate
const mounted = useRef();
useEffect(() => {
console.log(mounted);
if (!mounted.current) {
mounted.current = true;
} else {
console.log("======count 改变时才执行(相当于DidUpdate)=========");
console.log(count);
}
}, [count])
// 模拟 Didmount和DidUpdate 、 unmount
useEffect(() => {
// 在 componentDidMount,以及 count 更改时 componentDidUpdate 执行的内容
console.log("======初始化、或者 count 改变时才执行(相当于Didmount和DidUpdate)=========");
console.log(count);
return () => {
console.log("====unmount=======");
console.log(count);
}
}, [count])
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
<button onClick={() => setCount2(count2 + 1)}>
Click me2
</button>
</div>
);
});
export default UseEffectExample;
注意事项
- useState 只在初始化时执行一次,后面不再执行;
- useEffect 相当于是 componentDidMount,componentDidUpdate 和 componentWillUnmount 这三个函数的组合,可以通过传参及其他逻辑,分别模拟这三个生命周期函数;
- useEffect 第二个参数是一个数组,如果数组为空时,则只执行一次(相当于componentDidMount);如果数组中有值时,则该值更新时,useEffect 中的函数才会执行;如果没有第二个参数,则每次render时,useEffect 中的函数都会执行;
- React 保证了每次运行 effect 的同时,DOM 都已经更新完毕,也就是说 effect 中的获取的 state 是最新的,但是需要注意的是,effect 中返回的函数(其清除函数)中,获取到的 state 是更新前的。
- 传递给 useEffect 的函数在每次渲染中都会有所不同,这是刻意为之的。事实上这正是我们可以在 effect 中获取最新的 count 的值,而不用担心其过期的原因。每次我们重新渲染,都会生成新的 effect,替换掉之前的。某种意义上讲,effect 更像是渲染结果的一部分 —— 每个 effect “属于”一次特定的渲染。
- effect 的清除阶段(返回函数)在每次重新渲染时都会执行,而不是只在卸载组件的时候执行一次。它会在调用一个新的 effect 之前对前一个 effect 进行清理,从而避免了我们手动去处理一些逻辑 。为了说明这一点,下面按时间列出一个可能会产生的订阅和取消订阅操作调用序列:
function FriendStatus(props) {
// ...
useEffect(() => {
// ...
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});
// ...
}
// Mount with { friend: { id: 100 } } props
ChatAPI.subscribeToFriendStatus(100, handleStatusChange); // 运行第一个 effect
// Update with { friend: { id: 200 } } props
ChatAPI.unsubscribeFromFriendStatus(100, handleStatusChange); // 清除上一个 effect
ChatAPI.subscribeToFriendStatus(200, handleStatusChange); // 运行下一个 effect
// Update with { friend: { id: 300 } } props
ChatAPI.unsubscribeFromFriendStatus(200, handleStatusChange); // 清除上一个 effect
ChatAPI.subscribeToFriendStatus(300, handleStatusChange); // 运行下一个 effect
// Unmount
ChatAPI.unsubscribeFromFriendStatus(300, handleStatusChange); // 清除最后一个 effect
单页应用如何提高加载速度?
参考答案
为了提高网页性能,可以采取以下优化措施:
- 代码分割:将代码拆分为小块,按需加载,减少加载时间。
- 缓存资源:利用浏览器缓存重复使用的文件,如CSS、JS、图片等。
- 预加载关键资源:提前加载首页所需的关键资源,如JS、CSS或数据。
- 图片格式优化:选择合适的图片格式,压缩文件大小,使用字体文件代替小图标。
- Gzip压缩:使用Gzip压缩减少文件大小,提高传输效率。
- CDN使用:通过CDN缓存和传递文件,加快下载速度和提高可靠性。
- API请求优化:减少API调用次数,使用缓存和延迟加载技术优化API效率。
- 服务器端渲染:使用SSR生成HTML,减少客户端渲染时间,但可能增加服务器负担。
React有哪些性能优化的方法?
参考答案
React 渲染性能优化的三个方向,其实也适用于其他软件开发领域,这三个方向分别是:
- 减少计算的量。 -> 对应到 React 中就是减少渲染的节点 或者 降低组件渲染的复杂度
- 利用缓存。-> 对应到 React 中就是如何避免重新渲染,利用函数式编程的 memo 方式来避免组件重新渲染
- 精确重新计算的范围。 对应到 React 中就是绑定组件和状态关系, 精确判断更新的'时机'和'范围'. 只重新渲染'脏'的组件,或者说降低渲染范围
减少渲染的节点/降低渲染计算量(复杂度)
首先从计算的量上下功夫,减少节点渲染的数量或者降低渲染的计算量可以显著的提高组件渲染性能。
不要在渲染函数都进行不必要的计算
比如不要在渲染函数(render)中进行数组排序、数据转换、订阅事件、创建事件处理器等等. 渲染函数中不应该放置太多副作用
减少不必要的嵌套
有些团队是重度的 styled-components 用户,其实大部分情况下我们都不需要这个玩意,比如纯静态的样式规则,以及需要重度性能优化的场景。除了性能问题,另外一个困扰我们的是它带来的节点嵌套地狱(如上图)。
所以我们需要理性地选择一些工具,比如使用原生的 CSS,减少 React 运行时的负担.
一般不必要的节点嵌套都是滥用高阶组件/RenderProps 导致的。所以还是那句话‘只有在必要时才使用 xxx’。 有很多种方式来代替高阶组件/RenderProps,例如优先使用 props、React Hooks
虚拟列表
虚拟列表是常见的‘长列表'和'复杂组件树'优化方式,它优化的本质就是减少渲染的节点。
虚拟列表只渲染当前视口可见元素。
虚拟列表常用于以下组件场景:
- 无限滚动列表,grid, 表格,下拉列表,spreadsheets
- 无限切换的日历或轮播图
- 大数据量或无限嵌套的树
- 聊天窗,数据流(feed), 时间轴
- 等等
惰性渲染
惰性渲染的初衷本质上和虚表一样,也就是说我们只在必要时才去渲染对应的节点。
举个典型的例子,我们常用 Tab 组件,我们没有必要一开始就将所有 Tab 的 panel 都渲染出来,而是等到该 Tab 被激活时才去惰性渲染。
还有很多场景会用到惰性渲染,例如树形选择器,模态弹窗,下拉列表,折叠组件等等。
选择合适的样式方案
在样式运行时性能方面大概可以总结为:CSS > 大部分CSS-in-js > inline style
避免重新渲染
减少不必要的重新渲染也是 React 组件性能优化的重要方向. 为了避免不必要的组件重新渲染需要在做到两点:
- 保证组件纯粹性。即控制组件的副作用,如果组件有副作用则无法安全地缓存渲染结果
- 通过shouldComponentUpdate生命周期函数来比对 state 和 props, 确定是否要重新渲染。对于函数组件可以使用React.memo包装
另外这些措施也可以帮助你更容易地优化组件重新渲染:
简化 props
如果一个组件的 props 太复杂一般意味着这个组件已经违背了‘单一职责’,首先应该尝试对组件进行拆解. ② 另外复杂的 props 也会变得难以维护, 比如会影响shallowCompare效率, 还会让组件的变动变得难以预测和调试.
简化的 props 更容易理解, 且可以提高组件缓存的命中率
不变的事件处理器
避免使用箭头函数形式的事件处理器, 例如:
<ComplexComponent onClick={evt => onClick(evt.id)} otherProps={values} />
假设 ComplexComponent 是一个复杂的 PureComponent, 这里使用箭头函数,其实每次渲染时都会创建一个新的事件处理器,这会导致 ComplexComponent 始终会被重新渲染.
更好的方式是使用实例方法:
class MyComponent extends Component {
render() {
<ComplexComponent onClick={this.handleClick} otherProps={values} />;
}
handleClick = () => {
/*...*/
};
}
即使现在使用hooks,我依然会使用useCallback来包装事件处理器,尽量给下级组件暴露一个静态的函数:
const handleClick = useCallback(() => {
/*...*/
}, []);
return <ComplexComponent onClick={handleClick} otherProps={values} />;
但是如果useCallback依赖于很多状态,你的useCallback可能会变成这样:
const handleClick = useCallback(() => {
/*...*/
// 🤭
}, [foo, bar, baz, bazz, bazzzz]);
这种写法实在让人难以接受,这时候谁还管什么函数式非函数式的。我是这样处理的:
function useRefProps<T>(props: T) {
const ref = useRef < T > props;
// 每次渲染更新props
useEffect(() => {
ref.current = props;
});
}
function MyComp(props) {
const propsRef = useRefProps(props);
// 现在handleClick是始终不变的
const handleClick = useCallback(() => {
const { foo, bar, baz, bazz, bazzzz } = propsRef.current;
// do something
}, []);
}
设计更方便处理的 Event Props. 有时候我们会被逼的不得不使用箭头函数来作为事件处理器:
<List>
{list.map(i => (
<Item key={i.id} onClick={() => handleDelete(i.id)} value={i.value} />
))}
</List>
上面的 onClick 是一个糟糕的实现,它没有携带任何信息来标识事件来源,所以这里只能使用闭包形式,更好的设计可能是这样的:
// onClick传递事件来源信息
const handleDelete = useCallback((id: string) => {
/*删除操作*/
}, []);
return (
<List>
{list.map(i => (
<Item key={i.id} id={i.id} onClick={handleDelete} value={i.value} />
))}
</List>
);
如果是第三方组件或者 DOM 组件呢? 实在不行,看能不能传递data-*属性:
const handleDelete = useCallback(event => {
const id = event.currentTarget.dataset.id;
/*删除操作*/
}, []);
return (
<ul>
{list.map(i => (
<li key={i.id} data-id={i.id} onClick={handleDelete} value={i.value} />
))}
</ul>
);
不可变数据
不可变数据可以让状态变得可预测,也让 shouldComponentUpdate '浅比较'变得更可靠和高效。
相关的工具有Immutable.js、Immer、immutability-helper 以及 seamless-immutable。
简化 state
不是所有状态都应该放在组件的 state 中. 例如缓存数据。按照我的原则是:如果需要组件响应它的变动, 或者需要渲染到视图中的数据才应该放到 state 中。这样可以避免不必要的数据变动导致组件重新渲染.
使用 recompose 精细化比对
尽管 hooks 出来后,recompose 宣称不再更新了,但还是不影响我们使用 recompose 来控制shouldComponentUpdate方法, 比如它提供了以下方法来精细控制应该比较哪些 props:
/* 相当于React.memo */
pure()
/* 自定义比较 */
shouldUpdate(test: (props: Object, nextProps: Object) => boolean): HigherOrderComponent
/* 只比较指定key */
onlyUpdateForKeys( propKeys: Array<string>): HigherOrderComponent
其实还可以再扩展一下,比如omitUpdateForKeys忽略比对某些 key.
精细化渲染
所谓精细化渲染指的是只有一个数据来源导致组件重新渲染, 比如说 A 只依赖于 a 数据,那么只有在 a 数据变动时才渲染 A, 其他状态变化不应该影响组件 A。
Vue 和 Mobx 宣称自己性能好的一部分原因是它们的'响应式系统', 它允许我们定义一些‘响应式数据’,当这些响应数据变动时,依赖这些响应式数据视图就会重新渲染。
响应式数据的精细化渲染
大部分情况下,响应式数据可以实现视图精细化的渲染, 但它还是不能避免开发者写出低效的程序. 本质上还是因为组件违背‘单一职责’.
举个例子,现在有一个 MyComponent 组件,依赖于 A、B、C 三个数据源,来构建一个 vdom 树。现在的问题是什么呢?现在只要 A、B、C 任意一个变动,那么 MyComponent 整个就会重新渲染。
更好的做法是让组件的职责更单一,精细化地依赖响应式数据,或者说对响应式数据进行‘隔离’. 如下图, A、B、C 都抽取各自的组件中了,现在 A 变动只会渲染 A 组件本身,而不会影响父组件和 B、C 组件。
对于 Vue 或者 Mobx 来说,一个组件的渲染函数就是一个依赖收集的上下文。上面 List 组件渲染函数内'访问'了所有的列表项数据,那么 Vue 或 Mobx 就会认为你这个组件依赖于所有的列表项,这样就导致,只要任意一个列表项的属性值变动就会重新渲染整个 List 组件。
解决办法也很简单,就是将数据隔离抽取到单一职责的组件中。对于 Vue 或 Mobx 来说,越细粒度的组件,可以收获更高的性能优化效果。
不要滥用 Context
其实 Context 的用法和响应式数据正好相反。笔者也看过不少滥用 Context API 的例子, 说到底还是没有处理好‘状态的作用域问题’.
首先要理解 Context API 的更新特点,它是可以穿透React.memo或者shouldComponentUpdate的比对的,也就是说,一旦 Context 的 Value 变动,所有依赖该 Context 的组件会全部 forceUpdate.
这个和 Mobx 和 Vue 的响应式系统不同,Context API 并不能细粒度地检测哪些组件依赖哪些状态,所以说上节提到的‘精细化渲染’组件模式,在 Context 这里就成为了‘反模式’.
react 中,在什么场景下需要使用 useContext?
参考答案
在 React 中,useContext 是一个用于在组件树中共享状态或数据的钩子。它允许我们在没有通过属性逐层传递的情况下,将数据从祖先组件传递到后代组件。useContext 主要用于避免 prop drilling 问题,即当需要将数据从顶层组件传递到深层嵌套的组件时,可能会涉及多层组件传递属性,代码会变得冗长和难以维护。
使用 useContext 的场景
-
全局状态管理:
- 当你需要在多个组件之间共享全局状态时,
useContext是一个简单而有效的工具。例如,用户认证状态、主题设置或语言选择等全局数据可以通过useContext在整个应用中访问。
const UserContext = React.createContext(); function App() { const [user, setUser] = useState(null); return ( <UserContext.Provider value={user}> <UserProfile /> </UserContext.Provider> ); } function UserProfile() { const user = useContext(UserContext); return <div>{user ? `Welcome, ${user.name}` : 'Not logged in'}</div>; } - 当你需要在多个组件之间共享全局状态时,
-
避免 prop drilling:
- 当数据需要从顶层组件传递到深层嵌套的子组件时,使用
useContext可以避免将数据逐层通过props传递。这样可以减少中间组件不必要的属性传递,保持代码的简洁和清晰。
const ThemeContext = React.createContext(); function App() { const theme = 'dark'; return ( <ThemeContext.Provider value={theme}> <Toolbar /> </ThemeContext.Provider> ); } function Toolbar() { return ( <div> <ThemedButton /> </div> ); } function ThemedButton() { const theme = useContext(ThemeContext); return <button className={theme}>Themed Button</button>; } - 当数据需要从顶层组件传递到深层嵌套的子组件时,使用
-
跨组件通信:
- 在组件树的不同部分之间进行通信时,
useContext提供了一种简单的方式来共享信息,而不需要通过复杂的回调或全局事件总线。
- 在组件树的不同部分之间进行通信时,
-
复杂应用中的配置和设置:
- 在需要全局配置(如路由、表单验证、国际化等)的复杂应用中,
useContext使得这些配置可以被所有需要的组件访问,而不需要反复传递。
- 在需要全局配置(如路由、表单验证、国际化等)的复杂应用中,
-
在与
useReducer结合使用时:useReducer可以用来管理复杂的本地状态。将useReducer与useContext结合使用时,可以将状态和分发函数提供给需要的组件,而无需逐层传递。
const CountContext = React.createContext(); function reducer(state, action) { switch (action.type) { case 'increment': return { count: state.count + 1 }; case 'decrement': return { count: state.count - 1 }; default: throw new Error(); } } function Counter() { const [state, dispatch] = useReducer(reducer, { count: 0 }); return ( <CountContext.Provider value={{ state, dispatch }}> <ChildComponent /> </CountContext.Provider> ); } function ChildComponent() { const { state, dispatch } = useContext(CountContext); return ( <div> Count: {state.count} <button onClick={() => dispatch({ type: 'increment' })}>+</button> <button onClick={() => dispatch({ type: 'decrement' })}>-</button> </div> ); }
适用性与注意事项
-
适用性:
useContext适用于需要跨多个组件共享状态的场景,避免不必要的属性传递,特别是在状态涉及到多个组件层级时。
-
注意事项:
- 不要滥用
useContext。如果数据仅在少量组件之间共享,或局部状态足够处理问题,可能并不需要使用useContext。 useContext提供的数据是引用类型的,如果上下文中的数据变化会导致使用该上下文的所有组件重新渲染。因此,确保合理组织和管理上下文的数据以避免性能问题。
- 不要滥用
React 中的 ref 有什么用
参考答案
隐藏答案
参考答案AI解析
使用 refs 获取。组件被调用时会新建一个该组件的实例。refs 会指向这个实例,可以是一个回调函数,回调函数会在组件被挂载后立即执行。
如果把 refs 放到原生 DOM 组件的 input 中,我们就可以通过 refs 得到 DOM 节点;如果把 refs 放到 React 组件中,那么我们获得的就是组件的实例,因此就可以调用实例的方法(如果想访问该组件的真实 DOM,那么可以用 React.findDOMNode 来找到 DOM 节点,但是不推崇此方法)。
refs 无法用于无状态组件,无状态组件挂载时只是方法调用,没有新建实例。在 v16 之后,可以使用 useRef。
不同版本的 React 都做过哪些优化?
参考答案
隐藏答案
参考答案AI解析
React渲染页面的两个阶段:
- 调度阶段(reconciliation):在这个阶段 React 会更新数据生成新的 Virtual DOM,然后通过Diff算法,快速找出需要更新的元素,放到更新队列中去,得到新的更新队列。
- 渲染阶段(commit):这个阶段 React 会遍历更新队列,将其所有的变更一次性更新到DOM上。
React 15 架构
React15架构可以分为两层:
- Reconciler(协调器)—— 负责找出变化的组件;
- Renderer(渲染器)—— 负责将变化的组件渲染到页面上;
在React15及以前,Reconciler采用递归的方式创建虚拟DOM,递归过程是不能中断的。如果组件树的层级很深,递归会占用线程很多时间,递归更新时间超过了16ms,用户交互就会卡顿。
为了解决这个问题,React16将递归的无法中断的更新重构为异步的可中断更新,由于曾经用于递归的虚拟DOM数据结构已经无法满足需要。于是,全新的Fiber架构应运而生。
React 16 架构
为了解决同步更新长时间占用线程导致页面卡顿的问题,也为了探索运行时优化的更多可能,React开始重构并一直持续至今。重构的目标是实现Concurrent Mode(并发模式)。
从v15到v16,React团队花了两年时间将源码架构中的Stack Reconciler重构为Fiber Reconciler。
React16架构可以分为三层:
- Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入Reconciler;
- Reconciler(协调器)—— 负责找出变化的组件:更新工作从递归变成了可以中断的循环过程。Reconciler内部采用了Fiber的架构;
- Renderer(渲染器)—— 负责将变化的组件渲染到页面上。
React 17 优化
React16的expirationTimes模型只能区分是否>=expirationTimes决定节点是否更新。React17的lanes模型可以选定一个更新区间,并且动态的向区间中增减优先级,可以处理更细粒度的更新。
Lane用二进制位表示任务的优先级,方便优先级的计算(位运算),不同优先级占用不同位置的“赛道”,而且存在批的概念,优先级越低,“赛道”越多。高优先级打断低优先级,新建的任务需要赋予什么优先级等问题都是Lane所要解决的问题。
Concurrent Mode的目的是实现一套可中断/恢复的更新机制。其由两部分组成:
- 一套协程架构:Fiber Reconciler
- 基于协程架构的启发式更新算法:控制协程架构工作方式的算法
使用 useState (const [test, setTest] = useState([]))时,为什么连续调用 setTest({...test, newValue}) 会出现值的丢失?
参考答案
隐藏答案
参考答案
useState是异步执行的,也就是执行 setTest 后,不会立即更新 test 的结果,多次调用时,出现了值覆盖的情况。
如果本次的状态更新依赖于上一次最近的状态更新,那么我们可以给 setTest 传递一个函数进去,函数的参数即为最后一次更新的状态的值:
setTest(prevState => ([
...prevState,
newValue
]))
说说对 React 中Element、Component、Node、Instance 四个概念的理解
参考答案
在 React 中,Element、Component、Node、Instance 是四个重要的概念。
- Element:Element 是 React 应用中最基本的构建块,它是一个普通的 JavaScript 对象,用来描述 UI 的一部分。Element 可以是原生的 DOM 元素,也可以是自定义的组件。它的作用是用来向 React 描述开发者想在页面上 render 什么内容。Element 是不可变的,一旦创建就不能被修改。
- Component:Component 是 React 中的一个概念,它是由 Element 构成的,可以是函数组件或者类组件。Component 可以接收输入的数据(props),并返回一个描述 UI 的 Element。Component 可以被复用,可以在应用中多次使用。分为
Class Component以及Function Component。 - Node:Node 是指 React 应用中的一个虚拟节点,它是 Element 的实例。Node 包含了 Element 的所有信息,包括类型、属性、子节点等。Node 是 React 内部用来描述 UI 的一种数据结构,它可以被渲染成真实的 DOM 元素。
- Instance:Instance 是指 React 应用中的一个组件实例,它是 Component 的实例。每个 Component 在应用中都会有一个对应的 Instance,它包含了 Component 的所有状态和方法。Instance 可以被用来操作组件的状态,以及处理用户的交互事件等。
什么是JSX?
参考答案
JSX即JavaScript XML。一种在React组件内部构建标签的类XML语法。JSX为react.js开发的一套语法糖,也是react.js的使用基础。React在不使用JSX的情况下一样可以工作,然而使用JSX可以提高组件的可读性,因此推荐使用JSX。
class MyComponent extends React.Component {
render() {
let props = this.props;
return (
<div className="my-component">
<a href={props.url}>{props.name}</a>
</div>
);
}
}
优点:
- 允许使用熟悉的语法来定义 HTML 元素树;
- 提供更加语义化且移动的标签;
- 程序结构更容易被直观化;
- 抽象了 React Element 的创建过程;
- 可以随时掌控 HTML 标签以及生成这些标签的代码;
- 是原生的 JavaScript。
说说你对 useMemo 的理解
参考答案
Memo
在class的时代,我们一般是通过pureComponent来对数据进行一次浅比较,引入Hook特性后,我们可以使用Memo进行性能提升。
在此之前,我们来做一个实验
import React, { useState } from "react";
import ReactDOM from "react-dom";
import "./styles.css";
function App() {
const [n, setN] = useState(0);
const [m, setM] = useState(10);
console.log("执行最外层盒子了");
return (
<>
<div>
最外层盒子
<Child1 value={n} />
<Child2 value={m} />
<button
onClick={() => {
setN(n + 1);
}}
>
n+1
</button>
<button
onClick={() => {
setM(m + 1);
}}
>
m+1
</button>
</div>
</>
);
}
function Child1(props) {
console.log("执行子组件1了");
return <div>子组件1上的n:{props.value}</div>;
}
function Child2(props) {
console.log("执行子组件2了");
return <div>子组件2上的m:{props.value}</div>;
}
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
上面的代码我设置了两个子组件,分别读取父组件上的n跟m,然后父组件上面设置两个点击按钮,当点击后分别让设置的n、m加1。以下是第一次渲染时log控制台的结果
执行最外层盒子了
执行子组件1了
执行子组件2了
跟想象中一样,render时先进入App函数,执行,发现里面的两个child函数,执行,创建虚拟dom,创建实体dom,最后将画面渲染到页面上。
使用Memo优化
当我点击n+1按钮时,此时state里面的n必然+1,也会重新引发render渲染,并把新的n更新到视图中。我们再看控制台
执行最外层盒子了
执行子组件1了
执行子组件2了
+ 执行最外层盒子了
+ 执行子组件1了
+ 执行子组件2了 //为什么组件2也渲染了,里面的m没有变化
你会发现子组件2也渲染了,显然react重新把所有的函数都执行了一遍,把未曾有n数据的子组件2也重新执行了。
如何优化?我们可以使用memo把子组件改成以下代码
const Child1 = React.memo((props) => {
console.log("执行子组件1了");
return <div>子组件1上的n:{props.value}</div>;
});
const Child2 = React.memo((props) => {
console.log("执行子组件2了");
return <div>子组件2上的m:{props.value}</div>;
});
再重新点击试试?
执行最外层盒子了
执行子组件1了
执行子组件2了
+ 执行最外层盒子了
+ 执行子组件1了
会发现没有执行子组件2了
这样的话react就会只执行对应state变化的组件,而没有变化的组件,则复用上一次的函数,也许memo也有memory的意思,代表记忆上一次的函数,不重新执行(我瞎猜的- -!!)
出现bug
上面的代码虽然已经优化好了性能,但是会有一个bug
上面的代码是由父组件控制<button>的,如果我把控制state的函数传递给子组件,会怎样呢?
<Child2 value={m} onClick={addM} /> //addM是修改M的函数
点击按钮让n+1
执行最外层盒子了
执行子组件1了
执行子组件2了
+ 执行最外层盒子了
+ 执行子组件1了
+ 执行子组件2了
又重新执行子组件2。
为什么会这样?因为App重新执行了,它会修改addM函数的地址(函数是复杂数据类型),而addM又作为props传递给子组件2,那么就会引发子组件2函数的重新执行。
useMemo
这时候就要用useMemo解决问题。
useMemo(()=>{},[])
useMemo接收两个参数,分别是函数和一个数组(实际上是依赖),函数里return 函数,数组内存放依赖。
const addM = useMemo(() => {
return () => {
setM({ m: m.m + 1 });
};
}, [m]); //表示监控m变化
使用方式就跟useEffect似的。
useCallback
上面的代码很奇怪有没有
useMemo(() => {
return () => {
setM({ m: m.m + 1 });
};
}, [m])
react就给我们准备了语法糖,useCallback。它是这样写的
const addM = useCallback(() => {
setM({ m: m.m + 1 });
}, [m]);
是不是看上去正常多了?
最终代码
import React, { useCallback, useMemo, useState } from "react";
import ReactDOM from "react-dom";
import "./styles.css";
function App() {
const [n, setN] = useState(0);
const [m, setM] = useState({ m: 1 });
console.log("执行最外层盒子了");
const addN = useMemo(() => {
return () => {
setN(n + 1);
};
}, [n]);
const addM = useCallback(() => {
setM({ m: m.m + 1 });
}, [m]);
return (
<>
<div>
最外层盒子
<Child1 value={n} click={addN} />
<Child2 value={m} click={addM} />
<button onClick={addN}>n+1</button>
<button onClick={addM}>m+1</button>
</div>
</>
);
}
const Child1 = React.memo((props) => {
console.log("执行子组件1了");
return <div>子组件1上的n:{props.value}</div>;
});
const Child2 = React.memo((props) => {
console.log("执行子组件2了");
return <div>子组件2上的m:{props.value.m}</div>;
});
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
总结
- 使用
memo可以帮助我们优化性能,让react没必要执行不必要的函数 - 由于复杂数据类型的地址可能发生改变,于是传递给子组件的
props也会发生变化,这样还是会执行不必要的函数,所以就用到了useMemo这个api useCallback是useMemo的语法糖
React18新特性
参考答案
React 团队在 2022 年 3 月 29 日正式发布了 React 的第 18 个版本。 我将在这篇文章里简单介绍 React 18 的新特性,React Concurrent Mode(并发模式)的实现,以及简要的升级指南。
New Features
Automatic Batching
早在 React 18 之前,React 就已经可以对 state 更新进行批处理了:
function App() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
function handleClick() {
setCount((c) => c + 1); // Does not re-render yet
setFlag((f) => !f); // Does not re-render yet
// React will only re-render once at the end (that's batching!)
}
return (
<div>
<div>{count}</div>
<button onClick={handleClick}>Next</button>
</div>
);
}
上面这个例子中,用户点击按钮时会产生两次 state 的更新,按理来说每次 state 更新都会导致一次 re-render。但是,这两次更新完全可以合成一次,从而减少无谓的 re-render 带来的性能损失。
这种批处理只限于 React 原生事件内部的更新。
在 React 18 中,批处理支持处理的操作范围扩大了:Promise,setTimeout,native event handlers 等这些非 React 原生的事件内部的更新也会得到合并:
// Before: only React events were batched.
setTimeout(() => {
setCount((c) => c + 1);
setFlag((f) => !f);
// React will render twice, once for each state update (no batching)
}, 1000);
// After: updates inside of timeouts, promises,
// native event handlers or any other event are batched.
setTimeout(() => {
setCount((c) => c + 1);
setFlag((f) => !f);
// React will only re-render once at the end (that's batching!)
}, 1000);
Transitions
Transitions 是 React 中一个用于区分高优更新和非高优更新的新概念。
- 高优的更新/渲染:包括鼠标点击、打字等对实时交互性要求很高的更新场景,卡顿时会影响用户的交互行为,使用户明显感到整个页面卡顿。
- 非高优的更新/渲染:普通的 UI 更新,不与用户的交互相关,一些对更新实时性要求没那么高的场景。
这里有一个 demo,上方是一个滑动条用于控制下方树的倾角,最顶上的扇区展示了当前的掉帧情况,当用户拉动滚动条时,下方的树的每一个节点都会重新渲染,这会带来明显的卡顿,不仅是下方树的渲染卡顿,上方的滚动条也会无法实时跟着用户的交互移动,这会给用户带来明显的卡顿感。
类似场景下常见的做法应该是 debounce 或 throttle ,React 18 为我们提供了原生的方式来解决这个问题:使用 starTransition 和 useTransition。
starTransition:用于标记非紧急的更新,用starTransition包裹起来就是告诉 React,这部分代码渲染的优先级不高,可以优先处理其它更重要的渲染。用法如下:
import { startTransition } from "react";
// Urgent
setSliderValue(input);
// Mark any state updates inside as transitions
startTransition(() => {
// Transition: Show the results, non-urgent
setGraphValue(input);
});
- useTransition:除了能提供 startTransition 以外,还能提供一个变量来跟踪当前渲染的执行状态:
import { useTransition } from "react";
const [isPending, startTransition] = useTransition();
return isPending && <Spinner />;
在勾选了 Use startTransition 后 ,滑动条的更新渲染不会再被树的渲染阻塞了,尽管树叶的渲染仍然需要较多的时间,但是用户使用起来不再有之前那么卡顿了。
Suspense
Suspense 是 React 提供的用于声明 UI 加载状态的 API:
<Suspense fallback={<Loading />}>
<ComponentThatSuspends />
<Sibling />
</Suspense>
上面这串代码里,组件 ComponentThatSuspends 在请求处理数据过程中,React 会在它的位置上展示 Loading 组件。
React 16 和 17 中也已经有 Suspense 了,但是它不是完全体,有许多功能仍未就绪。在 React 团队的计划中,Suspense 的完全体是基于 Concurrent React 的,所以在 React 18,Suspense 相较之前有了一些变化。
ComponentThatSuspends 的兄弟组件会被中断
还是上面那个例子:
<Suspense fallback={<Loading />}>
<ComponentThatSuspends />
<Sibling />
</Suspense>
- Legacy Suspense 中,同级兄弟组件会立即挂载(mounted)到 DOM,相关的 effects 和生命周期会被触发,最后会隐藏这个组件。具体可以查看 代码示例。
- Concurrent Suspense 中,同级兄弟组件并不会从 DOM 上卸载,相关的 effects 和生命周期会在 ComponentThatSuspends 处理完成时触发。具体可以查看 代码示例。
Suspense 边界之外的 ref
另一个差异是父级 ref 传入的时间:
<Suspense fallback={<Loading />}>
<ComponentThatSuspends />
<Sibling />
</Suspense>
- 在 Legacy Suspense 中,在渲染之初
refPassedFromParent.current立即指向 DOM 节点,此时ComponentThatSuspends还未处理完成。 - 在 Concurrent Suspense 中,在
ComponentThatSuspends完成处理、Suspense 边界解除锁定之前refPassedFromParent.current一直为 null。
也就是说,在父级代码中访问此类 ref 都需要关注当前 ref 是否已经指向相应的节点。
Suspense for SSR
React 18 之前的 SSR, 客户端必须一次性的等待 HTML 数据加载到服务器上并且等待所有 JavaScript 加载完毕之后再开始 hydration, 等待所有组件 hydration 后,才能进行交互。即整个过程需要完成从获取数据(服务器)→ 渲染到 HTML(服务器)→ 加载代码(客户端)→ 水合物(客户端)这一套流程。这样的 SSR 并不能使我们的完全可交互变快,只是提高了用户的感知静态页面内容的速度。
React 18 的 Suspense:
- 服务器不需要等待被 Suspense 包裹组件是否加载到完毕,即可发送 HTML,而代替 Suspense 包裹的组件是 fallback 中的内容,一般是一个占位符(spinner),以最小内联
<script>标签标记此 HTML 的位置。等待服务器上组件的数据准备好后,React 再将剩余的 HTML 发送到同一个流中。 - hydration 的过程是逐步的,不需要等待所有的 js 加载完毕再开始 hydration,避免了页面的卡顿。
- React 会提前监听页面上交互事件(如鼠标的点击),对发生交互的区域优先进行 hydration。
New Client and Server Rendering APIs
Client
-
createRoot- 新的 root API,在 React 就版本中都是通过
ReactDom.render将应用组件渲染到页面的根元素,在 React 18 中,只有使用ReactDom.createRoot才能使用新特性。
- 新的 root API,在 React 就版本中都是通过
import * as ReactDOM from "react-dom";
import App from "./App";
// before React 18
const root = document.getElementById("app");
ReactDOM.render(<App />, root);
// React 18
const root = ReactDOM.createRoot(document.getElementById("app"));
root.render(<App />, root);
hydrateRoot:同理,用于替代 ReactDOM.hydrate。
Server
renderToPipeableStream 用于 Node 环境,实现流式传输;renderToReadableStream 用于 Deno 或 Cloudflare workers 等更现代的运行时中。
New Hooks
-
useTransition:见上 -
useDeferredValue- startTransition 可以用来标记低优先的 state 更新;而 useDeferredValue 可以用来标记低优先的变量。
- 下方代码的具体效果是当
input的值改变时,返回的graphValue并不会立即改变,会首先返回上一次的input值,如果当前不存在更紧急的更新,才会变成最新的input,因此可以通过graphValue是否改变来进行一些低优先级的更新。可以在渲染比较耗时的情况下把优先级滞后,在多数情况不会存在不必要的延迟。在较快的机器上,滞后会更少或者根本不存在,在较慢的机器上,会变得更明显。但不论哪种情况,应用都会保持可响应。
import { useDeferredValue } from "react";
const Comp = (input) => {
const graphValue = useDeferredValue(input); // ...updating depends on graphValue
};
不常用的 hooks
以下的新 hook 主要用于解决 SSR 相关的问题或者是为第三方库的开发设计的,对于普通 React 应用开发者来说几乎用不到:
useId用于解决 SSR 时客户端与服务端难以生成统一的 ID 的问题。useSyncExternalStore是一个为第三方库编写提供的新 hook,主要用于支持 React 18 在 concurrent rendering 下与第三方 store 的数据同步问题。useInsertionEffect主要用于提高第三方 CSS in JS 库渲染过程中样式注入的性能。
Concurrent Rendering
React 18 最重要的更新就是全面启用了 concurrent rendering。它不能算是新功能,实际上是 React 内部工作方式的重大变化。为了最终实现 concurrent rendering,React 布局已久。
问题
在页面元素很多,且需要频繁 re-render 的场景下,React 15 会出现掉帧的现象。其根本原因是大量的同步计算任务阻塞了浏览器的 UI 渲染。JS 运算、页面布局和绘制都是运行在浏览器的主线程当中,他们之间是互斥的。如果 JS 运算持续占用主线程,页面就没法得到及时的更新。当我们更新 state 触发 re-render 时,React 会遍历应用的所有节点,计算出差异,然后再更新 UI。更新一旦开始,中途就无法中断,直到遍历完整棵树,才能释放主线程。如果页面元素很多,整个过程占用的时机就可能超过 16ms,造成浏览器卡顿。
可以看到,React 15 的实现导致浏览器卡顿的关键在于每一次 re-render 开始了就无法停止,所以 React 团队想了一种解决方法:把 re-render 变成 可中断 的。
实现
思路
- 将 re-render 时的 JS 计算拆分成更小粒度的任务,可以随时暂停、继续和丢弃执行的任务。
- 当 JS 计算的时间达到 16 毫秒之后使其暂停,把主线程让给 UI 绘制,防止出现渲染掉帧的问题。
- 在浏览器空闲的时候继续执行之前没执行完的小任务。
架构演进
React 15 时期还没有 concurrent 的概念。它主要分为 Reconciler 和 Renderer 两部分:Reconciler 负责生成虚拟 DOM 并进行 diff,找出变动的虚拟 DOM,然后 Renderer 负责将变化的组件渲染到不同的宿主环境中。
React 16 的架构改动较大,多了一层 Scheduler,并且 Reconciler 的部分基于 Fiber 完成了重构。
React 17 相较先前并没有在架构上有大的改动,它是一个用以稳定 concurrent mode 的过渡版本,另外,它使用 Lanes 重构了优先级算法。
Reconciler
重构以前的 React Reconciler 是基于栈实现的,重构后的 React Reconciler 是基于 Fiber 实现的。
Fiber
Fiber 是一种数据结构,源码定义在 这里。简化来讲,它的主要结构如下:
{
...
stateNode, // 一般为 ReactComponent
// 的实例或者 DOM 元素
child, // 子 Fiber 节点
sibling, // 同层的下一个 Fiber 节点
return, // 指向父节点
alternate, // 连接 Current Fiber 树和
// workInProgress Fiber 树
...
}
ReactElement,Fiber,DOM 三者的关系:
- ReactElement:所有采用 JSX 语法书写的节点都会被转译,最终会以
React.createElement(...)的方式,创建出来一个与之对应的 ReactElement 对象。 - Fiber:Fiber 对象是通过 ReactElement 对象进行创建的,多个 Fiber 对象构成了一棵 Fiber 树,Fiber 树是构造 DOM 树的数据模型,Fiber 树的任何改动,最后都体现到 DOM 树上。
- DOM:将文档解析为一个由节点和对象(包含属性和方法的对象)组成的结构集合,也就是常说的 DOM 树。JS 可以访问和操作存储在 DOM 中的内容,也就是操作 DOM 对象,进而触发 UI 渲染。
开发人员通过编程只能控制 ReactElement 树的结构,ReactElement 树驱动 Fiber 树,Fiber 树再驱动 DOM 树,最后展现到页面上。所以 Fiber 树的构造过程,其核心就是 ReactElement 对象到 Fiber 对象的转换过程。(因为篇幅问题,此处不做过多展开。)
双缓存
React 应用中最多同时存在两棵 Fiber 树。当前屏幕上显示内容对应的 Fiber 树叫做 Current Fiber,正在内存中构建的 Fiber 树叫做 workInProgress Fiber,他们通过 alternate 属性相互连接。当 workInProgress Fiber 树构建好了以后,只需要切换一下 current 指针的指向,这两棵树的身份就会完成互换。
在这种双缓存的机制下,我们可以随时暂停或放弃对 workInProgress Fiber 树的修改,这就使得 React 更新的 中断 成为了可能。
流程
整个 Reconciliation 的流程可以简单地分为两个阶段:
- Render 阶段:当 React 需要进行 re-render 时,会遍历 Fiber 树的节点,根据 diff 算法将变化应用到 workInProgress 树上,这个阶段是随时可中断的。
- Commit 阶段:当 workInProgress 树构建完成之后,将其作为 Current 树,并把 DOM 变动绘制到页面上,这个阶段是不可中断的,必须一气呵成,类似操作系统中「原语」的概念。
Scheduler
对于大部分浏览器来说,每 1s 会有 60 帧,所以每一帧差不多是 16.6 ms,如果 Reconciliation 的 Render 阶段的更新时间过长,挤占了主线程其它任务的执行时间,就会导致页面卡顿。
思路
- 将 re-render 时的 JS 计算拆分成更小粒度的任务,可以随时暂停、继续和丢弃执行的任务。
- 当 JS 计算的时间达到 16 毫秒之后使其暂停,把主线程让给 UI 绘制,防止出现渲染掉帧的问题。
- 在浏览器空闲的时候继续执行之前没执行完的小任务。
让我们回看一下回看上面的解决思路,React 给出的解决方案是将整次 Render 阶段的长任务拆分成多个小任务:
- 每个任务执行的时间控制在 5ms。
- 把每一帧 5ms 内未执行的任务分配到后面的帧中。
- 给任务划分优先级,同时进行时优先执行高优任务。
这就留下了三个问题。
如何把每个任务执行的时间控制在 5ms?
中断 - shouldYield()
Reconciler 的设计使 re-render 具备了 可中断 的特性,而 Scheduler 用于控制 何时中断。
在这里先对比一下 Concurrent Mode 和 非 Concurrent Mode 下的代码:
// Sync Mode,即 React 原本的不可中断的更新模式
function workLoopSync() {
// Already timed out, so perform work without checking if we need to yield.
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
// Concurrent Mode
function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
可以看到在每次遍历前,都会通过 Scheduler 提供的 shouldYield 方法判断是否需要中断遍历。
Scheduler 提供的 shouldYield 方法在 源码 中叫 shouldYieldToHost,它通过综合判断已消耗的时间(是否超过 5ms)、是否有用户输入等高优事件来决定是否需要中断遍历,给浏览器渲染和处理其它任务的时间,防止页面卡顿。源码中的注释对于哪些条件/情况下 yield 写得非常清晰。
function shouldYieldToHost() {
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
// frameInterval = 5ms
// The main thread has only been blocked for a really short amount of time;
// smaller than a single frame. Don't yield yet.
return false;
}
// The main thread has been blocked for a non-negligible amount of time. We
// may want to yield control of the main thread, so the browser can perform
// high priority tasks. The main ones are painting and user input. If there's
// a pending paint or a pending input, then we should yield. But if there's
// neither, then we can yield less often while remaining responsive. We'll
// eventually yield regardless, since there could be a pending paint that
// wasn't accompanied by a call to `requestPaint`, or other main thread tasks
// like network events.
if (enableIsInputPending) {
if (needsPaint) {
// There's a pending paint (signaled by `requestPaint`). Yield now.
return true;
}
if (timeElapsed < continuousInputInterval) {
// We haven't blocked the thread for that long. Only yield if there's a
// pending discrete input (e.g. click). It's OK if there's pending
// continuous input (e.g. mouseover).
if (isInputPending !== null) {
return isInputPending();
}
} else if (timeElapsed < maxInterval) {
// Yield if there's either a pending discrete or continuous input.
if (isInputPending !== null) {
return isInputPending(continuousOptions);
}
} else {
// We've blocked the thread for a long time. Even if there's no pending
// input, there may be some other scheduled work that we don't know about,
// like a network event. Yield now.
return true;
}
}
// `isInputPending` isn't available. Yield now.
return true;
}
如何把每一帧 5ms 内未执行的任务分配到后面的帧中?
时间切片
如果任务的执行因为超过了 5ms 等被中断了,那么 React Scheduler 会借助一种效果接近于 setTimeout 的方式来开启一个宏任务,预定下一次的更新:
let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === "function") {
// Node.js and old IE.
// There's a few reasons for why we prefer setImmediate.
// Unlike MessageChannel, it doesn't prevent a Node.js process from exiting.
// (Even though this is a DOM fork of the Scheduler, you could get here
// with a mix of Node.js 15+, which has a MessageChannel, and jsdom.)
// [Bug: using MessageChannel prevents node.js process from exiting · Issue #20756 · facebook/react · GitHub](https://github.com/facebook/react/issues/20756)
// But also, it runs earlier which is the semantic we want.
// If other browsers ever implement it, it's better to use it.
// Although both of these would be inferior to native scheduling.
schedulePerformWorkUntilDeadline = () => {
localSetImmediate(performWorkUntilDeadline);
};
} else if (typeof MessageChannel !== "undefined") {
// DOM and Worker environments.
// We prefer MessageChannel because of the 4ms setTimeout clamping.
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
} else {
// We should only fallback here in non-browser environments.
schedulePerformWorkUntilDeadline = () => {
localSetTimeout(performWorkUntilDeadline, 0);
};
}
requestIdleCallback?
在其它的很多文章中,都提到了 requestIdleCallback 这个 API,然后说 React 团队考虑到兼容性和刷新帧率的问题,没有直接采用它,而是基于 MessageChannel 进行了模拟实现。但是从我看到的源码来说,React 是在借助 MessageChannel 模拟 setTimeout 的行为,将未完成的任务以宏任务的形式发放给浏览器,被动地让浏览器自行安排执行时间,而 requestIdleCallback 是主动从浏览器处获取空闲信息并执行任务,个人感觉不太像是一种对 requestIdleCallback 的 polyfill。
其它文章大多引用的是 这部分源码,可以看到这是在 v17 的分支上的,目前最新的 React 源码中已经没有了这个文件,应该是 React 更换了实现方式(#20025,#20915),那些文章里的说法感觉有些过时?
在 Reconciliation 的 Render 阶段,假设它耗时比较长,为 150ms,那么我们可以把他拆分为单个节点的计算时间之和。单个节点的计算非常快,假设都为 0.1ms。那么可以根据宏任务在帧中执行的特点(一帧里可以执行多个宏任务,同时浏览器还会将宏任务合理分配到不同帧中),将渲染过程改为如下过程:
// 假设 Render 阶段的计算拆分为 m 个节点,在第 n 帧结束
第 1 帧开始
宏任务开始
执行第 1 个节点,耗时 0.1ms
执行第 2 个节点,耗时 0.1ms
执行第 3 个节点,耗时 0.1ms
执行第 4 个节点,耗时 0.1ms
...
执行第 50 个节点,耗时 0.1ms
总耗时 5ms,开始下一个宏任务
渲染开始
由于更新是在内存中计算的,节点没有任何更新,那么不进行重新渲染
第 1 帧结束
第 2 帧开始
宏任务开始
执行第 51 个节点,耗时 0.1ms
执行第 52 个节点,耗时 0.1ms
执行第 53 个节点,耗时 0.1ms
...
执行第 100 个节点,耗时 0.1ms
总耗时 5ms,开始下一个宏任务
渲染开始
由于更新是在内存中计算的,节点没有任何更新,那么不进行重新渲染
第 2 帧结束
...
第 n 帧开始
宏任务开始
执行第 m-2 个节点,耗时 0.1 ms
执行第 m-1 个节点,耗时 0.1 ms
执行第 m 个节点,耗时 0.1 ms
所有节点计算完毕!
开始更新创建真实节点
渲染开始
真实节点更新,将其渲染到浏览器上
第 n 帧结束
如何给任务划分优先级?
基于 Lanes 的优先级控制
React 17 开始采用基于 Lanes 的优先级控制方案:
不同的 Lanes 可以简单理解为不同的数值,数值越小,表明优先级越高。比如用户事件比较紧急,那么可以对应比较高的优先级如 SyncLane;UI 界面过渡的更新不那么紧急,可以对应比较低的优先级如 TransitionLane;网络加载的更新也不那么紧急,可以对应低优先级 RetryLane。
通过这种优先级,我们就能判断哪些更新优先执行,哪些更新会被中断滞后执行了。举个例子来讲:假如有两个更新,他们同时对 App 组件的一个 count 属性更新:
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
DefaultLane
</button>
<button onClick={() => startTransition(() => { setCount(count + 1) })}>
TransitionLane1
</button>
假设 TransitionLane1 按钮先点击, TransitionLane1 更新开始,按照之前提到时间切片的形式进行更新。中途触发了 DefaultLane 按钮点击,进而触发 DefaultLane 更新。那么此时就会通过 lane 进行对比,发现 DefaultLane 优先级高于 TransitionLane1。此时会中断 TransitionLane1 更新,开始 DefaultLane 更新。直到 DefaultLane 更新完成时,再重新开始 TransitionLane1 更新。
升级指南
- 改变根节点的挂载方式使用新的 API
createRoot,使用旧的 API 仍然兼容,只有在使用createRoot了之后才会有 React 18 的新特性。 - React 18 会启用上面提到的全自动批处理,这算是一个 breaking change,不过 React 也提供了一个
flushSyncAPI 用于退出全自动批处理,用法如下:
import { flushSync } from "react-dom";
function handleClick() {
flushSync(() => {
setCounter((c) => c + 1);
});
// React has updated the DOM by now
flushSync(() => {
setFlag((f) => !f);
});
// React has updated the DOM by now
}
- 如果不用
flushSync的话两个 setState 只会进行一次 re-render,用了之后会触发两次。 - TS 类型定义上的较大变化:如果有用到 children,需要在组件 props 的定义中写明它的类型,这在以往是可以忽略不写的。其它 TS 相关的改动可以见 这里。
interface MyButtonProps {
color: string;
children?: React.ReactNode;
}
- React 18 不再支持 IE。
React中,useRef、ref、forwardsRef 的区别是什么?
参考答案
useRef、ref 和 forwardRef 都涉及到引用(refs)的使用,但它们的用途和行为有所不同。下面是它们的主要区别:
1. useRef
-
用途:在函数组件中创建和管理引用。
useRef返回一个可变的ref对象,该对象的.current属性可以用来访问 DOM 节点或保存任意值。 -
使用方式:
import { useRef, useEffect } from 'react'; function MyComponent() { const inputRef = useRef(null); useEffect(() => { inputRef.current.focus(); // 访问和操作 DOM 元素 }, []); return <input ref={inputRef} />; } -
特点:
useRef创建的引用对象在组件的整个生命周期内保持不变。- 可以用来保存任意数据,除了 DOM 节点。
2. ref
-
用途:在类组件中使用,或者通过
React.forwardRef在函数组件中使用,来访问 DOM 节点或组件实例。 -
使用方式:
class MyClassComponent extends React.Component { constructor(props) { super(props); this.inputRef = React.createRef(); } componentDidMount() { this.inputRef.current.focus(); // 访问和操作 DOM 元素 } render() { return <input ref={this.inputRef} />; } }function MyFunctionComponent(props, ref) { return <input ref={ref} />; } const ForwardedComponent = React.forwardRef(MyFunctionComponent); -
特点:
ref用于访问类组件的实例或函数组件的 DOM 元素。- 在函数组件中使用
ref需要配合React.forwardRef使用。
3. forwardRef
-
用途:允许函数组件接收
ref并将其转发到子组件的 DOM 元素或其他组件。 -
使用方式:
import React, { forwardRef } from 'react'; const MyComponent = forwardRef((props, ref) => ( <input ref={ref} {...props} /> )); function App() { const inputRef = useRef(null); useEffect(() => { inputRef.current.focus(); // 访问和操作 DOM 元素 }, []); return <MyComponent ref={inputRef} />; } -
特点:
forwardRef高阶组件允许函数组件接收ref,并将ref转发到子组件的 DOM 元素或其他组件上。- 适用于需要将
ref传递给深层组件的情况。
题库内容较多,就不一一写出来了,可以点击一起沟通交流,最近6.7月也在面试前端岗,整理了一些最近会问到的前端开发面试题库。