如何在非 React 專案中使用 Redux

鬍子大哈發表於2017-06-28
  • 轉載請註明出處,保留原文連結和作者資訊。

目錄

  • 1、前言
  • 2、單純使用 Redux 的問題
    • 2.1、問題 1:程式碼冗餘
    • 2.2、問題2:不必要的渲染
  • 3、React-redux 都幹了什麼
  • 4、構建自己專案中的 “Provider” 和 “connect”
    • 4.1、包裝渲染函式
    • 4.2、避免沒有必要的渲染
  • 5、總結
  • 6、練習

1、前言

最近在知乎上看到這麼一個問題: 請教 redux 與 eventEmitter? - 知乎

最近一個小專案中(沒有使用 react),因為事件、狀態變化稍多,想用 redux 管理,可是並沒有發現很方便。..

說起 Redux,我們一般都說 React。似乎 Redux 和 React 已經是天經地義理所當然地應該捆綁在一起。而實際上,Redux 官方給自己的定位卻是:

Redux is a predictable state container for JavaScript apps.

Redux 絕口不提 React,它給自己的定義是 “給 JavaScript 應用程式提供可預測的狀態容器”。也就是說,你可以在任何需要進行應用狀態管理的 JavaScript 應用程式中使用 Redux。

但是一旦脫離了 React 的環境,Redux 似乎就脫韁了,用起來桀驁不馴,難以上手。本文就帶你分析一下問題的原因,並且提供一種在非 React 專案中使用 Redux 的思路和方案。這不僅僅對在非 React 的專案中使用 Redux 很有幫助,而且對理解 React-redux 也大有裨益。

本文假設讀者已經熟練掌握 React、Redux、React-redux 的使用以及 ES6 的基本語法。

2、單純使用 Redux 的問題

我們用一個非常簡單的例子來講解一下在非 React 專案中使用 Redux 會遇到什麼問題。假設頁面上有三個部分,header、body、footer,分別由不同模組進行渲染和控制:

<div id='header'></div>
<div id='body'></div>
<div id='footer'></div>複製程式碼

這個三個部分的元素因為有可能會共享和發生資料變化,我們把它存放在 Redux 的 store 裡面,簡單地構建一個 store:

const appReducer = (state, action) => {
  switch (action.type) {
    case 'UPDATE_HEADER':
      return Object.assign(state, { header: action.header })
    case 'UPDATE_BODY':
      return Object.assign(state, { body: action.body })
    case 'UPDATE_FOOTER':
      return Object.assign(state, { footer: action.footer })
    default:
      return state
  }
}

const store = Redux.createStore(appReducer, {
  header: 'Header',
  body: 'Body',
  footer: 'Footer'
})複製程式碼

很簡單,上面定義了一個 reducer,可以通過三個不同的 action:UPDATE_HEADERUPDATE_BODYUPDATE_FOOTER 來分別進行對頁面資料進行修改。

有了 store 以後,頁面其實還是空白的,因為沒有把 store 裡面的資料取出來渲染到頁面。接下來構建三個渲染函式,這裡使用了 jQuery:

/* 渲染 Header */
const renderHeader = () => {
  console.log('render header')
  $('#header').html(store.getState().header)
}
renderHeader()

/* 渲染 Body */
const renderBody = () => {
  console.log('render body')
  $('#body').html(store.getState().body)
}
renderBody()

/* 渲染 Footer */
const renderFooter = () => {
  console.log('render footer')
  $('#footer').html(store.getState().footer)
}
renderFooter()複製程式碼

現在頁面就可以看到三個 div 元素裡面的內容分別為:HeaderBodyFooter。我們打算 1s 以後通過 store.dispatch 更新頁面的資料,模擬 app 資料發生了變化的情況:

/* 資料發生變化 */
setTimeout(() => {
  store.dispatch({ type: 'UPDATE_HEADER', header: 'New Header' })
  store.dispatch({ type: 'UPDATE_BODY', body: 'New Body' })
  store.dispatch({ type: 'UPDATE_FOOTER', footer: 'New Footer' })
}, 1000)複製程式碼

然而 1s 以後頁面沒有發生變化,這是為什麼呢?那是因為資料變化的時候並沒有重新渲染頁面(呼叫 render 方法),所以需要通過 store.subscribe 訂閱資料發生變化的事件,然後重新渲染不同的部分:

store.subscribe(renderHeder)
store.subscribe(renderBody)
store.subscribe(renderFooter)複製程式碼

好了,現在終於把 jQuery 和 Redux 結合起來了。成功了用 Redux 管理了這個簡單例子裡面可能會發生改變的狀態。但這裡有幾個問題:

2.1、問題 1:程式碼冗餘

編寫完一個渲染的函式以後,需要手動進行第一次渲染初始化;然後手動通過 store.subscribe 監聽 store 的資料變化,在資料變化的時候進行重新呼叫渲染函式。這都是重複的程式碼和沒有必要的工作,而且還可能提供了忘了subscribe 的可能。

2.2、問題2:不必要的渲染

上面的例子中,程式進行一次初始化渲染,然後資料更新的渲染。3 個渲染函式裡面都有一個 log。兩次渲染最佳的情況應該只有 6 個 log。

但是你可以看到出現了 12 個log,那是因為後續修改 UPDATE_XXX ,除了會導致該資料進行渲染,還會導致其餘兩個資料重新渲染(即使它們其實並沒有變化)。store.subscribe 一股腦的呼叫了全部監聽函式,但其實資料沒有變化就沒有必要重新渲染。

以上的兩個缺點在功能較為複雜的時候會越來越凸顯。

3、React-redux 都幹了什麼

可以看到,單純地使用 Redux 和 jQuery 目測沒有給我們帶來什麼好處和便利。是不是就可以否了 Redux 在非 React 專案中的用處呢?

回頭想一下,為什麼 Redux 和 React 結合的時候並沒有出現上面所提到的問題?你會發現,其實 React 和 Redux 並沒有像上面這樣如此暴力地結合在一起。在 React 和 Redux 這兩個庫中間其實隔著第三個庫:React-redux。

在 React + Redux 專案當中,我們不需要自己手動進行 subscribe,也不需要手動進行過多的效能優化,恰恰就是因為這些髒活累活都由 React-redux 來做了,對外只提供了一個 Providerconnect 的方法,隱藏了關於 store 操作的很多細節。

所以,在把 Redux 和普通專案結合起來的時候,也可以參考 React-redux,構建一個工具庫來隱藏細節、簡化工作。

這就是接下來需要做的事情。但在構建這個簡單的庫之前,我們需要了解一下 React-redux 幹了什麼工作。 React-redux 給我們提供了什麼功能?在 React-redux 專案中我們一般這樣使用:

import { connect, Provider } from 'react-redux'

/* Header 元件 */
class Header extends Component {
  render () {
    return (<div>{this.props.header}</div>)
  }
}

const mapStateToProps = (state) => {
  return { header: state.header }
}
Header = connect(mapStateToProps)(Header)

/* App 元件 */
class App extends Component {
  render () {
    return (
      <Provider store={store}>
        <Header />
      </Provider>
    )
  }
}複製程式碼

我們把 store 傳給了 Provider,然後其他元件就可以使用 connect 進行取資料的操作。connect 的時候傳入了 mapStateToPropsmapStateToProps 作用很關鍵,它起到了提取資料的作用,可以把這個元件需要的資料按需從 store 中提取出來。

實際上,在 React-redux 的內部:Provider 接受 store 作為引數,並且通過 context 把 store 傳給所有的子元件;子元件通過 connect 包裹了一層高階元件,高階元件會通過 context 結合 mapStateToPropsstore 然後把裡面資料傳給被包裹的元件。

如果你看不懂上面這段話,可以參考 動手實現 React-redux。說白了就是 connect 函式其實是在 Provider 的基礎上構建的,沒有 Provider 那麼 connect 也沒有效果。

React 的元件負責渲染工作,相當於我們例子當中的 render 函式。類似 React-redux 圍繞元件,我們圍繞著渲染函式,可以給它們提供不同於、但是功能類似的 Providerconnect

4、構建自己專案中的 Providerconnect

4.1、包裝渲染函式

參考 React-redux,下面假想出一種類似的 providerconnect 可以應用在上面的 jQuery 例子當中:

/* 通過 provider 生成這個 store 對應的 connect 函式 */
const connect = provider(store)

/* 普通的 render 方法 */
let renderHeader = (props) => {
  console.log('render header')
  $('#header').html(props.header)
}

/* 用 connect 取資料傳給 render 方法 */
const mapStateToProps = (state) => {
  return { header: state.header }
}
renderHeader = connect(mapStateToProps)(renderHeader)複製程式碼

你會看到,其實我們就是把元件換成了 render 方法而已。用起來和 React-redux 一樣。那麼如何構建 providerconnect 方法呢?這裡先搭個骨架:

const provider = (store) => {
  return (mapStateToProps) => { // connect 函式
    return (render) => {
      /* TODO */
    }
  }
}複製程式碼

provider 接受 store 作為引數,返回一個 connect 函式;connect 函式接受 mapStateToProps 作為引數返回一個新的函式;這個返回的函式類似於 React-redux 那樣接受一個元件(渲染函式)作為引數,它的內容就是要接下來要實現的程式碼。當然也可以用多個箭頭的表示方法:

const provider = (store) => (mapStateToProps) => (render) => {
  /* TODO */
}複製程式碼

storemapStateToPropsrender 都有了,剩下就是把 store 裡面的資料取出來傳給 mapStateToProps 來獲得 props;然後再把 props 傳給 render 函式。

const provider = (store) => (mapStateToProps) => (render) => {
  /* 返回新的渲染函式,就像 React-redux 的 connect 返回新元件 */
  const renderWrapper = () => {
    const props = mapStateToProps(store.getState())
    render(props)
  }
  return renderWrapper
}複製程式碼

這時候通過本節一開始假想的程式碼已經可以正常渲染了,同樣的方式改寫其他部分的程式碼:

/* body */
let renderBody = (props) => {
  console.log('render body')
  $('#body').html(props.body)
}
mapStateToProps = (state) => {
  return { body: state.body }
}
renderBody = connect(mapStateToProps)(renderBody)

/* footer */
let renderFooter = (props) => {
  console.log('render footer')
  $('#footer').html(props.footer)
}
mapStateToProps = (state) => {
  return { footer: state.footer }
}
renderFooter = connect(mapStateToProps)(renderFooter)複製程式碼

雖然頁面已經可以渲染了。但是這時候呼叫 store.dispatch 是不會導致重新渲染的,我們可以順帶在 connect 裡面進行 subscribe:

const provider = (store) => (mapStateToProps) => (render) => {
  /* 返回新的渲染函式,就像 React-redux 返回新元件 */
  const renderWrapper = () => {
    const props = mapStateToProps(store.getState())
    render(props)
  }
  /* 監聽資料變化重新渲染 */
  store.subscribe(renderWrapper)
  return renderWrapper
}複製程式碼

贊。現在 store.dispatch 可以導致頁面重新渲染了,已經原來的功能一樣了。但是,看看控制檯還是列印了 12 個 log,還是沒有解決無關資料變化導致的重新渲染問題。

4.2、避免沒有必要的渲染

在上面的程式碼中,每次 store.dispatch 都會導致 renderWrapper 函式執行, 它會把 store.getState() 傳給 mapStateToProps 來計算新的 props 然後傳給 render

實際上可以在這裡做手腳:快取上次的計算的 props,然後用新的 props 和舊的 props 進行對比,如果兩者相同,就不呼叫 render

const provider = (store) => (mapStateToProps) => (render) => {
  /* 快取 props */
  let props
  const renderWrapper = () => {
    const newProps = mapStateToProps(store.getState())
    /* 如果新的結果和原來的一樣,就不要重新渲染了 */
    if (shallowEqual(props, newProps)) return
    props = newProps
    render(props)
  }
  /* 監聽資料變化重新渲染 */
  store.subscribe(renderWrapper)
  return renderWrapper
}複製程式碼

這裡的關鍵點在於 shallowEqual。因為 mapStateToProps 每次都會返回不一樣的物件,所以並不能直接用 === 來判斷資料是否發生了變化。這裡可以判斷兩個物件的第一層的資料是否全相同,如果相同的話就不需要重新渲染了。例如:

const a = { name: 'jerry' }
const b = { name: 'jerry' }

a === b // false
shallowEqual(a, b) // true複製程式碼

這時候看看控制檯,只有 6 個 log 了。成功地達到了效能優化的目的。這裡 shallowEqual 的實現留給讀者自己做練習。

到這裡,已經完成了類似於 React-redux 的一個 Binding,可以愉快地使用在非 React 專案當中使用了。完整的程式碼可以看這個 gist

5、總結

通過本文可以知道,在非 React 專案結合 Redux 不能簡單粗暴地將兩個使用起來。要根據專案需要構建這個場景下需要的工具庫來簡化關於 store 的操作,當然可以直接參照 React-redux 的實現來進行對應的繫結。

也可以總結出,其實 React-redux 的 connect 幫助我們隱藏了很多關於store 的操作,包括 store 的資料變化的監聽重新渲染、資料對比和效能優化等。

6、練習

對本文所講內容有興趣的朋友可以做一下本文配套的練習:

  1. 實現一個 shallowEqual
  2. 給 provider 加入 mapDispatchToProps

相關文章