从零开始打造一款轻量级响应式状态管理库[observable-lite]

1,000 阅读9分钟

前言/介绍

  • 上一篇打造了一款基于rxjs状态管理库,文章链接在此基于Rxjs打造自己的状态管理库 。 但在开发过程中,发现observable-store这个库在处理大json数据,有性能问题,收集历史state,或setState时,有大量的deepcopy操作,在小对象性能差异不会那么明显,但是大json处理,会有极其不友好的显示体验。其实如果不依赖rxjs,采用Proxy,再配合pubsub模式。可以让代码更简洁。往往现实中,对一些事件或异步事件依赖要求,不那么高的项目。完全可以不引用rxjs。所以是一个仁者见仁,智者见智的方案,rxjs in everything , 我也不太提倡。

  • 所以闲暇之余,我从零开始,开发了一款响应式的状态管理库observable-lite,代码量非常少,可以对大json,做深层的观察控制,并且有很好性能。功能不复杂。在此我分享一下,这款库其中开发的技术细节。以便于小伙伴们以后想自己做一些响应式的应用,会有所启发,授人以渔。当然有兴趣小伙伴们,我们可以一起开发。

79f0f736afc37931080af31ef5632e4342a911d7.jpeg

简单画一画结构

截屏2021-07-05下午2.53.25.png

目标和思想

  1. 首先维持单一数据源的结构不变。
  2. 不使用deepcopy,对对象进行响应式控制,不管层级有深,确保每个叶子对象都被observe。
  3. 维持轻量级使用,react框架,提供use函数,方便使用
  4. 提供decorator函数,方便对class的属性做obervable处理

总的思想是,先进行proxy,之所以选用proxy,是因为对数组的处理过程,proxy要比defineProperty方案精简的多。然后就是pubsub,再最后就是开发对应的hooks函数和decorator函数,供用户方便使用。那么下面首先看一下Proxy对象如何实现?

Proxy如何确保每个子对象被监听?

Proxy是什么东西,我就不介绍了,大家如果在这里就碰到问题,可以先自行去baidu。再回来接着了解。我们知道new Proxy(obj,handler) 这个只能对第一层的Obj对象进行劫持。 比如我们有这样一个对象

   const obj = {
            a: 1,
            b: [{ c: 2 }, { d: 3 }],
            f: { g: 3, r: 7 },
            h: { q: [{ j: 6 }, { y: 8 }, { cc: { dd: "hao" } }], h: 9 }
        }

如果使用proxy只能获取到第一层a,b,f,h这几个key的对象。 那么里面的对象我们怎么处理呢? 当然有同学就会说,采用BFS(广度优先遍历)。当然我们最初看Vue双向绑定使用defineProperty也是这么做的。起初我也是这样写的。但其实后来我发现有一个更好的方式去做,开销更小。如果采用BFS,我们还需要考虑什么时候我不需要observable,所以要有一个开关,因为在做BFS的时候,需要不断修改对象,这样使得set和get会有重复开销。其实这个问题很简单,我们只需要在Proxy的handler里面的get方法处理对对象的劫持的判断。 其实proxy对象,在访问子属性和修改子属性的时候会自动触发get方法。那么在get的时候,完全可以获得到子对象。所以我们只需要在这里做Obervable即可。

不上代码干说,就是耍流氓,这么low的事,我干不出来,下面请看代码。

    const proxyTraps = {
            get(target, name) {
                //判断是否已经被代理过
                if (name === "__isProxy") {
                    return true
                }
                let targetProp = target[name];
        
                 //asObservable判断对象是否被obervable如果没有就observable
                return asObservable(targetProp)
            },
            set(target, name, value, receiver) {
                target[name] = value
                return true
            },
     
  }   
  const obj = {
            a: 1,
            b: [{ c: 2 }, { d: 3 }],
            f: { g: 3, r: 7 },
            h: { q: [{ j: 6 }, { y: 8 }, { cc: { dd: "hao" } }], h: 9 }
  }
  const testTarget =  new Proxy(obj, proxyTraps)

Observable过程中怎么获取对象的Path值

  1. 为什么要获取对象的Path,到底有什么作用?
  • 主要有两个作用:第一是获取到path可以对对象做精细监听,用在修改一个很深的对象的时候,比如上面代码中的那个obj。 obj.b[0].c=5 这个操作,在proxy中set方法中,我们只能得到key为c。 没有path的话,会很难处理。 我们需要这样一个path结构['b',0,'c']
  • 第二个作用是,在pubsub中,我们也需要这个path,这样可以将消息精准推送到每个watcher总,所以代码中维护了一个observable的map。为了方便,key值就是path。
  1. 那么Path怎么获取呢?
  • 其实很简单,在Proxy的get函数中获取,每次触发get函数时,能得到一个关系,一个关系来自上一个对象。我们需要将这个关系延续下来,请看代码
    const proxyTraps = {
            get(target, name) {
                //判断是否已经被代理过
                if (name === "__isProxy") {
                    return true
                }
                let targetProp = target[name];
                //判断是否为对象或数组
                if (isPassType(targetProp) && name != $felix) {
                    let tags: ExtraTags
                    //判断当前的path,如果有就使用当前的,没有就继承上层的path
                    if (!targetProp[$felix]) {
                        tags = { ...target[$felix], path: [...target[$felix].path] }
                    } else {
                        tags = { ...targetProp[$felix] }
                    }
                    //获取path最后一个key.
                    let lastKey = tags.path.length > 0 ? tags.path[tags.path.length - 1] : null
                    //避免path的重复创建
                    if (lastKey?.toString() !== name) {
                        if (Array.isArray(target)) {
                            tags.path.push(parseInt(name as string))
                        } else {
                            tags.path.push(name)
                        }
                    }
                    //defineProperty,存储path
                    extend_(targetProp, $felix, tags)
                    //asObservable判断对象是否被obervable如果没有就observable
                    return asObservable(targetProp)
                }
                return targetProp
       
            },
            set(target, name, value, receiver) {
                target[name] = value
                return true
            },
     
  } 
  1. Path怎么存储,存储在哪里?
  • 利用Symbol,所以在代码中我们定义这样一段代码const $felix = Symbol("tags") $felix是我们定义的Symbol类型,这样的好处是避免用户不小心,误使用到这个属性。extend_方法是执行了一个Reflect.defineProperty,讲此属性定义不可枚举。这样就不会被迭代。并且随对象一起绑定。所以对象的每个属性都会有一个path,藏匿在$felix这个属性中。

PubSub模式(发布订阅)

写发布订阅的思路跟Vue同源, 定义observable的依赖管理器,定义change队列,里面存储每个被劫持对象的改变的新值和旧值。定义notify方法,触发observable监听方法。 伪代码如下

class ObsBase{
    //依赖收集器
    public observables: Map<string, ObsTarget[]> = new Map();
    // change队列
    public changes: ChangeContext[] = []
}
const proxyTraps = {
    get(target, name){
        //...
    },
    set(target, name, value, receiver){
        //...判断历史state
        //...获取path
        //...构造change对象
        //..添加change到队列
        addchange(change)
        notify(path)
    }
    
}

上面讲了一些难点,需要大家自行看代码或消化,如有问题可以评论区留言,也可以加我微信。下面讲一下,关键如何在react中应用。可能对初学者,关注的就是怎么使用。那么我就不废话了。

使用useObservableStore

先看一下我们在react的hooks中怎么用,observable-lite提供一个useObservableStore这个自定义的hook函数。下面我们用一个例子来讲解一下吧。

import React, { useEffect, useState } from 'react'
import { ObsStore } from './observable-lite/obs-store'
import { useObservableStore } from "./observable-lite/hooks";
import { ChangeContext } from './observable-lite/utils/interfaces';

export default function TestUseObs() {
    //定义一个值类型的store,用法跟useState相似
    const [count, setCount] = useObservableStore(1);
    //定义一个对象类型的store
    const [obj, _,key] = useObservableStore({
        a: 1,
        b: 2,
        c: [{ d: 3 }, [{ f: 5 }]]
    })
    return (
        <div>
            <span>使用useObservableStore,普通值对象的情况,当前数:{count}</span>
            <button
                onClick={() => {
                    //值类型的使用set函数,去改变state
                    setCount(count + 1);
                }}
            >
                点击累加
      </button>
            <div>
                <div>使用useObservableStore,如果初始化的是一个对象,可以直接操作,不用使用set函数</div>
                <div>当前对象:{JSON.stringify(obj)}</div>
                <button onClick={() => {
                    //可以直接对对象进行操作和修改
                    obj.a += 1
                    obj.b += 1
                    obj.c[0].d += 1
                    obj.c[1][0].f += 1
                }}>
                    点击对象累加
               </button>
            </div>
        </div>
    );
}


useObservableStore 返回值是一个数组,包含3个对象 [state,setState,key] 第一个state是一个observable对象,如果是对象可以直接修改。第二个是setState方法,可以用此修改对象,也可以用直接赋值操作,只针对对象。值类型,请用setState, 第三个参数key,如果手动监听某个对象,需要依赖此key,否则,这个key也用不到。example地址

使用observable装饰器和connect(类似mobx用法)

import React, { useEffect, useState } from "react";
import { ObsStore } from "./observable-lite/obs-store";
import { observable } from "./observable-lite/observable";
import { connect } from "./observable-lite/hooks";
import { ChangeContext } from "./observable-lite/utils/interfaces";

//定义自己的observable类
//这样的好处,可以将ajax或与数据相关的逻辑放在这里处理,相当一个model层
class MyExpObs<T> extends ObsStore<T> {
  //使用observable装饰器,监听对象。这个会返回一个proxy对象
  @observable obj: any = {}; 
  @observable testVal: number = 1;
}

export default connect(function ObsClassExp() {
  //获取当前store实例
  const store = MyExpObs.getInstance() as MyExpObs<any>;
  return (
    <div>
      <span>testVal:{store.testVal}</span>
      <button
        onClick={() => {
          store.testVal += 1;
        }}
      >
        点击修改值
      </button>
      <p></p>
      <span>obj:{JSON.stringify(store.obj)}</span>
      <button
        onClick={() => {
          store.obj = { first: "hui", last: "tang" };
        }}
      >
        修改对象
      </button>
      <p></p>
      {store.obj.last && (
        <button
          onClick={() => {
            store.obj.last = "update tang";
            store.obj.newLast = "new tang";
          }}
        >
          修改对象属性
        </button>
      )}
    </div>
  );
}, MyExpObs);

connect函数是一个react高阶组件,它第二参数是传递你所使用的store类,它会监听当前store里所有的observable变量,如果有改变,就会重新rending。如果不传递第二个参数,会监听所有的obserable变量,包含其他的store类中的observable变量。 example地址

根据对象Path手动监听对象或子对象

改造第一个hooks的例子,我们来看看如何自己监听某个属性

import React, { useEffect, useState } from 'react'
import { ObsStore } from './observable-lite/obs-store'
import { useObservableStore } from "./observable-lite/hooks";
import { ChangeContext } from './observable-lite/utils/interfaces';

export default function TestUseObs() {
    //定义一个对象类型的store
    const [obj, _,key] = useObservableStore({
        a: 1,
        b: 2,
        c: [{ d: 3 }, [{ f: 5 }]]
    })
    const [f,setF] =useState(null)
    useEffect(()=>{
       //开始手动订阅一个属性的变化
       //定义一个对象的path,key名的数组,比如要监听obj.c[1][0].f这个属性的变化,我们可以如下定义 
       const path = ['c',1,0,'f']
       //掉用store实例,调用subscribe方法订阅
       const mark= ObsStore.getInstance().subscribe(path,(change:ChangeContext)=>{
             console.log(change)
             //回调获取新值
             setF(change.newValue)
        },key)
        return function () {
            //取消订阅
            ObsStore.getInstance().unsubscribe(path,mark)
        }
    },[])

    return (
        <div>
            <div>
                <div>使用useObservableStore</div>
                <div>当前对象:{JSON.stringify(obj)}</div>
                <button onClick={() => {
                    obj.a += 1
                    obj.b += 1
                    obj.c[0].d += 1
                    obj.c[1][0].f += 1
                }}>
                    点击对象累加
                </button>
            </div>
            <div>
                 <p>手动观察某一个具体变量</p>
                 <span>obj.c[1][0].f:{f}</span>
            </div>
        </div>
    );
}

subscribe 第三个参数需要传递一个key,这是针对hooks的情况,如果是使用@observable装饰器的变量,则不需要传递key。 具体可以看例子 example地址

后续

目前只是beta版本,所以并不建议大家在生产环境使用。后续我将会补一些jtest代码,完善功能,发npm包。当然后续也可以补上在vue中使用的例子。这段时间,欢迎大家也可以在评论区提意见,我们可以一起改进。最后感谢大家的阅读和支持。