函數式+狀態管理探索前端開發

起因

不管是用主流的前端框架業務開發還是在寫一些 sdk,通常的編程範式都是面向對象的,尤其 es6 新增 Class 語法糖後,功能模塊的劃分都基於類的力度。在寫過和維護過不少代碼後,漸漸覺的在狀態複雜的應用中,按局部狀態、行爲來劃分並不能讓整體代碼結構很清晰,且 js 天生的函數靈活性在類的場景下也很受約束, 所以嘗試從函數式的角度來尋找一些突破口。

用了小半年的時間,在自己相對熟悉的音視頻領域,採用函數式編程+狀態管理的編程思路,寫了一個簡單的 hls 播放器,算是對函數式編程有了一些理解和實踐。

問題

不管是面向對象還是函數式,十分重要的一點是關注點分離。對於一個關注點、功能點,面向對象主要是實現細節的封裝,只對外提供簡單的 api 暴露。而對於一個關注點內部,又可分爲輕薄的控制層、對狀態抽象管理的模型層、具體業務邏輯實現,IO 操作等的服務層。

控制層:是各功能模塊之間交互的銜接點,串聯在一起實現整體的功能,而功能模塊的劃分是否合理直接影響這一層的設計。是在 AController 中實例化一個 BController 還是在 CController 中實例化 AController、BController?

模型層: 對狀態的管理,對於局部狀態,遇到的最多的問題就是a.b.c,b爲undefined的運行時報錯,代碼中充斥着大量的防禦性檢測。更嚴重的是全局狀態,隨着應用的複雜,全局狀態越來越多,模塊依賴其他模塊的狀態導致需要大量的 getter、setter,a.bInstance.cProp 怎麼看都不爽。

服務層: 代碼量隨着迭代越來越多,代碼不容易複用,橫跨整個文件的通過 this 對屬性的獲取和修改

面向對象的層級結構設計並不簡單、以類爲力度劃分功能帶來了各個模塊之間狀態,方法的冗餘調用、而通過 this 對狀態的處理路徑也難以跟蹤、限制了函數的靈活性

函數式

很好的講函數式的書

函數式講究把一個大的功能模塊拆分成一個個小的函數,再由這些小函數組合成完整的功能。使用函數來抽象操作和控制流程。

操作: 函數在數學層面代表值的映射y=f(x),在函數式層面重在引用透明,即函數內的操作只依賴輸入參數,不受其他外部狀態影響,保證函數的純粹性,我認爲這是不現實的....,不可能把所有的依賴都以參數的形式傳入函數,函數的結果也不只是產生一個新的值。參見下面對狀態的管理。

控制流:函數式的強大在我看來在於對控制流的抽象,使得在對狀態的處理過程中(同步的計算邏輯、異步的操作等),能以統一的口徑在各個函數中流轉,最終產生結果

函數式範式重在思維的轉換,由命令式轉向聲明式,命令式給人的感覺是從一個方法進入另一個方法,層層遞進,越來越深,是一種縱向的概念,而函數式是把所有操作都放在一個水平面上,在同一水平面,數據從一個流程進入下一個流程,是一種橫向的概念,包括對同步的處理,異步的處理,產生副作用的IO操作,都抽象在一個維度

***還是從最基本的看起。。。。*****

curry+compose

小函數組合成大功能,面臨的第一個問題是參數的數量, y=f(x) z=f1(t,y) n=f2(z),把 f,f1,f2 組合在一起由參數 x 得到結果 n,中間過程是匹配不上的,那就規定組合的函數都只接受一個參數吧!(對於接受多個參數的函數,通過 curry,暫存前面的參數,轉換成只接受最後一個參數的部分函數)

const curry = fn => {
  let len = fn.length;
  return function _curry(...args) {
    if (args.length < len) {
      return _curry.bind(null, ...args);
    }
    return fn.apply(null, args);
  };
};

const compose = (...fns) => {
  const fnReversed = fns.reverse();
  return args => {
    return fnReversed.reduce((ret, fn) => fn(ret), args);
  };
};

y=f(x)
z=f1(t,y)
n=f2(z)

--->

let f1_1 = curry(f1)(t);
let getN = compose(
  f2,
  f1_1,
  f
)
getN(x) = n

container

對控制流的處理纔是函數式的優雅所在,單純的函數組合並不能處理複雜的流程,能將控制流與操作抽象在同一水平面,需要藉助容器的概念,容器作爲輸入值的載體,容器上定義一些統一的接口,對輸入值應用某些操作,並且數據可以從一種容器進入另一種容器進行進一步操作

針對不同的場景,容器又可細分爲不同的子類,子類提供統一的接口不同的實現,根據存儲值的不同狀態,調用相同的API卻執行不同操作

class Container {

 constructor(v){
   this._value = v;
 }

 static of(v){
   return new Container(v)
 }

 map(f){
   return new Container(f(this._value))
 }

}

Continer 定義map方法,對存儲的值應用一個fn

**對於帶有map方法的這一類數據結構叫做 functor,Array 有 map方法,Array就是一個functor**

Container.of(1).map(x=>x+1) --> Container(2)

Container 的衍生 Maybe、Either、Task、IO 等

Maybe: 專注處理空值監測,可以很好的處理 a.b.c 的問題

Either: 專注處理異常

Task: 異步處理,類似 Promise, 參見實現,單元測試

IO: 專注對副作用的處理

Maybe 的實現

class Maybe {
  static of(value) {
    if (value === undefined || value === null) {
      return Empty.of();
    }
    return Just.of(value);
  }
}

class Empty extends Maybe {
  static of(value) {
    return new Empty(value);
  }

  map() {
    return this;
  }
  join() {
    return this;
  }
  chain() {
    return this;
  }
  ap() {
    return this;
  }
  value() {
    return this._value;
  }
  getOrElse(f) {
    if (typeof f === 'function') {
      return f();
    }
    return f;
  }
  toString() {
    return 'Empty';
  }
}

class Just extends Maybe {
  static of(value) {
    return new Just(value);
  }

  map(fn) {
    const v = fn(this._value);
    return Maybe.of(v);
  }

  join() {
    return this.value();
  }

  chain(f) {
    return this.map(f).join();
  }

  ap(f) {
    return f.map(this.value());
  }

  getOrElse(f) {
    let v = this.value();
    if (typeof f === 'function' && v && v.constructor === Empty) {
      return f(v.value());
    }
    return this.value();
  }
}

eg:
Maybe.of(null).map(() => {}); // do nothing
Maybe.of(1).map(x => x + 1); // Maybe(2)

// Just Empty 提供相同的API,對於不同的輸入值,空值檢測發生在內部,自動選擇使用不同的容器,針對對相同的操作,爲空時自動略過

eg:
// 處理if邏輯判斷
maybe(
  ()=>{
    //levels not exist,do some things,eg: load master m3u8
  },
  levels=>{
    // levels exist,do some things with levels
  },
  Maybe.of(store).map(prop('levels))
)

在實際使用中,我們可以把所有狀態數據存儲在中心 store 中,而從 store 中 getState()獲取到的數據都是 Maybe 化的,對數據的操作和子屬性的訪問通過 map(f),這樣可以很好的避免a.b.c類的運行時異常

對 Either、Task 等介紹可參見 上文提到的 很好的講函數式的書,另 自己對函數式基本組件的封裝

curry,在這裏主要用於簡化函數組合的複雜性,還有延遲執行,部分暫存等用處

compose,類似於傳送帶,將數據抽象在同一水平面流轉

容器,類似於傳送帶上一個個小盒子,提供統一的接口標準,使數據從一個盒子無縫進入另一個盒子,完成操作和流程控制

對狀態的管理

上面將函數式的處理流程比喻成狀態(數據)在傳送帶上流轉,但前端應用是複雜的,我們會有很多條傳送帶,各傳送帶之間會有狀態的交互,如何能很好的將全局狀態分發到各傳送帶?

在實踐中,借鑑了 react-redux 的思想,提供一箇中心 Store 的功能,各模塊從 store 中 getState,發送命令對 store 中數據進行更新,store 和各函數式模塊通過 connect 連接.

import { combineActions, combineStates, createStore } from 'vod-fp-utility';

let store = createStore(initState,actions)
let {id,connect,dispatch,getState,getConfig,subscribe.subOnce} = store;

connect:// `將store實例注入科裏化後的功能模塊函數,始終作爲科裏化的函數第一個參數`
dispatch:// 執行命令操作,可以是修改store的某個狀態,可以是分發某個事件
getState: //從store中獲取狀態
subscribe:// 訂閱某個事件,響應dispatch
getConfig:// 類似getState。只用來獲取config配置信息
subOnce://類似subscribe,只監聽執行一次


**connect是作爲狀態管理和函數式結合重要的中間橋樑!!!**

使用:
import {initState,ACTIONS} from "./store.js"
const store = createStore(initState,ACTIONS)
const manageHls = curry(({ dispatch, connect }, media, url)=>{
  // 這裏,manageHls中可以輕鬆的從 store中獲取state,dispatch動作
  // 通過`connect` loadPlaylist,createMediaSource等,在loadPlaylist和createMediaSource中
  // 可以同樣的和中心store進行交互
  Task.resolve(connect(bootstrap))
    .ap(connect(loadPlaylist)(url))
    .ap(connect(createMediaSource)(media))
    .error(e => {
      dispatch(ACTION.ERROR, e);
    });
})
store.connect(manageHls)(videNode,m3u8Url)

函數式的應用

看一個例子

簡化的需求背景:

hls 點播播放有標清、高清等檔位,切換檔位時,1. 先檢查檔位信息是否存在,2. 不存在要請求檔位 m3u8 文件,解析 m3u8 3. 存在的話直接切換

可能存在異常的場景: 1. http 請求失敗 2. m3u8 解析失敗

it('# test transform Task -> Either -> Task', done => {
  let store = {};
  let loadSuccessSpy = chai.spy();
  let changeSuccessSpy = chai.spy();

  let loadErrorFlag = 'loadSourceError';
  let parseM3u8ErrorFlag = 'parseM3u8Error';
  let parsedM3u8Data = 'parsedM3u8Data';

  let getState = key => Maybe.of(store).map(prop(key));
  let setState = (key, v) => (store[key] = v);

  let _doStoreLevels = text => {
    store['levels'] = text;
    return text;
  };

  let _loader = flag => {
    return Task.of((resolve, reject) => {
      setTimeout(
        () => (flag === loadErrorFlag ? reject(flag) : resolve(flag)),
        200
      );
    });
  };

  let parseM3u8 = flag => {
    if (flag === parseM3u8ErrorFlag) {
      return Fail.of(flag);
    }
    return Success.of(flag);
  };

  // loadSource :: boolean -> (Task(error) | Either(success|error))
  let loadSource = flag => {
    return _loader(flag)
      .chain(parseM3u8)
      .map(_doStoreLevels)
      .map(x => {
        loadSuccessSpy();
        return x;
      });
  };

  // changePlaylist :: boolean -> (Either(success) | loadSource)
  let changePlaylist = flag => {
    return maybe(
      () => loadSource(flag),
      levels => {
        changeSuccessSpy();
        return Success.of(levels);
      },
      getState('levels')
    );
  };

  changePlaylist(loadErrorFlag).error(e => {
    e.should.be.equal(loadErrorFlag);
    loadSuccessSpy.should.not.be.called();
    changeSuccessSpy.should.not.be.called();
  });

  setTimeout(() => {
    changePlaylist(parseM3u8ErrorFlag).error(e => {
      e.should.be.equal(parseM3u8ErrorFlag);
      changeSuccessSpy.should.not.be.called();
      loadSuccessSpy.should.not.be.called();
    });
  }, 350);

  setTimeout(() => {
    changePlaylist(parsedM3u8Data).map(x => {
      x.should.be.equal(parsedM3u8Data);
      loadSuccessSpy.should.be.called.once;
      changeSuccessSpy.should.not.be.called();
    });
  }, 700);

  setTimeout(() => {
    changePlaylist(parsedM3u8Data).map(x => {
      x.should.be.equal(parsedM3u8Data);
      loadSuccessSpy.should.be.called.once;
      changeSuccessSpy.should.be.called();
      done();
    });
  }, 1000);
});

最後

本文並不能讓你對函數式有多少了解,至少我自己目前也只有一些基本的認識(雖然這本書看了兩三遍)),但函數式的思想還是值得在項目中不斷實踐的。

mostly-adequate-guide

程序員的範疇輪

vod-fp-utility

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章