首頁 > 軟體

Immer 功能最佳實踐範例教學

2022-10-26 14:02:37

一、前言

Immer  是 mobx 的作者寫的一個 immutable 庫,核心實現是利用 ES6 的 proxy,幾乎以最小的成本實現了 js 的不可變資料結構,簡單易用、體量小巧、設計巧妙,滿足了我們對 JS 不可變資料結構的需求。

二、學習前提

閱讀這篇文章需要以下知識儲備:

  • JavaScript 基礎語法
  • es6 基礎語法
  • node、npm 基礎知識

三、歷史背景

在 js 中,處理資料一直存在一個問題:

拷貝一個值的時候,如果這個值是參照型別(比如物件、陣列),直接賦值給另一個變數的時候,會把值的參照也拷貝過去,在修改新變數的過程中,舊的變數也會被一起修改掉。

要解決這個問題,通常我們不會直接賦值,而是會選擇使用深拷貝,比如JSON.parse(JSON.stringify()),再比如 lodash 為我們提供的 cloneDeep 方法……

但是,深拷貝並不是十全十美的。

這個時候,immer 誕生了!

四、immer 功能介紹

基本思想是,使用 Immer,會將所有更改應用到臨時  draft,它是  currentState  的代理。一旦你完成了所有的  mutations,Immer 將根據對  draft state  的  mutations  生成 nextState。這意味著你可以通過簡單地修改資料來與資料互動,同時保留不可變資料的所有好處。

一個簡單的比較範例

const baseState = [
  {
    title: 'Learn TypeScript',
    done: true,
  },
  {
    title: 'Try Immer',
    done: false,
  },
];

假設我們有上述基本狀態,我們需要更新第二個 todo,並新增第三個。但是,我們不想改變原始的 baseState,我們也想避免深度克隆以保留第一個 todo

不使用 Immer

如果沒有 Immer,我們將不得不小心地淺拷貝每層受我們更改影響的 state 結構

const nextState = [...baseState]; // 淺拷貝陣列
nextState[1] = {
  // 替換第一層元素
  ...nextState[1], // 淺拷貝第一層元素
  done: true, // 期望的更新
};
// 因為 nextState 是新拷貝的, 所以使用 push 方法是安全的,
// 但是在未來的任意時間做相同的事情會違反不變性原則並且導致 bug!
nextState.push({ title: 'Tweet about it' });

使用 Immer

使用 Immer,這個過程更加簡單。我們可以利用  produce  函數,它將我們要更改的 state 作為第一個引數,對於第二個引數,我們傳遞一個名為 recipe 的函數,該函數傳遞一個  draft  引數,我們可以對其應用直接的  mutations。一旦  recipe  執行完成,這些  mutations  被記錄並用於產生下一個狀態。 produce  將負責所有必要的複製,並通過凍結資料來防止未來的意外修改。

import produce from 'immer';
const nextState = produce(baseState, draft => {
  draft[1].done = true;
  draft.push({ title: 'Tweet about it' });
});

使用 Immer 就像擁有一個私人助理。助手拿一封信(當前狀態)並給您一份副本(草稿)以記錄更改。完成後,助手將接受您的草稿併為您生成真正不變的最終信件(下一個狀態)。

第二個範例

如果有一個層級很深的物件,你在使用 redux 的時候,想在 reducer 中修改它的某個屬性,但是根據 reduce 的原則,我們不能直接修改 state,而是必須返回一個新的 state

不使用 Immer

const someReducer = (state, action) => {
  return {
    ...state,
    first: {
      ...state.first,
      second: {
        ...state.first.second,
        third: {
          ...state.first.second.third,
          value: action,
        },
      },
    },
  };
};

使用 Immer

const someReducer = (state, action) => {
  state.first.second.third.value = action;
};

好處

  • 遵循不可變資料正規化,同時使用普通的 JavaScript 物件、陣列、Sets 和 Maps。無需學習新的 API 或 "mutations patterns"!
  • 強型別,無基於字串的路徑選擇器等
  • 開箱即用的結構共用
  • 開箱即用的物件凍結
  • 深度更新輕而易舉
  • 樣板程式碼減少。更少的噪音,更簡潔的程式碼

更新模式

在 Immer 之前,使用不可變資料意味著學習所有不可變的更新模式。

為了幫助“忘記”這些模式,這裡概述瞭如何利用內建 JavaScript API 來更新物件和集合

更新物件

import produce from 'immer';
const todosObj = {
  id1: { done: false, body: 'Take out the trash' },
  id2: { done: false, body: 'Check Email' },
};
// 新增
const addedTodosObj = produce(todosObj, draft => {
  draft['id3'] = { done: false, body: 'Buy bananas' };
});
// 刪除
const deletedTodosObj = produce(todosObj, draft => {
  delete draft['id1'];
});
// 更新
const updatedTodosObj = produce(todosObj, draft => {
  draft['id1'].done = true;
});

更新陣列

import produce from 'immer';
const todosArray = [
  { id: 'id1', done: false, body: 'Take out the trash' },
  { id: 'id2', done: false, body: 'Check Email' },
];
// 新增
const addedTodosArray = produce(todosArray, draft => {
  draft.push({ id: 'id3', done: false, body: 'Buy bananas' });
});
// 索引刪除
const deletedTodosArray = produce(todosArray, draft => {
  draft.splice(3 /*索引 */, 1);
});
// 索引更新
const updatedTodosArray = produce(todosArray, draft => {
  draft[3].done = true;
});
// 索引插入
const updatedTodosArray = produce(todosArray, draft => {
  draft.splice(3, 0, { id: 'id3', done: false, body: 'Buy bananas' });
});
// 刪除最後一個元素
const updatedTodosArray = produce(todosArray, draft => {
  draft.pop();
});
// 刪除第一個元素
const updatedTodosArray = produce(todosArray, draft => {
  draft.shift();
});
// 陣列開頭新增元素
const addedTodosArray = produce(todosArray, draft => {
  draft.unshift({ id: 'id3', done: false, body: 'Buy bananas' });
});
// 根據 id 刪除
const deletedTodosArray = produce(todosArray, draft => {
  const index = draft.findIndex(todo => todo.id === 'id1');
  if (index !== -1) {
    draft.splice(index, 1);
  }
});
// 根據 id 更新
const updatedTodosArray = produce(todosArray, draft => {
  const index = draft.findIndex(todo => todo.id === 'id1');
  if (index !== -1) {
    draft[index].done = true;
  }
});
// 過濾
const updatedTodosArray = produce(todosArray, draft => {
  // 過濾器實際上會返回一個不可變的狀態,但是如果過濾器不是處於物件的頂層,這個依然很有用
  return draft.filter(todo => todo.done);
});

巢狀資料結構

import produce from 'immer';
// 複雜資料結構例子
const store = {
  users: new Map([
    [
      '17',
      {
        name: 'Michel',
        todos: [{ title: 'Get coffee', done: false }],
      },
    ],
  ]),
};
// 深度更新
const nextStore = produce(store, draft => {
  draft.users.get('17').todos[0].done = true;
});
// 過濾
const nextStore = produce(store, draft => {
  const user = draft.users.get('17');
  user.todos = user.todos.filter(todo => todo.done);
});

非同步 producers & createDraft

允許從 recipe 返回 Promise 物件。或者使用 async / await。這對於長時間執行的程序非常有用,只有在 Promise 鏈解析後才生成新物件

注意,如果 producer 是非同步的,produce 本身也會返回一個 promise。

例子:

import produce from 'immer';
const user = { name: 'michel', todos: [] };
const loadedUser = await produce(user, async draft => {
  draft.todos = await (await fetch('http://host/' + draft.name)).json();
});

請注意,draft 不應從非同步程式中“洩露”並儲存在其他地方。非同步過程完成後,draft 仍將被釋放

createDraft 和 finishDraft

createDraftfinishDraft 是兩個底層函數,它們對於在 immer 之上構建抽象的庫非常有用。避免了為了使用 draft 始終建立函數。

相反,人們可以建立一個 draft,對其進行修改,並在未來的某個時間完成該 draft,在這種情況下,將產生下一個不可變狀態。

例如,我們可以將上面的範例重寫為:

import { createDraft, finishDraft } from 'immer';
const user = { name: 'michel', todos: [] };
const draft = createDraft(user);
draft.todos = await (await fetch('http://host/' + draft.name)).json();
const loadedUser = finishDraft(draft);

五、效能提示

預凍結資料

當向 Immer producer 中的狀態樹新增大型資料集時(例如從 JSON 端接收的資料),可以在首先新增的資料的最外層呼叫 freeze(json) 來淺凍結它。這將允許 Immer 更快地將新資料新增到樹中,因為它將避免遞迴掃描和凍結新資料的需要。

可以隨時選擇退出

immer 在任何地方都是可選的,因此手動編寫效能非常苛刻的 reducers ,並將 immer 用於所有普通的的 reducers 是非常好的。即使在 producer 內部,您也可以通過使用 originalcurrent 函數來選擇退出 Immer 的某些部分邏輯,並對純 JavaScript 物件執行一些操作。

對於效能消耗大的的搜尋操作,從原始 state 讀取,而不是 draft

Immer 會將您在 draft 中讀取的任何內容也遞迴地轉換為 draft。如果您對涉及大量讀取操作的 draft 進行昂貴的無副作用操作,例如在非常大的陣列中使用 find(Index) 查詢索引,您可以通過首先進行搜尋,並且只在知道索引後呼叫 produce 來加快速度。這樣可以阻止 Immer 將在 draft 中搜尋到的所有內容都進行轉換。或者,使用 original(someDraft) 對 draft 的原始值執行搜尋,這歸結為同樣的事情。

將 produce 拉到儘可能遠的地方

始終嘗試將 produce “向上”拉動,例如 for (let x of y) produce(base, d => d.push(x))produce(base, d => { for (let x of y) ) d.push(x)}) 慢得多

六、陷阱

不要重新分配 recipe 引數

永遠不要重新分配 draft 引數(例如:draft = myNewState)。相反,要麼修改 draft,要麼返回新狀態。

Immer 只支援單向樹

Immer 假設您的狀態是單向樹。也就是說,任何物件都不應該在樹中出現兩次,也不應該有迴圈參照。從根到樹的任何節點應該只有一條路徑。

永遠不要從 producer 那裡顯式返回 undefined

可以從 producers 返回值,但不能以這種方式返回 undefined,因為它與根本不更新 draft 沒有區別!

不要修改特殊物件

Immer 不支援特殊物件 比如 window.location

只有有效的索引和長度可以在陣列上改變

對於陣列,只能改變數值屬性和 length 屬性。自定義屬性不會保留在陣列上。

只有來自 state 的資料會被 draft

請注意,來自閉包而不是來自基本 state 的資料將永遠不會被 draft,即使資料已成為新 darft 的一部分

const onReceiveTodo = todo => {
  const nextTodos = produce(todos, draft => {
    draft.todos[todo.id] = todo;
    // 注意,因為 todo 來自外部,而不是 draft,所以他不會被 draft,
    // 所以下面的修改會影響原來的 todo!
    draft.todos[todo.id].done = true;
    // 上面的程式碼相當於
    todo.done = true;
    draft.todos[todo.id] = todo;
  });
};

始終使用巢狀 producers 的結果

支援巢狀呼叫 produce,但請注意 produce 將始終產生新狀態,因此即使將 draft 傳遞給巢狀 produce,內部 produce 所做的更改也不會在傳遞給它的 draft 中可見,只會反映在產生的輸出中。

換句話說,當使用巢狀 produce 時,您會得到 draft 的 draft,並且內部 produce 的結果會被合併回原始 draft(或返回)

錯誤示範:

// 巢狀的錯誤寫法:
produce(state, draft => {
  produce(draft.user, userDraft => {
    userDraft.name += '!';
  });
});

正確示範:

// 巢狀的正確寫法:
produce(state, draft => {
  draft.user = produce(draft.user, userDraft => {
    userDraft.name += '!';
  });
});

Drafts 在參照上不相等

Immer 中的 draft 物件包裝在 Proxy 中,因此您不能使用 == 或 === 來測試原始物件與其 draft 之間的相等性,相反,可以使用 original:

const remove = produce((list, element) => {
  const index = list.indexOf(element); // 不會工作!
  const index = original(list).indexOf(element); // 用這個!
  if (index !== -1) {
    list.splice(index, 1);
  }
});
const values = [a, b, c];
remove(values, a);

如果可以的話,建議在 produce 函數之外執行比較,或者使用 .id 之類的唯一識別符號屬性,以避免需要使用 original

以上就是Immer 功能最佳實踐範例教學的詳細內容,更多關於Immer 功能教學的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com