Create a lightweight responsive state management library from scratch [observable-lite]

Create a lightweight responsive state management library from scratch [observable-lite]

Preface/Introduction

  • The last article created a state management library based on rxjs, and the article link is here to build your own state management library based on Rxjs . However, during the development process, it was found that the observable-store library has performance problems when processing large json data. When collecting historical state or setState, there are a large number of deepcopy operations. The performance difference in small objects will not be so obvious, but large json processing , There will be an extremely unfriendly display experience. In fact, if you do not rely on rxjs, use Proxy, and then cooperate with the pubsub mode. Can make the code more concise. Often in reality, projects that rely on some events or asynchronous events are not so demanding. It is not necessary to quote rxjs at all. So it is a solution where the benevolent sees the benevolence, the wise sees the wisdom, rxjs in everything, I don't really advocate.

  • So in my spare time, I started from scratch and developed a responsive state management library observable-lite with very small amount of code, which can do in-depth observation and control of large json, and has very good performance. The function is not complicated. Here I share the technical details of the development of this library. So that the friends who want to make some responsive applications on their own in the future will be inspired and teach others how to fish. Of course, friends who are interested, we can develop together.

Simply draw a picture structure

Goals and ideas

  1. 1. maintain the structure of a single data source unchanged.
  2. Do not use deepcopy, and control the objects responsively, regardless of the depth of the hierarchy, to ensure that each leaf object is observed.
  3. Maintain lightweight use, react framework, provide use function, easy to use
  4. Provide decorator function to facilitate obervable processing of class attributes

The general idea is to proceed with proxy first. The reason why proxy is chosen is that proxy is much more streamlined than the defineProperty solution for the processing of arrays. Then there is pubsub, and finally, the corresponding hooks and decorator functions are developed for the convenience of users. So let's first take a look at how the Proxy object is implemented?

How does Proxy ensure that each child object is monitored?

I will not introduce what Proxy is. If you encounter problems here, you can go to baidu first. Come back and learn more. we know

new Proxy(obj,handler)
This can only hijack the Obj object of the first layer. For example, we have such an object

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 } } Copy code

If you use proxy, you can only get the first layer

a,b,f,h
The objects of these keys. So how do we deal with the objects inside? Of course, some students will say, use BFS (breadth first traversal). Of course, we initially saw Vue two-way binding using defineProperty to do the same. At first I wrote that way too. But in fact, I later discovered that there is a better way to do it, with less overhead. If using BFS, we still need to consider when I don't need observable, so there must be a switch, because when doing BFS, you need to constantly modify the object, so that set and get will have repeated overhead. In fact, this problem is very simple, we only need to handle the judgment of the hijacking of the object in the get method of the Proxy handler. In fact, the proxy object automatically triggers the get method when accessing and modifying sub-attributes. Then when get, the child object can be obtained completely. So we just need to make Obervable here.

If you don't follow the code, you are playing rogue. I can't do such a low thing. Please see the code below.

const proxyTraps = { get ( target, name ) { //Determine whether it has been proxied if (name === "__isProxy" ) { return true } let targetProp = target[name]; //asObservable judges whether the object is obervable, if not 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 new the Proxy (obj, proxyTraps) copy the code

How to get the Path value of an object in the Observable process

  1. Why do you want to get the path of the object, and what is the effect?
  • There are two main functions: the first is to get the path to perform fine monitoring of the object, which is used when modifying a very deep object, such as the obj in the above code.
    obj.b[0].c=5
    For this operation, in the set method of the proxy, we can only get the key as c. Without path, it will be difficult to handle. We need such a path structure
    ['b',0,'c']
  • The second function is that in pubsub, we also need this path, so that the message can be accurately pushed to each watcher, so an observable map is maintained in the code. For convenience, the key value is path.
  1. So how to obtain Path?
  • In fact, it is very simple. Get it in the get function of Proxy. Every time the get function is triggered, you can get a relationship, and a relationship comes from the previous object. We need to continue this relationship, please see the code
const proxyTraps = { get ( target, name ) { //Determine whether it has been proxied if (name === "__isProxy" ) { return true } let targetProp = target[name]; //determine whether it is an object or an array if (isPassType(targetProp) && name != $felix) { let tags: ExtraTags //determine the current path, if there is one, use the current one, if not, then Inherit the upper path if (!targetProp[$felix]) { tags = {...target[$felix], path : [...target[$felix].path]} } else { tags = {...targetProp[$felix]} } //Get the last key of path. let lastKey = tags.path.length> 0 ? Tags.path[tags.path.length- 1 ]: null //Avoid repeated creation of path if (lastKey?.toString() != = name) { if ( Array .isArray(target)) { tags.path.push( parseInt (name as string )) } else { tags.path.push(name) } } //defineProperty, storage path extend_(targetProp, $felix, tags) //asObservable judge whether the object is obervable if not observable return asObservable(targetProp) } return targetProp }, set ( target, name, value, receiver ) { target[name] = value return true }, } Copy code
  1. How is Path stored and where is it stored?
  • Use Symbol, so we define such a piece of code in the code
    const $felix = Symbol("tags")
    $felix
    It is the Symbol type defined by us. The advantage of this is to prevent users from accidentally using this attribute. The extend_ method executes a Reflect.defineProperty, which means that this property definition is not enumerable. This will not be iterated. And bound together with the object. So every attribute of the object will have a path, hidden in
    $felix
    In this attribute.

PubSub mode (publish and subscribe)

The idea of writing publish and subscribe is the same as Vue, define observable dependency manager, define change queue, which stores the new value and old value of each hijacked object's change. Define the notify method to trigger the observable monitoring method. The pseudo code is as follows

class ObsBase { //Dependency collector public observables: Map < string , ObsTarget[]> = new Map (); //change queue public changes: ChangeContext[] = [] } const proxyTraps = { get ( target, name ) { //... }, set ( target, name, value, receiver ) { //...Judging the historical state//...Get path //...Construct a change object //.. Add change to the queue addchange(change) notify(path) } } Copy code

There are some difficulties mentioned above, you need to read the code or digest it yourself. If you have any questions, you can leave a message in the comment area, or add me to WeChat. Let me talk about how the key is applied in react. Perhaps for beginners, the concern is how to use it. Then I won't talk nonsense.

Use useObservableStore

Let's take a look at how we use react hooks. Observable-lite provides a custom hook function, useObservableStore. Let's use an example to explain it.

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 () { //Define a store of value type, usage is similar to useState const [count, setCount] = useObservableStore( 1 ); //Define a store of object type const [obj, _,key] = useObservableStore({ a : 1 , b : 2 , c : [{ d : 3 }, [{ f : 5 }]] }) return ( < div > < span > When using useObservableStore, ordinary value object, the current number: {count} </span > < button onClick = {() => { //Use the set function of the value type to change the state setCount(count + 1); }} > Click to add </button > < div > < div > Using useObservableStore, if you initialize an object, you can operate directly without using the set function </div > < div > Current object: {JSON.stringify(obj)} </div > < button onClick = {() => { //You can directly manipulate and modify the object obj.a += 1 obj.b += 1 obj.c[0].d += 1 obj.c[1][0].f += 1 }}> Click the object to accumulate </button > </div > </div > ); } Copy code

The return value of useObservableStore is an array containing 3 objects

[state,setState,key]
The first state is an observable object, if it is an object, it can be modified directly. The second is the setState method, you can use this to modify the object, you can also use the direct assignment operation, only for the object. Value type, please use setState, the third parameter key, if you manually monitor an object, you need to rely on this key, otherwise, this key will not be used. example address

Use observable decorator and connect (similar to mobx usage)

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" ; //Define your own observable class //This kind of advantage, you can put ajax or data-related logic here for processing, which is equivalent to a model layer class MyExpObs < T > extends ObsStore < T > { //Use observable decorator to monitor Object. This will return a proxy object @observable obj: any = (); @observable testVal: number = 1 ; } export default connect( function ObsClassExp () { //Get the current store instance const store = MyExpObs.getInstance() as MyExpObs< any >; return ( < div > < span > testVal:{store.testVal} </span > < button onClick = {() => { store.testVal += 1; }} > Click to modify the value </button > < p > </p > < span > obj:{JSON.stringify(store.obj)} </span > < button onClick = {() => { store.obj = {first: "hui", last: "tang" }; }} > Modify the object </button > < p > </p > {store.obj.last && ( < button onClick = {() => { store.obj.last = "update tang"; store.obj.newLast = "new tang"; }} > Modify object properties </button > )} </div > ); }, MyExpObs); Copy code

The connect function is a high-level react component. Its second parameter is to pass the store class you are using. It will monitor all observable variables in the current store. If there are changes, it will re-rending. If the second parameter is not passed, all obserable variables will be monitored, including observable variables in other store classes. example address

Manually monitor objects or sub-objects according to the object Path

Revamping the first hooks example, let s take a look at how to monitor an attribute by ourselves

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 () { //Define an object type store const [obj, _,key] = useObservableStore({ a : 1 , b : 2 , c : [{ d : 3 }, [{ f : 5 }]] }) const [f,setF] =useState( null ) useEffect( ()=> { //Start manually subscribing to an attribute change //Define an object's path, an array of key names, for example, to monitor the change of this attribute of obj.c[1][0].f, we can Define as follows const path = [ 'c' , 1 , 0 , 'f' ] // Disuse the store instance, call the subscribe method to subscribe const mark = ObsStore.getInstance().subscribe(path, ( change:ChangeContext )=> { console .log(change) //Callback to get the new value setF(change.newValue) },key) return function () { //Unsubscribe ObsStore.getInstance().unsubscribe(path,mark) } },[]) return ( < div > < div > < div > use useObservableStore </div > < div > Current object: {JSON.stringify(obj)} </div > < button onClick = {() => { obj.a += 1 obj.b += 1 obj.c[0].d += 1 obj.c[1][0].f += 1 }}> Click the object to accumulate </button > </div > < div > < p > Manually observe a specific variable </p > < span > obj.c[1][0].f:{f} </span > </div > </div > ); } Copy code

The third parameter of subscribe needs to pass a key. This is for hooks. If it is a variable that uses the @observable decorator, you do not need to pass the key. For details, you can see the example example address

Follow-up

It is currently only a beta version, so it is not recommended for everyone to use it in a production environment. In the follow-up, I will add some jtest code, improve functions, and send npm packages. Of course, the examples used in vue can also be added later. During this time, everyone is welcome to comment in the comment area, and we can improve together. Finally, thank you all for your reading and support.