面試官(8): React 強調的『不可變數據結構』怎麼實現?

往期


前言

咱們在學習 React 的過程當中常常會碰到一個概念,那就是數據的不可變性(immutable),不可變數據是函數式編程裏的重要概念,由於可變數據在提供方便的時候會帶了不少棘手的反作用,那麼咱們應該如何處理這些棘手的問題,如何實現不可變數據呢?javascript


文章目錄

  1. 可變數據的反作用
  2. 不可變數據的解決方案
  3. 實現更簡單的immutable

1.可變數據的反作用

 咱們應該都知道的基本知識,在JavaScript中分爲原始類型和引用類型.   前端

JavaScript原始類型:Undefined、Null、Boolean、Number、String、Symboljava

JavaScript引用類型:Objectnode

同時引用類型在使用過程當中常常會產生反作用.react

const person = {player: {name: 'Messi'}};

const person1 = person;

console.log(person, person1);

//[ { name: 'Messi' } ] [ { name: 'Messi' } ]

person.player.name = 'Kane';

console.log(person, person1);
//[ { name: 'Kane' } ] [ { name: 'Kane' } ]
複製代碼

咱們看到,當修改了person中屬性後,person1的屬性值也隨之改變,由於這兩個變量的指針指向了同一塊內存,當一個變量被修改後,內存隨之變更,而另外一個變量因爲指向同一塊內存,天然也隨之變化了,這就是引用類型的反作用.git

但是絕大多數狀況下咱們並不但願person1的屬性值也發生改變,咱們應該如何解決這個問題?es6

2.不可變數據的解決方案

2.1 淺複製

  在ES6中咱們能夠用Object.assign 或者 ...對引用類型進行淺複製.github

const person = [{name: 'Messi'}];
const person1 = person.map(item =>
  ({...item, name: 'Kane'})
)

console.log(person, person1);
// [{name: 'Messi'}] [{name: 'Kane'}]
複製代碼

person的確被成功複製了,可是之因此咱們稱它爲淺複製,是由於這種複製只能複製一層,在多層嵌套的狀況下依然會出現反作用.面試

const person = [{name: 'Messi', info: {age: 30}}];
const person1 = person.map(item =>
  ({...item, name: 'Kane'})
)

console.log(person[0].info === person1[0].info); // true

複製代碼

上述代碼代表當利用淺複製產生新的person1後其中嵌套的info屬性依然與原始的personinfo屬性指向同一個堆內存對象,這種狀況依然會產生反作用.typescript

咱們能夠發現淺複製雖然能夠解決淺層嵌套的問題,可是依然對多層嵌套的引用類型無能爲力.

2.2 深克隆

既然淺複製(克隆)沒法解決這個問題,咱們天然會想到利用深克隆的方法來實現多層嵌套複製的問題.

咱們以前已經討論過如何實現一個深克隆,在此咱們不作深究,深克隆毫無疑問能夠解決引用類型產生的反作用.

面試官系列(1): 如何實現深克隆

實現一個在生產環境中能夠用的深克隆是很是繁瑣的事情,咱們不只要考慮到正則SymbolDate等特殊類型,還要考慮到原型鏈循環引用的處理,固然咱們能夠選擇使用成熟的開源庫進行深克隆處理.

但是問題就在於咱們實現一次深克隆的開銷太昂貴了,如何實現深克隆中咱們展現了一個勉強可使用的深克隆函數已經處理了至關多的邏輯,若是咱們每使用一次深克隆就須要一次如此昂貴的開銷,程序的性能是會大打折扣.

const person = [{name: 'Messi', info: {age: 30}}];

for (let i=0; i< 100000;i++) {
  person.push({name: 'Messi', info: {age: 30}});
}
console.time('clone');
const person1 = person.map(item =>
  ({...item, name: 'Kane'})
)
console.timeEnd('clone');
console.time('cloneDeep');
const person2 = lodash.cloneDeep(person)
console.timeEnd('cloneDeep');

// clone : 105.520ms
// cloneDeep : 372.839ms
複製代碼

咱們能夠看到深克隆的的性能相比於淺克隆大打折扣,可是淺克隆又不能從根本上杜絕引用類型的反作用,咱們須要找到一個兼具性能和效果的方案.

2.3 immutable.js

immutable.js是正是兼顧了使用效果和性能的解決方案

原理以下: Immutable實現的原理是Persistent Data Structur(持久化數據結構),對Immutable對象的任何修改或添加刪除操做都會返回一個新的Immutable對象, 同時使用舊數據建立新數據時,要保證舊數據同時可用且不變。

爲了不像 deepCopy同樣 把全部節點都複製一遍帶來的性能損耗,Immutable 使用了 Structural Sharing(結構共享),即若是對象樹中一個節點發生變化,只修改這個節點和受它影響的父節點,其它節點則進行共享。請看下面動畫

咱們看到動畫中右側的子節點因爲發生變化,相關父節點進行了重建,可是左側樹沒有發生變化,最後造成的新的樹依然複用了左側樹的節點,看起來真的是無懈可擊.

immutable.js 的實現方法確實很高明,畢竟是花了 Facebook 工程師三年打造的全新數據結構,相比於深克隆,帶來的 cpu 消耗很低,同時內存佔用也很小.

可是 immutable.js 就沒有弊端嗎?

在使用過程當中,immutable.js也存在不少問題.

我目前碰到的坑有:

  1. 因爲實現了完整的不可變數據,immutable.js的體積過於龐大,尤爲在移動端這個狀況被凸顯出來.
  2. 全新的api+不友好的文檔,immutable.js使用的是本身的一套api,所以咱們對js原生數組、對象的操做通通須要拋棄從新學習,可是官方文檔不友好,不少狀況下須要本身去試api.
  3. 調試錯誤困難,immutable.js自成一體的數據結構,咱們沒法像讀原生js同樣讀它的數據結構,不少狀況下須要toJS()轉化爲原生數據結構再進行調試,這讓人很崩潰.
  4. 極易引發濫用,immutable.js 在 react 項目中原本是能夠大幅提升軟件性能,經過深度對比避免大量重複渲染的,可是不少開發者習慣在 react-redux 的 connect 函數中將 immutable.js 數據經過 toJS轉化爲正常的 js 數據結構,這個時候新舊 props 就永遠不會相等了,就致使了大量重複渲染,嚴重下降性能.
  5. 版本更新卡殼,immutable.js 在4.0.0-rc.x 上大概卡了一年了,在3.x 版本中對 typescript 支持極差,而新版本一直卡殼

immutable.js在某種程度上來講,更適合於對數據可靠度要求頗高的大型前端應用(須要引入龐大的包、額外的學習成本甚至類型檢測工具對付immutable.js與原生js相似的api),中小型的項目引入immutable.js的代價有點高昂了,但是咱們有時候不得不利用immutable的特性,那麼如何保證性能和效果的狀況下減小immutable相關庫的體積和提升api友好度呢?

3.實現更簡單的immutable

咱們的原則已經提到了,要儘量得減少體積,這就註定了咱們不能像immutable.js那樣本身定義各類數據結構,並且要減少使用成本,因此要用原生js的方式,而不是自定義數據結構中的api.

這個時候須要咱們思考如何實現上述要求呢?

咱們要經過原生js的api來實現immutable,很顯然咱們須要對引用對象的set、get、delete等一系列操做的特性進行修改,這就須要defineProperty或者Proxy進行元編程.

咱們就以Proxy爲例來進行編碼,固然,咱們須要事先了解一下Proxy使用方法.

咱們先定義一個目標對象

const target = {name: 'Messi', age: 29};
複製代碼

咱們若是想每訪問一次這個對象的age屬性,age屬性的值就增長1.

const target = {name: 'Messi', age: 29};
const handler = {
  get: function(target, key, receiver) {
    console.log(`getting ${key}!`);
    if (key === 'age') {
      const age = Reflect.get(target, key, receiver)
      Reflect.set(target, key, age+1, receiver);
      return age+1
    }
    return Reflect.get(target, key, receiver);
  }
};

const a = new Proxy(target, handler);

console.log(a.age, a.age);
//getting age!
//getting age!
//30 31
複製代碼

是的Proxy就像一個代理器,當有人對目標對象進行處理(set、has、get等等操做)的時候它會攔截操做,並用咱們提供的代碼進行處理,此時Proxy至關於一箇中介或者叫代理人,固然Proxy的名字也說明了這一點,它常常被用於代理模式中,例如字段驗證、緩存代理、訪問控制等等。

咱們的目的很簡單,就是利用Proxy的特性,在外部對目標對象進行修改的時候來進行額外操做保證數據的不可變。

在外部對目標對象進行修改的時候,咱們能夠將被修改的引用的那部分進行拷貝,這樣既能保證效率又能保證可靠性.

  1. 那麼如何判斷目標對象是否被修改過,最好的方法是維護一個狀態
function createState(target) {
    this.modified = false; // 是否被修改
    this.target = target; // 目標對象
    this.copy = undefined; // 拷貝的對象
  }
複製代碼
  1. 此時咱們就能夠經過狀態判斷來進行不一樣的操做了
createState.prototype = {
    // 對於get操做,若是目標對象沒有被修改直接返回原對象,不然返回拷貝對象
    get: function(key) {
      if (!this.modified) return this.target[key];
      return this.copy[key];
    },
    // 對於set操做,若是目標對象沒被修改那麼進行修改操做,不然修改拷貝對象
    set: function(key, value) {
      if (!this.modified) this.markChanged();
      return (this.copy[key] = value);
    },

    // 標記狀態爲已修改,並拷貝
    markChanged: function() {
      if (!this.modified) {
        this.modified = true;
        this.copy = shallowCopy(this.target);
      }
    },
  };

  // 拷貝函數
  function shallowCopy(value) {
    if (Array.isArray(value)) return value.slice();
    if (value.__proto__ === undefined)
      return Object.assign(Object.create(null), value);
    return Object.assign({}, value);
  }
複製代碼
  1. 最後咱們就能夠利用構造函數createState接受目標對象state生成對象store,而後咱們就能夠用Proxy代理store,producer是外部傳進來的操做函數,當producer對代理對象進行操做的時候咱們就能夠經過事先設定好的handler進行代理操做了.
const PROXY_STATE = Symbol('proxy-state');
  const handler = {
    get(target, key) {
      if (key === PROXY_STATE) return target;
      return target.get(key);
    },
    set(target, key, value) {
      return target.set(key, value);
    },
  };

  // 接受一個目標對象和一個操做目標對象的函數
  function produce(state, producer) {
    const store = new createState(state);
    const proxy = new Proxy(store, handler);

    producer(proxy);

    const newState = proxy[PROXY_STATE];
    if (newState.modified) return newState.copy;
    return newState.target;
  }

複製代碼
  1. 咱們能夠驗證一下,咱們看到producer並無干擾到以前的目標函數.
const baseState = [
  {
    todo: 'Learn typescript',
    done: true,
  },
  {
    todo: 'Try immer',
    done: false,
  },
];

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

console.log(baseState, nextState);
/* [ { todo: 'Learn typescript', done: true }, { todo: 'Try immer', done: true } ] [ { todo: 'Learn typescript', done: true , { todo: 'Try immer', done: true }, { todo: 'Tweet about it', done: false } ] */
複製代碼

沒問題,咱們成功實現了輕量級的 immutable.js,在保證 api友好的同時,作到了比 immutable.js 更小的體積和不錯的性能.

總結

實際上這個實現就是不可變數據庫immer 的迷你版,咱們閹割了大量的代碼才縮小到了60行左右來實現這個基本功能,實際上除了get/set操做,這個庫自己有has/getOwnPropertyDescriptor/deleteProperty等一系列的實現,咱們因爲篇幅的緣由不少代碼也十分粗糙,深刻了解能夠移步完整源碼.

在不可變數據的技術選型上,我查閱了不少資料,也進行過實踐,immutable.js 的確十分難用,儘管我用他開發過一個完整的項目,由於任何來源的數據都須要經過 fromJS()將他轉化爲 Immutable 自己的結構,而咱們在組件內用數據驅動視圖的時候,組件又不能直接用 Immutable 的數據結構,這個時候又須要進行數據轉換,只要你的項目沾染上了 Immutable.js 就不得不將整個項目所有的數據結構用Immutable.js 重構(不然就是處處可見的 fromjs 和 tojs 轉換,一方面影響性能一方面影響代碼可讀性),這個解決方案的侵入性極強,不建議你們輕易嘗試.


本文主要參考:

  1. immer
相關文章
相關標籤/搜索