起因
不管是用主流的前端框架業務開發還是在寫一些 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);
});
最後
本文並不能讓你對函數式有多少了解,至少我自己目前也只有一些基本的認識(雖然這本書看了兩三遍)),但函數式的思想還是值得在項目中不斷實踐的。