github-actions[bot]
1 year ago
committed by
GitHub
21 changed files with 419 additions and 36 deletions
@ -0,0 +1,182 @@ |
|||
--- |
|||
title: Suspense breaks styles |
|||
date: 2023-07-07 |
|||
author: zombieJ |
|||
--- |
|||
|
|||
We know that React 18 provides a `useInsertionEffect` hooks specifically for CSS-IN-JS, which has a faster timing priority than `useLayoutEffect`, so that the order of calls will not be affected by the order of writing: |
|||
|
|||
```tsx |
|||
useLayoutEffect(() => { |
|||
console.log('layout effect'); |
|||
}, []); |
|||
|
|||
useInsertionEffect(() => { |
|||
console.log('insertion effect'); |
|||
}, []); |
|||
|
|||
// Console: |
|||
// - insertion effect |
|||
// - layout effect |
|||
``` |
|||
|
|||
In early `@ant-design/cssinjs` implementation, we did not choose `useInsertionEffect` because we needed to be compatible with React 17 version, but simulated the effect of inserting in advance by adding styles in the render phase: |
|||
|
|||
```tsx |
|||
// pseudocode. Not used in real world |
|||
function useStyleInsertion(hash: string, counter: Record<string, number>) { |
|||
useMemo(() => { |
|||
if (!counter[hash]) { |
|||
// Insert only when current style not inserted |
|||
} |
|||
|
|||
counter[hash] += 1; |
|||
}, [hash]); |
|||
|
|||
useEffect( |
|||
() => () => { |
|||
counter[hash] -= 1; |
|||
|
|||
if (!counter[hash]) { |
|||
// Remove if set to clear on destroy |
|||
} |
|||
}, |
|||
[hash], |
|||
); |
|||
} |
|||
``` |
|||
|
|||
Above code will count the usage of styles, if the current style has not been inserted, it will insert the style in the render phase. Similarly, if the current style is configured to be unloaded when it is not in use, it will be cleared after the effect confirms the count. In addition, there is a similar logic that listens for changes in tokens, and when there are multiple tokens, it will clear all styles `<style />` corresponding to tokens that are no longer in use to avoid memory leaks caused by too many theme switches. |
|||
|
|||
These code can run perfectly in React 17, and also run very well in React 18's StrictMode. `counter` always appears and disappears in pairs. But under Suspense, it may have problems. |
|||
|
|||
## StrictMode |
|||
|
|||
The StrictMode of React 18 is different from [React 17](https://17.reactjs.org/docs/strict-mode.html) in that it will be called multiple times in each phase to ensure that developers clean up the Effect: |
|||
|
|||
````tsx |
|||
|
|||
```tsx |
|||
const My = () => { |
|||
console.log('render'); |
|||
|
|||
useMemo(() => { |
|||
console.log('memo'); |
|||
}, []); |
|||
|
|||
useEffect(() => { |
|||
console.log('effect'); |
|||
|
|||
return () => { |
|||
console.log('effect cleanup'); |
|||
}; |
|||
}, []); |
|||
}; |
|||
|
|||
<StrictMode> |
|||
<My /> |
|||
</StrictMode>; |
|||
|
|||
// Console: |
|||
// - render |
|||
// - memo |
|||
// - render |
|||
// - memo |
|||
// - effect |
|||
// - effect cleanup |
|||
// - effect |
|||
```` |
|||
|
|||
With above sample, we can know that `counter` in StrictMode will be accumulated, but the final value will be correct (that is, each component will only be counted once): |
|||
|
|||
- memo: 1 |
|||
- memo: 2 |
|||
- effect cleanup: 1 |
|||
|
|||
But StrictMode is just a simulation of Suspense. In the real scenario, the number of executions is not guaranteed to appear in pairs. |
|||
|
|||
## Suspense |
|||
|
|||
We use [umi](https://github.com/umijs/umi) for site development, which is split by page and loaded on demand by default. Display the loading state during the loading process through Suspense: |
|||
|
|||
```tsx |
|||
<BrowserRoutes> |
|||
<Routs> |
|||
<Suspense fallback={<Loading />} /> |
|||
</Routs> |
|||
</BrowserRoutes> |
|||
``` |
|||
|
|||
When switching pages, there is a chance that some styles will be lost when the page is switched back and forth: |
|||
|
|||
<img width="300" alt="Fetch Failed" src="https://github.com/ant-design/ant-design/assets/5378891/f2bc49ed-9db6-4d7e-a5d3-8db0cda7b640" /> |
|||
|
|||
Part of the style lost in Page 1 is the style unique to Page 1 (some tokens are customized through ConfigProvider), and the style of Page 2 is the style common to Page 1 and Page 2. |
|||
|
|||
With the style management logic we introduced at the beginning, Page 1 will be cleared all styles `<style />` corresponding to the token when Page 2 is rendered because it has styles corresponding to the independent token. This looks as expected, so the problem is that the style is not re-inserted when switching back to Page 1. |
|||
|
|||
### Wrong Counter |
|||
|
|||
With a series of breakpoints, we found that this problem is caused by the asynchronous nature of Suspense. It will call the component multiple times during the loading process, so the timing of the component style registration will also be called multiple times. And since our counter is in the render phase, the counter will be called multiple times under Suspense, which will cause the value of the counter to be incorrect: |
|||
|
|||
- render: 0 |
|||
- useMemo: 1 |
|||
- render: 1 |
|||
- useMemo: 2 |
|||
- effect: 2 |
|||
- Not like StrictMode, effect is not executed again, so effect cleanup will not be executed |
|||
|
|||
Counter is not synchronized, so the token manager thinks that the style is no longer in use, so it performs batch cleaning, while the component style manager thinks that other components are still in use, so when re-entering Page 1, the style will not be re-inserted. |
|||
|
|||
## useInsertionEffect |
|||
|
|||
Obviously, due to its characteristics, we cannot use `useMemo` as a counter, it will not appear in pairs with `useEffect`. So we consider using `useInsertionEffect` to insert styles: |
|||
|
|||
```tsx |
|||
// pseudocode. Not used in real world |
|||
useInsertionEffect(() => { |
|||
if (!counter[hash]) { |
|||
// Insert only when current style not inserted |
|||
} |
|||
counter[hash] += 1; |
|||
|
|||
return () => { |
|||
counter[hash] -= 1; |
|||
|
|||
if (!counter[hash]) { |
|||
// Remove if set to clear on destroy |
|||
} |
|||
}; |
|||
}, [hash]); |
|||
``` |
|||
|
|||
And for React 17 version, it is downgraded to `useLayoutEffect`: |
|||
|
|||
```tsx |
|||
const useMergedInsertionEffect = useInsertionEffect || useLayoutEffect; |
|||
|
|||
useMergedInsertionEffect(() => { |
|||
// Same as above |
|||
}, [hash]); |
|||
``` |
|||
|
|||
With this modification, we found that React 17's CI was failed. After checking, we found that `useLayoutEffect` will have a timing problem: |
|||
|
|||
```tsx |
|||
// Some logic measure dom size |
|||
useLayoutEffect(() => { |
|||
// This is not correct since style is not applied |
|||
const { clientHeight } = nodeRef.current; |
|||
}, []); |
|||
|
|||
// Inject style |
|||
useLayoutEffect(() => { |
|||
// ... |
|||
}, [hash]); |
|||
``` |
|||
|
|||
Measure logic in `useLayoutEffect` is executed before injecting style, resulting in incorrect size information. It can also be predicted that this will have an impact on developers. So we have to compromise, and in React 17 version, it will be downgraded to the original `useMemo` insertion. |
|||
|
|||
## Summary |
|||
|
|||
Suspense brings rendering performance improvements, but it also makes timing very important. It is not the best way to only 'work on' StrictMode. Different logic is used for different React versions is not good choice since it will have timing problem. `render` will trigger from parent node to child node in turn, while `useInsertionEffect` is the opposite. However, from the perspective of antd, the component styles are independent of each other, so this problem will not affect us. |
@ -0,0 +1,180 @@ |
|||
--- |
|||
title: Suspense 引发的样式丢失问题 |
|||
date: 2023-07-07 |
|||
author: zombieJ |
|||
--- |
|||
|
|||
我们知道,React 18 提供了一个专门为 CSS-IN-JS 使用的 `useInsertionEffect` hooks,它会比 `useLayoutEffect` 拥有更快的时序优先级,从而保证不会因为书写顺序而影响调用顺序的问题: |
|||
|
|||
```tsx |
|||
useLayoutEffect(() => { |
|||
console.log('layout effect'); |
|||
}, []); |
|||
|
|||
useInsertionEffect(() => { |
|||
console.log('insertion effect'); |
|||
}, []); |
|||
|
|||
// Console: |
|||
// - insertion effect |
|||
// - layout effect |
|||
``` |
|||
|
|||
在早期 `@ant-design/cssinjs` 实现中,由于需要兼容 React 17 版本,我们并没有选择 `useInsertionEffect`,而是通过在 render 阶段添加样式的方式来模拟提前插入的效果: |
|||
|
|||
```tsx |
|||
// pseudocode. Not used in real world |
|||
function useStyleInsertion(hash: string, counter: Record<string, number>) { |
|||
useMemo(() => { |
|||
if (!counter[hash]) { |
|||
// Insert only when current style not inserted |
|||
} |
|||
|
|||
counter[hash] += 1; |
|||
}, [hash]); |
|||
|
|||
useEffect( |
|||
() => () => { |
|||
counter[hash] -= 1; |
|||
|
|||
if (!counter[hash]) { |
|||
// Remove if set to clear on destroy |
|||
} |
|||
}, |
|||
[hash], |
|||
); |
|||
} |
|||
``` |
|||
|
|||
以上代码会对使用样式进行统计,如果发现当前样式没有被插入过,就会在 render 阶段插入样式,否则就不会插入。同样的,如果发现当前样式配置了未使用时卸载,则会在 effect 确认计数后清除。此外,还有一套类似的代码会监听 token 的变化,当存在多份 token 时会对不再使用的 token 对应的所有样式 `<style />` 进行清理,以避免过多的主题切换导致的内存泄漏。 |
|||
|
|||
这段代码在 React 17 可以完美运行,在 React 18 的 StrictMode 下也运行的十分正常。`counter` 总是成对出现与消失。但是它在 Suspense 下,就会有概率出现问题了。 |
|||
|
|||
## StrictMode |
|||
|
|||
React 18 的 StrictMode 和 [React 17](https://17.reactjs.org/docs/strict-mode.html)不同的是,它会在各个阶段进行多次调用,从而确保开发者对 Effect 进行了清理: |
|||
|
|||
```tsx |
|||
const My = () => { |
|||
console.log('render'); |
|||
|
|||
useMemo(() => { |
|||
console.log('memo'); |
|||
}, []); |
|||
|
|||
useEffect(() => { |
|||
console.log('effect'); |
|||
|
|||
return () => { |
|||
console.log('effect cleanup'); |
|||
}; |
|||
}, []); |
|||
}; |
|||
|
|||
<StrictMode> |
|||
<My /> |
|||
</StrictMode>; |
|||
|
|||
// Console: |
|||
// - render |
|||
// - memo |
|||
// - render |
|||
// - memo |
|||
// - effect |
|||
// - effect cleanup |
|||
// - effect |
|||
``` |
|||
|
|||
从上面的例子可以知道,`counter` 在 StrictMode 虽然会累加,但是最终会是正确的值(即每个组件只计算 1 次统计): |
|||
|
|||
- memo: 1 |
|||
- memo: 2 |
|||
- effect cleanup: 1 |
|||
|
|||
但是 StrictMode 只是对 Suspense 的模拟。在真实场景下,执行次数并不会保证成对出现。 |
|||
|
|||
## Suspense |
|||
|
|||
我们使用 [umi](https://github.com/umijs/umi) 进行站点开发,它默认按页拆包、按需加载。通过 Suspense 的方式在加载过程中显示 loading 状态: |
|||
|
|||
```tsx |
|||
<BrowserRoutes> |
|||
<Routs> |
|||
<Suspense fallback={<Loading />} /> |
|||
</Routs> |
|||
</BrowserRoutes> |
|||
``` |
|||
|
|||
在页面切换时,偶发出现页面往复切换时部分样式丢失的情况: |
|||
|
|||
<img width="300" alt="Fetch Failed" src="https://github.com/ant-design/ant-design/assets/5378891/f2bc49ed-9db6-4d7e-a5d3-8db0cda7b640" /> |
|||
|
|||
其中 Page 1 丢失部分的样式为 Page 1 独有的样式(通过 ConfigProvider 定制了一些 token),而 Page 2 的样式则为 Page 1 与 Page 2 通用的样式。 |
|||
|
|||
在我们最初介绍的样式管理逻辑可以明白,Page 1 由于为独立 token 对应的样式,因而在 Page 2 渲染时会被清理掉所有的对应 token 的样式 `<style />`。这看起来是符合预期的,那么问题就出在了切回 Page 1 时样式没有被重新插入。 |
|||
|
|||
### 计数器错误 |
|||
|
|||
在经过一系列断点后,我们发现这个问题出现在计数器不同步之上。由于 Suspense 的特性,它会在加载过程中多次调用组件,所以组件样式注册的时机也会被调用多次。而由于我们的计数器是在 render 阶段进行的,所以在 Suspense 下,计数器会被多次调用,从而导致计数器的值不正确: |
|||
|
|||
- render: 0 |
|||
- useMemo: 1 |
|||
- render: 1 |
|||
- useMemo: 2 |
|||
- effect: 2 |
|||
- 不像 StrictMode,effect 并没有再次执行,所以 effect cleanup 也不会执行 |
|||
|
|||
计数器不同步导致 token 层面已经认为样式已经没有再使用所以进行了批量清理,而在组件样式层面则认为还有其他组件在使用,所以当重新进入 Page 1 时并不会重新插入样式。 |
|||
|
|||
## useInsertionEffect |
|||
|
|||
显而易见,Suspense 由于其特性,我们不能通过 `useMemo` 来做计数器,它不会和 `useEffect` 成对出现。所以我们考虑需要使用 `useInsertionEffect` 来进行样式的插入: |
|||
|
|||
```tsx |
|||
// pseudocode. Not used in real world |
|||
useInsertionEffect(() => { |
|||
if (!counter[hash]) { |
|||
// Insert only when current style not inserted |
|||
} |
|||
counter[hash] += 1; |
|||
|
|||
return () => { |
|||
counter[hash] -= 1; |
|||
|
|||
if (!counter[hash]) { |
|||
// Remove if set to clear on destroy |
|||
} |
|||
}; |
|||
}, [hash]); |
|||
``` |
|||
|
|||
而对于 React 17 版本,则降级为 `useLayoutEffect`: |
|||
|
|||
```tsx |
|||
const useMergedInsertionEffect = useInsertionEffect || useLayoutEffect; |
|||
|
|||
useMergedInsertionEffect(() => { |
|||
// Same as above |
|||
}, [hash]); |
|||
``` |
|||
|
|||
经过这样的修改后,我们发现 React 17 的 CI 挂了。在检查后,发现 `useLayoutEffect` 就会出现时序问题: |
|||
|
|||
```tsx |
|||
// Some logic measure dom size |
|||
useLayoutEffect(() => { |
|||
// This is not correct since style is not applied |
|||
const { clientHeight } = nodeRef.current; |
|||
}, []); |
|||
|
|||
// Inject style |
|||
useLayoutEffect(() => { |
|||
// ... |
|||
}, [hash]); |
|||
``` |
|||
|
|||
测量的 `useLayoutEffect` 先于注入样式执行,导致获取了错误的尺寸信息。也可以预测到这会对开发者产生影响。因而我们退而求其次,在 React 17 版本时会降级为原先的 `useMemo` 插入。 |
|||
|
|||
## 总结 |
|||
|
|||
Suspense 在带来渲染能力提升的同时也让时序变得十分重要,仅仅对 StrictMode 进行处理并不是一个最优的方式。针对不同的 React 版本使用不同的逻辑其实会存在不同版本之间的时序问题,`render` 会从父节点到子节点依次触发,而 `useInsertionEffect` 则相反。不过从 antd 角度来说,组件样式之间相互独立,所以这种时序问题并不会对我们产生影响。 |
Loading…
Reference in new issue