探索 Redux4.0 版本迭代 論基礎談展望(對比 React context)

lucas_580e331d326b4發表於2019-02-16

DJ Snake on libe

Redux 在幾天前(2018.04.18)釋出了新版本,6 commits 被合入 master。從誕生起,到如今 4.0 版本,Redux 保持了使用層面的平滑過渡。同時前不久, React 也從 15 升級到 16 版本,開發者並不需要作出太大的變動,即可“無痛升級”。但是在版本迭代的背後很多有趣的設計值得了解。Redux 此次升級同樣如此。

本文將從此次版本升級展開,從原始碼改動入手,進行分析。通過後文內容,相信讀者能夠在 JavaScript 基礎層面有更深認識。

本文支援前端初學者學習,同時更適合有 Redux 原始碼閱讀經驗者,核心原始碼並不會重複分析,更多將聚焦在升級改動上。

改動點總覽

這次升級改動點一共有 22 處,最主要體現在 TypeScript 使用、CommonJS 和 ES 構建、關於 state 拋錯三方面上。對於工程和配置的改動,我們不再多費筆墨。主要從程式碼細節入手,基礎入手,著重分析以下幾處改動:

  • 中介軟體 API dispatch 引數處理;
  • applyMiddleware 改動;
  • bindActionCreators 對 this 透明化處理;
  • dispatching 時,對 state 的凍結;
  • Plain Object 型別判斷;

話不多說,我們直接進入正題。

applyMiddleware 引數處理

這項改動由 Asvarox 提出。熟悉 Redux 原始碼中 applyMiddleware.js 設計的讀者一定對 middlewareAPI 並不陌生:對於每個中介軟體,都可以感知部分 store,即 middlewareAPI。這裡簡單展開一下:

 const middlewareAPI = {
   getState: store.getState,
   dispatch: (action) => dispatch(action)
 };
 chain = middlewares.map(middleware => middleware(middlewareAPI));
 dispatch = compose(...chain)(store.dispatch)

建立一箇中介軟體 store:

let newStore = applyMiddleware(mid1, mid2, mid3, ...)(createStore)(reducer, null);

我們看,applyMiddleware 是個三級 curry 化的函式。它將陸續獲得了三個引數,第一個是 middlewares 陣列,[mid1, mid2, mid3, …],第二個是 Redux 原生的 createStore,最後一個是 reducer;

applyMiddleware 利用 createStore 和 reducer 建立了一個 store,然後 store 的 getState 方法和 dispatch 方法又分別被直接和間接地賦值給 middlewareAPI 變數。middlewares 陣列通過 map 方法讓每個 middleware 帶著 middlewareAPI 這個引數分別執行一遍。執行完後,獲得 chain 陣列,[f1, f2, … , fx, …,fn],接著 compose 將 chain 中的所有匿名函式,[f1, f2, … , fx, …, fn],組裝成一個新的函式,即新的 dispatch,當新 dispatch 執行時,[f1, f2, … , fx, …, fn] 將會從右到左依次執行。以上解釋改動自:pure render 專欄

好了,把中介軟體機制簡要解釋之後,我們看看這次改動。故事源於 Asvarox 設計了一個自定義的中介軟體,這個中介軟體接收的 dispatch 需要兩個引數。他的“傑作”就像這樣:

const middleware = ({ dispatch }) => next => (actionCreator, args) => dispatch(actionCreator(...args));

對比傳統編寫中介軟體的套路:

const middleware = store => next => action => {...}

我們能清晰地看到他的這種編寫方式會有什麼問題:在原有 Redux 原始碼基礎上,actionCreator 引數後面的 args 將會丟失。因此他提出的改動點在:

     const middlewareAPI = {
       getState: store.getState,
-      dispatch: (action) => dispatch(action)
+      dispatch: (...args) => dispatch(...args)
     }

如果你好奇他為什麼會這樣設計自己的中介軟體,可以參考 #2501 號 issue。我個人認為對於需求來說,他的這種“奇葩”方式,可以通過其他手段來規避;但是對於 Redux 庫來說,將 middlewareAPI.dispatch 引數展開,確實是更合適的做法。

此項改動我們點到為止,不再鑽牛角尖。應該學到:基於 ES6 的不定引數與展開運算子的妙用。雖然一直在說,一直在提,但在真正開發程式時,我們仍然要時刻注意,並養成良好習慣。

基於此,同樣的改動也體現在:

   export default function applyMiddleware(...middlewares) {
  -  return (createStore) => (reducer, preloadedState, enhancer) => {
  -  const store = createStore(reducer, preloadedState, enhancer)
  +  return (createStore) => (...args) => {
  +  const store = createStore(...args)
     let dispatch = store.dispatch
     let chain = []

這項改動由 jimbolla 提出。

bindActionCreators 對 this 透明化處理

Redux 中的 bindActionCreators,達到 dispatch 將 action 包裹起來的目的。這樣通過 bindActionCreators 建立的方法,可以直接呼叫 dispatch(action) (隱式呼叫)。可能很多開發者並不常用,所以這裡稍微展開,在 action.js 檔案中, 我們定義了兩個 action creators:

function action1(){
  return {
   type:`type1`
  }
}
function action2(){
  return {
   type:`type2`
  }
}

在另一檔案 SomeComponent.js 中,我們便可以直接使用:

import { bindActionCreators } from `redux`;
import * as oldActionCreator from `./action.js`

class C1 extends Component {
  constructor(props) { 
    super(props);

    const {dispatch} = props;
    this.boundActionCreators = bindActionCreators(oldActionCreator, dispatch);
  }

  componentDidMount() {
    // 由 react-redux 注入的 dispatch:
    let { dispatch } = this.props;
    let action = TodoActionCreators.addTodo(`Use Redux`);
    dispatch(action);
  }

  render() {
      // ...
      let { dispatch } = this.props;
      let newAction = bindActionCreators(oldActionCreator, dispatch)
      return <Child {...newAction}></child>
  }
}

這樣一來,我們在子元件 Child 中,直接呼叫 newAction.action1 就相當於呼叫 dispatch(action1),如此做的好處在於:沒有 store 和 dispatch 的元件,也可以進行動作分發。

一般這個 API 應用不多,至少筆者不太常用。因此上面做一個簡單介紹。有經驗的開發中一定不難猜出 bindActionCreators 原始碼做了什麼,連帶著這次改動:

function bindActionCreator(actionCreator, dispatch) {
-  return (...args) => dispatch(actionCreator(...args))
+  return function() { return dispatch(actionCreator.apply(this, arguments)) }
 }

我們看這次改動,對 actionCreator 使用 apply 方法,明確地進行 this 繫結。那麼這樣做的意義在哪裡呢?

我舉一個例子,想象我們對原始的 actionCreator 進行 this 繫結,並使用 bindActionCreators 方法:

const uniqueThis = {};
function actionCreator() {
  return { type: `UNKNOWN_ACTION`, this: this, args: [...arguments] }
};
const action = actionCreator.apply(uniqueThis,argArray);
const boundActionCreator = bindActionCreators(actionCreator, store.dispatch);
const boundAction = boundActionCreator.apply(uniqueThis,argArray);

我們應該期望 boundAction 和 action 一致;且 boundAction.this 和 uniqueThis 一致,都等同於 action.this。這如此的期望下,這樣的改動無疑是必須的。

對 state 的凍結

Dan Abramov 認為,在 reducer 中使用 getState() 和 subscribe() 方法是一種反模式。store.getState 的呼叫會使得 reducer 不純。事實上,原版已經在 reducer 執行過程中,禁用了 dispatch 方法。原始碼如下:

  function dispatch(action) {
    // ...

    if (isDispatching) {
      throw new Error(`Reducers may not dispatch actions.`)
    }

    try {
      isDispatching = true
      currentState = currentReducer(currentState, action)
    } finally {
      isDispatching = false
    }

    var listeners = currentListeners = nextListeners
    for (var i = 0; i < listeners.length; i++) {
      listeners[i]()
    }

    return action
  }

同時,這次修改在 getState 方法以及 subscribe、unsubscribe 方法中進行了同樣的凍結處理:

 if (isDispatching) {
  throw new Error(
    `You may not call store.subscribe() while the reducer is executing. ` +
      `If you would like to be notified after the store has been updated, subscribe from a ` +
      `component and invoke store.getState() in the callback to access the latest state. ` +
      `See https://redux.js.org/api-reference/store#subscribe(listener) for more details.`
  )
}

筆者認為,這樣的做法毫無爭議。顯式丟擲異常無意是合理的。

Plain Object 型別判斷

Plain Object 是一個非常有趣的概念。這次改動圍繞判斷 Plain Object 的效能進行了激烈的討論。最終將引用 lodash isPlainObject 的判斷方法改為 ./utils/isPlainObject 中自己封裝的做法:

- import isPlainObject from `lodash/isPlainObject`;
+ import isPlainObject from `./utils/isPlainObject`

簡單來說,Plain Object:

指的是通過字面量形式或者new Object()形式定義的物件。

Redux 這次使用了以下程式碼來進行判斷:

export default function isPlainObject(obj) {
  if (typeof obj !== `object` || obj === null) return false

  let proto = obj
  while (Object.getPrototypeOf(proto) !== null) {
    proto = Object.getPrototypeOf(proto)
  }

  return Object.getPrototypeOf(obj) === proto
}

如果讀者對上述程式碼不理解,那麼需要補一下原型、原型鏈的知識。簡單來說,就是判斷 obj 的原型鏈有幾層,只有一層就返回 true。如果還不理解,可以參考下面示例程式碼:

function Foo() {}

// obj 不是一個 plain object
var obj = new Foo();

console.log(typeof obj, obj !== null);

let proto = obj
while (Object.getPrototypeOf(proto) !== null) {
  proto = Object.getPrototypeOf(proto)
}

// false
var isPlain = Object.getPrototypeOf(obj) === proto;
console.log(isPlain);

而 loadash 的實現為:

function isPlainObject(value) {
  if (!isObjectLike(value) || baseGetTag(value) != `[object Object]`) {
    return false
  }
  if (Object.getPrototypeOf(value) === null) {
    return true
  }
  let proto = value
  while (Object.getPrototypeOf(proto) !== null) {
    proto = Object.getPrototypeOf(proto)
  }
  return Object.getPrototypeOf(value) === proto
}

export default isPlainObject

isObjectLike 原始碼:

function isObjectLike(value) {
  return typeof value == `object` && value !== null
}

baseGetTag 原始碼:

const objectProto = Object.prototype
const hasOwnProperty = objectProto.hasOwnProperty
const toString = objectProto.toString
const symToStringTag = typeof Symbol != `undefined` ? Symbol.toStringTag : undefined
function baseGetTag(value) {
  if (value == null) {
    return value === undefined ? `[object Undefined]` : `[object Null]`
  }
  if (!(symToStringTag && symToStringTag in Object(value))) {
    return toString.call(value)
  }
  const isOwn = hasOwnProperty.call(value, symToStringTag)
  const tag = value[symToStringTag]
  let unmasked = false
  try {
    value[symToStringTag] = undefined
    unmasked = true
  } catch (e) {}

  const result = toString.call(value)
  if (unmasked) {
    if (isOwn) {
      value[symToStringTag] = tag
    } else {
      delete value[symToStringTag]
    }
  }
  return result
}

根據 timdorr 給出的對比結果,dispatch 方法中:

master: 4690.358ms
nodash: 82.821ms

這一組 benchmark 引發的討論自然少不了,也引出來了 Dan Abramov。筆者對此不發表任何意見,感興趣的同學可自行研究。從結果上來看,摒除了部分對 lodash 的依賴,在效能表現上說服力增強。

展望和總結

提到 Redux 發展,自然離不開 React,React 新版本一經推出,極受追捧。尤其是 context 這樣的新 API,某些開發者認為將逐漸取代 Redux。

筆者認為,圍繞 React 開發應用,資料狀態管理始終是一個極其重要的話題。但是 React context 和 Redux 並不是完全對立的

首先 React 新特性 context 在大型資料應用的前提下,並不會減少模版程式碼。而其 Provider 和 Consumer 的一一對應特性,即 Provider 和 Consumer 必須來自同一次 React.createContext 呼叫(可以用 hack 方式解決此“侷限”),彷彿 React 團隊對於此特性的發展方向設計主要體現在小型狀態管理上。如果需要實現更加靈活和直接的操作,Redux 也許會是更好的選擇。

其次,Redux 豐富的生態以及中介軟體等機制,決定了其在很大程度上具有不可替代性。畢竟,已經使用 Redux 的專案,遷移成本也將是極大的,至少需要開發中先升級 React 以支援新版 context 吧。

最後,Redux 作為一個“釋出訂閱系統”,完全可以脫離 React 而單獨存在,這樣的基因也決定了其後天與 React 本身 context 不同的性徵。

我認為,新版 React context 是對 React 本身“短板”的長線補充和完善,未來大概率也會有所打磨調整。Redux 也會進行一系列迭代,但就如同這次版本升級一樣,將趨於穩定,更多的是細節上調整。

退一步講,React context 的確也和 Redux 有千絲萬縷的聯絡。任何類庫或者框架都具有其短板,Redux 同樣也如此。我們完全可以使用新版 React context,在使用層面來規避 Redux 的一些劣勢,模仿 Redux 所能做到的一切。如同 didierfranc 的 react-waterfall,國內@方正的 Rectx,都是基於新版 React context 的解決方案。

最後,我很贊同@誠身所說:
選擇用什麼樣的工具從來都不是決定一個開發團隊成敗的關鍵,根據業務場景選擇恰當的工具,並利用工具反過來約束開發者,最終達到控制整體專案複雜度的目的,才是促進一個開發團隊不斷提升的核心動力。

沒錯,真正對專案起到決定性作用的還是是開發者本身,完善基礎知識,提升開發技能,讓我們從 Redux 4.0 的改動看起吧。

廣告時間:
如果你對前端發展,尤其對 React 技術棧感興趣:我的新書中,也許有你想看到的內容。關注作者 Lucas HC,新書出版將會有送書活動。

Happy Coding!

PS: 作者 Github倉庫 和 知乎問答連結 歡迎各種形式交流!

我的其他幾篇關於React技術棧的文章:

從setState promise化的探討 體會React團隊設計思想

React 應用設計之道 – curry 化妙用

元件複用那些事兒 – React 實現按需載入輪子

通過例項,學習編寫 React 元件的“最佳實踐”

React 元件設計和分解思考

從 React 繫結 this,看 JS 語言發展和框架設計

做出Uber移動網頁版還不夠 極致效能打造才見真章**

React+Redux打造“NEWS EARLY”單頁應用 一個專案理解最前沿技術棧真諦

相關文章