我所认识的前端数据流

栏目: JavaScript · 发布时间: 4年前

内容简介:历史总是在新思想的火花碰撞中演进,从现在我们有一个原始对象数据,想要在这个数据发生变化的时候,有一个回调方法可以直接被触发执行,例如:当然,我们这个对象的数据类型肯定是不固定的,也有可能是Array、Map、Set之类的,当它执行了一些原生的方法(比如push、splice之类的)致使它的值发生了变化,我们都需要在约定的回调函数得到执行。

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

提出问题

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

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处理,也不要多余框架绑定。


以上所述就是小编给大家介绍的《我所认识的前端数据流》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

运营之光 2.0

运营之光 2.0

黄有璨 / 电子工业出版社 / 2017-4 / 99

在互联网行业内,“运营”这个职能发展到一定阶段后,往往更需要有成熟的知识体系和工作方法来给予行业从业者以指引。 《运营之光:我的互联网运营方法论与自白 2.0》尤其难得之处在于:它既对“什么是运营”这样的概念认知类问题进行了解读,又带有大量实际的工作技巧、工作思维和工作方法,还包含了很多对于运营的思考、宏观分析和建议,可谓内容完整而全面,同时书中加入了作者亲历的大量真实案例,让全书读起来深入......一起来看看 《运营之光 2.0》 这本书的介绍吧!

RGB转16进制工具
RGB转16进制工具

RGB HEX 互转工具

随机密码生成器
随机密码生成器

多种字符组合密码

HSV CMYK 转换工具
HSV CMYK 转换工具

HSV CMYK互换工具