React 知識梳理(三):手寫一個自己的 React-redux

weixin_34253539發表於2018-06-21

示例程式碼請點這裡

上一次我們簡單瞭解了一下 redux(文章在這裡),今天我們來結合 React,實現自己的 React-redux。

一、建立專案

我們用 create-react-app 建立一個新專案,刪除 src 下的冗餘部分,新增自己的檔案,如下:

# 修改後的目錄結構
++ src
++++ component
++++++ Head
-------- Head.js
++++++ Body
-------- Body.js
++++++ Button
-------- Button.js
---- App.js
---- index.css
---- index.js

// index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));

// App.js
import React, { Component } from 'react';
import Head from './component/Head/Head';
import Body from './component/Body/Body';
export default class App extends Component {
  render() {
    return (
      <div className="App">
        <Head />
        <Body />
      </div>
    );
  }
}

# Head.js
import React, { Component } from 'react';
export default class Head extends Component {
  render() {
    return (
      <div className="head">Head</div>
    );
  }
}

# Body.js
import React, { Component } from 'react';
import Button from '../Button/Button';
export default class Body extends Component {
  render() {
    return (
      <div>
        <div className="body">Body</div>
        <Button />
      </div>
    );
  }
}

# Button.js
import React, { Component } from 'react';
export default class Button extends Component {
  render() {
    return (
      <div className="button">
        <div className="btn">改變 head</div>
        <div className="btn">改變 body</div>
      </div>
    );
  }
}
複製程式碼

以上程式碼並不複雜,我們再來給他們寫點樣式,最後看下效果:

我們看到,現在 head ,和 body 內的文案都是我們寫死的,這樣並不利於我們的開發,因為這些值我們無法改變,現在我們想點選下邊按鈕的時候,改變相應的文案,以現在的程式碼我們是無法實現的。
當然,我們可以通過一系列 props 的傳遞,來達到我們的目的,可是,那樣會相當繁瑣,因為不僅涉及到父子元件的值傳遞,還有和兄弟元件的子元件之間的值傳遞。
此時,我們需要一個全域性共享的 store ,讓我們可以在任何地方都能輕鬆的訪問,可以十分便捷的完成資料的獲取和修改。

二、context

在 React 中,為我們提供了 context 這個 API 來解決這樣的巢狀場景(context具體介紹在這裡,在 React 16.3 以上的版本,context 已經有了更新,具體請看這裡)。
context 為我們提供了一個全域性共享的狀態,在任何後代元件中,都可以很輕鬆的訪問頂級元件的 store。
我們這樣修改我們的程式碼:

# App.js
import PropTypes from 'prop-types';
...
export default class App extends Component {
  static childContextTypes = {
    store: PropTypes.object
  }
  getChildContext () {
    const state = {
      head: '我是全域性 head',
      body: '我是全域性 body',
      headBtn: '修改 head',
      bodyBtn: '修改 body'
    }
    return { store: state };
  }
  render() {
   ...
  }
}


# Head.js
import React, { Component } from 'react';
import PropTypes from 'prop-types';
export default class Head extends Component {
  static contextTypes = {
    store: PropTypes.object
  }
  constructor (props) {
    super(props)
    this.state = {};
  }
  componentWillMount(){
    this._upState();
  }
  _upState(){
    const { store } = this.context;
    this.setState({
      ...store
    })
  }
  render() {
    return (
      <div className="head">{this.state.head}</div>
    );
  }
}


# body.js
import PropTypes from 'prop-types';
...
export default class Body extends Component {
  static contextTypes = {
    store: PropTypes.object
  }
  constructor (props) {
    super(props)
    this.state = {};
  }
  componentWillMount(){
    this._upState();
  }
  _upState(){
    const { store } = this.context;
    this.setState({
      ...store
    })
  }
  render() {
    return (
      <div>
        <div className="body">{this.state.body}</div>
        <Button />
      </div>
    );
  }
}

# Button.js
import React, { Component } from 'react';
import PropTypes from 'prop-types';
export default class Button extends Component {
  static contextTypes = {
    store: PropTypes.object
  }
  constructor (props) {
    super(props)
    this.state = {};
  }
  componentWillMount(){
    this._upState();
  }
  _upState(){
    const { store } = this.context;
    this.setState({
      ...store
    })
  }
  render() {
    return (
      <div className="button">
        <div className="btn">{this.state.headBtn}</div>
        <div className="btn">{this.state.bodyBtn}</div>
      </div>
    );
  }
}
複製程式碼

檢視頁面,我們可以看到,在頂層元件中的全域性 store 已經被各個後代元件訪問到:

我們再來梳理下使用 context 的步驟:

1、在頂層元件中通過 childContextTypes 規定資料型別。
2、在頂層元件中通過 getChildContext 設定資料。
3、在後代元件中通過 contextTypes 規定資料型別。
4、在後代元件中通過 context 引數獲取資料。

通過以上步驟,我們建立了一個全域性共享的 store 。你可能會有疑問,為什麼在後代元件中我們定義了 _upState 方法,而沒有把內容直接寫在生命週期中,這個問題先不回答,在下面,你將會看到為什麼。現在,我們來把這個 store 和我們之前寫的 redux 進行結合(有關 redux 的部分,請看上一篇文章,?這裡

三、React-redux

我們來新建 redux 資料夾,完成我們的 redux(關於以下程式碼含義,請看上一篇文章):

# index.js
export * from './createStore';
export * from './storeChange';

# createStore.js
export const createStore = (state, storeChange) => {
  const listeners = [];
  let store = state || {};
  const subscribe = (listen) => listeners.push(listen);
  const dispatch = (action) => {
    const newStore = storeChange(store, action);
    store = newStore; 
    listeners.forEach(item =>  item())
  };
  const getStore = () => {
    return store;
  }
  return { store, dispatch, subscribe, getStore }
}

# storeChange.js
export const storeChange = (store, action) => {
  switch (action.type) {
    case 'HEAD':
      return { 
        ...store,  
        head: action.head
      }
    case 'BODY':
      return { 
        ...store,
        body: action.body
      }
    default:
      return { ...store }
  }
}
複製程式碼

通過以上程式碼,我們完成了 redux ,其中 createStore.js 的程式碼,幾乎完全和上一篇內容相同,只是略作了修改,有興趣的朋友可以自己看下。現在我們來和 context 結合:

# App.js
...
import { createStore, storeChange } from './redux';

export default class App extends Component {
  static childContextTypes = {
    store: PropTypes.object,
    dispatch: PropTypes.func,
    subscribe: PropTypes.func,
    getStore: PropTypes.func
  }
  getChildContext () {
    const state = {
      head: '我是全域性 head',
      body: '我是全域性 body',
      headBtn: '修改 head',
      bodyBtn: '修改 body'
    }
    const { store, dispatch, subscribe, getStore } = createStore(state,storeChange)
    return { store, dispatch, subscribe, getStore };
  }
  render() {
   ...
  }
}

# Head.js
...
export default class Head extends Component {
  static contextTypes = {
    store: PropTypes.object,
    subscribe: PropTypes.func,
    getStore: PropTypes.func
  }
  ...
  componentWillMount(){
    const { subscribe } = this.context;
    this._upState();
    subscribe(() => this._upState())
  }
  _upState(){
    const { getStore } = this.context;
    this.setState({
      ...getStore()
    })
  }
  render() {
   ...
  }
}

# Body.js
...
export default class Body extends Component {
  static contextTypes = {
   // 和 Head.js 相同
  }
  ...
  componentWillMount(){
    // 和 Head.js 相同
  }
  _upState(){
   // 和 Head.js 相同
  }
  render() {
    return (
      <div>
        <div className="body">{this.state.body}</div>
        <Button />
      </div>
    );
  }
}

# Button.js
...
export default class Button extends Component {
  static contextTypes = {
    store: PropTypes.object,
    dispatch: PropTypes.func,
    subscribe: PropTypes.func,
    getStore: PropTypes.func
  }
  constructor (props) {
    super(props)
    this.state = {};
  }
  componentWillMount(){
   // 和 Head.js 相同
  }
  _upState(){
    // 和 Head.js 相同
  }
  render() {
    ...
  }
}

複製程式碼

以上程式碼,我們用 createStore 方法,建立出全域性的 store。並且把 store、 dispatch、subscribe 通過 context傳遞, 讓各個後代元件可以輕易的獲取到這些全域性的屬性。最後我們用 setState 來改變各個後代元件的 state ,並給 subscribe 中新增了監聽函式,當 store 發生改變時,讓元件重新獲取到 store, 重新渲染。在這裡,我們看到了 _upState 的用處,它讓我們很方便的新增 store 改變後的回撥。
觀察頁面,我們發現頁面並沒有異常,在後代頁面依舊可以訪問到 context。這樣,是不是說明我們結合成功了呢?先別急,讓我們來改變下資料試一下。我們修改 Button.js 給按鍵新增點選事件,來改變 store :

# Button.js
...
  changeContext(type){
    const { dispatch } = this.context;
    dispatch({ 
      type: type,
      head: '我是修改後的資料'
    });
  }
  render() {
    return (
      <div className="button">
        <div className="btn" onClick={() => this.changeContext('HEAD')}>{this.state.headBtn}</div>
        <div className="btn" onClick={() => this.changeContext('BODY')}>{this.state.bodyBtn}</div>
      </div>
    );
  }
複製程式碼

點選按鍵,我們看到:

資料成功重新整理。 至此,我們已經成功的將自己的 redux 和 react 結合了起來。

四、優化

1、connect

雖然我們實現了 redux 和 react 的結合,但是我們看到,上面的程式碼是有很多問題的,比如:
1)有大量的重複邏輯
在各個後代元件中,我們都是在 context 中獲取 store ,然後更新各自的 state ,還同樣的新增了監聽事件。
2)程式碼幾乎不可複用
在各個後代元件中,對 context 的依賴過強。假設你的同事想用下 Body 元件,可是他的程式碼中並沒有設定 context 那麼 Body 元件就是不可用的。

關於這些問題,我們可以通過高階元件來解決(關於高階元件的問題,大家請點這裡或者這裡),我們可以把重複的程式碼邏輯,封裝起來,我們給這個封裝好的方法起個名字叫 connect 。 這只是一個名字而已,大家不必糾結,如果你願意,你完全可以管它叫做 aaa。
我們在 redux 資料夾下新建一個 connect 檔案:

# connect.js
import React, { Component } from 'react';
import PropTypes from 'prop-types';
export const connect = (Comp) => {
  class Connect extends Component {
    render(){
      return (
        <div className="connect">
          <Comp />
        </div>
      );
    }
  }
  return Connect;
}
複製程式碼

我們看到,connect 是一個高階元件,它接收一個元件,然後返回處理後的元件。我們 Head 元件來驗證一下這個高階元件是否可用:

# Head.js
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from '../../redux';
class Head extends Component {
 ...
}
export default connect(Head);
複製程式碼

重新整理頁面我們可以知道,connect 正在發揮它應有的功能,已經成功的在 Head 元件外層套了一層 div:

由此,我們是不是可以讓 connect 做更多的事,比如,把有關 context 的東西都交給它,我們試著這樣改造 connect 和 Head:

# connect.js
import React, { Component } from 'react';
import PropTypes from 'prop-types';
export const connect = (Comp) => {
  class Connect extends Component {
    static contextTypes = {
      store: PropTypes.object,
      dispatch: PropTypes.func,
      subscribe: PropTypes.func,
      getStore: PropTypes.func
    }
    constructor (props) {
      super(props)
      this.state = {};
    }
    componentWillMount(){
      const { subscribe } = this.context;
      this._upState();
      subscribe(() => this._upState())
    }
    _upState(){
      const { getStore } = this.context;
      this.setState({
        ...getStore()
      })
    }
    render(){
      return (
        <div className="connect">
          <Comp {...this.state} />
        </div>
      );
    }
  }
  return Connect;
}

# Head.js
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from '../../redux';
class Head extends Component {
  render() {
    return (
      <div className="head">{this.props.head}</div> // 從 props 中取值
    );
  }
}
export default connect(Head);
複製程式碼

我們看到,改造後的 Head 元件變得非常精簡,我們只需要關心具體的業務邏輯,而任何於 context 有關的操作都被轉移到了 connect 中去。我們按照同樣的方式改造 Body 和 Button 元件:

# Body.js
...
class Body extends Component {
  render() {
    return (
      <div>
        <div className="body">{this.props.body}</div>
        <Button />
      </div>
    );
  }
}
export default connect(Body)

# Button.js
...
class Button extends Component {
  changeContext(type, value){
    const { dispatch } = this.context;  // context 已經不存在了
    dispatch({ 
      type: type,
      head: value
    });
  }
  render() {
    return (
      <div className="button">
        <div className="btn" onClick={() => this.changeContext('HEAD', '我是改變的資料1')}>{this.props.headBtn}</div>
        <div className="btn" onClick={() => this.changeContext('HEAD', '我是改變的資料2')}>{this.props.bodyBtn}</div>
      </div>
    );
  }
}
export default connect(Button)
複製程式碼

重新整理頁面,並沒有什麼問題,一切似乎都很美好,可是當我們點選按鍵時,錯誤降臨。 我們發現,在 Button 中,dispatch 是無法獲取到的,我們現在唯一的資料來源都是通過 props ,而在 connect 中,我們並沒有處理 dispatch ,那麼,我們繼續改造我們的 connect:

# Button.js
    ...
 const { dispatch } = this.props;  // 從 props 中取值
  ... 
  
# connect.js
...
export const connect = (Comp) => {
  class Connect extends Component {
   ...
    constructor (props) {
      super(props)
      this.state = {
        dispatch: () => {}
      };
    }
    componentWillMount(){
      const { subscribe, dispatch } = this.context; // 取出 dispatch 
      this.setState({
        dispatch
      })
      this._upState();
      subscribe(() => this._upState())
    }
   ...
  }
  return Connect;
}
複製程式碼

現在看來,一切似乎都已經解決。讓我們再來一起回顧下我們究竟做了什麼:

1)我們封裝了 connect ,把所有有關的 connect 的操作都交給他來負責。
2)我們改造了後代元件,讓它們從 props 中來獲取資料,不再依賴 context。

現在,再來對照之前我們提出的問題,發現,我們已經很好的解決了它們。
可是,這樣真的就可以了嗎?
我們再來觀察 connect 中的程式碼,我們發現,所有的 PropTypes 都是我們固定寫死的,缺乏靈活性,也不太利於我們開發,畢竟,每個元件所要獲取的資料都不盡相同,如果能讓 connect 再接收一個引數,來規定 PropTypes 那再好不過了。
根據這個需求,我們來繼續改造我們的程式碼:

# connect.js
...
export const connect = (Comp, propsType) => {
  class Connect extends Component {
    static contextTypes = {
      store: PropTypes.object,
      dispatch: PropTypes.func,
      subscribe: PropTypes.func,
      getStore: PropTypes.func,
      ...propsType
    }
    ...
  }
  return Connect;
}

# Head.js
...
const propsType = {
  store: PropTypes.object,
}
export default connect(Head, propsType);

複製程式碼

以上,我們重新改造了 connect ,讓他接收兩個引數,把一些固定要傳遞的屬性,我們可以寫死,然後再新增進我們在每個元件內部單獨定義的 propsType。

2、Provider

我們看到,在所有的後代元件中,已經分離出了有關 context 的操作,但是,在 App.js 中,依舊還有和 context 相關的內容。其實,在 App 中用到 context 只是為了把 store 存放進去,好讓後代元件可以從中獲取資料。那麼,我們完全可以通過容器元件來進行狀態提升,把這部分髒活從 App 元件中分離出來,提升到新建的容器元件中。我們只需要給他傳入需要存放進 context 的 store 就可以了。
依據之前的想法,我們在 redux 資料夾下新建一個 Provider,並把所有和業務無關的程式碼從 App 中取出:

# Provider
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { createStore, storeChange } from '../redux';
export class Provider extends Component {
  static childContextTypes = {
    store: PropTypes.object,
    dispatch: PropTypes.func,
    subscribe: PropTypes.func,
    getStore: PropTypes.func
  }
  getChildContext () {
    const state = this.props.store;
    const { store, dispatch, subscribe, getStore } = createStore(state,storeChange)
    return { store, dispatch, subscribe, getStore };
  }
  render(){
    return (
      <div className="provider">{this.props.children}</div>
    );
  }
}

# App.js 
...
export default class App extends Component {
  render() {
    return (
      <div className="App">
        <Head />
        <Body />
      </div>
    );
  }
}

# index.js
...
import { Provider } from './redux'
const state = {
  head: '我是全域性 head',
  body: '我是全域性 body',
  headBtn: '修改 head',
  bodyBtn: '修改 body'
}
ReactDOM.render(
  <Provider store={state}>
    <App />
  </Provider>, 
  document.getElementById('root')
);
複製程式碼

經過改造的 App 元件也變得非常清爽。
我們在 index.js 中定義了全域性 store ,通過容器元件 Provider 塞入 context 中,讓所有的後代元件都可以輕鬆獲取到,而在 App 元件中,我們只需要關注具體的業務邏輯就好。

最後的話

本文通過一些簡單的程式碼示例,完成了一個自己的 react-redux ,當然,以上程式碼還過於簡陋,存在很多問題,和我們常用的 react-redux 庫也有些許區別,我們重點在於瞭解它們內部的一些原理。
如有描述不正確的地方,歡迎大家指正!

相關文章