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 会是你不错的选择

相关推荐
come1123413 分钟前
Vue 响应式数据传递:ref、reactive 与 Provide/Inject 完全指南
前端·javascript·vue.js
前端风云志35 分钟前
TypeScript结构化类型初探
javascript
musk12121 小时前
electron 打包太大 试试 tauri , tauri 安装打包demo
前端·electron·tauri
翻滚吧键盘1 小时前
js代码09
开发语言·javascript·ecmascript
万少2 小时前
第五款 HarmonyOS 上架作品 奇趣故事匣 来了
前端·harmonyos·客户端
OpenGL2 小时前
Android targetSdkVersion升级至35(Android15)相关问题
前端
rzl022 小时前
java web5(黑马)
java·开发语言·前端
Amy.Wang2 小时前
前端如何实现电子签名
前端·javascript·html5
海天胜景2 小时前
vue3 el-table 行筛选 设置为单选
javascript·vue.js·elementui
今天又在摸鱼2 小时前
Vue3-组件化-Vue核心思想之一
前端·javascript·vue.js