性能在可视化搭建也是极为重要的,如何尽可能减少业务感知,最大程度的提升性能是关键。

其实声明式一定程度上可以说是牺牲了性能换来了可维护性,所以在一个完全声明式的框架下做性能优化还是非常有挑战的。我们采取了两种策略来优化性能,分别是自动批处理与冻结。

自动批处理

首先,框架内任何状态更新都不会立即触发响应,而是统一收集起来后,一次性触发响应,如下面的例子:

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.nameprops.email,当连续调用两次 setProps 分别修改 props.nameprops.email 时,只会合并触发一次 fetcher 而不是两次,这种设计让业务代码减少了重复执行的次数,简化了业务逻辑复杂度。

另一方面,在自动批处理的背后,还有一个框架如何执行 selector 的性能优化点,即框架是否能感知到 fetcher 依赖了 props.nameprops.email?如果框架知道,那么当比如 props.appId 或者其他 state. 状态变化时,根本不需要执行 fetcher 内的 selector 判断返回引用是否变化,这能减少巨大的碎片化堆栈时间。

一个非常有效的收集方式是利用 Proxy,将 selector 内用到的数据代理化,利用代理监听哪些函数绑定了哪些变量,并在这些变量变化时按需重新执行。

笔者用一段较为结构化的文字描述这背后的性能优化是如何发生的。

一、组件元信息声明式依赖了某些值

比如下面的代码,在 meta.fetcher 利用 selector 获取了 props.nameprops.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.nameprops.email

二、状态变化

当任何地方触发了状态变化,都不会立刻计算,而是在 nextTick 时机触发清算。比如:

setProps('1', props => ({ ...props, name: 'bob' }))
setProps('1', props => ({ ...props, email: '666@qq.com' }))

虽然连续触发了两次 setProps,但框架内只会在 nextTick 时机总结出发生了一次变化,此时组件 ID 为 1 的组件实例 props.nameprops.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

如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。

关注 前端精读微信公众号

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证

黄子毅
7k 声望9.5k 粉丝