Immer.js 不可变数据,让你的操作更优雅~

前言

在官方文档里说的是: Immer 可让您以更方便的方式使用不可变状态。之前一直不明白这个库的意义,使用场景。感觉在项目中使用的场景并不多,后来接触了 React 之后,在写状态管理器的 reducer 时,就经常写出过类似的代码:

js 复制代码
const reducer = (state, action) => {
  switch (action.type) {
    case 'type1':
      const { typeVal } = action.payload
      return {
        ...state,
        obj2: {
          ...state.obj2,
          type: typeVal,
        },
      }
  }
}

上述的代码并非不行,只是过多的 ... 便会稍微显得有些冗余,如果层数在更深,那更是头皮发麻。

使用 Immer 改写

那么我们想想,假设我们能够像下面这样书写,不就轻松很多了吗?

js 复制代码
state.obj2.type = typeVal

但是我们 reducer 是一个纯函数,且不该改变 state 的状态,然后返回一个全新的 nextState,那么就不能够像上面的写法那样,因为上面的写法改变了源数据。

如何使得 state.obj2.type = typeVal 化为可能呢? Immer 就大显身手了~

查阅 zustand 文档时,看到了使用 Immer 来优化深层嵌套对象状态更新 的例子。同理,当我们使用 Immer 来优化我们上面的 reducer 时,代码就会变成 ------------

js 复制代码
import produce from 'immer'

const reducer = (state, action) => {
  // 第一个参数放入 state
  // 第二个参数是一个函数,函数的第一个属性是 state的草稿状态,所以这里用 draft 来命名。
  // draft 和 state 拥有相同的属性,我们可以直接修改 draft,最终会返回一个全新的 newState
  return produce(state, (draft) => {
    switch (action.type) {
      case 'type1':
        const { typeVal } = action.payload
        draft.obj2.type = typeVal // 关键赋值
        break
    }
  })
}

我们发现,当使用 produce 包裹起来之后,我们就可以在第二个参数里使用 draft.obj2.type = typeVal。甚至不需要在 case 内部去 return draft; 因为 produce() 函数的返回值就是state

Immer 原理

那么这酷炫的操作是如何实现的呢?简单拜读了 Immer 的源码,分析下其原理。。

代码是从 produce(state, (draft)=> {}) 开始,那么我们就从这里出发,省略部分源码,我们会发现实际上,这里是创建了一个 proxy 对象。

js 复制代码
// 这里的第二个参数,是 parent ,父对象,由于我们是第一层,所以并没有父对象,直接为 undefined
const proxy = createProxy(baseState, undefined)

然后我们会发现 createProxy 的实现,先定义了一个 state 对象。

js 复制代码
const createProxy = (base, parent) => {
  // state 还有部分属性,就略过了,我们只重点看原理
  const state = {
    base, // 源对象,永远不变
    copy: null, // 当出发set访问器时,会修改该属性
    parent, // 父对象,用来回溯
    modified: false, // 判断是否被修改过(即是否发生过属性 set),该属性用来区分:我们后面使用 copy 还是 base
    proxies: {}, // 遇到普通的非proxy对象时,将其存储进该属性
  }

  // revocable 的作用是创建一个可撤销的 proxy 对象,使用 revoke 方法撤销,但因为我们这里只是简单介绍源码,就略过这一部分
  // objectTraps 是proxy的handle,源码里有针对多种情况,我这里只针对 对象 做考虑了。
  const { revoke, proxy } = Proxy.revocable(state, objectTraps)

  return proxy
}

好了,接下来就是通过 objectTraps 来处理访问器代码。后面详细代码里在说,先跳过这部分。接下来就是将 proxy 对象作为 draft参数 传入给用户提供的函数。

最后再将执行完的全新的 proxy ,在从中剥离出我们需要的对象即可。

简单的 Immer 就实现了,下面是全文代码。

js 复制代码
// 源数据
const obj = {
  name: 'pnm',
  age: 10,
  obj1: {
    name: 'hello',
    age: 11,
    obj1A2: {
      name: '66',
    },
    obj2: {
      name: 'world',
      age: 12,
      obj2A2: {
        name: '66',
      },
    },
  },
}

// ============================================================================
const isProxy = (value) => {
  // 如果是我们定义的 proxy 对象,会触发我们在get访问器中的约定 __PROXY_STATE__
  return !!value && !!value['__PROXY_STATE__']
}
const isProxyable = (value) => {
  if (!value) return false
  if (typeof value !== 'object') return false
  return true
}

const markChanged = (state) => {
  if (!state.modified) {
    state.modified = true
    // 浅拷贝 base 并赋给 copy
    state.copy = Object.assign({}, state.base)
    console.log('copy <==== proxies', state.copy, state.proxies)

    // 这一步很关键,因为 copy 里的是变化过的数据,proxies 中存放的是没有变化的proxy对象
    Object.assign(state.copy, state.proxies)

    // 子属性修改了,肯定父属性也就变了,往父辈回溯
    if (state.parent) markChanged(state.parent)
  }
}

const each = (value, cb) => {
  for (let key in value) cb(key, value[key])
}
// ===========================================================================
const objectTraps = {
  get(state, prop) {
    // 【注】这里约定了:当get __PROXY_STATE__ 属性时,返回 state
    if (prop == '__PROXY_STATE__') return state

    if (isProxy(state[prop])) {
      return state[prop]
    }

    if (state.modified) {
      // 如果这个状态有被修改过了,那么处理 copy 里的值
      const value = state.copy[prop]

      // 当这个属性可以被 proxy化,且和源数据是同地址(同一个对象)
      if (isProxyable(value) && value === state.base[prop]) {
        return (state.copy[prop] = createProxy(value, state))
      }

      return value
    } else {
      // 没有被修改过则进入该条件中。

      // 如果 proxies 中有这个属性,说明这个属性已经被proxy化,那么直接返回
      if (state.proxies?.hasOwnProperty(prop)) return state.proxies[prop]

      // 因为没被修改过,所以 copy 里没数据,则从 base 中获取数据。
      const value = state.base[prop]
      // 当这个值并非 proxy 且又为普通对象,那么创建proxy,并将其挂载到 proxies 的 [prop] 属性上
      // 为了不影响源数据,所以 proxies 作为了 base 的替身。
      if (!isProxy(value) && isProxyable(value)) {
        return (state.proxies[prop] = createProxy(value, state))
      }

      return value
    }
  },

  set(state, prop, value) {
    // 该状态没有被修改过
    if (!state.modified) {
      // 如果 旧值 和 新值一样,说明没有改变,直接返回
      if (
        (prop in state.base && state.base[prop] === value) ||
        (state.proxies?.hasOwnProperty(prop) && state.proxies[prop] === value)
      ) {
        return true
      }

      // 改变 modified 为 true,并且浅拷贝 copy ,和 parent
      markChanged(state)
    }

    // 这里将新值赋给copy,不改变源数据
    state.copy[prop] = value
    return true
  },
}

const createProxy = (base, parent) => {
  const state = {
    base,
    copy: null,
    parent,
    modified: false,
    proxies: {},
  }

  const { proxy } = Proxy.revocable(state, objectTraps)

  return proxy
}

const processResult = (proxy) => {
  // 【注】这里的 __PROXY_STATE__ 并没有赋值过,只是一个约定,在 get 访问器中,约定了遇到  __PROXY_STATE__ 就返回 state
  // 【注2】 为什么要如此做?
  //  这是为了去除 proxy 带来的影响,因为如果直接 proxy.xx 属性,那么这个 xx 属性会进入到 get 访问器中。
  //  如果先把 proxy 外壳去除,这样,我们的 state 就是一个 普通对象,那么调用 对象.属性,就不会进入到 proxy 的 get 访问器中
  const state = proxy['__PROXY_STATE__']

  let source = null
  if (state.modified) {
    source = state.copy
  } else {
    source = state.base
  }

  for (const key in source) {
    if (Object.hasOwnProperty.call(source, key)) {
      const tempState = source[key]
      if (isProxy(tempState)) {
        source[key] = processResult(tempState)
      }
    }
  }

  return source
}

// ============ 下方的produce 为暴露给用户使用的方法 ==================
const produce = (baseState, recipe) => {
  /**
   * ...省略部分源码
   * 源码里做了一些判断,是否是函数,数组等,这里不过多介绍,只假设为 object 的情况
   */

  const proxy = createProxy(baseState, undefined)

  // 用户传入的方法,修改值并触发 proxy 的访问器
  const result = recipe(proxy)

  // 从 proxy 中剥离新的修改过的数据。
  return processResult(proxy)
}

const newObj = produce(obj, (draft) => {
  draft.obj1.obj2.age = 15
})

console.log({ newObj, obj })

这里有一个比较有意思的点,__PROXY_STATE__ 这个属性,我当时在源码里找了半天没找到这个属性在哪里赋值了,后来发现,这个属性不是赋值来的,而是一个约定,即:我 get 这个属性,那么你要给我一个非 proxy 的 state 对象

这里只是简单的将核心剖析了一下,详细源码还是建议大家阅读 Immer 的源码~这里就不做过多介绍了。

Immer.js 和 Immutable.js

简单提一嘴,还有一个和 Immer 同样的比较出名的库是 Immutable,但大部分情况下还是 Immer 会更好用些, Immutable 是 facebook 团队写的, Immer 是 mobx 作者写的。

Immutable 有自己的数据结构,使用的 API 比较多,上手难度会比 Immer 难一些,大部分情况下你使用 Immer,只需要记住 produce 即可,且库容量对比起来,Immer 更小巧轻量,gzip 压缩一下才 3kb,所以建议如果有使用不可变数据的情况下, Immer 会是你不错的选择

相关推荐
Fan_web13 分钟前
jQuery——事件委托
开发语言·前端·javascript·css·jquery
安冬的码畜日常14 分钟前
【CSS in Depth 2 精译_044】第七章 响应式设计概述
前端·css·css3·html5·响应式设计·响应式
莹雨潇潇1 小时前
Docker 快速入门(Ubuntu版)
java·前端·docker·容器
Jiaberrr1 小时前
Element UI教程:如何将Radio单选框的圆框改为方框
前端·javascript·vue.js·ui·elementui
Tiffany_Ho2 小时前
【TypeScript】知识点梳理(三)
前端·typescript
安冬的码畜日常3 小时前
【D3.js in Action 3 精译_029】3.5 给 D3 条形图加注图表标签(上)
开发语言·前端·javascript·信息可视化·数据可视化·d3.js
太阳花ˉ3 小时前
html+css+js实现step进度条效果
javascript·css·html
小白学习日记4 小时前
【复习】HTML常用标签<table>
前端·html
john_hjy4 小时前
11. 异步编程
运维·服务器·javascript