Skip to content

🦉 响应式 🦉

简介

响应式是 JavaScript 框架中的一个重要话题。其目标是提供一种简单的方式来操作状态,使界面在状态改变时能自动更新,并且具备良好的性能。

为此,Owl 提供了一个基于代理(Proxy)的响应式系统,核心是 reactive 原语。reactive 函数接收一个对象作为第一个参数,可选地接收一个回调函数作为第二个参数,并返回该对象的代理版本。这个代理会追踪通过它读取的属性,并在这些属性被任何版本的响应式对象修改时调用提供的回调函数。它还会递归地对读取到的子对象返回响应式版本,从而实现深层跟踪。

useState

虽然 reactive 原语非常强大,但它在组件中的使用模式非常标准:当组件依赖的状态发生改变时,希望组件重新渲染。为此,Owl 提供了一个标准钩子:useState。简而言之,这个钩子调用 reactive 并传入当前组件的渲染函数作为回调函数。这意味着当组件读取的状态发生改变时,它会重新渲染。

以下是 useState 使用的简单示例:

js
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 传递的响应式对象实现的。以下是示例:

js
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 是响应式系统的基本原语。它接收一个对象或数组作为第一个参数,第二个可选参数是一个函数,在任意被追踪的值发生更新时会调用该函数。

js
const obj = reactive({ a: 1 }, () => console.log("changed"));

obj.a = 2; // 无输出:还未读取 a 键
console.log(obj.a); // 输出 2,并读取 a 键 => 开始追踪
obj.a = 3; // 输出 "changed",因为更新了已追踪的值

响应式对象的一个重要特性是它们可以被重新观察:这将创建一个独立的代理,追踪另一组键:

js
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。但需要注意响应式对象的生命周期:保留对这些对象的引用可能会阻止组件及其数据被垃圾回收。

订阅是临时的

状态变化的订阅是临时的。当一个观察者被通知某个状态对象发生变化时,它的所有订阅会被清除,如果它仍然关心某些属性,就需要再次读取它们。例如:

js
const obj = reactive({ a: 1 }, () => console.log("observer called"));

console.log(obj.a); // 输出 1,开始追踪 a
obj.a = 3; // 输出 "observer called",并清除订阅
obj.a = 4; // 不再输出,因为没有追踪

这可能看起来不直观,但在组件中非常合理:

js
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";
  }
}

如果我们递增第二个计数器的值,组件不会重新渲染,因为当前并未显示它。当切换显示第二个计数器时,我们不再希望第一个计数器的变化触发渲染,这也是实际发生的情况:组件只会在读取过的状态发生变化时重新渲染。

响应式 MapSet

响应式系统对标准容器类型 MapSet 提供了特殊支持。读取某个键会将观察者订阅该键;添加、删除、清除元素会通知使用迭代器(如 .entries().keys())的观察者。

异常出口

有时我们希望绕过响应式系统。响应式对象在每次交互时创建代理对象是有成本的,虽然通常这种成本由只更新必要部分带来的性能提升所抵消,但在某些场景下我们希望能够跳过这种代理的创建。为此可以使用 markRaw

markRaw

将对象标记为不响应式,这意味着如果它被包含在响应式对象中,会原样返回,并且其中的键不会被观察:

js
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

这在某些稀有场景中非常有用。例如你想使用一个很大的对象数组来渲染列表,但这些对象是不可变的:

js
this.items = useState([
  { label: "some text", value: 42 },
  // 共 1000 个对象
]);

模板中:

xml
<t t-foreach="items" t-as="item" t-key="item.label" t-esc="item.label + item.value"/>

每次渲染都会读取 1000 个属性并创建 1000 个响应式对象。如果你知道这些对象不会变,可以标记为 raw:

js
this.items = useState([
  markRaw({ label: "some text", value: 42 }),
  // 共 1000 个对象
]);

但请小心使用:这绕过了响应式系统,可能导致 UI 与状态不同步:

js
// 会触发渲染
this.items.push(markRaw({ label: "another label", value: 1337 }));

// 不会触发渲染!
this.items[17].value = 3;
// UI 和状态现在处于不同步状态,直到其他变更触发渲染

简而言之:只有当应用性能明显变慢,且性能分析表明耗时主要在创建无用响应式对象时,才使用 markRaw

toRaw

markRaw 是防止对象成为响应式对象,而 toRaw 是从响应式对象中获取原始对象。这在某些场景中很有用。因为代理对象与原始对象不相等:

js
const obj = {};
const reactiveObj = reactive(obj);
console.log(obj === reactiveObj); // false
console.log(obj === toRaw(reactiveObj)); // true

也可以用于调试,避免递归展开代理导致混淆。

高级用法

以下是一些使用响应式系统的“非标准”方式的片段,帮助你理解其强大之处,并展示在某些场景下如何使代码更简单。

通知管理器

在应用中展示通知很常见,可能需要从任意组件触发通知,并统一堆叠显示:

js
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 观察它:

js
export const store = reactive({
  list: [],
  add(item) {
    this.list.push(item);
  },
});

export function useStore() {
  return useState(store);
}

组件中使用:

js
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:

js
import { store } from "./store";
// 所有 List 实例都会更新
store.add("New list item!");

还可以将带方法的对象设为响应式对象,甚至可以将类实例设为响应式对象,这对单元测试很有帮助:

js
class Store {
  list = [];
  add(item) {
    this.list.push(item);
  }
}

export const store = reactive(new Store());

本地存储同步

有时我们希望跨页面刷新持久化状态,可以将状态存储在 localStorage 中。可以通过自定义钩子在状态改变时自动更新 localStorage

js
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,否则不会有任何订阅。

本站内容仅供学习与参考