React中的不可变数据——immer是如何实现的?

1,987 阅读10分钟

前置知识

在React中,常见的性能优化可能有使用pureComponentmemo来减少组件渲染的次数。这时,react会对父组件传过来的prop进行浅比较,如果数据没有变化,则不会对子组件重新执行render。如果这个时候我们对一个数组直接操作pushpop这些原地更新数组的方法,并将其作为prop传入子组件,就会发生子组件无法触发重新渲染的问题。下面是一个示例: 在这个示例的函数式组件中,如果我们直接修改一个数组并更新,我们会发现组件压根没有渲染,但是点击forceUpdate按钮后我们可以看到实际上数据是改变了的,这就是因为react在内部使用了浅比较,在setList的时候,就会判断当前更新的state和上次的是否相同,相同就不进行调度更新了。 源码地址: github.com/facebook/re…

什么是浅比较?

JS中的类型分为基本数据类型和引用类型,在js中===是做浅比较,只检查左右两边是否是同一个对象的引用,如果是原始值,则会比较两边的原始值是否相等。

什么是不可变数据?

不可变数据的概念来自函数式编程。 在函数式编程中,对已初始化的“变量”是不可以更改的,每次更改都要创建一个新的“变量”。 Javascript 在语言层没有实现不可变数据,需要借助第三方库来实现。(immutable.js 或者 immer.js)

不可变数据能做什么?

针对上文react的例子出现的问题,我们需要思考下,为什么react源码只进行浅比较?很简单,因为性能问题,如果一个对象树层级非常深,我们很难去确定这棵树是否发生了变化,但是如果这个对象发生了改变,变成了一个新对象,那么我们就很轻松的可以确定这个对象发生了变化,也就可以顺其自然的进行重新渲染了。那么现在的问题就变成了我们如何去修改原始对象,并返回一个新的对象呢?这里有几个方法:

  • 对于数组来说,我们可以使用concat等返回新数组的方法对原始数组进行修改
  • 对于数组或者对象,也可以使用扩展运算符来生成一个新的数组或者对象。

缺点:只适用于层级浅的数据:

list: [...state.list, 'add']

如果处理层级嵌套很深的数据时,这种方法实际上是非常繁杂(不可用)的:

newObj:{
  ...state.oldObj,
  b:{
     ...state.oldObj.b
     c:{
        ...state.oldObj.b.c,
        newKey: 'add'
     }
  }
}
  • 深拷贝

缺点:性能太差,有可能只修改层级很深,但只有一个值变化的对象,却需要深拷贝整个对象。

那么如何做才能做到性能与不可变数据的兼顾呢?这里就引出了今天的主角——immer

Immer 介绍

Immer 利用ES6 Proxy的特性,基于Copy-on-write的机制,实现了一种不可变数据的操作方式:

import { produce } from "immer";
const obj = {
  a: {},
  b: [1, 2, 3],
  c: {
    d: {
      e: [3, 4, 5],
      f: 6,
    },
    f: 7,
  },
};

const copy = produce(obj, () => {
  // nothing to do
});

const modified = produce(obj, (draft) => {
  draft.b.push(4);
  draft.c.f = 8;
});

console.log(copy === obj); // true
console.log(modified === obj); // false
console.log(modified.a === obj.a); // true
console.log(modified.b === obj.b); // false
console.log(modified.c === obj.c); //false
console.log(modified.c.d === obj.c.d); // true

Immer的基本思想是,所有更改都应用于临时的draftState,它是 currentState 的代理。一旦完成所有变更,Immer 将基于草稿状态的变更生成 nextState。这意味着可以通过简单地修改数据而与数据进行交互,同时保留不可变数据的所有优点。 Immer的核心API是Produce:

produce(currentState, producer: (draftState) => void): nextState

本文的重点就是动手实现一个produce函数,揭开Immer神秘的面纱。

Immer 思路

引出问题

根据前面的示例代码,我们可以思考下,如何在不用深拷贝的情况下对一个对象进行修改并返回一个新的对象呢?

这里我们首先要做的是监控到对象中哪些属性被修改了,针对被修改的数据,我们要对其做浅拷贝,这样才不会影响到原始对象,最后只需要原始对象和拷贝后的对象做合并,如果对象没有拷贝,我就复用原本的,如果拷贝了,就是用拷贝之后的对象。Immer的基本原理就是这样,那么如何去实现呢?

总结一下上面这段话,我们需要做下面几个事:

  • 监听对象的哪些属性被修改了
  • 对于将要被修改的对象,首先做浅拷贝操作,然后在拷贝的对象上做修改操作
  • 合并原始对象与被修改的(浅拷贝后)的对象

可能有小伙伴会问,为什么是浅拷贝呢?因为我们前面监控的是对象的属性,这个是可以精确到每一层的,所以我们拷贝的粒度同样可以精确到单层,如下图这个对象,如果绿色节点发生了修改,我们只需要对红色节点做一层浅拷贝,然后把绿色节点属性给修改了就好了,最后切换一下父节点的指向,让其指向新拷贝的节点即可。

image.png

image.png

核心思路

前面说到,我们需要监听对象的哪些属性发生了改变,自然而然的,我们会想到使用代理,即Proxy对对象进行监听,接下来的工作基本都是围绕Proxy来进行的:

以下是核心思路

  • 使用Proxy对对象进行代理监听
  • 对于读取的操作,我们使用get来进行属性读取的拦截,由于我们可能已经对当前属性做了修改,且修改后的内容是存在于拷贝的对象上的,所以这里我们在获取属性值的时候需要进行判断,如果当前target拷贝过,则从拷贝后的对象的属性上获取属性值,否则从原始对象上进行获取。如果拦截后的属性值是一个对象,我们需要继续对这个对象进行代理,并将其保存在proxies变量中,在之后的工作中,我们需要通过这个判断一个对象是否被代理来确定这个对象时候被访问或修改过。
  • 对于修改或新增的操作,我们使用set进行监听,如果一个对象发生了属性的修改或新增,我们将会对该对象(target)做一层浅拷贝,并在浅拷贝的对象上进行修改操作,注意永远不要修改原对象!。这里会新增一个copies变量来存储原始对象和拷贝对象的关系。
  • 对于其他操作,我们都可以根据上面的原理进行扩展,这里只实现一个最小版本。

Immer 实现

变量初始化

从前文我们可以知道,首先我们需要维护两个状态——proxiescopies,这里我们使用WeakMap进行存储:

const proxies = new WeakMap(); // 用于存储被代理的对象,key 为原对象,value 为代理对象
const copies = new WeakMap(); // 用于缓存被修改的对象,key 为原对象,value 为修改后的对象

代理监听

首先我们先实现对对象监听的方法,这里我们定义一个创建代理的方法createProxy

function createProxy(state) {
  if (isPlainObject(state) || Array.isArray(state)) {
    if (!proxies.has(state)) {
      const proxy = new Proxy(state, handler);
      proxies.set(state, proxy);
      return proxy;
    } else {
      return proxies.get(state);
    }
  }
  return state;
}

// 判断是否是计划中的普通对象
function isPlainObject(value) {
  if (value === null || typeof value !== "object") return false;
  const proto = Object.getPrototypeOf(value);
  return proto === Object.prototype || proto === null;
}

我们写一个简单的handler来对对象进行拦截看一下效果:

const handler = {
  get(target, key) {
    console.log("get", key);
    return createProxy(target[key]);
  },
  set(target, key, val) {
    console.log("set", key);
    target[key] = val;
  },
};

测试代码:

const obj = {
    a: 1,
    b: {
      c: {
        d: 1,
      },
    },
};
const pro = createProxy(obj);
pro.b.c.e = 1;
console.log(proxies);

如上的代码所示,我们通过在get中递归的对属性值进行代理,就可以拿到所有被代理的属性值,后面我们对其遍历即可找到最后被拷贝的节点:

image.png

原理知道了,我们开始正式去写handler

拦截get

对于get,如果数据已经被修改过,那么我们在获取值的时候就需要在拷贝的对象上去找,所以这里实现一个getSource来实现对对象的获取:

function getSource(target) {
  return copies.get(target) || target;
}

然后我们就可以实现get,对对象进行代理,并对被访问的属性继续进行递归的代理:

const handler = {
  get(target, key) {
    const source = getSource(target);
    return createProxy(target[key]);
  },
  // ....
};

拦截set

回想一下,set主要做的是修改新增的操作,如果一个对象发生了修改或者新增,我们就要对其拷贝一份,然后在拷贝的对象上进行操作:

function copySource(target) {
  if (!copies.has(target)) {
    const copy = Array.isArray(target) ? [...target] : { ...target };
    copies.set(target, copy);
    return copy;
  }
  return copies.get(target);
}
const handler = {
  get(target, key) {
    const source = getSource(target);
    return createProxy(source[key]);
  },
  set(target, key, val) {
    const copy = copySource(target);
    copy[key] = val;
    return true;
  },
};

这时候先停一停,还是刚刚的例子,我们来打印一下copies变量和proxies变量,看是否有值了:

image.png

没问题,对于新增属性ecopies记录了原始值与拷贝值,便于我们后续的操作。

现在handler已经实现了,现在我们已经可以做到对数据的修改拷贝并监听了,接下来就是实现一个finalize函数,将原始对象与拷贝后的对象建立关联,深度合并原值与拷贝值。

整合

这里要明白一点,如果一个层级很深的节点发生了修改或者新增,那么从这颗节点向上的所有父元素都可认为发生了修改,都需要进行浅拷贝操作,所以我们要做的就是判断一棵树那些路径下发生了修改,从而递归的进行浅拷贝,具体代码如下:

  function hasChange(target) {
    if (!proxies.has(target)) {
      // 没有被代理,说明没有修改过子对象
      return false;
    }
    if (copies.has(target)) {
      // 如果被复制了,则说明当前对象被修改了
      return true;
    }
    const keys = Object.keys(base);
    for (let i = 0; i < keys.length; i++) {
      const value = target[keys[i]];
      // 对象或者数组需要再次使用 hasChange 递归检查。
      if ((Array.isArray(value) || isPlainObject(value)) && hasChanges(value)) {
        return true;
      }
    }
    // 其他数据类型都是原始类型,不要判断,直接返回 false。
    return false;
  }

  function finalize(target) {
    if (isPlainObject(target) || Array.isArray(target)) {
      if (!hasChange(target)) {
        return target;
      }
      // 当前对象或者子对象发生了修改,进行浅拷贝操作:
      const copy = copySource(target);
      if (Array.isArray(copy)) {
        // 数组处理方式,检查子节点
        copy.forEach((value, index) => {
          copy[index] = finalize(copy[index]);
        });
      } else {
        // 普通对象处理方式,检查子属性值
        Object.keys(copy).forEach((prop) => {
          copy[prop] = finalize(copy[prop]);
        });
      }
      return copy;
    }
    return target;
  }

测试

这里使用介绍里的例子测试下我们的代码:

const obj = {
    a: {},
    b: [1, 2, 3],
    c: {
      d: {
        e: [3, 4, 5],
        f: 6,
      },
      f: 7,
    },
};

const copy = produce(obj, () => {
    // nothing to do
});

const modified = produce(obj, (draft) => {
    draft.b.push(4);
    draft.c.f = 8;
});

console.log(copy === obj); // true
console.log(modified === obj); // false
console.log(modified.a === obj.a); // true
console.log(modified.b === obj.b); // false
console.log(modified.c === obj.c); //false
console.log(modified.c.d === obj.c.d); // true

image.png

可以看到基本的Immer produce方法我们已经实现了,后面大家可以逐步发挥自己的想象力,去扩展内容~

完整代码

最后附上完整代码

// 判断是否是计划中的普通对象
function isPlainObject(value) {
  if (value === null || typeof value !== "object") return false;
  const proto = Object.getPrototypeOf(value);
  return proto === Object.prototype || proto === null;
}

function produce(target, callback) {
  const proxies = new WeakMap(); // 用于存储被代理的对象,key 为原对象,value 为代理对象
  const copies = new WeakMap(); // 用于缓存被修改的对象,key 为原对象,value 为修改后的对象

  function getSource(target) {
    return copies.get(target) || target;
  }

  const handler = {
    get(target, key) {
      const source = getSource(target);
      return createProxy(source[key]);
    },
    set(target, key, val) {
      const copy = copySource(target);
      copy[key] = val;
      return true;
    },
  };

  function copySource(target) {
    if (!copies.has(target)) {
      const copy = Array.isArray(target) ? [...target] : { ...target };
      copies.set(target, copy);
      return copy;
    }

    return copies.get(target);
  }

  function createProxy(target) {
    if (isPlainObject(target) || Array.isArray(target)) {
      if (!proxies.has(target)) {
        const proxy = new Proxy(target, handler);
        proxies.set(target, proxy);
        return proxy;
      } else {
        return proxies.get(target);
      }
    }
    return target;
  }

  function hasChange(target) {
    if (!proxies.has(target)) {
      // 没有被代理,说明没有修改过子对象
      return false;
    }
    if (copies.has(target)) {
      // 如果被复制了,则说明当前对象被修改了
      return true;
    }
    const keys = Object.keys(target);
    for (let i = 0; i < keys.length; i++) {
      const value = target[keys[i]];
      // 对象或者数组需要再次使用 hasChange 递归检查。
      if ((Array.isArray(value) || isPlainObject(value)) && hasChange(value)) {
        return true;
      }
    }
    // 其他数据类型都是原始类型,不要判断,直接返回 false。
    return false;
  }

  function finalize(target) {
    if (isPlainObject(target) || Array.isArray(target)) {
      if (!hasChange(target)) {
        return target;
      }
      const copy = copySource(target);
      if (Array.isArray(copy)) {
        // 数组处理方式,检查子节点
        copy.forEach((value, index) => {
          copy[index] = finalize(copy[index]);
        });
      } else {
        // 普通对象处理方式,检查子属性值
        Object.keys(copy).forEach((prop) => {
          copy[prop] = finalize(copy[prop]);
        });
      }
      return copy;
    }
    return target;
  }

  const proxy = createProxy(target);
  callback(proxy);
  return finalize(target);
}