OWL教程6 OWL五大组成部分之五响应式系统
参考文档:https://github.com/odoo/owl/blob/master/doc/reference/reactivity.md
粗略的讲,Owl有五个主要部分:
- 虚拟DOM系统(src/blockdom)
- 组件系统(src/component)
- 模板编译器(src/compiler 目录)
- 一个小的运行时让各层组合在一起(src/app)
- 响应式系统(src/reactivity.ts)
🦉 Reactivity 🦉
一 介绍
响应性是javascript框架中的一个重要话题。我们的目标是提供一种简单的方法来操作状态,使用户界面根据状态变化自动更新,并以一种高性能的方式进行操作。
为此,Owl提供了一个基于响应式原语的基于代理的响应性系统。响应式函数接受一个对象作为第一个参数,并接受一个可选的回调函数作为第二个参数,它返回对象的代理。该代理跟踪通过它读取的属性,并在通过同一对象的任何响应版本更改任何一个属性时调用回调函数。它通过在读取子对象时返回响应版本来深入实现这一点。
二 useState
虽然响应性原语非常强大,但它在组件中的使用遵循一个非常标准的模式:当组件所依赖的渲染状态的一部分发生变化时,组件希望被重新渲染。为此,owl提供了一个标准钩子:useState。简单地说,这个钩子只是用提供的对象调用reactive,并将当前组件的render函数作为它的回调。这将导致每当该组件读取的状态对象的任何部分被修改时,它都会重新渲染。
下面是如何使用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 });
}
}
该组件读取状态值,将其订阅到对该键的更改。只要值发生变化,Owl就会更新组件。请注意,state属性没有什么特别之处,您可以随意命名状态变量,如果有必要的话,您可以在同一个组件上拥有多个状态变量。这也允许在自定义钩子中使用useState,这些钩子可能需要特定于该钩子的状态。
fatux: 我特地从owl中扒拉了useState的源码,源码其实很简单,内部调用了reactive, 当state发生变化的时候,调用了render函数重新渲染组件。
/**
* Creates a reactive object that will be observed by the current component.
* Reading data from the returned object (eg during rendering) will cause the
* component to subscribe to that data and be rerendered when it changes.
*
* @param state the state to observe
* @returns a reactive object that will cause the component to re-render on
* relevant changes
* @see reactive
*/
function useState(state) {
const node = getCurrent();
let render = batchedRenderFunctions.get(node);
if (!render) {
render = batched(node.render.bind(node, false));
batchedRenderFunctions.set(node, render);
// manual implementation of onWillDestroy to break cyclic dependency
node.willDestroy.push(clearReactivesForCallback.bind(null, render));
}
return reactive(state, render);
}
1 响应式的 props
从2.0版本开始,默认情况下Owl渲染不再是“深度”的:组件只有在它的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 });
}
}
当单击计数器按钮时,只有计数器重新渲染,因为父程序从未读取状态中的“value”键。当点击“Reset Counter”按钮时,同样的事情发生了:只有Counter组件重新渲染。重要的不是状态在哪里更新,而是更新了状态的哪些部分,以及哪些组件依赖于这些部分。这是由Owl通过自动调用作为props传递给子组件的响应性对象的useState来实现的。
当单击最后一个按钮时,父节点被重新渲染,但是子节点不关心test key:它没有读取它。我们给它的props (this.state)也没有改变,因此,父元素更新了,但子元素没有.
对于大多数日常操作,useState应该涵盖您的所有需求。如果您对更高级的用例和技术细节感到好奇,请继续阅读。
2 调试订阅
Owl提供了一种方法来显示组件订阅了哪些响应对象和键:你可以查看component.owl.subscriptions。请注意,这是在内部__owl__字段上,不应该在任何类型的生产代码中使用,因为这个属性的名称或它的任何属性或方法都可能在任何时候发生变化,即使在Owl的稳定版本中也是如此,并且将来可能只在调试模式下可用。
三 响应性reactive
reactive函数是基本的响应性原语。它的第一个参数是对象或数组,第二个参数是可选的函数。每当更新任何跟踪值时,都会调用该函数。
const obj = reactive({ a: 1 }, () => console.log("changed"));
obj.a = 2; // does not log anything: the 'a' key has not been read yet
console.log(obj.a); // logs 2 and reads the 'a' key => it is now tracked
obj.a = 3; // logs 'changed' because we updated a tracked value
fatux: 所谓的依赖是, 某个地方读取了变量的值, 读取相当于订阅,系统会自动记录这个订阅关系, 当值发生变化的时候,所有的订阅对象都会自动更新.
响应式对象的一个重要属性是它们可以被重新观察:这将创建一个独立的代理来跟踪另一组键:
const obj1 = reactive({ a: 1, b: 2 }, () => console.log("observer 1"));
const obj2 = reactive(obj1, () => console.log("observer 2"));
console.log(obj1.a); // logs 1, and reads the 'a' key => it is now tracked by observer 1
console.log(obj2.b); // logs 2, and 'b' is now tracked by observer 2
obj2.a = 3; // only logs 'observer1', because observer2 does not track a
obj2.b = 3; // only logs 'observer2', because observer1 does not track b
console.log(obj2.a, obj1.b); // logs 3 and 3, while the object is observed independently, it is still a single object
因为useState返回一个普通的响应性对象,所以可以在useState的结果上调用reactive,以便在组件上下文之外观察该对象的变化,也可以在组件外部创建的响应性对象上调用useState。在这些情况下,需要注意那些响应性对象的生命周期,因为保持对这些对象的引用可能会防止组件及其数据的垃圾收集,即使Owl已经销毁了它。
1 订阅是短暂的
对状态更改的订阅是短暂的,每当观察者被通知状态对象发生更改时,它的所有订阅都会被清除,这意味着如果它仍然关心状态对象,它应该再次读取它关心的属性。例如:
const obj = reactive({ a: 1 }, () => console.log("observer called"));
console.log(obj.a); // logs 1, and reads the 'a' key => it is now tracked by the observer
obj.a = 3; // logs 'observer1' and clears the subscriptions of the observer
obj.a = 4; // doesn't log anything, the key is no longer observed
fatux: 订阅是一次性的, 不过可以反复订阅
这似乎违反直觉,但它在组件的上下文中非常有意义:
class DoubleCounter extends Component {
static template = xml`
<t t-esc="state.selected + ': ' + state[state.selected].value"/>
<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";
}
}
在这个组件中,如果我们增加第二个计数器的值,组件将不会重现渲染,这是有意义的,因为重新渲染将没有效果,因为第二个计数器不会显示。如果我们将组件切换为显示第二个计数器,那么我们现在不再希望组件在第一个计数器的值发生变化时进行渲染,而这就是所发生的事情:组件仅在前一次渲染期间或之后读取的状态片段发生变化时进行渲染。如果一个状态片段在最后一次渲染中没有被读取,我们知道它的值不会影响渲染的输出,所以我们可以忽略它。
2 响应式的Map
and Set
响应性系统内置了对标准容器类型Map和Set的特殊支持。它们的行为就像人们所期望的那样:读取一个键为观察者订阅该键,向其中添加或删除一个项,通知在该响应性对象上使用了任何迭代器的观察者,例如.entries()或.keys(),同样也会清除它们
四 逃生舱口 escape hatches
有时,需要绕过响应性系统。在与响应性对象交互时创建代理的成本很高,虽然总体而言,我们通过只重新渲染需要代理的部分接口获得的性能优势超过了成本,但在某些情况下,我们希望能够首先选择不创建代理。这就是markRaw的目的:
1 markRaw
标记一个对象,使其被响应性系统忽略,这意味着如果该对象曾经是响应性对象的一部分,它将原样返回,并且该对象中的任何键都不会被观察到。
const someObject = markRaw({ b: 1 });
const state = useState({
a: 1,
obj: someObject,
});
console.log(state.obj.b); // attempt to subscribe to the "b" key in someObject
state.obj.b = 2; // No rerender will occur here
console.log(someObject === state.obj); // true
这在一些罕见的情况下是有用的。一个这样的例子是,如果你想使用一个可能很大的对象数组来呈现一个列表,但这些对象已知是不可变的:
this.items = useState([
{ label: "some text", value: 42 },
// ... 1000 total objects
]);
in the template:
<t t-foreach="items" t-as="item" t-key="item.label" t-esc="item.label + item.value"/>
这里,在每次渲染时,我们从一个响应对象中读取一千个键,这将导致一千个响应对象被创建。如果我们知道这些对象的内容不能改变,那么这就是浪费工作。如果所有这些对象都被标记为原始对象,我们就可以避免所有这些工作,同时保持依靠反应性来跟踪这些对象的存在和身份的能力:
this.items = useState([
markRaw({ label: "some text", value: 42 }),
// ... 1000 total objects
]);
然而,使用这个函数时要小心:这是反应系统的逃生口,因此,使用它可能会导致微妙的和意想不到的问题!例如:
// This will cause a rerender
this.items.push(markRaw({ label: "another label", value: 1337 }));
// THIS WILL NOT CAUSE A RENDER!
this.items[17].value = 3;
// The UI is now desynced from component's state until the next render caused by something else
简而言之:只有当你的应用程序明显变慢,并且分析显示大量时间花在创建无用的响应性对象上时,才使用markRaw。
2 toRaw
markRaw标记一个对象,使其永远不会被激活,而toRaw接受一个对象并返回底层的非响应对象。它在某些特殊情况下是有用的。特别是,因为响应性系统返回一个代理,返回的对象不等于原始对象:
const obj = {};
const reactiveObj = reactive(obj);
console.log(obj === reactiveObj); // false
console.log(obj === owl.toRaw(reactiveObj)); // true
它在调试期间也很有用,因为在调试器中递归地展开代理可能会令人困惑。
五 高级用法Advanced usage
以下是一些以“非标准”方式利用响应性系统的小片段,以帮助您了解它的功能,以及在哪些地方使用它可以使您的代码更简单。
1.通知管理器
显示通知在web应用程序中是一个很常见的需求,你可能想要显示来自应用程序中任何其他组件的通知,并且通知应该堆叠在一起,而不管哪个组件产生它们,下面是我们如何利用响应性来实现这一点:
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变量是一个响应对象。请注意我们没有给响应性回调:这是因为在这种情况下,我们所关心的只是在addNotification函数中添加或删除通知要经过响应性系统。NotificationContainer组件用useState重新观察这个对象,并在添加或删除通知时进行更新。
2 存储 store
在web应用程序中,集中存储/观察应用程序状态是一个非常普遍的需求。由于响应性系统的工作方式,你可以把任何响应性对象当作一个存储,如果你在它上面调用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();
}
}
应用程序的任何地方:
import { store } from "./store";
// Will cause any instance of the List component in the app to update
store.add("New list item!");
请注意我们是如何将带有方法的对象变成响应性对象的,当使用这些方法来改变存储内容时,它会像预期的那样工作。虽然存储通常是一次性对象,但完全有可能使类实例响应:
class Store {
list = [];
add(item) {
this.list.push(item);
}
}
// Essentially equivalent to the previous code
export const store = reactive(new Store());
这对于单独对类进行单元测试非常有用。
3 同步本地存储
有时,您希望在重载期间持久化某些状态,您可以通过将其存储在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 });
}
}