我所认识的前端数据流

1,881 阅读13分钟

历史总是在新思想的火花碰撞中演进,从React的横空出世,前端开始慢慢从jQuery的蛮荒时代过渡到三大阵营“群雄逐鹿”,几大框架都是在解决数据层和视图层之间的驱动关系。当数据发生变化了之后,由框架自身来控制对视图层的渲染操作,而问题的关键恰好就在于依赖收集,如何才能知道数据发生变化了呢?所以Vue使用了defineProperty直接劫持数据的原始操作、angular使用脏检查监听所有可能发生的数据变更,React约定只能使用自带的setStateAPI来触发数据的变更。而也就是处于这个方兴未艾的时代,我们才慢慢开始思考数据流这个概念。

提出问题

现在我们有一个原始对象数据,想要在这个数据发生变化的时候,有一个回调方法可以直接被触发执行,例如:

    var obj =  {
        name:'Jack'
    }
    someFunc(function(){
        //当obj的值被改变时,想要自动触发此函数
        console.log(obj.name);
    });
    obj.name = 'Nico';//或者执行某个其他的改变值的函数

当然,我们这个对象的数据类型肯定是不固定的,也有可能是Array、Map、Set之类的,当它执行了一些原生的方法(比如push、splice之类的)致使它的值发生了变化,我们都需要在约定的回调函数得到执行。

解决问题

我们暂且不论业内流行的那些数据流框架,如果是我们自己来做,应该如何实现。如果不依赖任何api的话,我们可以直接写一个简单的观察者模型,如果使用defineProperty的话,可以直接劫持set、get方法,下面将对这种方式做简单的code处理。

  • 观察者模式
//假设我们的数据对象是类型固定的{name:string}
function Observer(obj) {
    var self = this;
    this._listener = {};
    Object.keys(obj).forEach(function (key) {
        self._listener[key] = [];
    });
}

Observer.prototype.subscribe = function (key, func) {
    if (!this._listener[key]) {
        this._listener[key] = [];
    }
    this._listener[key].push(func)
}

Observer.prototype.publish = function () {
    var key = Array.prototype.slice.call(arguments);
    var clients = this._listener[key];
    for (var i = 0; i < clients.length; i++) {
        clients[i].apply(this, arguments);
    }
}

var object = {name: 'Jack'};
var observer = new Observer(object);
observer.subscribe('name', function () {
    console.log('Hello '+object.name);
});

function changeName(val) {
    if (object.name !== val) {
        object.name = val;
        observer.publish('name');
    }
}

changeName('Nico');//Hello Nico

大家这里并不需要吐槽代码的粗糙,总的来说这段简陋的代码可以实现我们想要的效果,可以作为一种解决问题的思路:直接订阅数据对象的某个属性,当这个属性的值发生变化时,执行订阅的回调。虽然看起来是千疮百孔,但最大的问题是,这都是假设在数据结构固定的基础上的,如果结构变了,一切都将变得不可控制。

  • defineProperty
var object = {name: 'Jack'};
var value;
Object.defineProperty(object, 'name', {
    enumerable: true,
    configurable: true,
    set: function (val) {
        value = val;
        reaction();
    },
    get: function () {
        return value;
    }
});

function reaction() {
    console.log("Hello " + object.name);
}

object.name = "Nico";//Hello Nico

同样也是一段粗暴的代码,相比于上面,这个代码逻辑的实现似乎少了不少,不过也有同样的问题,当我们数据结构发生变化时,整个实现逻辑都得重新写。

既然我们短时间内无法在当下给出一个万能的解决方法,不妨看看业内的大佬是如何处理这个问题的,自然而然就不得不提到ReduxMobx,或许,看到这里您就又要笑了,不过这并不影响我接下来对它们的理解阐述,也希望能够对您有所启发。

Redux的哲学

总所周知Redux是由Facebook开源的一个数据流解决方案框架,几经FluxReFlux的洗礼俨然已是套成熟的框架,不过它的初衷是为了给React量身打造一个数据流解决方案的,谁让React坚称自己只是一个View层的处理呢。但从结果来看,Redux并非一定要结合React来使用,它提出的是一种函数式模块式的数据流设计方案,我们完全可以配合jQuery或者其他来一起使用。我们通过上述例子使用Redux来使用,再次来领悟一下它的魅力:

var Redux = require('redux');

var object = {
    name: 'Jack'
}
var store = Redux.createStore(function (initState = object, action) {
    switch (action.type) {
        case 'change':
            return Object.assign({}, initState, {
                name: action.value
            });
        default:
            return initState;
    }
});

store.subscribe(function () {
    console.log('Hello ' + store.getState().name);
});

store.dispatch({
    type: 'change',
    value: 'Nico'
});

可能大家会对这段代码再熟悉不过了。这种函数式、模块式的代码风格使得整个数据流的处理方式看起来十分的优雅,这也是Redux最具有魅力的地方。仔细观摩一下,整体的思路无非也是先传入我们的原始数据对象object,然后将订阅一个回调函数(subscribe),最后执行数据的更改(dispatch)。可能与我们上述所说的观察者模式不同之处在于,这里会直接把数据更改的操作当做初始化的参数传入到Redux里面(当然,Redux称此为reducer)。那我们是否也能按照这个思想将上面那段代码改造一下,使其变得可以像Redux那样使用呢:

//定义store
function createStore(reducer) {
    let currentState = undefined;
    let listeners = [];

    function dispatch(action) {
        currentState = reducer(currentState, action);
        for (var i in listeners) {
            listeners[i]();
        }
        return currentState;
    }

    dispatch({
        type: 'INIT'
    });
    return {
        dispatch: dispatch,
        subscribe: function (callback) {
            listeners.push(callback);
        },
        getState: function () {
            return currentState;
        }
    }
}

//创建store
let store = createStore(function (state = {name: "Jack"}, action) {
    switch (action.type) {
        case 'change':
            return Object.assign({}, state, {
                name: action.value
            });
        default:
            return state;
    }
});
store.subscribe(function () {
    console.log('Hello '+store.getState().name);//Hello Nico
})

store.dispatch({
    type: 'change',
    value: 'Nico'
});

瞟一眼,不,或许你真的没有看错,这二十行左右的代码确实使其可以像Redux一样运行(其实Redux源码去掉注释可能也就才两百行左右,不过里面会多一些像是combineReducers以及供插件使用的applyMiddleware之类的接口),整体思路跟我们上述所说的观察者模式几乎没有区别,难能可贵的是,谁又能想到可以以这样的一种形式来组织代码呢。

理性的思考一下,这样处理的确可以解决我们一开始提出的问题,但随着而来新的“问题”(纯属个人见解),第一,我们每一次dispatch都会导致回调函数被触发,这在React里面使用或许并不是问题,但如果结合jQuery之类的没有虚拟DOM的diff算法框架来使用,这种无差别的触发方式就显得有点难受了;第二,statereducer里面结构被直接修改,也会导致一些意想不到的bug,从上述代码里面即可见端倪,这是一个mutable的数据,所以Redux一再强调不能直接修改state,应该是通过返回一个新数据的形式来进行。这种一步到位隔离数据操作副作用的思想的确能解决很多问题,但所有的数据操作都将要使用一个一个的reducer来进行,这种“庞大的”代码组织方式着实让人有点不舒服。

Mobx的实现

Mobx同样是一个几经战火洗礼的库,我们也首先来看一下用它来解决我们的问题:

var Mobx = require('mobx');
var object = Mobx.observable({
    name: 'Jack'
});
Mobx.autorun(function () {
    console.log('Hello ' + object.name);
});
object.name = 'Nico'

相比于上述Redux的代码,最大的体会就是代码量大大的减少了,配置好了之后可以直接操作对象就能在回调得到触发了。可想而知,一定是通过劫持数据的原始赋值方式来进行,但是相比于我们上述所说的definePropertyMobx显然有更加健全的数据处理方式,不过万变不离其宗,这句话说起来很简单,也可以完全就此一概而论,但是里面的技术细节的实现还是非常讲究的。关于Mobx源码的讲解,网上肯定是有不少文章了,我也是几经波折,从从零开始用 proxy 实现 mobx这篇文章中才慢慢领悟到其中奥秘。说到这里,插一句题外话,实在是想强烈给大家推荐一下大佬@ascoders博客

当然你肯定猜到了我接下来要为你展示什么了,看完上面代码,我也相信你心里也是没有什么压力的,都是从最基础简单的,而对Mobx的分解也“力图”一如既往。

从能满足我们最基础使用的开始,显而易见,我们只需要两个函数即可,一个是监听原始数据对象,会把原始数据对象当作参数传进去,里面会对其的赋值做劫持操作,我们姑且称之为observable;同时还需要一个函数,把我们需要进行的回调函数传进去,当数据发生变化的时候这个回调函数将会被触发,这里称之为observe

那么以上两个函数何以能够实现我们的需求呢?整个数据流框架的核心在于依赖收集触发回调。依赖收集肯定是绑定在数据的get方法上,也就说只要执行了取数,我们就可以知道哪个数据的哪个字段需要做“监听”,用于触发回调:

    new Proxy(object,{
        /**
         * 
         * @param target 需要取数的原始数据对象
         * @param key 需要取数的原始数据对象的key值
         * @param receiver
         */
    get(target, key, receiver) {
        let value = Reflect.get(target, key, receiver);
        //接下来我们就可以把这个target+key的关系做一个“监听”处理
        ...
        return value;
      },
    })

剩下的触发回调方法肯定是在数据的set操作上面,意思即是,当数据被变更了,我们也根据object+key执行其对应的回调函数:

    new Proxy(object,{
    set(target, key, value, receiver) {
        const oldValue = Reflect.get(target, key, receiver);
        const result = Reflect.set(target, key, value, receiver);
        if (value !== oldValue) {
        //根据target+key执行对应的回调函数
         ...
        }
        return result;
      }
    })

(这里,我们只把需要触发的值“存储”起来,然后在其被赋值的时候触发,例如我们的回调里面只需要objectname属性,我们只会在name属性被变更时才触发回调,这种方式称之为惰性求值)

由于数据的setget是完全独立的两个操作,想要在set里面执行get所对应的取值回调函数,于是一个持久化的全局对象运应而生——globalState,它里面会有一个属性专门用来做target+key的关系存储,命名为objectReactionBindings

    class GlobalState {
        public objectReactionBindings = new WeakMap<object,Map<PropertyKey,Set<Reaction>>> ();
    }

暂且可以不必细究这个对象的数据结构,需要知道的是,这里面会存储target+key的关系对象,这样我们就能完成在get中通过target+key进行依赖收集,然后set中再次通过这个target+key完成所对应的回调触发。

通过以上分析,我们的observable函数要怎么写已经初见端倪了,还有一个observe的回调应该怎么“搞”还没说明,由于我们肯定得在数据被赋值之前就要知道具体需要“监听”的是哪些数据,不然当数据被改变了都不知道应该触发哪些回调,而依赖收集上面已经提到必然是通过get方法的触发,自然而然,我们需要在数据初始化的时候就执行一次observe里面的回调函数,这样就能完成数据的收集了。

考虑到我们回调触发是在set方法中执行的,这个回调将被“挂载”到globalState,为了扩展其他的一些操作使这个“回调”更加灵活些,我们更希望它是一个可以专门用来触发回调的“反应”对象,里面不仅会专门存储这个回调函数,还可以扩展一些其他参数的操作,我们称之为Reaction

    type IFunc=(...args:any[])=>any;
    class Reaction { 
        private callback:IFunc|null;
        constructor(callback:IFunc){
            this.callback=callback;
        }
    public track(callback?: IFunc) {
    if (!callback) {
      return;
    }
    try {
      callback();
    } finally {
     ...
    }
  }

  public run() {
    if (this.callback) {
      this.callback();
    }
  }
    }

顺其自然,我们的observe函数也可以写了,会像这样处理:

    declare type Func=(...args:any[])=>any;
    function observe(callback:Func){
        const reaction = new Reaction(()=>{
            reaction.track(callback);
        });
        reaction.run();
    }

这段代码里,reaction被初始化了之后就会执行run方法,这里执行的逻辑就是我们上述所提到的对数据的依赖收集在初始化完成之后就立马执行。

最后,我们只需要将一开始的setget相关的逻辑补充一下即可:

    function observable<T extends object>(obj:T  = {} as any):T{
        return new Proxy(obj, {
      get(target, key, receiver) {
        let value = Reflect.get(target, key, receiver);
        bindCurrentReaction(target, key);
        return value;
      },
      set(target, key, value, receiver) {
        const oldValue = Reflect.get(target, key, receiver);
        const result = Reflect.set(target, key, value, receiver);
        if (value !== oldValue) {
          queueRunReactions<T>(target, key);
        }
        return result;
      }
    });
    }
    
    //绑定target+key的reaction到globalState
    function bindCurrentReaction<T extends object>(object: T, key: PropertyKey) {
  const { keyBinder } = getBinder(object, key);
  if (!keyBinder.has(globalState.currentReaction)) {
    keyBinder.add(globalState.currentReaction);
  }
}

//在globalState通过查询target+key得到回调并触发
function queueRunReactions<T extends object>(target: T, key: PropertyKey) {
  const { keyBinder } = getBinder(target, key);
  Array.from(keyBinder).forEach(reaction => {
        reaction.forEach(observer=>{
            observer.run();
        })
  });
}

    
    function getBinder(object: any, key: PropertyKey) {
  let keysForObject = globalState.objectReactionBindings.get(object);
  if (!keysForObject) {
    keysForObject = new Map();
    globalState.objectReactionBindings.set(object, keysForObject);
  }
  let reactionsForKey = keysForObject.get(key);
  if (!reactionsForKey) {
    reactionsForKey = new Set();
    keysForObject.set(key, reactionsForKey);
  }
  return {
    binder: keysForObject,
    keyBinder: reactionsForKey
  };
}
    

当然,实际情况中还要考虑一些并发执行,debug以及像是对MapSet之类的对象支持,所以实际的代码和逻辑要远比上述代码复杂得多,有兴趣可以去git仓库看一下源码。

Mobx同样也是基于此的实现,只不过Mobx4并非使用的是Proxy对象做代理处理,而是defineProperty,这使得它需要通过其他的一些参数对象来完成对数据的采集、绑定。例如常见的Array对象,在Mobx4里面会对数组的所有操作方式都做劫持处理,这使得其在返回的“新对象”中也可以像原生对象一样。

如果说Redux实在让人用起来有些不爽,那Mobx也并非完美无缺,估计它最大的缺点就是,实在是找不到它有什么缺点了。

最后说到头,前端里面无论哪种数据流工具,都是为了解决问题而存在的。既然有了数据层的解决方案,剩下的就是打通视图层的操作了。Vue里面会通过内置的“数据流”将依赖的DOM节点绑定到具体的数据对象上,以至于它可以自动完成DOM更新,其本质上说无异于绑定了DOM节点的“Mobx”;React使用数据更新之后的虚拟DOMDiff算法渲染改变部分,本质上说都是为极大程度上了简化多余又繁琐的视图操作(不然干嘛不直接使用jQuery呢)。我个人有点嫌弃Vue繁琐的绑定式写法,也不喜欢React的diff算法(这种比较方式在现代浏览器中是否真的需要?性能开销是不是很大啊),理想的方式是,像React一样使用Vue,不要做多余的diff处理,也不要多余框架绑定。