immer 原理解析

4,581 阅读13分钟

前言

通常理解一个事物的原理,你需要先理解这个事物出现的动机。其次你还要能理解这个事物是基于什么基础概念来实现的。满足以上两点你才能更好的理解这个事物。

immer.js 出现的动机,或者说要解决的痛点,其实是让 js 对于复杂对象(嵌套较深)的修改变得更加容易、可读。而 immer 的原理的基础是 ES6 中 Proxy 的概念。所以要能理解 immer,其实你需要在实践中有过修改复杂状态的经历,并且这个修改的过程是你很强的痛点,你才能体会到用 immer 的快感。其次你还要对 Proxy 这个概念很熟悉。

本文的重点会放在原理实现上面,对于动机和 proxy 概念会提到,但是不会深入。如果对于动机和 proxy 不了解的同学,建议等理解了再回来看本文。

从一个例子开始

假设我们现在有个 User 组件,组件有一个初始化的状态,这个组件的功能是通过输入框来改变这个 state.address.geo.lat 的值。如下所示:

const initialUser = {
  "id": 1,
  "name": "Leanne Graham",
  "username": "Bret",
  "email": "Sincere@april.biz",
  "address": {
    "street": "Kulas Light",
    "suite": "Apt. 556",
    "city": "Gwenborough",
    "zipcode": "92998-3874",
    "geo": {
      "lat": "-37.3159",
      "lng": "81.1496"
    }
  },
  "phone": "1-770-736-8031 x56442",
  "website": "hildegard.org",
  "company": {
    "name": "Romaguera-Crona",
    "catchPhrase": "Multi-layered client-server neural-net",
    "bs": "harness real-time e-markets"
  }
};

function User() {
  const [user, setUser] = useState(initialUser);

  return (
    <div>
      <label>修改 user.address.geo.lat </label>
      <input
        value={user.address.geo.lat}
        onChange={e => {
          setUser({
            ...user,
            address: {
              ...user.address,
              geo: {
                ...user.address.geo,
                lat: e.target.value
              }
            }
          })
        }}
      />
    </div>
  )
}

因为这个状态是一个复杂状态(嵌套很深),所以修改起来,我们不得不用大量的 spread operator 来实现。不仅阅读起来难受,写起来也很容易出错。其实这已经不算是一个非常复杂的例子了,因为甚至有时候,我们还要先判断一个字段有没有,如果没有,还要先创建这个字段,然后再给这个字段添加属性。那一个更新状态的内容写起来这么复杂,有没有更好的解决方案呢? immer 应运而生。

以上面的例子为例,用 immer 来实现的代码如下所示(省去了 initialUser 的部分):

import produce from 'immer';

// 省略了 initialUser 的部分

function User() {
  const [user, setUser] = useState(initialState);

  return (
    <div>
      <label>修改 user.address.geo.lat </label>
      <input
        value={user.address.geo.lat}
        onChange={e => {
          const newUser = produce(user, draft => {
            draft.address.geo.lat = e.target.value;
          });
          setUser(newUser);
        }}
      />
    </div>
  )
}

显然,这样的写法可读性更强,阅读的人可以很容易的知道你要修改的内容是什么;其次,避免了大量的 spread operator 的嵌套,出错的可能性也更低。

但是有的同学会说了,这个用 cloneDeep 的方式就好了呀。我深拷贝一下这个 initialUser 对象,然后再修改这个 copy 的 address.geo.lat,最后再 setUser 就好了,如下:

import {cloneDeep} from 'lodash';

function User() {
  const [user, setUser] = useState(initialState);

  return (
    <div>
      <label>修改 user.address.geo.lat </label>
      <input
        value={user.address.geo.lat}
        onChange={e => {
          const newUser = cloneDeep(user);
          newUser.address.geo.lat = e.target.value;
          setUser(newUser);
        }}
      />
    </div>
  )
}

确实,这种解决办法似乎也是很好的解决了可读性和代码简洁的问题,但是却有一个问题。就是,对于所有没有改变的内容,进行了无谓的拷贝。也就是说如果我深拷贝一个对象,那么这个对象和原来的对象是完全不同的两个对象,虽然我改变的只是 user.address.geo.lat,但是其实拷贝后的对象的 company 和原来对象的 company 也是两个完全不同的对象,用 === 来去比较,返回的值是 false。但是 immer 不同:

import {cloneDeep} from 'lodash';
const copy = cloneDeep(initialUser);
console.log(copy.company === initialUser.company); // false

import produce from 'immer';
const copy2 = produce(initialUser, () => {});
console.log(copy2.company === initialUser.company); // true

这其实也就导致了另外一个问题,就是如果恰好有其他组件用到了 company 这个属性的话,其实每次你修改 user.address.geo.lat,但是另外一个组件也会渲染。company 的内容没有变化,但是内存地址却发生了变化,用 shouldComponentUpdate 判断的话,上一次的属性和新的属性是不一样的,所以要重新渲染。但是如果是 immer 的话就可以避免这种情况。

所以说,其实 immer 除了解决可读性和书写简洁的问题之外,还解决了一个很重要的问题,就是状态共享(structurally sharing the things that weren't changed)。

那么 immer 到底如何实现这个功能和效果的呢?

题外话

关于状态共享的问题,其实在 immer 出现之前,react 技术社区已经有了很多关于 immutability 相关的库,比如 immutable.js,但是 immutable.js 的 api 非常的繁琐和复杂,看看文档就知道了。并且 immutable.js 实现 immutability 的方式和 immer 也完全不同,immutable.js 是通过算法的方式来实现的,但是 immer 是通过 js 语言和语法的方式实现的。这也是为什么 immer 在社区获得这么认可的原因:简洁的 api 加上状态共享的特型,让 immer 成为 react 社区中的一股清流。

关于 immutable.js 的实现原理,网上有很多文章,我摘录一篇,有兴趣的同学可以看看,可以作为视野的拓展。

实现 immer 原理前要掌握的两个概念

introducing immer 中说:

How does Immer work?

Well, two words; 1) Copy-on-write. 2). Proxies.

那其实要理解 immer 就要先了解和熟悉这两个概念。

copy on write 比较虚,是一种概念,或者说是一种编程思想、方法。

而 proxy 是一种具体的语法,虽然这个语法也是基于一种思想而产生的,但是在这里,就是指非常具体的 ES6 中的 Proxy 语法。

这两个概念不是本文重点探讨的内容,本文会简单罗列一些内容,不会深入探讨。在正式开始理解 immer 原理之前,建议重点和深入理解一下 proxy 这个具体的语法。immer 的所有实现几乎都是基于这个语法来实现的。 copy on write 这个概念,因为是比较虚的概念,大家作为一种编程思想了解一下即可。

copy on write

直接摘录一段 wikipedia 的内容:

Copy-on-write (COW), sometimes referred to as implicit sharing or shadowing, is a resource-management technique used in computer programming to efficiently implement a "duplicate" or "copy" operation on modifiable resources. If a resource is duplicated but not modified, it is not necessary to create a new resource; the resource can be shared between the copy and the original. Modifications must still create a copy, hence the technique: the copy operation is deferred until the first write. By sharing resources in this way, it is possible to significantly reduce the resource consumption of unmodified copies, while adding a small overhead to resource-modifying operations.

这个概念的核心在于上文中我划出来的两个内容,如果说有关键词的话,就是两个:shared 和 defferred。具体不展开阐述了。建议看完文章之后再回头来看这段话,可能会有更深刻的理解。

proxy

对 proxy 不熟悉的同学,建议直接看 mdn。这里不做过多的说明和叙述了。

简单总结一下,就是 proxy 是源对象的代理,当你对代理进行读/写的时候,会进入到 handler 的 get, set 或者是 delete 等方法中,从而实现对对象的劫持。通过这种方式相当于获得了一个增强功能版的源对象,貌似有点像面向切面编程(AOP)?

实现 immer 的时候,还需要掌握一个新的语法,就是 ES6 中的 Map。这个从我的理解主要是因为 Object 的 key 必须是字符串,而 Map 的 key 可以是对象,immer 中为了方便获取 copy 的内容,所以用的 Map。不理解可以先放着,往后面看。看到实际的例子应该自然就能懂。

immer 原理

接下来进入本文的正题,如何实现一个 immer。

第一版

我们先用 js 来实现一个简单的共享状态的拷贝功能:

const state = {
  "phone": "1-770-736-8031 x56442",
  "website": "hildegard.org",
  "company": {
    "name": "Romaguera-Crona",
    "catchPhrase": "Multi-layered client-server neural-net",
    "bs": "harness real-time e-markets"
  }
};

const copy = {...state};
copy.website = 'www.google.com';

console.log(copy.company === state.company); // true

很简答, 通过 spread operator 的方式实现浅拷贝,然后修改 website 属性。那如何通过 proxy 来实现呢?

const state = {
  "phone": "1-770-736-8031 x56442",
  "website": "hildegard.org",
  "company": {
    "name": "Romaguera-Crona",
    "catchPhrase": "Multi-layered client-server neural-net",
    "bs": "harness real-time e-markets"
  }
};

let copy = {};
const handler = {
  set(target, prop, value) {
    copy = {...target}; // 浅拷贝
    copy[prop] = value; // 给拷贝对象赋值
  }
}

const proxy = new Proxy(state, handler);
proxy.website = 'www.google.com';

console.log(copy.website); // 'www.google.com'
console.log(copy.company === state.company); // true

就是在 写 操作的时候,进行浅拷贝,然后写入属性,这个时候,copy 和原来的 state 共享了除 写 属性之外的所有属性(认真理解一下这句话)。接着我们来封装一下,实现我们的第一个版本的 tiny-immer:

function immer(state, thunk) {
  let copy = {};
  const handler = {
    set(target, prop, value) {
      copy = {...target}; // 浅拷贝
      copy[prop] = value; // 给拷贝对象赋值
    }
  };

  const proxy = new Proxy(state, handler);
  thunk(proxy);
  return copy;
}

const state = {
  "phone": "1-770-736-8031 x56442",
  "website": "hildegard.org",
  "company": {
    "name": "Romaguera-Crona",
    "catchPhrase": "Multi-layered client-server neural-net",
    "bs": "harness real-time e-markets"
  }
};

const copy = immer(state, draft => {
  draft.website = 'www.google.com';
});

console.log(copy.website); // 'www.google.com'
console.log(copy.company === state.company); // true

第二版

可是如果我们要修改的是 state.company.name 呢?这个时候上面的代码就不满足了,我们需要用下面所示的 thunk 来进行变更:

const copy = immer(state, draft => {
  draft.company.name = 'google';
});

如果你了解 AST,或者说知道 babel 对于上面这个语句的编译过程的话,你其实可以明白,这个时候这个 写 操作其实并不会被 set 捕获(劫持)。因为你只对 state 做了代理(劫持),并没有对 state.company 做代理(劫持)。上面这个语句的编译的过程分为两个步骤:第一个步骤是 get 的过程,获取 draft.company;第二个步骤是 set 的过程,将 draft.company 的 name 属性写为 'google' 这个字符串。所以,如果要让上面这个语句进入到 set 劫持中,必须要求第一个'读',或者说 get 的过程,也就是 draft.company 返回的内容也是一个 proxy。也就是说我们需要增加一个 get 的劫持。

因为 set 的过程这个时候只是对局部,或者说要修改的部分的一个劫持,所以我们需要对这部分的内容缓存起来。最后真正执行复制的时候,再以此获取这些被修改的内容。所以除了增加 get 的劫持之外,我们还需要增加一个 copies 的缓存。并且在最后的时候,添加一个 finalize 函数,让这个函数帮我们去以此对有过写操作的内容做一个复制。

感觉说的很拗口,并且很晦涩难懂了,其实这个过程其实虽然是很明确的,但是如果单纯的用语言来描述就很晦涩难懂,还是看代码吧:

function immer(state, thunk) {
  let copies = new Map(); // Map 的 key 可以是一个对象,非常适合用来缓存被修改的对象

  const handler = {
    get(target, prop) { // 增加一个 get 的劫持,返回一个 Proxy
      return new Proxy(target[prop], handler);
    },
    set(target, prop, value) {
      const copy = {...target}; // 浅拷贝
      copy[prop] = value; // 给拷贝对象赋值
      copies.set(target, copy);
    }
  };

  function finalize(state) { // 增加一个 finalize 函数
    const result = {...state};
    Object.keys(state).map(key => { // 以此遍历 state 的 key
      const copy = copies.get(state[key]);
      if(copy) { // 如果有 copy 表示被修改过
        result[key] = copy; // 就是用修改后的内容
      } else {
        result[key] = state[key]; // 否则还是保留原来的内容
      }
    });
    return result;
  }

  const proxy = new Proxy(state, handler);
  thunk(proxy);
  return finalize(state);
}

const state = {
  "phone": "1-770-736-8031 x56442",
  "website": {site: "hildegard.org"}, // 注意这里为了方便测试状态共享,将简单数据类型改成了对象
  "company": {
    "name": "Romaguera-Crona",
    "catchPhrase": "Multi-layered client-server neural-net",
    "bs": "harness real-time e-markets"
  }
};

const copy = immer(state, draft => {
  draft.company.name = 'google';
});

console.log(copy.company.name); // 'google'
console.log(copy.website === state.website); // true

再来对代码做一个详细的解释和说明,首先 thunk 函数改成上面的样子之后,draft.company.name = 'google' 这个语句会发生两次劫持。第一次是 draft.company,这个读操作会进入到 draft 的 get 劫持中。为了让 company 在 set 的时候能进入到 set 劫持中,必须给 draft.company 生成一个代理,这样才会有第二次的劫持,也就是 draft.company 返回一个代理之后,draft.company.name = 'google' 这个 写 操作才会发生第二次劫持,也就是进入到 set 中。 基于这个原理,所以就有了上面新添加的 handler 中的 get() {} 部分的内容。其实上面的内容已经非常接近 immer 的实现了。但是还缺少一些必要的验证、缓存,还有优化,以及对于 每个子属性的 递归过程等等,再把 delete, has 等劫持也都添加上就是一个比较完整的 immer 实现了。

为了能让大家更直观地理解两次劫持是什么意思。以及最终的 finalize 是一个什么过程,我制作了一个动画,但是因为 ppt 的尺寸有限,所以对上面的状态对象作了更改。其实原理和过程是一样的。

绿色的表示原来的对象树。蓝色表示给这个对象创建了一个代理(Proxy),红色的点表示创建了一个副本到 copies 的这个 Map 中,这个其实也相当于是一个标志,方便最终 finalize 的时候,能识别出哪些内容是更改过的,哪些是没有更改的。

不知道为啥,上面的动图不动了……

终版

理解了上面两个版本的内容,其实你就可以很好的理解 immer 了。最终我们把缓存,以及除了 get set 之外的其他劫持操作也都添加上,并且加上一些校验和递归的过程,我们就可以实现一个比较完整的 immer 了,虽然我知道上面的步骤距离真正的 immer 还有一些差距,但是其实 immer 最核心的内容就是上面的内容,如果能理解上面的内容的话,真正的 immer 的代码需要实现的就是业务逻辑了,和 immer 的设计思想无关了。

最终,我们把以上的功能都加上之后的代码如下:

function immer(baseState, thunk) {
    // Maps baseState objects to proxies
    const proxies = new Map()
    // Maps baseState objects to their copies
    const copies = new Map()

    const objectTraps = {
        get(target, prop) {
            return createProxy(getCurrentSource(target)[prop])
        },
        has(target, prop) {
            return prop in getCurrentSource(target)
        },
        ownKeys(target) {
            return Reflect.ownKeys(getCurrentSource(target))
        },
        set(target, prop, value) {
            const current = createProxy(getCurrentSource(target)[prop])
            const newValue = createProxy(value)
            if (current !== newValue) {
                const copy = getOrCreateCopy(target)
                copy[prop] = newValue
            }
            return true
        },
        deleteProperty(target, property) {
            const copy = getOrCreateCopy(target)
            delete copy[property]
            return true
        }
    }

    // creates a copy for a base object if there ain't one
    function getOrCreateCopy(base) {
        let copy = copies.get(base)
        if (!copy) {
            copy = Array.isArray(base) ? base.slice() : Object.assign({}, base)
            copies.set(base, copy)
        }
        return copy
    }

    // returns the current source of trugth for a base object
    function getCurrentSource(base) {
        const copy = copies.get(base)
        return copy || base
    }

    // creates a proxy for plain objects / arrays
    function createProxy(base) {
        if (isPlainObject(base) || Array.isArray(base)) {
            if (proxies.has(base)) return proxies.get(base)
            const proxy = new Proxy(base, objectTraps)
            proxies.set(base, proxy)
            return proxy
        }
        return base
    }

    // checks if the given base object has modifications, either because it is modified, or
    // because one of it's children is
    function hasChanges(base) {
        const proxy = proxies.get(base)
        if (!proxy) return false // nobody did read this object
        if (copies.has(base)) return true // a copy was created, so there are changes
        // look deeper
        const keys = Object.keys(base)
        for (let i = 0; i < keys.length; i++) {
            if (hasChanges(base[keys[i]])) return true
        }
        return false
    }

    // given a base object, returns it if unmodified, or return the changed cloned if modified
    function finalize(base) {
        if (isPlainObject(base)) return finalizeObject(base)
        if (Array.isArray(base)) return finalizeArray(base)
        return base
    }

    function finalizeObject(thing) {
        if (!hasChanges(thing)) return thing
        const copy = getOrCreateCopy(thing)
        Object.keys(copy).forEach(prop => {
            copy[prop] = finalize(copy[prop])
        })
        return copy
    }

    function finalizeArray(thing) {
        if (!hasChanges(thing)) return thing
        const copy = getOrCreateCopy(thing)
        copy.forEach((value, index) => {
            copy[index] = finalize(copy[index])
        })
        return copy
    }

    // create proxy for root
    const rootClone = createProxy(baseState)
    // execute the thunk
    thunk(rootClone)
    // and finalize the modified proxy
    return finalize(baseState)
}

function isPlainObject(value) {
    if (value === null || typeof value !== "object") return false
    const proto = Object.getPrototypeOf(value)
    return proto === Object.prototype || proto === null
}

module.exports = immer

对 immer 熟悉的朋友应该能知道,其实这个就是 immer 最开始的版本的原始代码。大家剥离了业务逻辑和核心思想之后,可以仔细阅读一下,一定会有收获。

后记

虽然写完了,但是还是觉得没有完整,或者说没有完美的表达出我想表达的内容。自己理解和能清楚地让他人理解其实还是有很遥远的距离,这个 gap 需要我不断地努力才行,希望自己的进步能逐渐填平这个 gap,这个过程也是自己不断进步的过程。新的一年希望自己越来越强,能更好更快更深更高的突破一个个瓶颈,加油!