响应式与视图渲染
如何描述一个视图?
远古时期的方式是画!这里画一个框,那里画一道线。状态改变了,那就清除掉原来的视图,重新画!而这种描述一个视图的方式从 canvas 的 API 中可见一斑。
这么画也忒麻烦了。于是掀起了以声明式的方式描述视图的潮流。由 “这里画个框” 变成了 “这里有个框” 。而如今的 html 以及 dom 就是这种方式的产物。
对于一些静态的视图是这是一种很有效的方式。但是,随着 web 应用的发展,视图内的动态数据越来越多,这种方式也变得捉襟见肘。之后就带来了 MVC,描述视图则可以看作成定义由业务 model 到视图 view 的转化函数,以模板的形式表示。
模板引擎并未完全将 model 和 view 联系起来,这种单向的生成意味着每次更新必须刷新整个视图。不过这也是没有办法的办法,在后台渲染的时代这种渲染工作需要在后台完成。随着浏览器性能、ajax 的发展以及 JavaScript 的完善 ,视图渲染也发生了改变。
JavaScript 既能定义 model ,又有直接操作 dom 改变视图的能力。于是在前端,model 能知道自己能够影响那部分视图,更新时候需要改变哪些视图;而视图又知道需要监听那些 model,销毁时需要注销那些监听事件。这样在前端 model 和 view 才能说是真正联系起来了,前端总算有了自己的 MVVM。 同时这种 model 绑定视图,视图即时响应 model 的变化而变化的方式也逐渐成为前端描述视图的主流方式。
响应式数据的发展
如今谈及 mvvm 就会想到 vue,谈及数据绑定就想到 defineProperty、Proxy。其实 mvvm 这个概念来自于微软,而微软也在第一时间带来其前端的 mvvm 视图库 —— knockoutjs。
在前端上古时代,那时候 mvvm 的代表是 knockoutjs 和 angularjs,然而两者都有其各自的麻烦之处。knockoutjs 为响应式的 viewModel 单独封装了一个数据结构,意味着业务 model 与 viewModel 之间存在一步转换,从而提高了上手门槛。 angularjs 的视图更新依赖一些劫持操作,以至于和其他的原生库配合时难以一起很好的工作。
vue 利用 es5 的新特性 defineProperty 隐式的生成了响应式的数据结构,对于业务开发者而言,绑定视图的 model 是透明的,无疑拉低了入门难度,提高了易用性,同时也不存在 angularjs 的配合问题。于是渐渐成为了今日 mvvm 的主流。
当然这种隐式的方式也有自己的问题,譬如之前提到的性能问题,还有就是破坏了原有数据结构的封装性,当数据源存在私有字段的时候会存在问题。
最基本的响应式数据
由于是演示代码,在这里我希望能够尽量清晰地描述这个数据结构,而不是通过各种 “奇淫技巧” 去提高易用性。所以采取地也是显示地方式定义这个数据。
响应式地数据 Reactive 是针对原生数据地封装,我们希望当内部数据变动时,能够即时更新依赖这个响应数据的其他数据进行更新。
那么首先其内部必然有个字段存储这个原生数据。
export class Reactive<T> {
#val: T
constructor(val: T) {
this.#val = val
}
getVal() {
return this.#val
}
}
对于这个数据地监听者而言,我们可以抽象一个接口。包含一个 emit 方法,用于触发更新;一个 destory 方法作为析构函数,解除对响应式数据地监听。
export interface Watcher<T> {
emit: (r: Reactive<T>) => void
destroy: () => void
}
于是在响应式结构内部,内部的更新方法变更值的同时,也要同时通知所有依赖其的 watcher 进行更新。
export class Reactive<T> {
// ...
#watchers: Watcher<T>[] = []
setVal(newVal: T) {
this.#val = newVal
this.#watchers.forEach(v => v.emit(this))
}
updateVal(fn: (t: T) => T) {
const newVal = fn(this.#val)
this.setVal(newVal)
}
}
当然还有 watcher 的监听与注销方法。
export class Reactive<T> {
// ...
attach(watcher: Watcher<T>) {
this.#watchers = this.#watchers
.filter(v => v != watcher)
.concat([watcher])
}
detach(watcher: Watcher<T>) {
this.#watchers = this.#watchers
.filter(v => v != watcher)
}
}
数据的监听者
其实我们已经声明监听者的接口,接下来就是借助这个接口实现两个常见的监听者作为例子。
-
Effect
Effect 是响应数据变化执行相应函数的数据结构,类似于 vue 的侦听器。
export class Effect<T> implements Watcher<T>{
#target: Reactive<T> | null = null
#fn = (t: Reactive<T>) => { }
constructor(
fn: (t: Reactive<T>) => void,
target: Reactive<T> | null = null
) {
this.#fn = fn
this.attachTo(target)
}
emit(t: Reactive<T>) {
this.#fn(t)
}
attachTo(target: Reactive<T> | null) {
this.#target?.detach(this)
this.#target = target
this.#target?.attach(this)
}
destroy() {
this.#target?.detach(this)
}
}
-
Computed
Computed 是将多个响应式数据转换为一个新的响应数据的数据结构,类似于 vue 的计算属性。
type Head<Tuple extends any[]> = Tuple extends [infer Result, ...any[]] ? Result : never
type Tail<Tuple extends any[]> = ((...args: Tuple) => void) extends ((a: any, ...args: infer T) => void) ? T : never
type ReactArgus<T extends any[]> = {
0: [Reactive<Head<T>>, ...ReactArgus<Tail<T>>],
1: []
}[T extends [] ? 1 : T extends any[] ? 0 : never]
export class Computed<S extends any[], T>
extends Reactive<T>
implements Watcher<unknown>{
#argus: ReactArgus<S>
#fn: (s: S) => T
constructor(fn: (s: S) => T, argus: ReactArgus<S>) {
const val = fn((argus as Reactive<unknown>[]).map(
(v) => v.getVal()
) as S);
super(val);
(argus as Reactive<unknown>[]).forEach(v => {
v.attach(this)
})
this.#argus = argus
this.#fn = fn
}
emit() {
const val = this.#fn((this.#argus as Reactive<unknown>[]).map(
(v) => v.getVal()
) as S)
this.setVal(val)
}
destroy() {
this.#argus.forEach((v) => {
(v as Reactive<unknown>).detach(this)
})
}
}
响应式数据的拓展
不可否认,目前实现的一系列数据结构都相当简单。
简单对于业务开发而言,不是一件好事。这往往意味着功能与开发效率的缺失。
但是对于架构开发而言,这绝不是一件坏事。将架构设计地简单需要精准的功能和接口设计,以实现高可读与易维护。更复杂的功能,则可以通过拓展的方式去实现。
譬如继承并添加几行代码,就能将 Effect 拓展成只触发一次的 EffectOnce
export class EffectOnce<T> extends Effect<T>{
emit(t:Reactive<T>){
super.emit(t)
this.destroy()
}
}
同样通过继承,还能将同步执行的 Computed 变成异步执行的 AsyncComputed,避免短时间频繁的更新。
type UpdateTask = {
taskId: any,
cb: () => void,
done: Done[],
next: UpdateTask
} | null
type Done = {
resolve: (value: unknown) => void,
reject: () => void,
}
export class AsyncComputed<S extends any[], T>
extends Computed<S, T>{
static taskLink: UpdateTask = null
static taskTimeout: any = null
static pushTask = (task: UpdateTask) => {
// 不存在 task
if (!task) return
let cur: UpdateTask = this.taskLink
// 已经有相同的 task
while (cur) {
if (cur.taskId === task.taskId) {
cur.done = cur.done.concat(task.done)
return
}
}
// link 中已经有待更新的 task
if (this.taskLink) {
this.taskLink.next = task
return
}
// link 为空
this.taskLink = task
this.taskTimeout = setTimeout((() => {
while (this.taskLink) {
try {
this.taskLink.cb()
this.taskLink.done.forEach(
v => v.resolve(null)
)
} catch (e) {
this.taskLink.done.forEach(
v => v.reject()
)
} finally {
this.taskLink = this.taskLink.next
}
}
this.taskTimeout = null
}), 0)
}
emit() {
return new Promise((resolve, reject) => {
const done: Done = { resolve, reject }
AsyncComputed.pushTask({
taskId: this,
next: null,
cb: () => { super.emit() },
done: [done]
})
})
}
}
手动定义依赖太累?可以像 Vue 一样,在全局维护一个栈做依赖收集,实现 AutoReactive 和 AutoComputed;
缺少复合结构的支持?可以自己实现一个 ReactiveMap,ReactiveArray ……
越写越多,越整越复杂。说不定能搞出自己的 Rxjs。不过对于我们实现的这个框架而言,没必要依赖这么复杂的结构。
总结
响应式数据结构是实现视图库的基础。虽然我们目前实现的数据结构相当简单,但足以应付接下来的内容。所以下一篇文章,将实现一个响应式更新的视图库。