响应式

响应式允许 Qwik 跟踪哪些组件订阅了哪些状态。此信息使 Qwik 能够仅在状态更改时使相关组件失效,从而最大程度地减少需要重新渲染的组件数量。

如果没有细粒度的响应式,状态更改将需要从根组件重新渲染,这将迫使整个组件树被急切地下载。

重要的是要注意,Qwik 不像 Angular 那样进行更改检测。相反,Qwik 依赖于信号,当相关状态发生变化时,以手术方式更新组件模板,而无需对整个状态进行脏检查。

代理

响应式要求框架跟踪应用程序状态和组件之间的关系。框架必须至少渲染一次整个应用程序才能构建响应式图。此响应式图的构建最初发生在服务器上,并序列化为 HTML,以便浏览器可以使用该信息,而无需被迫对所有组件进行单次遍历来重建图。(Qwik 不需要进行水合来注册事件或构建响应式图)。

响应式可以通过几种方式实现

  1. 使用显式注册监听器,使用 .subscribe()(例如,RxJS)
  2. 使用编译器进行隐式注册(例如,Svelte)
  3. 使用代理进行隐式注册。

Qwik 使用代理的原因如下

  1. 使用显式注册,例如 .subscribe(),将要求系统序列化所有已订阅的监听器,以避免水合。序列化已订阅的闭包将不可行,因为所有订阅函数都必须是延迟加载的和异步的(太昂贵了)。
  2. 使用编译器隐式创建图将可行,但仅适用于组件。组件内通信仍然需要 .subscribe() 方法,因此会遇到上述问题。

由于上述限制,Qwik 使用代理来跟踪响应式图。

  • 使用 useStore() 创建一个存储代理。
  • 代理会注意到读取并创建可序列化的订阅。
  • 代理会注意到写入并使用订阅信息使相关组件失效。

计数器示例

export const Counter = component$(() => {
  const store = useStore({ count: 0 });
 
  return <button onClick$={() => store.count++}>{store.count}</button>;
});
  1. 服务器执行组件的初始渲染。服务器渲染包括创建由 store 表示的代理。
  2. 初始渲染调用 OnRender 方法,该方法包含对 store 代理的引用。渲染将代理置于“学习”模式。在构建 JSX 期间,代理观察到对 count 属性的读取。因为代理处于“学习”模式,所以它记录了 Counter 内部的文本节点对 store.count 有一个订阅。
  3. 服务器将应用程序的状态序列化为 HTML。这包括 store 以及订阅信息,该信息表明 Counter 内部的文本节点订阅了 store.count
  4. 在浏览器中,用户单击按钮。因为单击事件处理程序闭包了 store,所以 Qwik 恢复了存储代理。代理包含应用程序状态(计数)和订阅,该订阅将 Counter 内部的文本节点与 state.count 关联起来。
  5. 事件处理程序将 store.count 加 1。因为 store 是一个代理,所以它会注意到写入并使用订阅信息创建一个信号操作来更新 Counter 内部的文本节点。
  6. requestAnimationFrame 之后,信号值通过将文本节点的值更新为信号的值来反映在 DOM 中。

取消订阅示例

export const ComplexCounter = component$(() => {
  const store = useStore({ count: 0, visible: true });
 
  return (
    <>
      <button onClick$={() => (store.visible = !store.visible)}>
        {store.visible ? 'hide' : 'show'}
      </button>
      <button onClick$={() => store.count++}>increment</button>
      {store.visible ? <p>{store.count}</p> : null}
    </>
  );
});

此示例是一个更复杂的计数器。

  • 它包含 increment 按钮,该按钮始终将 store.count 加 1。
  • 它包含一个 show/hide 按钮,该按钮决定是否显示计数。
  1. 在初始渲染时,计数是可见的。因此,服务器创建一个订阅,记录 ComplexCounter 需要在 store.countstore.visible 发生变化时重新渲染。
  2. 如果用户单击 hideComplexCounter 将重新渲染。重新渲染将清除所有订阅并记录新的订阅。这次 JSX 不会读取 store.count。因此,只有 store.visible 被添加到订阅列表中。
  3. 用户单击 increment 将更新 store.count,但这样做不会导致组件重新渲染。这是正确的,因为计数器不可见,所以重新渲染将是无操作的。
  4. 如果用户单击 show,组件将重新渲染,这次 JSX 将读取 store.visiblestore.count。订阅列表再次更新。
  5. 现在,单击 increment 更新 store.count。因为计数是可见的,所以 ComplexCounter 订阅了 store.count

请注意,订阅集如何在组件渲染其 JSX 的不同分支时自动更新。代理的优势在于订阅在应用程序执行时自动更新,并且系统始终可以计算出最小的失效组件集。

深层对象

到目前为止,示例展示了 store (useStore()) 是一个包含原始值的简单对象。

export const MyComp = component$(() => {
  const store = useStore({
    person: { first: null, last: null },
    location: null
  });
 
  store.location = {street: 'main st'};
 
  return (
    <section>
      <p>{store.person.last}, {store.person.first}</p>
      <p>{store.location.street}</p>
    </section>
  );
})

在上面的示例中,Qwik 会自动将子对象 personlocation 包装成代理,并在所有深层属性上正确创建订阅。

上面描述的包装行为有一个令人惊讶的副作用。对代理进行读写操作会自动包装对象,这意味着对象的标识会发生变化。这通常不会造成问题,但开发者应该牢记这一点。

export const MyComp = component$(() => {
  const store = useStore({ person: null });
  const person = { first: 'John', last: 'Smith' };
  store.person = person; // store.person auto wraps object into proxy
 
  if (store.person !== person) {
    // The consequence of auto wrapping is that the object identity changes.
    console.log('store auto-wrapped person into a proxy');
  }
});

乱序渲染

Qwik 组件是乱序渲染的。组件可以渲染,而无需强制父组件先渲染,也无需强制子组件作为组件渲染的结果进行渲染。这是 Qwik 的一个重要特性,因为它允许 Qwik 应用程序仅重新渲染由于状态更改而失效的组件,而不是在状态更改时重新渲染整个组件树。

当组件渲染时,它们需要访问它们的 props。父组件创建 props。props 必须是 可序列化的,以便组件能够独立于父组件进行渲染。

使子组件失效

重新渲染组件时,子组件的 props 要么保持不变,要么被更新。子组件只有在 props 发生变化时才会失效。

export const Child = component$((props: { count: number }) => {
  return <span>{props.count}</span>;
});
 
export const MyApp = component$(() => {
  const store = useStore({ a: 0, b: 0, c: 0 });
 
  return (
    <>
      <button onClick$={() => store.a++}>a++</button>
      <button onClick$={() => store.b++}>b++</button>
      <button onClick$={() => store.c++}>c++</button>
      {JSON.stringify(store)}
 
      <Child count={store.a} />
      <Child count={store.b} />
    </>
  );
});

在上面的示例中,有两个 <Child/> 组件。

  • 每次点击按钮时,三个计数器中的一个就会递增。计数器状态的改变会导致 MyApp 组件在每次点击时重新渲染。
  • 如果 store.c 已经递增,那么所有子组件都不会重新渲染。(因此,它们的代码不会被延迟加载)
  • 如果 store.a 已经递增,那么只有 <Child count={store.a}/> 会重新渲染。
  • 如果 store.b 已经递增,那么只有 <Child count={store.b}/> 会重新渲染。

请注意,子组件只有在它们的 props 发生变化时才会重新渲染。这是 Qwik 应用程序的一个重要特性,因为它可以显著减少应用程序在某些状态更改时必须执行的重新渲染量。虽然减少重新渲染可以提高性能,但真正的益处是,如果应用程序的大部分内容不需要重新渲染,它们就不会被下载。

贡献者

感谢所有帮助改进这份文档的贡献者!

  • wmertens
  • bado22
  • RATIU5
  • manucorporat
  • adamdbradley
  • fleish80
  • saikatdas0790
  • dario-piotrowicz
  • the-r3aper7
  • AnthonyPAlicea
  • mhevery
  • wtlin1228
  • mrhoodz
  • thejackshelton
  • aivarsliepa
  • zanettin