Redux 的設計十分精妙,透過原始碼可以看到裡面有很多程式碼技巧,釋出訂閱、組合函式、函式柯里化、函數語言程式設計等方式的運用,給 Redux 又增加了一絲神祕的面紗,今天我們將這些面紗逐步的解開。
Redux 的作用
Redux 官方文件對 Redux 的定義如下:
一個面向 JavaScript 應用的可預測狀態容器。
- 狀態容器是什麼?通俗點說它就是一個 JS 庫,來管理前端開發中的資料,它將資料集中管理,用到什麼只需從整個資料集合中取出。
- 可預測又是什麼?這個解釋在下面再給出。
- 為什麼會需要 Redux ? 說一個解釋性比較強的,React 中兩個子元件需要通訊,一般是傳遞給父元件做中轉,但是有多級子元件的時候就會狀態提升很多層,比較麻煩,使用 Redux 就會很方便。
我們知道 Redux 有自己的工作流程,就是 store 會通過一個資料樹儲存整個應用的狀態,然後元件會訂閱資料 state,通過元件的派發(dispatch)行為(action)給 store 的方式來重新整理 view,Redux 有三大原則(唯一資料來源、保持只讀狀態、資料改變只能通過純函式來執行),這裡我們先不去解釋這是什麼,先一點一點實現一個 myRedux,之後再看它的工作流程會十分清晰。
Redux 簡易實現
比如頁面有個 HTML 結構:
<div id='cont'></div>
複製程式碼
然後我們建立一個 state 資料集合:
const stateAll = {
people:{
eyes: '有點不舒服',
color: 'red'
}
}
複製程式碼
假設我們現在渲染資料只從這個資料集合來取資料渲染,可以編寫如下邏輯:
function render(data){
var ele = document.getElementById('cont')
ele.innerHTML = data.people.eyes;
ele.style.color = data.people.color;
}
stateAll.people.eyes = '還是有點不舒服';
stateAll.people.eyes = '感覺差不多了';
stateAll.people.eyes = '沒問題了';
stateAll.people.color = 'green';
render(stateAll)
複製程式碼
現在假如多個人同時使用操作了這個公共變數,呼叫 render 時候只會顯示最終的那個結果,其他修改的過程無法被記錄檢測,這個過程是可以被任意修改並且無法預測,現在我們把問題複雜化,讓資料變化變成可預測的。
舉個例子,假如 A 去醫院看病,去個小醫院可以直接找個醫生瞧病拿藥(stateAll.people.eyes='隨意修改'),但是去大醫院的話,是需要掛號預約的,比如 A 眼睛不舒服,需要先找到眼科,再掛對應眼科的號,掛成功了才可以進入科室瞧病,所以我們需要對上面的程式碼進行流程完善一下:
function dispatch(action){
switch (action.type){
case 'EYES_QUESTION_LOG':
stateAll.people.eyes = action.data
break;
case 'EYES_COLOR_LOG':
stateAll.people.color = action.data
break;
default:
break;
}
}
dispatch({
type: "EYES_QUESTION_LOG",
data: "眼睛有點紅"
})
dispatch({
type: "EYES_COLOR_LOG",
data: "lightcoral"
})
render(stateAll)
複製程式碼
為什麼需要這個東西呢?
比如 A 想知道眼睛的病情描述和眼睛的顏色狀況,在小醫院的話可能甲、乙、丙、丁四個醫生都給他瞧過並分別記錄的他的眼睛問題,等回過頭來去找病情記錄得分別去各個醫生那裡去要。醫生很忙的,一找就是半天還不一定能找到(這裡甲乙丙丁就是多個開發者或者多個函式,同時操作了同一個患者病情記錄)。但是現在大醫院規定所有的病情記錄必須得通過同一個科室(dispatch)的系統記錄,甲、乙、丙、丁醫生需要通過 dispatch({type:"
現在再仔細看下,我們會發現一個問題,我的 render 現在只能在 dispatch 之後才可以得到正確的結果,相當於醫生修改完病歷之後我看不到,需要跑去總科室處要一下資料,現在不想跑了,等醫生髮布完我就想立馬知道情況。所以現在就用到“釋出訂閱”設計模式。
釋出—訂閱模式又叫觀察者模式,它定義物件間的一對多的依賴關係,當一個物件的狀態發生改變時,所有依賴於它的物件都將得到通知。在 JavaScript 開發中,一般用事件模型來替代傳統的釋出—訂閱模式。
//建立一個倉庫
function createStore () {
//需要訂閱的函式集合
let listeners = []
//外部每呼叫一次subscribe就往被訂閱的集合中存入 listener引數必須為一個函式
const subscribe = (listener) => {
listeners.push(listener)
}
//dispatch執行就是讓被訂閱的函式執行
const dispatch = (action) => {
listeners.forEach((listener) => listener())
}
//通過閉包的形式返回 外部可呼叫內部方法
return { dispatch, subscribe }
}
複製程式碼
現在可以通過:
const store = createStore();
store.subscribe(()=>{
//把渲染函式訂閱上
render(stateAll)
})
store.dispatch(); //通知結果
複製程式碼
這樣醫生將來通知只需要 dispacth 之後患者就可以知道了結果,不需要等醫生髮布後主動跑去問情況。
但是現在是沒有資料的,需要將資料和處理方式整合到 createStore 中,方便統一呼叫:
//dispatch函式繼續改寫為reducer 增加一個引數state
function reducer(state, action){
switch (action.type){
case 'EYES_QUESTION_LOG':
state.people.eyes = action.data
break;
...
}
}
//增加了兩個引數state, stateChanger
function createStore (state, stateChanger) {
const listeners = []
//這樣可以直接從store中取資料
const getState = () => state
const subscribe = (listener) => {
listeners.push(listener)
}
const dispatch = (action) => {
//這裡新資料處理的地方 就是之前的dispatch({type: "EYES_COLOR_LOG",data: "lightcoral"}) 只不過這裡換成引數的方式
stateChanger(state, action)
listeners.forEach((listener) => listener())
}
return { getState, dispatch, subscribe }
}
//將現有的資料集合和處理方式傳入進去
const store = createStore(stateAll, reducer);
store.subscribe(()=>{
//通過store的方式獲取資料 不用管全域性變數的名稱了
render(store.getState())
})
//派發資料 action
store.dispatch({
type: "EYES_QUESTION_LOG",
data: "好點了"
})
複製程式碼
createStore 傳入兩個引數,第一個就是整個初始資料集合 state,第二個就是最開始寫的 dispatch 函式,它有個官方名字叫 reducer。通過 store.getState 獲取共享狀態,通過 store.dispatch 修改共享狀態,通過 store.subscribe 監聽資料資料狀態被修改後進行重新渲染頁面。
現在整個流程是可以跑通,但是還是一直在操作全域性那個資料集合,只不過是換了一種全新的形式,這樣假如醫生記錄的資料是“好點了”,但是總資料不小心被人直接 state.people.eyes = “問題有點嚴重” 做了這樣的處理,達不到預期的結果,所以現在我們引入一個純函式的概念,就是 reducer 內部不再進行 state 資料的直接操作,而是變成一個純函式,這樣避免了直接修改資料的不可預測性。
在進行修改 reducer 之前,先介紹下函數語言程式設計中的一個概念——純函式:
- 對於同一引數,返回同一結果
- 完全取決於傳入的引數
- 不會產生副作用
對於同一引數返回同一結果:
let x = 1;
const add = (y) => x + y
console.log(add(2)) //3
console.log(add(2)) //3
複製程式碼
兩次 add 函式呼叫傳入的都是 2,可以看到返回結果同樣是 3,滿足第一個條件,繼續看第二個條件完全取決於傳入的引數:
let x = 1;
const add = (y) => x + y
console.log(add(2)) //3
x = 2;
console.log(add(2)) // 4
複製程式碼
很明顯,這個結果值沒有完全依賴傳入的引數,修改了外部變數 x 的值,返回值也跟著變化,同時第一個條件也不成立,所以這個函式不是純函式。再繼續看下第三個條件不會產生副作用,如下程式碼:
const add = (obj, y) => {
obj.x = 3
return obj.x + y
}
const obj = {
x:1
}
console.log(add(obj,2)) //5
console.log(obj.x) // 2
複製程式碼
在執行 add 時修改了外部 obj.x 的值,所以相當於對外部的變數產生了副作用,修改為純函式:
const add = (y) => {
let obj = {
x:1
}
obj.x = 2
return obj.x + y
}
console.log(add(2)) //4
或者
const add = (x,y) => x + y //也是一個純函式
複製程式碼
這個在 add 內部修改了 obj.x 的值,但是對外部是沒有任何影響的,不管我傳的引數是多少,不會受其他干擾,可預期到結果值,同時滿足上述三個條件,所以它是一個純函式。所謂的對外部有副作用,就是我們在函式中進行了:
- 發出 HTTP 呼叫
- 改變外部資料或者 Dom 狀態
- console.log()
- Math.random()
- 獲取的當前時間
所以判斷是否是純函式,只要同時滿足上述三個條件即可。
在我們平時開發中並非所有函式都需要是純的, 比如操作 DOM 的事件處理程式就不適合純函式。使用純函式的目的是為了更好的進行單元測試,函數語言程式設計不依賴、也不會改變外界的狀態,只要給定輸入引數,返回的結果必定相同,因此,每一個函式都可以被看做獨立單元。純函式是很嚴格的,有的時候甚至只能進行資料的操作,在 Redux 中之所以要這麼用純函式編寫 reducer 主要是為了記錄資料前後變化,達到資料可預測的結果(開頭預留的問題)。
瞭解了純函式,先開始修改下 reducer 函式,使其變成一個純函式,先引入一個淺拷貝知識點,淺拷貝就是隻拷貝物件的第一層,裡面的層都是引用拷貝,新資料的修改會觸發原資料的變化。可以通過物件展開運算子或者 Object.assign() 實現淺拷貝:
const state = {
a:1,
b:{
c: 1
}
}
const newstate = Object.assign({}, state, {
a:2
})
newstate.b.c=4
console.log(state.b.c) // =>4 因為Object.assign是淺拷貝,深層次的拷貝的是引用,所以受newstate的影響
//可以在每一層要修改的資料的地方進行一次淺拷貝
const newstate = Object.assign({}, state, {
a:2,
b:{
...state.b,
c: 5
}
})
newstate.b.c=4
console.log(statestate.b.c) //=> 1 不受newstate 的影響,因為 b 又被淺拷貝了一份出來
複製程式碼
開始修改 reducer 使其內容都淺拷貝一份並返回出來(這裡資料不多的情況下,深拷貝也是可以的,這裡涉及到 redux 的資料優化問題,先不介紹):
function reducer(state, action){
switch (action.type){
case 'EYES_QUESTION_LOG':
return Object.assign({}, state, {
people: {
...state.people,
eyes: action.data
}
})
break;
case 'EYES_COLOR_LOG':
return Object.assign({}, state, {
people: {
...state.people,
color: action.data
}
})
break;
default:
break;
}
}
//ceateStore函式中的dispatch也需要修改下
const dispatch = (action) => {
state = stateChanger(state, action) //將reducer中的值每次淺拷貝一份 返回出來
listeners.forEach((listener) => listener())
}
複製程式碼
這樣修改之前,dispatch 之前修改了全域性變數 dispatch 之後的結果會受全域性變數的影響,比如:
stateAll.people.eyes = '111'
store.dispatch({
type: "EYES_QUESTION_LOG",
data: "好點了"
})
複製程式碼
此時頁面中會列印出 111,因為資料物件指向同一個地址,修改後會直接變化,現在我們的 reducer 是純函式,而且裡面的資料都是淺拷貝一份出來的,這樣修改之後無論全域性變數如何變化,我們 dispatch 的結果永遠是自己控制的資料,不受外界影響,所以介面會列印出“好點了”。現在的 stateAll 我們是放在全域性的,再繼續優化下,直接放在 reducer 中建立:
function reducer(state, action){
if(!state){
return {
people:{
eyes: '有點疼',
color: 'red'
}
}
}
...
}
function createStore (stateChanger) {
//修改為只接受一個引數,內部讓state初始為null 這樣可以在reducer中預設建立初始值
let state = null;
...
dispatch({}) //這裡主動執行以下,使預設值生效
}
複製程式碼
到這裡我們已經完成了一個簡易版的 Redux,使用了觀察者設計模式來建立 store,使用函數語言程式設計來編寫 reducer,它不屬於原始碼的部分,而是留給我們來手動編寫的,Redux 的核心函式只有 createStore 這一個。
本小節的完整程式碼會在文末統一貼出。
JS 中介軟體介紹
中介軟體是一種獨立的系統軟體或服務程式,分散式應用軟體藉助這種軟體在不同的技術之間共享資源。中介軟體位於客戶機/ 伺服器的作業系統之上,管理計算機資源和網路通訊。是連線兩個獨立應用程式或獨立系統的軟體。相連線的系統,即使它們具有不同的介面,但通過中介軟體相互之間仍能交換資訊。執行中介軟體的一個關鍵途徑是資訊傳遞。
以上解釋來自百度百科。其中最關鍵的一句是資訊傳遞,在 JS 中我們引入中介軟體的概念就是為了傳遞資訊,它在 Node.js 中被廣泛使用,"它泛指一種特定的設計模式、一系列的處理單元、過濾器和處理程式,以函式的形式存在,連線在一起,形成一個非同步佇列,來完成對任何資料的預處理和後處理"。
為什麼會需要中介軟體?
比如 A 在醫生髮布結果後,雖然知道了當前的病情狀態,但是他還想知道此前的病情狀態來做個對比,那麼只能醫生在每次釋出的時候在傳遞一個之前的狀態操作,假如說這個醫生很負責,每天都要給患者傳送報告,一個療程下來就會增加雙倍的操作,現在通過一箇中介軟體,可以讓之前的資料包告由中介軟體處理,醫生還是隻負責 dispatch 患者當前病情,不用操心之前的那個狀態了。說白了就是我們開發中,想在 dispatch 前後列印出資料 log 記錄,不可能去在 dispatch 前後都寫上 console.log(),這樣的話重複程式碼就太多了,所以可以藉助中介軟體:
let looger = function({dispatch,getState}){
return function(next){
return function(action){
//可以優化處理 根據action操作
console.log('dispatch之前資料:', getState())
let result = next(action)
console.log('dispatch之後資料:', getState())
return result;
}
}
}
複製程式碼
再比如,醫生想釋出報告後,讓患者延遲一會兒再接收,你會想加個定時器不就可以了?答案是可以的,但是不要墨守成規,再大膽一點,我讓 dispatch 也可以執行一個非同步的函式,所以 redux-thunk 這個中介軟體就出現了。我們看下如何實現:
let thunk = function({dispatch,getState}){
return function(next){
return function(action){
if (typeof action === 'function') {
return action(dispatch, getState)
}
return next(action)
}
}
}
// es6寫法
let thunk = ({ dispatch, getState }) => next => action => {
if (typeof action === 'function') {
return action(dispatch, getState)
}
return next(action)
}
複製程式碼
引數先忽略掉,怎麼呼叫也先別管,直接看這個中介軟體的函式結構:
let thunk = function(){
return function(){
return function(){
return '處理結果'
}
}
}
複製程式碼
先不要問為什麼,先記住這樣的結構,在 Redux 中編寫中介軟體就是通過這種方式來建立。redux-thunk 原始碼也就短短的幾行,所以建立一箇中介軟體相對來說還是比較輕鬆的,但是 redux-saga 就不是,它是更復雜的一種處理情況,是一個管理 Redux 應用非同步操作的中介軟體,用於代替 redux-thunk 的。
redux-thunk 採用的是擴充套件 action 的方式,使 store.dispatch 的內容從普通物件擴充套件到函式,而 saga 通過建立 Sagas 將所有非同步操作邏輯存放在一個地方進行集中處理,以此將 React 中的同步操作與非同步操作區分開來,以便於後期的管理與維護。在 saga 中,全域性監聽器和接收器都使用 Generator 函式和 saga 自身的一些輔助函式實現對整個流程的管控。
雖然它比 redux-thunk 複雜很多,但是本質上它也是一箇中介軟體,只不過裡面多了其他功能,而 redux-thunk、redux-promise、redux-logger 這幾個常見的原始碼都十分短小清晰,直接拿來可以用,自己寫也完全沒問題。
JS 函式柯里化
瞭解函式的柯里化之前,先看一下高階函式(high-order function),高階函式是滿足下面兩個條件其中一個的函式:
- 函式可以作為引數
- 函式可以作為返回值
我們平時使用的 setTimeout,map,filter,reduce 等都屬於高階函式,函式的柯里化,也屬於高階函式的一種應用。柯里化函式的定義是:
它用於建立已經設定好了一個或多個引數的函式。函式的柯里化的基本使用方法和函式繫結是一樣的:使用一個閉包返回一個函式。兩者的區別在於,當函式被呼叫時,返回的函式還需要設定一些傳入的引數
注:以上解釋來自《JavaScript高階程式設計(第3版)》。
柯里化是轉換函式呼叫從 f(a,b,c) 至 f(a)(b)(c),比如有一個計算和的函式:
function add(num1, num2, num3){
return num1 + num2 + num3;
}
add(1, 2, 3) //=>6
//改為柯里化形式
function add(num1){
return (num2) => (num3) => {
return num1 + num2 + num3
}
}
add(1)(2)(3) //=>6
複製程式碼
看到這裡是不是就和我們上面中介軟體的寫法一致了,當然這裡的柯力化只是舉個最簡單的例子(這是因為引數個數已知並且固定)。現在假如我們有兩個中介軟體函式:
function middle1(api){
return (num2) => (num3) => {
return num2(num3)
}
}
function middle2(api){
return (num2) => (num3) => {
return num2+num3
}
}
複製程式碼
現在我想做的是讓資料過來的時候傳入這兩個中介軟體,資料從 middle1 流向 middle2,並最終執行 middle2 中的處理結果,我們可以這樣做:
var b = middle2({})(2);
var newDispatch = middle1({})(b);
applyMiddleware(middle1,middle2).dispatch(6) // 6 + 2 =>8
複製程式碼
在執行 middle2({}) 之後返回:
(num2) => (num3) => {
return num2+num3
}
複製程式碼
再次執行 middle2({})(2) ,上面這個函式的 num2 就是傳入的引數 2,此時返回:
(num3) => {
return num2+num3 //此時的num2 是2 num3還沒有值
}
複製程式碼
接來下執行 middle1({}) 之後返回:
(num2) => (num3) => {
return num2(num3)
}
複製程式碼
我們可以看到和上面執行 middle2({}) 的結果是一致的,接著將 b 執行的結果再傳入 newDispatch,現在變成了將 middle1 中的引數 num2 變成 b 的執行結果:
1號函式:
(num3) => {
//num2就是 2號函式: (num3) => {
// return num2+num3
//}
return num2(num3)
}
複製程式碼
所以我們最後執行 newDispatch(6) 就是相當於執行最後的邏輯, 1號函式接受外界的6這個引數,num3 變成了6,再執行2號函式,將 num3(6)傳入並執行 return num2+num3,這裡打個斷點,就會很清晰的明白執行流程了。通過這樣的操作,我們就將兩個中介軟體函式串聯了起來。我們知道 JS 中介軟體就是負責資訊傳輸的,可以理解為它不修改源資料,而是能夠採集資訊附帶的資源或改變資訊的原本運輸方式但不修改源資訊,比如將同步改為非同步(這句話純屬個人解釋,無任何參考源)。上面的程式碼還需要繼續完善:
function middle1(api){
return (next) => (num3) => {
return next(num3)
}
}
//這裡改寫 不在中介軟體的地方求值
function middle2(api){
return (num2) => (num) => {
return num2(num)
}
}
//將處理函式封裝
function applyMiddleware(arg1,arg2){
const api = {}
let b = arg2(api)(function(num){
console.log(num+':我是外面傳來的資料')
});
let newDispatch = arg1(api)(b);
return {
dispatch:newDispatch
}
}
applyMiddleware(middle1,middle2).dispatch(6) //=>6:我是外面傳來的資料
複製程式碼
中介軟體中不再進行求值運算,b 函式之前是接受一個常量,現在改為一個函式,在這裡可以接受到外部傳入的值,從而可以進行其他計算,將 newDispatch 閉包的形式暴露出去,外部可以直接呼叫。
現在已經大致明白了中介軟體的如何進行資料串聯並且通過一個 applyMiddleware 處理函式來取得結果,現在將這個功能和上面我們已經實現的一個簡單 Redux 結合起來使用,就可以實現我們上面提到的中介軟體所說的一些優點。
Redux 整合中介軟體
上面提到執行邏輯放在了 applyMiddleware 這個函式中,現在和 Redux 結合起來,讓 Redux 執行這個邏輯去,這樣的話整個資料又變成了可預測的。之前的建立 store 的方式是 createStore(reducer),現在我們整合中介軟體,所以需要先修改下:
const store = applyMiddleware(thunk,looger,createStore,reducer);
複製程式碼
前兩個引數就是我們所謂的中介軟體函式,如第四小節中的例子,為了讓資料在兩個中介軟體中序列,第三個引數就是 createStore 函式,目的就是為了讓之前 b 函式中的資料處理邏輯讓給 redux 執行,第四個引數就是我們的 reducer 管理資料的。先理解了這些,再看這個 applyMiddleware 函式如何改寫才能讓給 Redux 執行邏輯:
function applyMiddleware(arg1,arglog,arg2,arg3){
//建立 store
var store = arg2(arg3)
// 獲取dispatch
var dispatch = store.dispatch;
//先暫時建立一個 新的dispatch 理解為增強之前的 dispatch
var newDispatch = function(){};
//為了讓中介軟體函式可以使用使用store的 getState 和 dispatch功能函式
var middlewareAPI = {}
//這裡就是上面例子所說的 讓資料串流
var b = arglog(middlewareAPI)(dispatch);
newDispatch = arg1(middlewareAPI)(b);
// 這裡多了一個...store,因為我們要讓中介軟體處理函式處理中介軟體之後外部的store依然可以使用原來store的功能
return {
...store,
dispatch:newDispatch
}
}
複製程式碼
先看第一句就是我們第二小節的建立 store 的方式,只不過這裡是作為引數傳入到 applyMiddleware 函式中去建立,這裡的重點是我們將 dispatch 替換了例子中的那個函式形參,因為這樣的話資料又回到了 Redux 中去處理了,這個 applyMiddleware 就完全成為了一個純函式,只是起到了整合中介軟體的作用,對資料並不會產生修改等無法預測的影響。現在我們 dispatch 一個常量或者一個物件是沒問題的,但是我們想 dispatch 一個函式是不行的,所以再繼續修改下:
function applyMiddleware(arg1,arglog,arg2,arg3){
...
var middlewareAPI = {
getState: store.getState,
dispatch: (action) => newDispatch(action)
}
...
}
let thunk = function({dispatch,getState}){
return function(next){
return function(action){
// dispatch的是函式
if (typeof action === 'function') {
return action(dispatch, getState)
}
//普通呼叫
return next(action)
}
}
}
複製程式碼
這樣改裝之後我們也就明白了中介軟體的第一層函式中的物件形參是什麼了,就是在 middlewareAPI 這個物件,這樣的話在中介軟體函式中我們可以轉發引數到 dispatch(fun) 中,比如使用 thunk 這個中介軟體:
var common = function(dispatch){
console.log('2s之後會列印這個非同步結果,請稍後...')
setTimeout(function(){
dispatch({
type: 'UPDATAS',
data: 'newdata'
})
},2000)
}
store.dispatch(common)
複製程式碼
thunk 函式中第三層的形參 action 就是 common 函式,common 裡面執行 dispatch 時候觸發了 newDispatch,此時的 newDispatch 已經有了新值,它執行的時候會觸發 next(action),此時的 next 就是在 applyMiddleware 中給第一個中介軟體傳的最後一個引數 dispatch。
這個是有點太繞了,就是函式的各種回撥,打個斷點,慢慢的看執行流程,再回過頭來肉眼多看幾遍程式碼的處理流程,就會明白。現在我們引數都是寫的固定的傳入兩個中介軟體,假如數我想傳入多個現在的方式就不適用了,所以還是需要繼續優化,利用引數擷取,除去後兩個固定的引數,剩下的就是我們的中介軟體的個數,但是這樣不是很好,再把問題複雜化一下,現在我還是想直接呼叫 createStore 來建立 store,將 applyMiddleware 作為引數傳給 createStore,看看如何改寫:
function creatStore(reducer, preloadState, enhancer){
//creatStore(reducer,middelewareFun) 只有兩個引數時(第二個引數為中介軟體函式,preloadState可選) 將第二個引數視為enhancer增強函式
if(typeof preloadState === 'function' && typeof enhancer === 'undefined'){
enhancer = preloadState;
preloadState = undefined;
}
//如果傳了中介軟體則處理 否則直接執行無中間的情況
if(typeof enhancer !== 'undefined'){
if(typeof enhancer !== 'function'){
throw new Error('中介軟體必須是一個函式!');
}
/**
* enhancer是上面applyMiddleware函式返回的匿名函式 接收了 enhancer 傳來的 createStore
* // 第一層匿名函式
* return function (createStore) {
// 接收了 enhancer(createStore) 傳來的 reducer, preloadedState
return function (reducer, preloadedState, enhancer) {
...
}
};
*/
return enhancer(creatStore)(reducer, preloadState);
}
//reducer為一個函式必須
if(typeof reducer !== 'function'){
throw new Error('reducer 必須為一個函式');
}
}
const store = creatStore(changeState, applyMiddleware(thunk,looger));
複製程式碼
第一層判斷就是防止第二個引數不傳入預設值,做一個引數交換,剩下的無非都是做一些限制性的判斷。這裡最關鍵的一句話就是 return enhancer(creatStore)(reducer, preloadState); 說白了這個還是去主動呼叫的 applyMiddleware 換一身衣服而已。applyMiddleware 這個函式也得變變身:
function applyMiddleware(...middlewares){
//第一層匿名函式(createStore)接收一個引數
return (createStore) => (...args) => {
// 第二層匿名函式...args代表(reducer, preloadedState)接收兩個引數
/**
* 在下面的函式creatStore的enhancer(creatStore)(reducer, preloadState)
* 只傳了reducer, preloadState兩個引數 也就是在這個過程中就當做無中介軟體的情況處理
*/
var store = createStore(...args)
...
}
}
複製程式碼
這樣的好處就是我們所有的中介軟體函式只傳給 applyMiddleware 就可以,使用者呼叫的時候引數是分離的,沒有參在一塊,執行順序是 applyMiddleware(thunk,looger) --> creatStore() --> enhancer(creatStore)(reducer, preloadState) ,具體的引數說明請看註釋。改寫完了函式的外表,applyMiddleware 中的中介軟體處理函式也可以接著改寫:
function applyMiddleware(...middlewares){
...
var middlewareAPI = {
getState: store.getState,
dispatch: (action) => dispatch(action)
}
middlewares.forEach(middleware =>
dispatch = middleware(middlewareAPI)(dispatch)
)
return {
...store,
dispatch
}
}
複製程式碼
我們上面是建立一個 newDispatch 來儲存新的 dispatch,沒有必要這樣處理,直接將 dispatch 覆蓋掉就行,所以我們這樣可以通過迴圈,處理傳入進來的中介軟體函式,不需要手動一個個單獨執行。到這裡雖然已經可以了,但是還是想折騰一下,換個寫法試試:
function middle1(api){
return (next) => (num3) => {
return next(num3)
}
}
function middle2(api){
return (num2) => (num) => {
return num2(num)
}
}
function applyMiddleware(arg1,arg2){
const api = {}
var a = arg1(api);
var b = arg2(api);
let newDispatch = a(b(function(num){
console.log(num+':我是外面傳來的資料')
}));
return {
dispatch:newDispatch
}
}
applyMiddleware(middle1,middle2).dispatch(6) // 6:我是外面傳來的資料
複製程式碼
applyMiddleware 中採用 compose 的方式處理結果也是一樣,執行流程也是一樣的,只不過它又是一個變身的寫法,同樣要處理不固定引數,所以要實現 fun1(fun2(fun3('我是store.dispatch'))) 到 compose([fun1,fun2,fun3])('我是store.dispatch') 這個過程才是重點,可以通過多種方式來實現。
方式1:最容易理解的一種方式,就是通過 for 迴圈,依次遍歷執行並將結果返回給下一個函式執行,這個的迴圈是逆序的,我們可以將陣列翻轉一下,正序使用迴圈遍歷也可以。
function compose(...fns) {
return function (res) {
for (var i = fns.length - 1; i > -1; i--) {
res = fns[i](res)
}
return res
}
}
複製程式碼
方式2:使用遞迴。
function compose(...args) {
let count = args.length - 1
let result
return function fun (...arg1) {
result = args[count].apply(null, arg1)
if (count <= 0) {
return result
}
count--
return fun.call(null, result)
}
}
複製程式碼
方式3:藉助高階函式 reduce 來實現,它上一次處理的返回值,無預設指定初始值引數時候引數1代表處理的陣列第一個值,引數2從 1 開始計算下標, 引數2代表當前處理的資料。
function compose(...funcs){
return function(...args){
return funcs.reduce(function(a,b){
//a 上一次處理的返回值(無預設指定初始值引數時候a代表處理的陣列第一個值,b從1開始計算下標) b當前處理的資料
return a(b(...args))
})
}
}
複製程式碼
方式4:藉助高階函式 reduceRight 來實現,它的執行邏輯和 reduce 相反。
function compose(...funcs){
return function(...args){
return funcs.reduceRight(function(a,b){
// 和reduce對比 這裡的引數位置對換下
return b(a(...args))
})
}
}
}
複製程式碼
所以我們的 applyMiddleware 可以使用為 compose 的形式改寫:
function applyMiddleware(...middlewares){
...
var chain = []
chain = middlewares.map(middleware => middleware(middlewareAPI))
dispatch = compose(...chain)(store.dispatch)
...
}
複製程式碼
到這裡中間處理函式我們已經完全實現了。
接下來再優化程式碼,比如,我們要保證 reducer 為一個純函式,對它來做一些限制,比如我們在 reducer 函式中又執行了 store.dispatch({type:'UPDATA',data:2}) 不加鎖就會死迴圈等等:
function creatStore(reducer, preloadState, enhancer){
...
let isDispatching = false;
const subscribe = (listenerFun) => {
//訂閱不允許在reducer操作
if(isDispatching){
throw new Error('Reducer 中不可以subscribe');
}
listeners.push(listenerFun);
}
const getState = () => {
//執行到 state = reducer(state, action); isDispatching 變成 true 未執行到 finally
if(isDispatching){
throw new Error('Reducer 中不可以讀取state');
}
return state
};
const dispatch = (action) => {
if (isDispatching) {
throw new Error('Reducers中不允許執行dispatch')
}
try{
//到這裡會出現死迴圈的情況 所以需要加鎖
isDispatching = true;
state = reducer(state, action);
}finally{
isDispatching = false;
}
for(let i = 0, len = listeners.length; i < len; i++){
listeners[i](state);
}
}
...
}
複製程式碼
增加了一個 isDispatching 鎖來判斷,在取值那裡的 reducer 是不允許操作 state ,儘管可以取到,但是就是規定不允許這麼做。到這裡還得繼續考慮一個比較嚴重的問題,我們現在只能處理一個 reducer,業務複雜的情況下,就這麼一箇中轉站,程式碼會十分龐大,很難去維護,所以需要拆分為多個 reducer,所以還得需要一個功能,就是把這些小的中轉站再彙集到一個大的中轉站,這樣資料還是由大的中轉自動處理,我們只管理各個小的 就可以,首先我們要明確資料格式應該轉換成什麼樣子:
var allState = combineReducers({
reducer,
reducer1
...
});
處理之後 allState 的資料格式如下:
{
reducer: {...reducer},
reducer1: {...reducer1}
}
複製程式碼
其實就是把原來零散的資料統一集中到一個大的 obj 下面了,建立 combineReducers 功能函式:
const combineReducerss = reducers => {
//這裡得到的state在creatstore之後合併為一個{a:{..},b:{...},...}這種形式
return (state = {}, action) => {
let all = {}
for(let key in reducers){
//reducers[key](state[key], action);中state[key]拆開為對應各個reducer的state 最後返回整個集合
all[key] = reducers[key](state[key], action);
}
return all
};
};
var reducer = combineReducers({
reducer1,
reducer2
...
});
複製程式碼
第一次呼叫 combineReducers 返回一個函式,是因為我們在 createStore 函式裡面 state = reducer(state, action) 這樣呼叫 reducer,相當於去執行了所有的 reducer,並最終將資料返回。我們也可以使用函數語言程式設計的方式,使用高階函式 reduce 將上面的方法再換個包裝:
const combineReducers = reducers => {
return (state = {}, action) => {
return Object.keys(reducers).reduce(
(nextState, key) => {
nextState[key] = reducers[key](state[key], action);
return nextState;
},
{}
);
};
};
複製程式碼
和 for 迴圈是一個道理,只不過這裡的 reduce 傳入了一個預設的引數,因為讓它預設是一個物件,才可以使用 Object[key] 的形式。現在每次都會返回一個新生成的物件,假如資料沒有變化,也是返回一個重新生成的物件,現在再優化一下:
const combineReducers = reducers => {
return (state = {}, action) => {
let hasChanged = false
const nextState = {}
for(let key in reducers){
const reducer = reducers[key]
const previousStateForKey = state[key]
const nextStateForKey = reducer(previousStateForKey, action)
nextState[key] = nextStateForKey
hasChanged = hasChanged || nextStateForKey !== previousStateForKey
}
return hasChanged ? nextState : state
};
};
複製程式碼
這裡重要的是 nextStateForKey !== previousStateForKey 做一個物件變化前後的對比,比較的是引用是否相同,而不是比較值,hasChanged = hasChanged || nextStateForKey !== previousStateForKey 預設是 false,只要前後資料對比發生了引用的變化,則 hasChanged 肯定就是 true,會返回 nextState 全新的狀態,資料引用一致的情況就不對外公佈新的狀態,還是返回原來的 state。
總結
我們已經把 Redux 的實現過程做了一個比較詳細的介紹,感謝您認真地看完了本篇文章,現在再回去翻看 Redux 的原始碼就會感覺比較有個清晰的認識了,相信裡面有些比較繞的寫法也會很清晰的讀懂。
釋出訂閱、組合函式、函式柯里化、函數語言程式設計等技巧在 Redux 中展現的淋漓盡致,尤其是 compose 功能的實現,還可以使用 Promise、Generator 等方式實現(本文沒有列舉),所以在以後的程式碼編寫過程中還是要多創新,把問題去複雜化一下有時也是好事!
第二小節程式碼:https://github.com/wineSu/redux/blob/master/demo.js 完整程式碼:https://github.com/wineSu/redux/blob/master/redux4.js