-
Notifications
You must be signed in to change notification settings - Fork 1
状态管理 mana‐observable
我们先来看 mana 里的状态是什么样子的?如果我们按照是否作用于 UI 来区分 mana 里的状态,很快会发现他们是纠缠在一起的,所以我们无法在 mana 里独立出作用于 UI 的状态集合,mana 里的数据状态大部分情况系是按照业务和抽象集中在一起的,他们的很多运行逻辑并不仅仅是为 UI 服务的。 所以在 mana 里我们希望逻辑与 UI 可以分离,不管一个状态是否驱动 UI,他都可以按照一般写法组织在逻辑里,不需要为了驱动 UI 而做出过多改变以至于重写业务逻辑。
- 是否可以使用类似 immer 的数据结构?我们会发现出于功能组合的需求,mana 内的对象引用是稳定的,这与 immer 为什么不能集成到 mobx 是有相似之处的。
- 是否可以使用 mobx 方案?对 mana 来说,mobx 写法要求依然过多,我们必须在写一个模块之前就考虑到他最终是否会驱动 UI,在 mana 里通常不是这样的。 我们最终选择构建一套适用于 OOP 场景的响应式数据结构,采用依赖追踪的方式对 UI 做双向绑定,实现思路上参考了 vue 和 mobx。
最基础的情况是对基本类型的支持
class State {
@prop()
count: number = 0;
}
const state = new State();
通过 prop
装饰器将属性转换为可观察属性,此外对于属性的使用与一般情况完全一致,在 React 中使用如下
export default () => {
const observableState = useObserve(state);
return (
<Button onClick={()=>state.count+=1}>
observableState.count
</Button>
);
};
当我们在组件之外对 count
属性进行操作时,也可以触发 UI 的变化。
除了基本类型以外,我们还需要支持基础数据结构,当前支持 ArrayMapPlainObject, 有了这些数据结构的支持,一般的数据状态管理就可以满足了。
class State {
@prop()
list: string[] = [];
}
const state = new State();
export default () => {
const observableState = useObserve(state);
return <>observableState.list.length</>;
};
为了完成对基础数据结构的支持,我们支持了对基础数据结构的响应式变换,所以独立的数据结构也可以在组件里以响应式的方式使用
export default () => {
const observableArrary = useObserveState([]);
return (
<Button onClick={()=>observableArrary.push('')}>
observableState.count
</Button>
);
};
一般情况下,我们的状态就是由上述状态的嵌套来完成的,不管是基础数据结构本身的嵌套,还是基础数据结构与对象的嵌套,我们都可以完成在嵌套状态下的依赖收集与数据绑定,这里不再给出例子。
我们通过将他们转换为可观察对象,用于 UI 渲染从而完成数据绑定。
除了上述基本情况,我们还要考虑到 UI 的复杂场景,对一些场景给出支持,或者明确用法。
不管是对象还是基础数据结构,都是转换为可观察对象以后再给 UI 使用的,就像引用的稳定一样,我们保证这种变换是稳定的,对象的可观察对象仍然是其本身,其可观察性主要体现在属性上。我们可以使用 observable API 来获取可观察对象。
observableState = observable(state)
observable(state) === observableState; // true
observable(observableState) === observableState; // true
在此基础上,我们可以完成可观察属性的共享。
const basic = observable({
count: 0,
});
class StateFoo {
@prop() basic = basic;
}
class StateBar {
@prop() basic = basic;
}
如果在 UI 中使用了上述链各个类的可观察属性,则无论从哪个改变了 basic 的值,UI 全部都会刷新。
虽然可观察对象的变化是稳定的,但是在使用驱动 UI 时,我们并不是直接使用了可观察对象,因为我们需要准确的管理可观察对象的变化与具体的 UI 上下文之间的关系,这就需要第二层变换,这层变换需要在渲染上下文中完成。useObserve不仅完成了可观察对象变化,也同时完成了与渲染上下文的绑定。 但是这种绑定关系会影响到对象在组件间传递的使用方式:
export default () => {
const observableState = useObserve(state);
return (
<ChildRender state={observableState}>
);
};
const ChildRender = (props) => {
const { state } = props; // count 变化会让父组件刷新
const state = useObserve(props.state); // count 变化会只会让子组件刷新
return (
<div>state.count</div>
);
}
如上面例子里,当组件间进行可观察对象传递时,我们应当再次使用 useObserve来重新绑定渲染上下文,否则对依赖的使用,会触发到原上下文的位置,从而造成刷新范围扩大。
实际上,我们鼓励尽量不适用组件间参数传递,应当尽量拿到状态对象的公共引用。在 mana 中,我们将数据状态管理与依赖注入一起使用,并通过上下文管理做到几乎不使用参数传递的状态管理。在此基础上,由于对状态的消费更多是通过稳定的引用来进行,我们可以放心的对所有组件使用 React.memo来进一步隔离父组件刷新对子组件的影响。
在 mana-observable 中,我们会在渲染过程中收集用户实际使用到可观察对象及其属性,我们认为只有上一次渲染中实际使用到的属性,才有需要在值变更的时候触发下一次更新。实际渲染中从一个可观察对象开始,他可以访问的数据和嵌套结构,可以看做一棵树,只有树上被访问的节点发生变化时,才会触发下一次渲染。
假设存在上图中的对象关系树,带有颜色的节点的属性是在渲染中访问到的属性信息,那么其值的变更会触发重新渲染,而灰色部分由于在渲染中没有用到,则对应的属性值或者其内部的信息变更时,不会触发重新渲染。
从原理上将,除非直接使用基础数据结构,否则重新渲染的触发一定反映在某个属性的变更上,这里我们将基础数据结构内部的变更,也上升到其顶层属性的变更,认为是变更的一种,从而触发 UI 变化。
为了进一步的提升性能和简化写法,我们的可观察数据转换发生在第一次尝试观察对象时
const arr: number[] = [];
class State {
@prop()
arr: number[] = arr;
}
const state = new State();
// before render
state.arr = arr; // true
// after render
state.arr = arr; // false
state.arr = observable(arr); // true
一般而言,我们不用关注这种变化及其发生的时机,因为这种变化并不影响外部对齐访问的 API。
一般的,我们希望 UI 渲染的逻辑能够简单纯粹,最好可以表达为
- 不可变数据结构 + 脏检查 一种思路是将 state 内部的变化,反应为 state 本身的变化,于是不可变数据结构的驱动方式出现了,这种思路下 state 与 UI 都逐层的分解,让每一层里重建 state 的成本不至于过高,数据的变更总是传递到上一层,用整层的变更来驱动 UI,当 State 被分离为一个大的 Store 时,往往要配合不同层次的脏检查;
- 可变数据结构 + 依赖追踪 另一种思路是响应 state 中具体的变化,将变更绑定在使用它的 UI 层次上,为了定位到数据与UI的消费关系,这种定位方法就是依赖追踪,在数据消费的时候关注数据消费时的上下文,并据此形成关联。这样在数据变更时,收集数据变更的事件,将其映射到不同 UI 层次的刷新上。
mana-observable 是使用可变数据结构+依赖追踪的方式来实现响应式的,其原理与 mobx 或者 vue 中的响应式原理有很多相似之处,如果对他们的响应式原理有所了解,应该能够快速的理解 mana-observable 所做的响应式处理。
- 可变数据结构
我们的可变数据结构有两种,对象属性和基础数据结构,其中对象属性应该是绝对主力。在前文简单的数据管理部分,已经对他们进行了介绍。并展示了他们的嵌套关系。
- 对象属性,我们将其转化为 getter/setter 定义。
- 基础数据结构,通过 proxy 访问,逐层的将其变更反应为顶层的变更,或者是其所服务的属性的变更。
- 依赖追踪 依赖追踪目的是建立被追踪的属性或数据结构,与当前 UI 之前的联系,让他们的变更可以触发 UI 的更新,这里具体 UI 的更新动作是从 UI 上下文中获取的。依赖追踪需要在多个层次里深度追踪的,那么就要把上文里获取的更新动作,在多个层次间传递,我们这里使用 proxy 完成依赖追踪,用户在 UI 的上下文里构建用于追踪的第一层 proxy,通过其访问的下一层属性,符合条件的时候也会基于持有的上下文包装成 proxy,从而实现深层追踪。
我们完成响应式的数据流如上图所示,Render 在渲染 UI 时,从上下文中获得可观察对象,并访问其属性,访问过程被 Tracker 代理,并将当前上下文对属性的使用记录在 Notifier 中,UI 和数据模型以相同的数据变更方式操作属性值,属性值的变更,将通知到对应的属性的 Notifier,由其触发 Render 重新渲染,进入下一轮的响应周期。
在原理介绍的部分,我们将渲染抽象为了
我们尝试了在 jupyterlab 依赖的 lumino 组件库上进行数据驱动,theia 依赖的 phosphor 同理。
class ObservableWidget extends Widget {
stateDiv: HTMLDivElement;
selfDiv: HTMLDivElement;
@prop() count: number = 0;
constructor(options?: Widget.IOptions) {
super(options);
this.stateDiv = document.createElement('div');
this.selfDiv = document.createElement('div');
this.node.appendChild(this.stateDiv);
this.node.appendChild(this.selfDiv);
this.update();
}
update(): void {
const self = this.observe(this);
const observableInstance = this.observe(instance);
this.stateDiv.innerHTML = `State count: ${observableInstance.count}`;
this.selfDiv.innerHTML = `ObservableWidget count: ${self.count}`;
}
observe<T = any>(state: T): T {
return Tracker.track(state, this.update.bind(this));
}
}
面向 OOP 的设计,非常适合在复杂场景下配合依赖注入(DI)体系使用,在依赖注入体系中,由于实例的重建成本较高,全局实例的引入进行功能组合更加容易,是非常适合 mana-observable 的场景。我们提供了 useInject 方法来支持在组件中以 Hooks 的方式使用可注入元素,并提供了 ObservableContext 上下文,用于切换依赖注入容器的上下文。
const Render = () => {
const state = useInject(State);
return <>state.count</>;
}
对于依赖注入容器,我们推荐 mana-syringe 方案,可以通过如下上下文接口的支持来兼容其他太依赖注入方案。
type Container = {
get: <T>(identifier: Token<T>) => T;
createChild: () => Container;
};
依赖注入可以为 mana-observable 提供强大的上下文切换支持,配合子容器等概念,可以极大的降低多维数据结构下的应用逻辑复杂度。