React 中的不可變數據 — Immer


Immer 是什么?

Immer 是一個不可變數據的 Javascript 庫,讓你更方便的處理不可變數據。

什么是不可變數據?

不可變數據概念來源於函數式編程。函數式編程中,對已初始化的“變量”是不可以更改的,每次更改都要創建一個新的“變量”。

Javascript 在語言層沒有實現不可變數據,需要借助第三方庫來實現。Immer 就是其中一種實現(類似的還有 immutable.js)。

為什么使用不可變數據?

在 React 性能優化一節中用了很長篇幅來介紹 shouldComponentUpdate,不可變數據也是由此引出。使用不可變數據可以解決性能優化引入的問題,所以重點介紹這一部分背景。

React 中的性能優化

避免調停(Avoid Reconciliation)

當一個組件的 props 或 state 變更,React 會將最新返回的元素與之前渲染的元素進行對比,以此決定是否有必要更新真實的 DOM。當它們不相同時,React 會更新該 DOM。雖然 React 已經保證未變更的元素不會進行更新,但即使 React 只更新改變了的 DOM 節點,重新渲染仍然花費了一些時間。在大部分情況下它並不是問題,不過如果它已經慢到讓人注意了,你可以通過覆蓋生命周期方法 shouldComponentUpdate 來進行提速。該方法會在重新渲染前被觸發。其默認實現總是返回 true,讓 React 執行更新:

shouldComponentUpdate(nextProps, nextState) { return true; } 

如果你知道在什么情況下你的組件不需要更新,你可以在 shouldComponentUpdate 中返回 false 來跳過整個渲染過程。其包括該組件的 render 調用以及之后的操作。

shouldComponentUpdate 的作用

這是一個組件的子樹。每個節點中,SCU 代表 shouldComponentUpdate 返回的值,而 vDOMEq 代表返回的 React 元素是否相同。最后,圓圈的顏色代表了該組件是否需要被調停(Reconciliation)。
should-component-update
節點 C2 的 shouldComponentUpdate 返回了 false,React 因而不會調用 C2 的 render,也因此 C4 和 C5 的 shouldComponentUpdate 不會被調用到。

對於 C1 和 C3,shouldComponentUpdate 返回了 true,所以 React 需要繼續向下查詢子節點。這里 C6 的 shouldComponentUpdate 返回了 true,同時由於 render 返回的元素與之前不同使得 React 更新了該 DOM。

最后一個有趣的例子是 C8。React 需要調用這個組件的 render,但是由於其返回的 React 元素和之前相同,所以不需要更新 DOM。

顯而易見,你看到 React 只改變了 C6 的 DOM。對於 C8,通過對比了渲染的 React 元素跳過了真實 DOM 的渲染。而對於 C2 的子節點和 C7,由於 shouldComponentUpdate 使得 render 並沒有被調用。因此它們也不需要對比元素了。

示例

上一小節有一個有趣的例子 C8,它完全沒有發生改變,React 卻還是對它進行了調停(Reconciliation)。我們完全可以通過條件判斷來避免此類問題,避免調停(Reconciliation),優化性能。

如果你的組件只有當 props.color 或者 state.count 的值改變才需要更新時,你可以使用 shouldComponentUpdate 來進行檢查:

class CounterButton extends React.Component {
  constructor(props) {
    super(props);
    this.state = {count: 1};
  }

  shouldComponentUpdate(nextProps, nextState) {
    if (this.props.color !== nextProps.color) {
      return true;
    }
    if (this.state.count !== nextState.count) {
      return true;
    }
    return false;
  }

  render() {
    return (
      <button
        color={this.props.color}
        onClick={() => this.setState(state => ({count: state.count + 1}))}>
        Count: {this.state.count}
      </button>
    );
  }
}

在這段代碼中,shouldComponentUpdate 僅檢查了 props.color 或 state.count 是否改變。如果這些值沒有改變,那么這個組件不會更新。如果你的組件更復雜一些,你可以使用類似“淺比較”的模式來檢查 props 和 state 中所有的字段,以此來決定是否組件需要更新。React 已經提供了一位好幫手來幫你實現這種常見的模式 - 你只要繼承 React.PureComponent 就行了(函數組件使用 React.memo)。所以這段代碼可以改成以下這種更簡潔的形式:

class CounterButton extends React.PureComponent {
  constructor(props) {
    super(props);
    this.state = {count: 1};
  }

  render() {
    return (
      <button
        color={this.props.color}
        onClick={() => this.setState(state => ({count: state.count + 1}))}>
        Count: {this.state.count}
      </button>
    );
  }
}

但 React.PureComponent 只進行淺比較,所以當 props 或者 state 某種程度是可變的話,淺比較會有遺漏,那你就不能使用它了。比如使用了數組或對象:(以下代碼是錯誤的)

class ListOfWords extends React.PureComponent { render() { return <div>{this.props.words.join(',')}</div>; } } class WordAdder extends React.Component { constructor(props) { super(props); this.state = { words: ['marklar'] }; this.handleClick = this.handleClick.bind(this); } handleClick() { // 這部分代碼很糟,而且還有 bug const words = this.state.words; words.push('marklar'); this.setState({words: words}); } render() { return ( <div> <button onClick={this.handleClick} /> <ListOfWords words={this.state.words} /> </div> ); } } 

words 數組使用 push 方法添加了一個元素,但 state 持有的 words 的引用並沒有發生變化。push 直接改變了數據本身,並沒有產生新的數據,淺比較無法感知到這種變化。React 會產生錯誤的行為,不會重新執行 render。為了性能優化,引入了另一個問題。

不可變數據的力量

避免該問題最簡單的方式是避免更改你正用於 props 或 state 的值。例如,上面 handleClick 方法可以用 concat 重寫:

handleClick() {
  this.setState(state => ({
    words: state.words.concat(['marklar'])
  }));
}

或者使用 ES6 數組擴展運算符:

handleClick() {
  this.setState(state => ({
    words: [...state.words, 'marklar'],
  }));
};

但是當處理深層嵌套對象時,以 immutable(不可變)的方式更新它們令人費解。比如可能寫出這樣的代碼:

handleClick() {
  this.setState(state => ({
    objA: {
      ...state.objA,
      objB: {
        ...state.objA.objB,
        objC: {
          ...state.objA.objB.objC,
          stringA: 'string',
        }
      },
    },
  }));
};

我們需要一個更友好的庫幫助我們直觀的使用 immutable(不可變)數據。

為什么不使用深拷貝/比較?

深拷貝會讓所有組件都接收到新的數據,讓 shouldComponentUpdate 失效。深比較每次都比較所有值,當數據層次很深且只有一個值變化時,這些比較是對性能的浪費。

視圖層的代碼,我們希望它更快響應,所以使用 immutable 庫進行不可變數據的操作,也算是一種空間換時間的取舍。

為什么是 Immer?

immutable.js

  • 自己維護了一套數據結構,Javascript 的數據類型和 immutable.js 的類型需要相互轉換,對數據有侵入性。
  • 庫的體積比較大(63KB),不太適合包體積緊張的移動端。
  • API 極其豐富,學習成本較高。
  • 兼容性非常好,支持 IE 較老的版本。

immer

  • 使用 Proxy 實現,兼容性差。
  • 體積很小(12KB),移動端友好。
  • API 簡潔,使用 Javascript 自己的數據類型,幾乎沒有理解成本。

優缺點對比之下,immer 的兼容性缺點在我們的環境下完全可以忽略。使用一個不帶來其他概念負擔的庫還是要輕松很多的。

Immer 概覽

Immer 基於 copy-on-write 機制。

Immer 的基本思想是,所有更改都應用於臨時的 draftState,它是 currentState 的代理。一旦完成所有變更,Immer 將基於草稿狀態的變更生成 nextState。這意味着可以通過簡單地修改數據而與數據進行交互,同時保留不可變數據的所有優點。
immer

本節圍繞 produce 這個核心 API 做介紹。Immer 還提供了一些輔助性 API,詳見官方文檔

核心 API:produce

語法1:

produce(currentState, recipe: (draftState) => void | draftState, ?PatchListener): nextState

語法2:

produce(recipe: (draftState) => void | draftState, ?PatchListener)(currentState): nextState

使用 produce

import produce from "immer"

const baseState = [
    {
        todo: "Learn typescript",
        done: true
    },
    {
        todo: "Try immer",
        done: false
    }
]

const nextState = produce(baseState, draftState => {
    draftState.push({todo: "Tweet about it"})
    draftState[1].done = true
})

上面的示例中,對 draftState 的修改都會反映到 nextState 上,並且不會修改 baseState。而 immer 使用的結構是共享的,nextState 在結構上與 currentState 共享未修改的部分。

// the new item is only added to the next state,
// base state is unmodified
expect(baseState.length).toBe(2) expect(nextState.length).toBe(3) // same for the changed 'done' prop expect(baseState[1].done).toBe(false) expect(nextState[1].done).toBe(true) // unchanged data is structurally shared expect(nextState[0]).toBe(baseState[0]) // changed data not (dûh) expect(nextState[1]).not.toBe(baseState[1])

柯理化 produce

給 produce 第一個參數傳遞函數時將會進行柯理化。它會返回一個函數,該函數接收的參數會被傳遞給 produce 柯理化時接收的函數。
示例:

// mapper will be of signature (state, index) => state
const mapper = produce((draft, index) => {
    draft.index = index
})

// example usage
console.dir([{}, {}, {}].map(mapper))
// [{index: 0}, {index: 1}, {index: 2}])

可以很好的利用這種機制簡化 reducer

import produce from "immer"

const byId = produce((draft, action) => {
    switch (action.type) {
        case RECEIVE_PRODUCTS:
            action.products.forEach(product => {
                draft[product.id] = product
            })
            return
    }
})

recipe 的返回值

通常,recipe 不需要顯示的返回任何東西,draftState 會自動作為返回值反映到 nextState。你也可以返回任意數據作為 nextState,前提是 draftState 沒有被修改。

const userReducer = produce((draft, action) => {
    switch (action.type) {
        case "renameUser":
            // OK: we modify the current state
            draft.users[action.payload.id].name = action.payload.name
            return draft // same as just 'return'
        case "loadUsers":
            // OK: we return an entirely new state
            return action.payload
        case "adduser-1":
            // NOT OK: This doesn't do change the draft nor return a new state!
            // It doesn't modify the draft (it just redeclares it)
            // In fact, this just doesn't do anything at all
            draft = {users: [...draft.users, action.payload]}
            return
        case "adduser-2":
            // NOT OK: modifying draft *and* returning a new state
            draft.userCount += 1
            return {users: [...draft.users, action.payload]}
        case "adduser-3":
            // OK: returning a new state. But, unnecessary complex and expensive
            return {
                userCount: draft.userCount + 1,
                users: [...draft.users, action.payload]
            }
        case "adduser-4":
            // OK: the immer way
            draft.userCount += 1
            draft.users.push(action.payload)
            return
    }
})

很顯然,這樣的方式無法返回 undefined

produce({}, draft => {
    // don't do anything
})
produce({}, draft => {
    // Try to return undefined from the producer
    return undefined
})

因為在 Javascript 中,不返回任何值和返回 undefined 是一樣的,函數的返回值都是 undefined 。如果你希望 immer 知道你確實想要返回 undefined 怎么辦?
使用 immer 內置的變量 nothing

import produce, {nothing} from "immer"

const state = {
    hello: "world"
}

produce(state, draft => {})
produce(state, draft => undefined)
// Both return the original state: { hello: "world"}

produce(state, draft => nothing)
// Produces a new state, 'undefined'

Auto freezing(自動凍結)

Immer 會自動凍結使用 produce 修改過的狀態樹,這樣可以防止在變更函數外部修改狀態樹。這個特性會帶來性能影響,所以需要在生產環境中關閉。可以使用 setAutoFreeze(true / false) 打開或者關閉。在開發環境中建議打開,可以避免不可預測的狀態樹更改。

在 setState 中使用 immer

使用 immer 進行深層狀態更新很簡單:

/**
 * Classic React.setState with a deep merge
 */
onBirthDayClick1 = () => { this.setState(prevState => ({  user: {  ...prevState.user,  age: prevState.user.age + 1 }  })) } /** * ...But, since setState accepts functions, * we can just create a curried producer and further simplify! */ onBirthDayClick2 = () => { this.setState(  produce(draft => {  draft.user.age += 1  }) ) } 

基於 produce 提供了柯理化的特性,直接將 produce 柯理化的返回值傳遞給 this.setState 即可。在 recipe 內部做你想要做的狀態變更。符合直覺,不引入新概念。

以 hook 方式使用 immer

Immer 同時提供了一個 React hook 庫 use-immer 用於以 hook 方式使用 immer。

useImmer

useImmer 和 useState 非常像。它接收一個初始狀態,返回一個數組。數組第一個值為當前狀態,第二個值為狀態更新函數。狀態更新函數和 produce 中的 recipe 一樣運作。

import React from "react";
import { useImmer } from "use-immer";


function App() {
  const [person, updatePerson] = useImmer({
    name: "Michel",
    age: 33
  });

  function updateName(name) {
    updatePerson(draft => {
      draft.name = name;
    });
  }

  function becomeOlder() {
    updatePerson(draft => {
      draft.age++;
    });
  }

  return (
    <div className="App">
      <h1>
        Hello {person.name} ({person.age})
      </h1>
      <input
        onChange={e => {
          updateName(e.target.value);
        }}
        value={person.name}
      />
      <br />
      <button onClick={becomeOlder}>Older</button>
    </div>
  );
}

很顯然,對這個例子來講,無法體現 immer 的作用:)。只是個展示用法的例子。

useImmerReducer

對 useReducer 的封裝:

import React from "react";
import { useImmerReducer } from "use-immer";

const initialState = { count: 0 };

function reducer(draft, action) { switch (action.type) { case "reset": return initialState; case "increment": return void draft.count++; case "decrement": return void draft.count--; } } function Counter() { const [state, dispatch] = useImmerReducer(reducer, initialState); return ( <>  Count: {state.count}  <button onClick={() => dispatch({ type: "reset" })}>Reset</button>  <button onClick={() => dispatch({ type: "increment" })}>+</button>  <button onClick={() => dispatch({ type: "decrement" })}>-</button>  </> ); }


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM