响应性
响应性允许 Qwik 跟踪哪些组件订阅了哪些状态。这个信息使得 Qwik 在状态改变时只使相关的组件失效,从而最小化需要重新渲染的组件数量。
没有细粒度的响应性,状态改变将需要从根组件重新渲染,这将强制整个组件树被急切地下载。
需要注意的是,Qwik 不像 Angular 那样进行变更检测。相反,Qwik 依赖信号来在相关状态改变时对组件模板进行精确更新,而无需对整个状态进行脏检查。
代理
响应性要求框架跟踪应用程序状态和组件之间的关系。框架必须至少渲染整个应用程序一次以构建响应性图。这个响应性图的构建最初发生在服务器上,并被序列化为 HTML,以便浏览器可以在不被迫通过所有组件进行单次遍历来重建图形的情况下使用这些信息(Qwik 不需要进行水合以注册事件或构建响应性图)。
响应性可以通过几种方式实现:
- 使用显式注册监听器的方式,例如
.subscribe()
(例如,RxJS)。 - 使用编译器隐式注册的方式,例如 Svelte。
- 使用代理进行隐式注册。
Qwik 使用代理的原因有几点:
- 使用显式注册,例如
.subscribe()
,将要求系统序列化所有订阅的监听器以避免水合。将订阅的闭包序列化是不可能的,因为所有订阅函数都必须是惰性加载和异步的(代价太高)。 - 使用编译器隐式创建图形可以工作,但仅适用于组件。组件内部的通信仍然需要
.subscribe()
方法,因此会遇到上述描述的问题。
由于上述约束,Qwik 使用代理来跟踪响应性图。
- 使用
useStore()
创建一个存储代理。 - 代理会注意到读取操作并创建可序列化的订阅。
- 代理会注意到写入操作并使用订阅信息来使相关组件失效。
计数器示例
export const Counter = component$(() => {
const store = useStore({ count: 0 });
return <button onClick$={() => store.count++}>{store.count}</button>;
});
- 服务器执行组件的初始渲染。服务器渲染包括创建代理
store
。 - 初始渲染调用 OnRender 方法,该方法引用了
store
代理。渲染将代理置于“学习”模式。在 JSX 构建过程中,代理观察到对count
属性的读取。由于代理处于“学习”模式,它记录了Counter
内部的文本节点对store.count
的订阅。 - 服务器将应用程序的状态序列化为 HTML。这包括
store
以及订阅信息,说明Counter
内部的文本节点订阅了store.count
。 - 在浏览器中,用户点击按钮。由于点击事件处理程序闭包了
store
,Qwik 恢复了存储代理。代理包含应用程序状态(计数)和订阅,将Counter
内部的文本节点与state.count
关联起来。 - 事件处理程序递增
store.count
。由于store
是代理,它会注意到写入操作,并使用订阅信息创建一个信号操作,以更新Counter
内部的文本节点。 - 在
requestAnimationFrame
之后,信号值通过将文本节点的值更新为信号值来在 DOM 中反映出来。
取消订阅示例
export const ComplexCounter = component$(() => {
const store = useStore({ count: 0, visible: true });
return (
<>
<button onClick$={() => (store.visible = !store.visible)}>
{store.visible ? '隐藏' : '显示'}
</button>
<button onClick$={() => store.count++}>增加</button>
{store.visible ? <p>{store.count}</p> : null}
</>
);
});
这个示例是一个更复杂的计数器。
- 它包含一个总是递增
store.count
的增加
按钮。 - 它包含一个
显示
/隐藏
按钮,用于确定是否显示计数。
- 在初始渲染时,计数是可见的。因此,服务器创建了一个订阅,记录了
ComplexCounter
需要在store.count
或store.visible
改变时重新渲染。 - 如果用户点击了
隐藏
,ComplexCounter
将重新渲染。重新渲染会清除所有订阅并记录新的订阅。这次 JSX 不会读取store.count
,因此只有store.visible
被添加到订阅列表中。 - 用户点击
增加
不会导致组件重新渲染。这是正确的,因为计数器不可见,所以重新渲染将是一个空操作。 - 如果用户点击
显示
,组件将重新渲染,这次 JSX 将读取store.visible
和store.count
。订阅列表再次更新。 - 现在,点击
增加
会更新store.count
。由于计数器是可见的,ComplexCounter
订阅了store.count
。
请注意,随着组件渲染不同分支的 JSX,订阅集合会自动更新。代理的优势在于订阅会随着应用程序的执行而自动更新,系统始终可以计算出最小的一组失效组件。
深层对象
到目前为止,示例中的存储(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 将自动将子对象 person
和 location
包装成代理,并在所有深层属性上正确创建订阅。
上述描述的包装行为有一个令人惊讶的副作用。从代理中读取和写入会自动包装对象,这意味着对象的标识发生了变化。这通常不是问题,但开发人员应该记住这一点。
export const MyComp = component$(() => {
const store = useStore({ person: null });
const person = { first: 'John', last: 'Smith' };
store.person = person; // store.person 自动将对象包装成代理
if (store.person !== person) {
// 自动包装的后果是对象标识发生了变化。
console.log('store 自动将 person 包装成了代理');
}
});
无序渲染
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 应用程序的一个重要特性,因为它极大地限制了应用程序在某些状态改变时必须进行的重新渲染量。尽管减少重新渲染具有性能优势,但真正的好处是,如果不需要重新渲染,大部分应用程序不会被下载。