|
| 1 | +性能在可视化搭建也是极为重要的,如何尽可能减少业务感知,最大程度的提升性能是关键。 |
| 2 | + |
| 3 | +其实声明式一定程度上可以说是牺牲了性能换来了可维护性,所以在一个完全声明式的框架下做性能优化还是非常有挑战的。我们采取了两种策略来优化性能,分别是自动批处理与冻结。 |
| 4 | + |
| 5 | +## 自动批处理 |
| 6 | + |
| 7 | +首先,框架内任何状态更新都不会立即触发响应,而是统一收集起来后,一次性触发响应,如下面的例子: |
| 8 | + |
| 9 | +```jsx |
| 10 | +const divMeta: ComponentMeta = { |
| 11 | + // ... |
| 12 | + fetcher: ({ selector, setRuntimeProps, componentId }) => { |
| 13 | + const name = selector(({ props }) => props.name) |
| 14 | + const email = selector(({ props }) => props.email) |
| 15 | + fetch('...', { |
| 16 | + data: { name, email } |
| 17 | + }).then((res) => { |
| 18 | + setRuntimeProps(componentId, old => ({ |
| 19 | + ...old ?? {}, |
| 20 | + data: res.data |
| 21 | + })) |
| 22 | + }) |
| 23 | + } |
| 24 | +} |
| 25 | + |
| 26 | +const App = () => { |
| 27 | + const { setProps } = useDesigner() |
| 28 | + const onClick = useCallback(() => { |
| 29 | + setProps('1', props => ({ ...props, name: 'bob' })) |
| 30 | + setProps('1', props => ({ ...props, email: '666@qq.com' })) |
| 31 | + }, []) |
| 32 | +} |
| 33 | +``` |
| 34 | +
|
| 35 | +上面例子中,`fetcher` 通过 `selector` 监听了 `props.name` 与 `props.email`,当连续调用两次 `setProps` 分别修改 `props.name` 与 `props.email` 时,只会合并触发一次 `fetcher` 而不是两次,这种设计让业务代码减少了重复执行的次数,简化了业务逻辑复杂度。 |
| 36 | +
|
| 37 | +另一方面,在自动批处理的背后,还有一个框架如何执行 `selector` 的性能优化点,即框架是否能感知到 `fetcher` 依赖了 `props.name` 与 `props.email`?如果框架知道,那么当比如 `props.appId` 或者其他 `state.` 状态变化时,根本不需要执行 `fetcher` 内的 `selector` 判断返回引用是否变化,这能减少巨大的碎片化堆栈时间。 |
| 38 | +
|
| 39 | +一个非常有效的收集方式是利用 Proxy,将 `selector` 内用到的数据代理化,利用代理监听哪些函数绑定了哪些变量,并在这些变量变化时按需重新执行。 |
| 40 | +
|
| 41 | +笔者用一段较为结构化的文字描述这背后的性能优化是如何发生的。 |
| 42 | +
|
| 43 | +一、组件元信息声明式依赖了某些值 |
| 44 | +
|
| 45 | +比如下面的代码,在 `meta.fetcher` 利用 `selector` 获取了 `props.name` 与 `props.email` 的值,并在这些值变化时重新执行 `fetcher`。 |
| 46 | +
|
| 47 | +```jsx |
| 48 | +const divMeta: ComponentMeta = { |
| 49 | + // ... |
| 50 | + fetcher: ({ selector, setRuntimeProps, componentId }) => { |
| 51 | + const name = selector(({ props }) => props.name) |
| 52 | + const email = selector(({ props }) => props.email) |
| 53 | + } |
| 54 | +} |
| 55 | +``` |
| 56 | +
|
| 57 | +在这背后,其实 `selector` 内拿到的 `props` 或者 `state` 都已经是 Proxy 代理对象,框架内部会记录这些调用关系,比如这个例子中,会记录组件 ID 为 1 的组件,`fetcher` 绑定了 `props.name` 与 `props.email`。 |
| 58 | +
|
| 59 | +二、状态变化 |
| 60 | +
|
| 61 | +当任何地方触发了状态变化,都不会立刻计算,而是在 `nextTick` 时机触发清算。比如: |
| 62 | +
|
| 63 | +```js |
| 64 | +setProps('1', props => ({ ...props, name: 'bob' })) |
| 65 | +setProps('1', props => ({ ...props, email: '666@qq.com' })) |
| 66 | +``` |
| 67 | +
|
| 68 | +虽然连续触发了两次 `setProps`,但框架内只会在 `nextTick` 时机总结出发生了一次变化,此时组件 ID 为 1 的组件实例 `props.name` 与 `props.email` 发生了变化。 |
| 69 | +
|
| 70 | +接着,会从内部 selector 依赖关系的缓存中找到,发现只有 `fetcher` 函数依赖了这两个值,所以就会精准的执行 `fetcher` 中两个 `selector`,执行结果发现相比之前的值引用变化了,最后判定需要重新执行 `fetcher`,至此响应式走完了一次流程。 |
| 71 | +
|
| 72 | +当然在 `fetcher` 函数内可能再触发 `setProps` 等函数修改状态,此时会立刻进入判定循环直到所有循环走完。另外假设此次状态变化没有任何 meta 声明式函数依赖了,那么即便画布有上千个组件,每个组件实例绑定了十几个 meta 声明式函数,此时都不会触发任何一个函数的执行,性能不会随着画布组件增加而恶化。 |
| 73 | +
|
| 74 | +## 冻结 |
| 75 | +
|
| 76 | +冻结可以把组件的状态凝固,从而不再响应任何事件,也不会重新渲染。 |
| 77 | +
|
| 78 | +```js |
| 79 | +const chart: ComponentMeta = { |
| 80 | + /** 默认 false */, |
| 81 | + defaultFreeze: true |
| 82 | +} |
| 83 | +``` |
| 84 | +
|
| 85 | +或者使用 `setFreeze` 修改冻结状态: |
| 86 | +
|
| 87 | +```js |
| 88 | +const { setFreeze } = useDesigner() |
| 89 | +// 设置 id 1 的组件为冻结态 |
| 90 | +setFreeze('1', true) |
| 91 | +``` |
| 92 | +
|
| 93 | +### 为什么要提供冻结能力? |
| 94 | +
|
| 95 | +当仪表盘内组件数量过多时,业务上会考虑做按需加载,或者按需查询。但因为组件间存在关联关系,可视化搭建框架(我们用 Designer 指代)在初始化依然会执行一些初始函数,比如 `init`,同时组件依然会进行一次初始化渲染,虽然业务层会做一些简化处理,比如提前 `Return null`, 但组件数量多了之后想要扣性能依然还有优化空间。 |
| 96 | +
|
| 97 | +所以 Designer 就提供了冻结能力,从根本上解决视窗外组件造成的性能影响。为什么可以根本解决性能影响呢?因为处于冻结态的组件: |
| 98 | +
|
| 99 | +- 前置性。通过 `defaultFreeze` 在组件元信息初始化设置为 `false`,那么所有初始化逻辑都不会执行。 |
| 100 | +- 不会响应任何状态变更,连内置的 `selector` 执行都会直接跳过,完全屏蔽了这个组件的存在,可以让 Designer 内部调度逻辑大大提效。 |
| 101 | +- 不会触发重渲染。如果组件初始化就设置为冻结,那么初始化渲染也不会执行。 |
| 102 | +
|
| 103 | +### 怎么使用冻结能力? |
| 104 | +
|
| 105 | +建议统一把所有组件 `defaultFreeze` 设置为 true,然后找一个地方监听滚动或者视窗的变化,通过 `setFreeze` 响应式的把视窗内组件解冻,把移除视窗的组件冻结。 |
| 106 | +
|
| 107 | +特别注意,如果有组件联动,冻结了触发组件会导致联动失效,因此业务最好把那些 **即便不在视窗内,也要作用联动** 的组件保持解冻状态。 |
| 108 | +
|
| 109 | +## 总结 |
| 110 | +
|
| 111 | +总结一下,首先因为声明式代码中修改状态的地方很分散,甚至执行时机都交由框架内部控制,因此手动 batch 肯定是不可行的,基于此得到了更方便,性能全方面优化了的自动 batch。 |
| 112 | +
|
| 113 | +其次是业务层面的优化,当组件在视窗外后,对其所有响应监听都可以停止,所以我们想到定义出冻结的概念,让业务自行决定哪些组件处于冻结态,同时冻结的组件从元信息的所有回调函数,到渲染都会完全停止,可以说,画布即便存在一万个冻结状态的组件,也仅仅只有内存消耗,完全可以做到 0 CPU 消耗。 |
| 114 | +
|
| 115 | +> 讨论地址是:[精读《自动批处理与冻结》· Issue #484 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/484) |
| 116 | +
|
| 117 | +**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** |
| 118 | +
|
| 119 | +> 关注 **前端精读微信公众号** |
| 120 | +
|
| 121 | +<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg"> |
| 122 | +
|
| 123 | +> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) |
0 commit comments