函數式實踐-從0到1寫個播放器

原由

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

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

問題

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

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

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

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

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

函數式

很好的講函數式的書程序員

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

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

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

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

***仍是從最基本的看起。。。。*****redux

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

相關文章
相關標籤/搜索