动态

详情 返回 返回

聊聊不可變數據結構 - 动态 详情

三年前,我接觸了 Immutable 庫,體會到了不可變數據結構的利好。

Immutable 庫具有兩個最大的優勢: 不可修改以及結構共享。

  • 不可修改(容易回溯,易於觀察。減少錯誤的發生)
let obj = { a: 1 };

handleChange(obj);

// 由於上面有 handleChange,無法確認 obj 此時的狀態
console.log(obj)
  • 結構共享( 複用內存,節省空間,也就意味着數據修改可以直接記錄完整數據,其內存壓力也不大,這樣對於開發複雜交互項目的重做等功能很有用)

當然,由於當時還在重度使用 Vue 進行開發,而且 受益於 Vue 本身的優化以及業務抽象和系統的合理架構,項目一直保持着良好的性能。同時該庫的侵入性和難度都很大,貿然引入項目也未必是一件好事。

雖然 Immutable 庫沒有帶來直接的收益,但從中學到一些思路和優化卻陪伴着我。

淺拷貝 assign 勝任 Immutable

當我們不使用任何庫,我們是否就無法享受不可變數據的利好?答案是否定的。

當面臨可變性數據時候,大部分情況下我們會使用深拷貝來解決兩個數據引用的問題。

const newData = deepCopy(myData);
newData.x.y.z = 7;
newData.a.b.push(9);

不幸的是,深度拷貝是昂貴的,在有些情況下更是不可接受的。深拷貝佔用了大量的時間,同時兩者之間沒有任何結構共享。但我們可以通過僅複製需要更改的對象和重用未更改的對象來減輕這種情況。如 Object.assign 或者 ... 來實現結構共享。

大多數業務開發中,我們都是先進行深拷貝,再進行修改。但是我們真的需要這樣做嗎?事實並非如此。從項目整體出發的話,我們只需要解決一個核心問題 “深層嵌套對象”。當然,這並不意味着我們把所有的數據都放在第一層。只需要不嵌套可變的數據項即可。

const staffA = {
  name: 'xx',
  gender: 'man',
  company: {},
  authority: []
}

const staffB = {...staffA}

staffB.name = 'YY'

// 不涉及到 複雜類型的修改即可
staffA.name // => 'xx'

const staffsA = [staffA, staffB]

// 需要對數組內部每一項進行淺拷貝
const staffsB = staffsA.map(x => ({...x}))

staffsB[0].name = 'gg'

staffsA[0].name // => 'xx'

如此,我們就把深拷貝變為了淺拷貝。同時實現了結構共享 (所有深度嵌套對象都被複用了) 。但有些情況下,數據模型並不是容易修改的,我們還是需要修改深度嵌套對象。那麼就需要這樣修改了。

const newData = Object.assign({}, myData, {
  x: Object.assign({}, myData.x, {
    y: Object.assign({}, myData.x.y, {z: 7}),
  }),
  a: Object.assign({}, myData.a, {b: myData.a.b.concat(9)})
});

這對於絕大部份的業務場景來説是相當高效的(因為它只是淺拷貝,並重用了其餘的部分) ,但是編寫起來卻非常痛苦。

immutability-helper 庫輔助開發

immutability-helper (語法受到了 MongoDB 查詢語言的啓發 ) 這個庫為 Object.assign 方案提供了簡單的語法糖,使得編寫淺拷貝代碼更加容易:

import update from 'immutability-helper';

const newData = update(myData, {
  x: {y: {z: {$set: 7}}},
  a: {b: {$push: [9]}}
});

const initialArray = [1, 2, 3];
const newArray = update(initialArray, {$push: [4]}); // => [1, 2, 3, 4]
initialArray // => [1, 2, 3]

可用命令

  • $push (類似於數組的 push,但是提供的是數組)
  • $unshift (類似於數組的 unshift,但是提供的是數組)
  • $splice (類似於數組的 splice, 但提供數組是一個數組, $splice: [ [1, 1, 13, 14] ] )

注意:數組中的項目是順序應用的,因此順序很重要。目標的索引可能會在操作過程中發生變化。

  • $toggle (字符串數組,切換目標對象的布爾數值)
  • $set (完全替換目標節點, 不考慮之前的數據,只用當前指令設置的數據)
  • $unset (字符串數組,移除 key 值(數組或者對象移除))
  • $merge (合併對象)
const obj = {a: 5, b: 3};
const newObj = update(obj, {$merge: {b: 6, c: 7}}); // => {a: 5, b: 6, c: 7}
  • $add(為 Map 添加 [key,value] 數組)
  • $remove (字符串對象,為 Map 移除 key)
  • $apply (應用函數到節點)
const obj = {a: 5, b: 3};
const newObj = update(obj, {b: {$apply: function(x) {return x * 2;}}});
// => {a: 5, b: 6}
const newObj2 = update(obj, {b: {$set: obj.b * 2}});
// => {a: 5, b: 6}

後面我們解析源碼時,可以看到不同指令的實現。

擴展命令

我們可以基於當前業務去擴展命令。如添加税值計算:

import update, { extend } from 'immutability-helper';

extend('$addtax', function(tax, original) {
  return original + (tax * original);
});
const state = { price: 123 };
const withTax = update(state, {
  price: {$addtax: 0.8},
});
assert(JSON.stringify(withTax) === JSON.stringify({ price: 221.4 }));

如果您不想弄髒全局的 update 函數,可以製作一個副本並使用該副本,這樣不會影響全局數據:

import { Context } from 'immutability-helper';

const myContext = new Context();

myContext.extend('$foo', function(value, original) {
  return 'foo!';
});

myContext.update(/* args */);

源碼解析

為了加強理解,這裏我來解析一下源代碼,同時該庫代碼十分簡潔強大:

先是工具函數(保留核心,環境判斷,錯誤警告等邏輯去除):

// 提取函數,大量使用時有一定性能優勢,且簡明(更重要)
const hasOwnProperty = Object.prototype.hasOwnProperty;
const splice = Array.prototype.splice;
const toString = Object.prototype.toString;

// 檢查類型
function type<T>(obj: T) {
  return (toString.call(obj) as string).slice(8, -1);
}

// 淺拷貝,使用 Object.assign 
const assign = Object.assign || /* istanbul ignore next */ (<T, S>(target: T & any, source: S & Record<string, any>) => {
  getAllKeys(source).forEach(key => {
    if (hasOwnProperty.call(source, key)) {
      target[key] = source[key] ;
    }
  });
  return target as T & S;
});

// 獲取對象 key
const getAllKeys = typeof Object.getOwnPropertySymbols === 'function'
  ? (obj: Record<string, any>) => Object.keys(obj).concat(Object.getOwnPropertySymbols(obj) as any)
  /* istanbul ignore next */
  : (obj: Record<string, any>) => Object.keys(obj);

// 所有數據的淺拷貝
function copy<T, U, K, V, X>(
  object: T extends ReadonlyArray<U>
    ? ReadonlyArray<U>
    : T extends Map<K, V>
      ? Map<K, V>
      : T extends Set<X>
        ? Set<X>
        : T extends object
          ? T
          : any,
) {
  return Array.isArray(object)
    ? assign(object.constructor(object.length), object)
    : (type(object) === 'Map')
      ? new Map(object as Map<K, V>)
      : (type(object) === 'Set')
        ? new Set(object as Set<X>)
        : (object && typeof object === 'object')
          ? assign(Object.create(Object.getPrototypeOf(object)), object) as T
          /* istanbul ignore next */
          : object as T;
}

然後是核心代碼(同樣保留核心) :

export class Context {
  // 導入所有指令
  private commands: Record<string, any> = assign({}, defaultCommands);

  // 添加擴展指令
  public extend<T>(directive: string, fn: (param: any, old: T) => T) {
    this.commands[directive] = fn;
  }
  
  // 功能核心
  public update<T, C extends CustomCommands<object> = never>(
    object: T,
    $spec: Spec<T, C>,
  ): T {
    // 增強健壯性,如果操作命令是函數,修改為 $apply
    const spec = (typeof $spec === 'function') ? { $apply: $spec } : $spec;

    // 數組(數組) 檢查,報錯
      
    // 返回對象(數組) 
    let nextObject = object;
    // 遍歷指令
    getAllKeys(spec).forEach((key: string) => {
      // 如果指令在指令集中
      if (hasOwnProperty.call(this.commands, key)) {
        // 性能優化,遍歷過程中,如果 object 還是當前之前數據
        const objectWasNextObject = object === nextObject;
        
        // 用指令修改對象
        nextObject = this.commands[key]((spec as any)[key], nextObject, spec, object);
        
        // 修改後,兩者使用傳入函數計算,還是相等的情況下,直接使用之前數據
        if (objectWasNextObject && this.isEquals(nextObject, object)) {
          nextObject = object;
        }
      } else {
        // 不在指令集中,做其他操作
        // 類似於 update(collection, {2: {a: {$splice: [[1, 1, 13, 14]]}}});
        // 解析對象規則後繼續遞歸調用 update, 不斷遞歸,不斷返回
        // ...
      }
    });
    return nextObject;
  }
}

最後是通用指令:

const defaultCommands = {
  $push(value: any, nextObject: any, spec: any) {
    // 數組添加,返回 concat 新數組
    return value.length ? nextObject.concat(value) : nextObject;
  },
  $unshift(value: any, nextObject: any, spec: any) {
    return value.length ? value.concat(nextObject) : nextObject;
  },
  $splice(value: any, nextObject: any, spec: any, originalObject: any) {
    // 循環 splice 調用
    value.forEach((args: any) => {
      if (nextObject === originalObject && args.length) {
        nextObject = copy(originalObject);
      }
      splice.apply(nextObject, args);
    });
    return nextObject;
  },
  $set(value: any, _nextObject: any, spec: any) {
    // 直接替換當前數值
    return value;
  },
  $toggle(targets: any, nextObject: any) {
    const nextObjectCopy = targets.length ? copy(nextObject) : nextObject;
    // 當前對象或者數組切換
    targets.forEach((target: any) => {
      nextObjectCopy[target] = !nextObject[target];
    });

    return nextObjectCopy;
  },
  $unset(value: any, nextObject: any, _spec: any, originalObject: any) {
    // 拷貝後循環刪除
    value.forEach((key: any) => {
      if (Object.hasOwnProperty.call(nextObject, key)) {
        if (nextObject === originalObject) {
          nextObject = copy(originalObject);
        }
        delete nextObject[key];
      }
    });
    return nextObject;
  },
  $add(values: any, nextObject: any, _spec: any, originalObject: any) {
    if (type(nextObject) === 'Map') {
      values.forEach(([key, value]) => {
        if (nextObject === originalObject && nextObject.get(key) !== value) {
          nextObject = copy(originalObject);
        }
        nextObject.set(key, value);
      });
    } else {
      values.forEach((value: any) => {
        if (nextObject === originalObject && !nextObject.has(value)) {
          nextObject = copy(originalObject);
        }
        nextObject.add(value);
      });
    }
    return nextObject;
  },
  $remove(value: any, nextObject: any, _spec: any, originalObject: any) {
    value.forEach((key: any) => {
      if (nextObject === originalObject && nextObject.has(key)) {
        nextObject = copy(originalObject);
      }
      nextObject.delete(key);
    });
    return nextObject;
  },
  $merge(value: any, nextObject: any, _spec: any, originalObject: any) {
    getAllKeys(value).forEach((key: any) => {
      if (value[key] !== nextObject[key]) {
        if (nextObject === originalObject) {
          nextObject = copy(originalObject);
        }
        nextObject[key] = value[key];
      }
    });
    return nextObject;
  },
  $apply(value: any, original: any) {
    // 傳入函數,直接調用函數修改
    return value(original);
  },
};

就這樣,作者寫了一個簡潔而強大的淺拷貝輔助庫。

優秀的 Immer 庫

Immer 是一個非常優秀的不可變數據庫,利用 proxy 來解決問題。不需要學習其他 api,開箱即用 ( gzipped 3kb )

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
})

關於 immer 性能優化請參考 immer performance。

核心代碼分析

該庫的核心還是在 proxy 的封裝,所以不全部介紹,僅介紹代理功能。

export const objectTraps: ProxyHandler<ProxyState> = {
  get(state, prop) {
    // PROXY_STATE是一個symbol值,有兩個作用,一是便於判斷對象是不是已經代理過,二是幫助proxy拿到對應state的值
    // 如果對象沒有代理過,直接返回
    if (prop === DRAFT_STATE) return state

    // 獲取數據的備份?如果有,否則獲取元數據
    const source = latest(state)

    // 如果當前數據不存在,獲取原型上數據
    if (!has(source, prop)) {
      return readPropFromProto(state, source, prop)
    }
    const value = source[prop]

    // 當前代理對象已經改回了數值或者改數據是 null,直接返回
    if (state.finalized_ || !isDraftable(value)) {
      return value
    }
    // 創建代理數據
    if (value === peek(state.base_, prop)) {
      prepareCopy(state)
      return (state.copy_![prop as any] = createProxy(
        state.scope_.immer_,
        value,
        state
      ))
    }
    return value
  },
  // 當前數據是否有該屬性
  has(state, prop) {
    return prop in latest(state)
  },
  set(
    state: ProxyObjectState,
    prop: string /* strictly not, but helps TS */,
    value
  ) {
    const desc = getDescriptorFromProto(latest(state), prop)

    // 如果當前有 set 屬性,意味當前操作項是代理,直接設置即可
    if (desc?.set) {
      desc.set.call(state.draft_, value)
      return true
    }

    // 當前沒有修改過,建立副本 copy,等待使用 get 時創建代理
    if (!state.modified_) {
      const current = peek(latest(state), prop)

      const currentState: ProxyObjectState = current?.[DRAFT_STATE]
      if (currentState && currentState.base_ === value) {
        state.copy_![prop] = value
        state.assigned_[prop] = false
        return true
      }
      if (is(value, current) && (value !== undefined || has(state.base_, prop)))
        return true
      prepareCopy(state)
      markChanged(state)
    }

    state.copy_![prop] = value
    state.assigned_[prop] = true
    return true
  },
  defineProperty() {
    die(11)
  },
  getPrototypeOf(state) {
    return Object.getPrototypeOf(state.base_)
  },
  setPrototypeOf() {
    die(12)
  }
}

// 數組的代理,把當前對象的代理拷貝過去,再修改 deleteProperty 和 set
const arrayTraps: ProxyHandler<[ProxyArrayState]> = {}
each(objectTraps, (key, fn) => {
  // @ts-ignore
  arrayTraps[key] = function() {
    arguments[0] = arguments[0][0]
    return fn.apply(this, arguments)
  }
})
arrayTraps.deleteProperty = function(state, prop) {
  if (__DEV__ && isNaN(parseInt(prop as any))) die(13)
  return objectTraps.deleteProperty!.call(this, state[0], prop)
}
arrayTraps.set = function(state, prop, value) {
  if (__DEV__ && prop !== "length" && isNaN(parseInt(prop as any))) die(14)
  return objectTraps.set!.call(this, state[0], prop, value, state[0])
}

其他

開發過程中,我們往往會在 React 函數中使用 useReducer 方法,但是 useReducer 實現較為複雜,我們可以用 useMethods 簡化代碼。useMethods 內部就是使用 immer (代碼十分簡單,我們直接拷貝 index.ts 即可)。

不使用 useMethods 情況下:

const initialState = {
  nextId: 0,
  counters: []
};

const reducer = (state, action) => {
  let { nextId, counters } = state;
  const replaceCount = (id, transform) => {
    const index = counters.findIndex(counter => counter.id === id);
    const counter = counters[index];
    return {
      ...state,
      counters: [
        ...counters.slice(0, index),
        { ...counter, count: transform(counter.count) },
        ...counters.slice(index + 1)
      ]
    };
  };

  switch (action.type) {
    case "ADD_COUNTER": {
      nextId = nextId + 1;
      return {
        nextId,
        counters: [...counters, { id: nextId, count: 0 }]
      };
    }
    case "INCREMENT_COUNTER": {
      return replaceCount(action.id, count => count + 1);
    }
    case "RESET_COUNTER": {
      return replaceCount(action.id, () => 0);
    }
  }
};

對比使用 useMethods :

import useMethods from 'use-methods';    

const initialState = {
  nextId: 0,
  counters: []
};

const methods = state => {
  const getCounter = id => state.counters.find(counter => counter.id === id);

  return {
    addCounter() {
      state.counters.push({ id: state.nextId++, count: 0 });
    },
    incrementCounter(id) {
      getCounter(id).count++;
    },
    resetCounter(id) {
      getCounter(id).count = 0;
    }
  };
};

鼓勵一下

如果你覺得這篇文章不錯,希望可以給與我一些鼓勵,在我的 github 博客下幫忙 star 一下。

博客地址

參考資料

immutability-helper

Immer

useMethods

user avatar soroqer 头像 zxl20070701 头像 tonyyoung 头像 febobo 头像 icecreamlj 头像 dalidexiaoxiami 头像 awbeci 头像 axuicn 头像 russell221 头像 onlythinking 头像 angular4 头像 kanjianliao 头像
点赞 28 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.