性能在可视化搭建也是极为重要的,如何尽可能减少业务感知,最大程度的提升性能是关键。
其实声明式一定程度上可以说是牺牲了性能换来了可维护性,所以在一个完全声明式的框架下做性能优化还是非常有挑战的。我们采取了两种策略来优化性能,分别是自动批处理与冻结。
首先,框架内任何状态更新都不会立即触发响应,而是统一收集起来后,一次性触发响应,如下面的例子:
const divMeta: ComponentMeta = {
// ...
fetcher: ({ selector, setRuntimeProps, componentId }) => {
const name = selector(({ props }) => props.name)
const email = selector(({ props }) => props.email)
fetch('...', {
data: { name, email }
}).then((res) => {
setRuntimeProps(componentId, old => ({
...old ?? {},
data: res.data
}))
})
}
}
const App = () => {
const { setProps } = useDesigner()
const onClick = useCallback(() => {
setProps('1', props => ({ ...props, name: 'bob' }))
setProps('1', props => ({ ...props, email: '666@qq.com' }))
}, [])
}
上面例子中,fetcher
通过 selector
监听了 props.name
与 props.email
,当连续调用两次 setProps
分别修改 props.name
与 props.email
时,只会合并触发一次 fetcher
而不是两次,这种设计让业务代码减少了重复执行的次数,简化了业务逻辑复杂度。
另一方面,在自动批处理的背后,还有一个框架如何执行 selector
的性能优化点,即框架是否能感知到 fetcher
依赖了 props.name
与 props.email
?如果框架知道,那么当比如 props.appId
或者其他 state.
状态变化时,根本不需要执行 fetcher
内的 selector
判断返回引用是否变化,这能减少巨大的碎片化堆栈时间。
一个非常有效的收集方式是利用 Proxy,将 selector
内用到的数据代理化,利用代理监听哪些函数绑定了哪些变量,并在这些变量变化时按需重新执行。
笔者用一段较为结构化的文字描述这背后的性能优化是如何发生的。
一、组件元信息声明式依赖了某些值
比如下面的代码,在 meta.fetcher
利用 selector
获取了 props.name
与 props.email
的值,并在这些值变化时重新执行 fetcher
。
const divMeta: ComponentMeta = {
// ...
fetcher: ({ selector, setRuntimeProps, componentId }) => {
const name = selector(({ props }) => props.name)
const email = selector(({ props }) => props.email)
}
}
在这背后,其实 selector
内拿到的 props
或者 state
都已经是 Proxy 代理对象,框架内部会记录这些调用关系,比如这个例子中,会记录组件 ID 为 1 的组件,fetcher
绑定了 props.name
与 props.email
。
二、状态变化
当任何地方触发了状态变化,都不会立刻计算,而是在 nextTick
时机触发清算。比如:
setProps('1', props => ({ ...props, name: 'bob' }))
setProps('1', props => ({ ...props, email: '666@qq.com' }))
虽然连续触发了两次 setProps
,但框架内只会在 nextTick
时机总结出发生了一次变化,此时组件 ID 为 1 的组件实例 props.name
与 props.email
发生了变化。
接着,会从内部 selector 依赖关系的缓存中找到,发现只有 fetcher
函数依赖了这两个值,所以就会精准的执行 fetcher
中两个 selector
,执行结果发现相比之前的值引用变化了,最后判定需要重新执行 fetcher
,至此响应式走完了一次流程。
当然在 fetcher
函数内可能再触发 setProps
等函数修改状态,此时会立刻进入判定循环直到所有循环走完。另外假设此次状态变化没有任何 meta 声明式函数依赖了,那么即便画布有上千个组件,每个组件实例绑定了十几个 meta 声明式函数,此时都不会触发任何一个函数的执行,性能不会随着画布组件增加而恶化。
冻结可以把组件的状态凝固,从而不再响应任何事件,也不会重新渲染。
const chart: ComponentMeta = {
/** 默认 false */,
defaultFreeze: true
}
或者使用 setFreeze
修改冻结状态:
const { setFreeze } = useDesigner()
// 设置 id 1 的组件为冻结态
setFreeze('1', true)
当仪表盘内组件数量过多时,业务上会考虑做按需加载,或者按需查询。但因为组件间存在关联关系,可视化搭建框架(我们用 Designer 指代)在初始化依然会执行一些初始函数,比如 init
,同时组件依然会进行一次初始化渲染,虽然业务层会做一些简化处理,比如提前 Return null
, 但组件数量多了之后想要扣性能依然还有优化空间。
所以 Designer 就提供了冻结能力,从根本上解决视窗外组件造成的性能影响。为什么可以根本解决性能影响呢?因为处于冻结态的组件:
defaultFreeze
在组件元信息初始化设置为 false
,那么所有初始化逻辑都不会执行。selector
执行都会直接跳过,完全屏蔽了这个组件的存在,可以让 Designer 内部调度逻辑大大提效。建议统一把所有组件 defaultFreeze
设置为 true,然后找一个地方监听滚动或者视窗的变化,通过 setFreeze
响应式的把视窗内组件解冻,把移除视窗的组件冻结。
特别注意,如果有组件联动,冻结了触发组件会导致联动失效,因此业务最好把那些 即便不在视窗内,也要作用联动 的组件保持解冻状态。
总结一下,首先因为声明式代码中修改状态的地方很分散,甚至执行时机都交由框架内部控制,因此手动 batch 肯定是不可行的,基于此得到了更方便,性能全方面优化了的自动 batch。
其次是业务层面的优化,当组件在视窗外后,对其所有响应监听都可以停止,所以我们想到定义出冻结的概念,让业务自行决定哪些组件处于冻结态,同时冻结的组件从元信息的所有回调函数,到渲染都会完全停止,可以说,画布即便存在一万个冻结状态的组件,也仅仅只有内存消耗,完全可以做到 0 CPU 消耗。
讨论地址是:精读《自动批处理与冻结》· Issue #484 · dt-fe/weekly