🦉 响应式 🦉
简介
响应式是 JavaScript 框架中的一个重要话题。其目标是提供一种简单的方式来操作状态,使界面在状态改变时能自动更新,并且具备良好的性能。
为此,Owl 提供了一个基于代理(Proxy)的响应式系统,核心是 reactive
原语。reactive
函数接收一个对象作为第一个参数,可选地接收一个回调函数作为第二个参数,并返回该对象的代理版本。这个代理会追踪通过它读取的属性,并在这些属性被任何版本的响应式对象修改时调用提供的回调函数。它还会递归地对读取到的子对象返回响应式版本,从而实现深层跟踪。
useState
虽然 reactive
原语非常强大,但它在组件中的使用模式非常标准:当组件依赖的状态发生改变时,希望组件重新渲染。为此,Owl 提供了一个标准钩子:useState
。简而言之,这个钩子调用 reactive
并传入当前组件的渲染函数作为回调函数。这意味着当组件读取的状态发生改变时,它会重新渲染。
以下是 useState
使用的简单示例:
class Counter extends Component {
static template = xml`
<div t-on-click="() => this.state.value++">
<t t-esc="state.value"/>
</div>`;
setup() {
this.state = useState({ value: 0 });
}
}
该组件在渲染时读取了 state.value
,因此订阅了该键的变化。每当该值改变时,Owl 会更新组件。注意:state
只是一个普通属性名,可以自定义,也可以在同一个组件中定义多个状态变量。这也允许在自定义钩子中使用 useState
,为每个钩子维护自己的状态。
响应式 props
从 Owl 2.0 开始,组件默认不再“深度”渲染:一个组件只有在其 props 发生变化时才会被其父组件重新渲染(通过简单的相等性检查)。如果 props 的深层属性发生变化怎么办?如果该属性是响应式的,Owl 会自动重新渲染需要更新的子组件,仅更新那些组件。这是通过重新观察作为 props 传递的响应式对象实现的。以下是示例:
class Counter extends Component {
static template = xml`
<div t-on-click="() => props.state.value++">
<t t-esc="props.state.value"/>
</div>`;
}
class Parent extends Component {
static template = xml`
<Counter state="this.state"/>
<button t-on-click="() => this.state.value = 0">Reset counter</button>
<button t-on-click="() => this.state.test++" t-esc="this.state.test"/>`;
setup() {
this.state = useState({ value: 0, test: 1 });
}
}
当点击计数器按钮时,只有 Counter
组件会重新渲染,因为 Parent
并没有读取 value
键。点击“Reset Counter”按钮时也是如此:只有 Counter
会重新渲染。关键不在于状态是在哪更新的,而在于状态的哪部分被更新了,以及哪些组件依赖于它。这通过对传递给子组件的响应式对象自动调用 useState
实现。
点击最后一个按钮时,父组件会重新渲染,但子组件并不关心 test
键:它并没有读取它。传递给子组件的 props(即 this.state
)也没有变化,因此父组件更新了,但子组件没有。
在大多数日常开发中,useState
就已足够。如果你对更高级的用法和技术细节感兴趣,请继续阅读。
调试订阅
Owl 提供了一种查看组件订阅了哪些响应式对象和键的方法:可以查看 component.__owl__.subscriptions
。注意这是内部字段,仅供调试使用,不应用于任何生产代码中,因为该属性或其子属性的方法可能随时变化,甚至可能在未来仅在调试模式下可用。
reactive
reactive
是响应式系统的基本原语。它接收一个对象或数组作为第一个参数,第二个可选参数是一个函数,在任意被追踪的值发生更新时会调用该函数。
const obj = reactive({ a: 1 }, () => console.log("changed"));
obj.a = 2; // 无输出:还未读取 a 键
console.log(obj.a); // 输出 2,并读取 a 键 => 开始追踪
obj.a = 3; // 输出 "changed",因为更新了已追踪的值
响应式对象的一个重要特性是它们可以被重新观察:这将创建一个独立的代理,追踪另一组键:
const obj1 = reactive({ a: 1, b: 2 }, () => console.log("observer 1"));
const obj2 = reactive(obj1, () => console.log("observer 2"));
console.log(obj1.a); // 输出 1,a 被 observer 1 追踪
console.log(obj2.b); // 输出 2,b 被 observer 2 追踪
obj2.a = 3; // 只输出 "observer 1"
obj2.b = 3; // 只输出 "observer 2"
console.log(obj2.a, obj1.b); // 输出 3 和 3,两个代理指向的是同一个对象
由于 useState
返回的是普通的响应式对象,因此可以在组件外部对其再次调用 reactive
来追踪变化;反过来,也可以在组件中对外部已创建的响应式对象使用 useState
。但需要注意响应式对象的生命周期:保留对这些对象的引用可能会阻止组件及其数据被垃圾回收。
订阅是临时的
状态变化的订阅是临时的。当一个观察者被通知某个状态对象发生变化时,它的所有订阅会被清除,如果它仍然关心某些属性,就需要再次读取它们。例如:
const obj = reactive({ a: 1 }, () => console.log("observer called"));
console.log(obj.a); // 输出 1,开始追踪 a
obj.a = 3; // 输出 "observer called",并清除订阅
obj.a = 4; // 不再输出,因为没有追踪
这可能看起来不直观,但在组件中非常合理:
class DoubleCounter extends Component {
static template = xml`
<t t-esc="'selected: ' + state.selected + ', value: ' + state[state.selected]"/>
<button t-on-click="() => this.state.count1++">increment count 1</button>
<button t-on-click="() => this.state.count2++">increment count 2</button>
<button t-on-click="changeCounter">Switch counter</button>
`;
setup() {
this.state = useState({ selected: "count1", count1: 0, count2: 0 });
}
changeCounter() {
this.state.selected = this.state.selected === "count1" ? "count2" : "count1";
}
}
如果我们递增第二个计数器的值,组件不会重新渲染,因为当前并未显示它。当切换显示第二个计数器时,我们不再希望第一个计数器的变化触发渲染,这也是实际发生的情况:组件只会在读取过的状态发生变化时重新渲染。
响应式 Map
和 Set
响应式系统对标准容器类型 Map
和 Set
提供了特殊支持。读取某个键会将观察者订阅该键;添加、删除、清除元素会通知使用迭代器(如 .entries()
或 .keys()
)的观察者。
异常出口
有时我们希望绕过响应式系统。响应式对象在每次交互时创建代理对象是有成本的,虽然通常这种成本由只更新必要部分带来的性能提升所抵消,但在某些场景下我们希望能够跳过这种代理的创建。为此可以使用 markRaw
。
markRaw
将对象标记为不响应式,这意味着如果它被包含在响应式对象中,会原样返回,并且其中的键不会被观察:
const someObject = markRaw({ b: 1 });
const state = useState({
a: 1,
obj: someObject,
});
console.log(state.obj.b); // 尝试订阅 b,但无效
state.obj.b = 2; // 不会触发渲染
console.log(someObject === state.obj); // true
这在某些稀有场景中非常有用。例如你想使用一个很大的对象数组来渲染列表,但这些对象是不可变的:
this.items = useState([
{ label: "some text", value: 42 },
// 共 1000 个对象
]);
模板中:
<t t-foreach="items" t-as="item" t-key="item.label" t-esc="item.label + item.value"/>
每次渲染都会读取 1000 个属性并创建 1000 个响应式对象。如果你知道这些对象不会变,可以标记为 raw:
this.items = useState([
markRaw({ label: "some text", value: 42 }),
// 共 1000 个对象
]);
但请小心使用:这绕过了响应式系统,可能导致 UI 与状态不同步:
// 会触发渲染
this.items.push(markRaw({ label: "another label", value: 1337 }));
// 不会触发渲染!
this.items[17].value = 3;
// UI 和状态现在处于不同步状态,直到其他变更触发渲染
简而言之:只有当应用性能明显变慢,且性能分析表明耗时主要在创建无用响应式对象时,才使用 markRaw
。
toRaw
markRaw
是防止对象成为响应式对象,而 toRaw
是从响应式对象中获取原始对象。这在某些场景中很有用。因为代理对象与原始对象不相等:
const obj = {};
const reactiveObj = reactive(obj);
console.log(obj === reactiveObj); // false
console.log(obj === toRaw(reactiveObj)); // true
也可以用于调试,避免递归展开代理导致混淆。
高级用法
以下是一些使用响应式系统的“非标准”方式的片段,帮助你理解其强大之处,并展示在某些场景下如何使代码更简单。
通知管理器
在应用中展示通知很常见,可能需要从任意组件触发通知,并统一堆叠显示:
let notificationId = 1;
const notifications = reactive({});
class NotificationContainer extends Component {
static template = xml`
<t t-foreach="notifications" t-as="notification" t-key="notification_key" t-esc="notification"/>
`;
setup() {
this.notifications = useState(notifications);
}
}
export function addNotification(label) {
const id = notificationId++;
notifications[id] = label;
return () => {
delete notifications[id];
};
}
这里 notifications
是一个响应式对象。我们没有为 reactive
提供回调函数,因为我们只关心添加或删除通知是否经过响应式系统。NotificationContainer
组件使用 useState
观察这个对象,在通知添加或删除时自动更新。
Store
集中管理应用状态也是常见需求。得益于响应式系统的机制,可以将任何响应式对象视为 store,并在组件中用 useState
观察它:
export const store = reactive({
list: [],
add(item) {
this.list.push(item);
},
});
export function useStore() {
return useState(store);
}
组件中使用:
import { useStore } from "./store";
class List extends Component {
static template = xml`
<t t-foreach="store.list" t-as="item" t-key="item" t-esc="item"/>
`;
setup() {
this.store = useStore();
}
}
任意地方修改 store:
import { store } from "./store";
// 所有 List 实例都会更新
store.add("New list item!");
还可以将带方法的对象设为响应式对象,甚至可以将类实例设为响应式对象,这对单元测试很有帮助:
class Store {
list = [];
add(item) {
this.list.push(item);
}
}
export const store = reactive(new Store());
本地存储同步
有时我们希望跨页面刷新持久化状态,可以将状态存储在 localStorage
中。可以通过自定义钩子在状态改变时自动更新 localStorage
:
function useStoredState(key, initialState) {
const state = JSON.parse(localStorage.getItem(key)) || initialState;
const store = (obj) => localStorage.setItem(key, JSON.stringify(obj));
const reactiveState = reactive(state, () => store(reactiveState));
store(reactiveState);
return useState(state);
}
class MyComponent extends Component {
setup() {
this.state = useStoredState("MyComponent.state", { value: 1 });
}
}
注意两次调用 store
都传入了 reactiveState
,而不是 state
,这是因为我们需要通过响应式对象读取属性,以便正确订阅变化。同时,我们在开始时手动调用一次 store
,否则不会有任何订阅。