关于javascript:聊聊不可变数据结构

34次阅读

共计 10291 个字符,预计需要花费 26 分钟才能阅读完成。

三年前,我接触了 Immutable 库,领会到了不可变数据结构的利好。

Immutable 库具备两个最大的劣势: 不可批改以及构造共享。

  • 不可批改 (容易回溯,易于察看。缩小谬误的产生)
let obj = {a: 1};

handleChange(obj);

// 因为下面有 handleChange,无奈确认 obj 此时的状态
console.log(obj)
  • 构造共享 (复用内存,节俭空间, 也就意味着数据批改能够间接记录残缺数据,其内存压力也不大,这样对于开发简单交互我的项目的重做等性能很有用)

当然,因为过后还在重度应用 Vue 进行开发,而且 受害于 Vue 自身的优化以及业务形象和零碎的正当架构,我的项目始终放弃着良好的性能。同时该库的侵入性和难度都很大,贸然引入我的项目也未必是一件坏事。

尽管 Immutable 库没有带来间接的收益,但从中学到一些思路和优化却陪伴着我。

浅拷贝 assign 胜任 Immutable

当咱们不应用任何库,咱们是否就无奈享受不可变数据的利好?答案是否定的。

当面临可变性数据时候,大部分状况下咱们会应用深拷贝来解决两个数据援用的问题。

const newData = deepCopy(myData);
newData.x.y.z = 7;
newData.a.b.push(9);

可怜的是,深度拷贝是低廉的,在有些状况下更是不可承受的。深拷贝占用了大量的工夫,同时两者之间没有任何构造共享。但咱们能够通过仅复制须要更改的对象和重用未更改的对象来加重这种状况。如 Object.assign 或者 … 来实现构造共享。

大多数业务开发中,咱们都是先进行深拷贝,再进行批改。然而咱们真的须要这样做吗?事实并非如此。从我的项目整体登程的话,咱们只须要解决一个外围问题“深层嵌套对象”。当然,这并不意味着咱们把所有的数据都放在第一层。只须要不嵌套可变的数据项即可。

const staffA = {
  name: 'xx',
  gender: 'man',
  company: {},
  authority: []}

const staffB = {...staffA}

staffB.name = 'YY'

// 不波及到 简单类型的批改即可
staffA.name // => 'xx'

const staffsA = [staffA, staffB]

// 须要对数组外部每一项进行浅拷贝
const staffsB = staffsA.map(x => ({...x}))

staffsB[0].name = 'gg'

staffsA[0].name // => 'xx'

如此,咱们就把深拷贝变为了浅拷贝。同时实现了构造共享 (所有深度嵌套对象都被复用了)。但有些状况下,数据模型并不是容易批改的,咱们还是须要批改深度嵌套对象。那么就须要这样批改了。

const newData = Object.assign({}, myData, {x: Object.assign({}, myData.x, {y: Object.assign({}, myData.x.y, {z: 7}),
  }),
  a: Object.assign({}, myData.a, {b: myData.a.b.concat(9)})
});

这对于绝大部份的业务场景来说是相当高效的 (因为它只是浅拷贝,并重用了其余的局部),然而编写起来却十分苦楚。

immutability-helper 库辅助开发

immutability-helper (语法受到了 MongoDB 查询语言的启发) 这个库为 Object.assign 计划提供了简略的语法糖,使得编写浅拷贝代码更加容易:

import update from 'immutability-helper';

const newData = update(myData, {x: {y: {z: {$set: 7}}},
  a: {b: {$push: [9]}}
});

const initialArray = [1, 2, 3];
const newArray = update(initialArray, {$push: [4]}); // => [1, 2, 3, 4]
initialArray // => [1, 2, 3]

可用命令

  • $push (相似于数组的 push, 然而提供的是数组)
  • $unshift (相似于数组的 unshift, 然而提供的是数组)
  • $splice (相似于数组的 splice, 但提供数组是一个数组, $splice: [ [1, 1, 13, 14] ] )

留神: 数组中的我的项目是程序利用的,因而程序很重要。指标的索引可能会在操作过程中发生变化。

  • $toggle (字符串数组,切换指标对象的布尔数值)
  • $set (齐全替换指标节点, 不思考之前的数据,只用以后指令设置的数据)
  • $unset (字符串数组,移除 key 值 ( 数组或者对象移除))
  • $merge (合并对象)
const obj = {a: 5, b: 3};
const newObj = update(obj, {$merge: {b: 6, c: 7}}); // => {a: 5, b: 6, c: 7}
  • $add(为 Map 增加 [key,value] 数组 )
  • $remove (字符串对象,为 Map 移除 key)
  • $apply (利用函数到节点)
const obj = {a: 5, b: 3};
const newObj = update(obj, {b: {$apply: function(x) {return x * 2;}}});
// => {a: 5, b: 6}
const newObj2 = update(obj, {b: {$set: obj.b * 2}});
// => {a: 5, b: 6}

前面咱们解析源码时,能够看到不同指令的实现。

扩大命令

咱们能够基于以后业务去扩大命令。如增加税值计算:

import update, {extend} from 'immutability-helper';

extend('$addtax', function(tax, original) {return original + (tax * original);
});
const state = {price: 123};
const withTax = update(state, {price: {$addtax: 0.8},
});
assert(JSON.stringify(withTax) === JSON.stringify({price: 221.4}));

如果您不想弄脏全局的 update 函数,能够制作一个正本并应用该正本,这样不会影响全局数据:

import {Context} from 'immutability-helper';

const myContext = new Context();

myContext.extend('$foo', function(value, original) {return 'foo!';});

myContext.update(/* args */);

源码解析

为了增强了解,这里我来解析一下源代码,同时该库代码非常简洁弱小:

先是工具函数 (保留外围, 环境判断,谬误正告等逻辑去除):

// 提取函数,大量应用时有肯定性能劣势,且扼要 (更重要)
const hasOwnProperty = Object.prototype.hasOwnProperty;
const splice = Array.prototype.splice;
const toString = Object.prototype.toString;

// 查看类型
function type<T>(obj: T) {return (toString.call(obj) as string).slice(8, -1);
}

// 浅拷贝,应用 Object.assign 
const assign = Object.assign || /* istanbul ignore next */ (<T, S>(target: T & any, source: S & Record<string, any>) => {getAllKeys(source).forEach(key => {if (hasOwnProperty.call(source, key)) {target[key] = source[key] ;
    }
  });
  return target as T & S;
});

// 获取对象 key
const getAllKeys = typeof Object.getOwnPropertySymbols === 'function'
  ? (obj: Record<string, any>) => Object.keys(obj).concat(Object.getOwnPropertySymbols(obj) as any)
  /* istanbul ignore next */
  : (obj: Record<string, any>) => Object.keys(obj);

// 所有数据的浅拷贝
function copy<T, U, K, V, X>(
  object: T extends ReadonlyArray<U>
    ? ReadonlyArray<U>
    : T extends Map<K, V>
      ? Map<K, V>
      : T extends Set<X>
        ? Set<X>
        : T extends object
          ? T
          : any,
) {return Array.isArray(object)
    ? assign(object.constructor(object.length), object)
    : (type(object) === 'Map')
      ? new Map(object as Map<K, V>)
      : (type(object) === 'Set')
        ? new Set(object as Set<X>)
        : (object && typeof object === 'object')
          ? assign(Object.create(Object.getPrototypeOf(object)), object) as T
          /* istanbul ignore next */
          : object as T;
}

而后是外围代码 (同样保留外围) :

export class Context {
  // 导入所有指令
  private commands: Record<string, any> = assign({}, defaultCommands);

  // 增加扩大指令
  public extend<T>(directive: string, fn: (param: any, old: T) => T) {this.commands[directive] = fn;
  }
  
  // 性能外围
  public update<T, C extends CustomCommands<object> = never>(
    object: T,
    $spec: Spec<T, C>,
  ): T {
    // 加强健壮性,如果操作命令是函数, 批改为 $apply
    const spec = (typeof $spec === 'function') ? {$apply: $spec} : $spec;

    // 数组 (数组) 查看,报错
      
    // 返回对象 (数组) 
    let nextObject = object;
    // 遍历指令
    getAllKeys(spec).forEach((key: string) => {
      // 如果指令在指令集中
      if (hasOwnProperty.call(this.commands, key)) {
        // 性能优化, 遍历过程中,如果 object 还是以后之前数据
        const objectWasNextObject = object === nextObject;
        
        // 用指令批改对象
        nextObject = this.commands[key]((spec as any)[key], nextObject, spec, object);
        
        // 批改后,两者应用传入函数计算,还是相等的状况下,间接应用之前数据
        if (objectWasNextObject && this.isEquals(nextObject, object)) {nextObject = object;}
      } else {
        // 不在指令集中,做其余操作
        // 相似于 update(collection, {2: {a: {$splice: [[1, 1, 13, 14]]}}});
        // 解析对象规定后持续递归调用 update, 一直递归,一直返回
        // ...
      }
    });
    return nextObject;
  }
}

最初是通用指令:

const defaultCommands = {$push(value: any, nextObject: any, spec: any) {
    // 数组增加,返回 concat 新数组
    return value.length ? nextObject.concat(value) : nextObject;
  },
  $unshift(value: any, nextObject: any, spec: any) {return value.length ? value.concat(nextObject) : nextObject;
  },
  $splice(value: any, nextObject: any, spec: any, originalObject: any) {
    // 循环 splice 调用
    value.forEach((args: any) => {if (nextObject === originalObject && args.length) {nextObject = copy(originalObject);
      }
      splice.apply(nextObject, args);
    });
    return nextObject;
  },
  $set(value: any, _nextObject: any, spec: any) {
    // 间接替换以后数值
    return value;
  },
  $toggle(targets: any, nextObject: any) {const nextObjectCopy = targets.length ? copy(nextObject) : nextObject;
    // 以后对象或者数组切换
    targets.forEach((target: any) => {nextObjectCopy[target] = !nextObject[target];
    });

    return nextObjectCopy;
  },
  $unset(value: any, nextObject: any, _spec: any, originalObject: any) {
    // 拷贝后循环删除
    value.forEach((key: any) => {if (Object.hasOwnProperty.call(nextObject, key)) {if (nextObject === originalObject) {nextObject = copy(originalObject);
        }
        delete nextObject[key];
      }
    });
    return nextObject;
  },
  $add(values: any, nextObject: any, _spec: any, originalObject: any) {if (type(nextObject) === 'Map') {values.forEach(([key, value]) => {if (nextObject === originalObject && nextObject.get(key) !== value) {nextObject = copy(originalObject);
        }
        nextObject.set(key, value);
      });
    } else {values.forEach((value: any) => {if (nextObject === originalObject && !nextObject.has(value)) {nextObject = copy(originalObject);
        }
        nextObject.add(value);
      });
    }
    return nextObject;
  },
  $remove(value: any, nextObject: any, _spec: any, originalObject: any) {value.forEach((key: any) => {if (nextObject === originalObject && nextObject.has(key)) {nextObject = copy(originalObject);
      }
      nextObject.delete(key);
    });
    return nextObject;
  },
  $merge(value: any, nextObject: any, _spec: any, originalObject: any) {getAllKeys(value).forEach((key: any) => {if (value[key] !== nextObject[key]) {if (nextObject === originalObject) {nextObject = copy(originalObject);
        }
        nextObject[key] = value[key];
      }
    });
    return nextObject;
  },
  $apply(value: any, original: any) {
    // 传入函数,间接调用函数批改
    return value(original);
  },
};

就这样,作者写了一个简洁而弱小的浅拷贝辅助库。

优良的 Immer 库

Immer 是一个十分优良的不可变数据库,利用 proxy 来解决问题。不须要学习其余 api,开箱即用 (gzipped 3kb)

import produce from "immer"

const baseState = [
  {
    todo: "Learn typescript",
 done: true
 },
 {
    todo: "Try immer",
 done: false
 }
]

// 间接批改,没有任何开发累赘,情绪美美哒
const nextState = produce(baseState, draftState => {draftState.push({todo: "Tweet about it"})
  draftState[1].done = true
})

对于 immer 性能优化请参考 immer performance。

外围代码剖析

该库的外围还是在 proxy 的封装,所以不全副介绍,仅介绍代理性能。

export const objectTraps: ProxyHandler<ProxyState> = {get(state, prop) {
    // PROXY_STATE 是一个 symbol 值,有两个作用,一是便于判断对象是不是曾经代理过,二是帮忙 proxy 拿到对应 state 的值
    // 如果对象没有代理过,间接返回
    if (prop === DRAFT_STATE) return state

    // 获取数据的备份?如果有,否则获取元数据
    const source = latest(state)

    // 如果以后数据不存在,获取原型上数据
    if (!has(source, prop)) {return readPropFromProto(state, source, prop)
    }
    const value = source[prop]

    // 以后代理对象曾经改回了数值或者改数据是 null,间接返回
    if (state.finalized_ || !isDraftable(value)) {return value}
    // 创立代理数据
    if (value === peek(state.base_, prop)) {prepareCopy(state)
      return (state.copy_![prop as any] = createProxy(
        state.scope_.immer_,
        value,
        state
      ))
    }
    return value
  },
  // 以后数据是否有该属性
  has(state, prop) {return prop in latest(state)
  },
  set(
    state: ProxyObjectState,
    prop: string /* strictly not, but helps TS */,
    value
  ) {const desc = getDescriptorFromProto(latest(state), prop)

    // 如果以后有 set 属性,象征以后操作项是代理,间接设置即可
    if (desc?.set) {desc.set.call(state.draft_, value)
      return true
    }

    // 以后没有批改过,建设正本 copy,期待应用 get 时创立代理
    if (!state.modified_) {const current = peek(latest(state), prop)

      const currentState: ProxyObjectState = current?.[DRAFT_STATE]
      if (currentState && currentState.base_ === value) {state.copy_![prop] = value
        state.assigned_[prop] = false
        return true
      }
      if (is(value, current) && (value !== undefined || has(state.base_, prop)))
        return true
      prepareCopy(state)
      markChanged(state)
    }

    state.copy_![prop] = value
    state.assigned_[prop] = true
    return true
  },
  defineProperty() {die(11)
  },
  getPrototypeOf(state) {return Object.getPrototypeOf(state.base_)
  },
  setPrototypeOf() {die(12)
  }
}

// 数组的代理,把以后对象的代理拷贝过来,再批改 deleteProperty 和 set
const arrayTraps: ProxyHandler<[ProxyArrayState]> = {}
each(objectTraps, (key, fn) => {
  // @ts-ignore
  arrayTraps[key] = function() {arguments[0] = arguments[0][0]
    return fn.apply(this, arguments)
  }
})
arrayTraps.deleteProperty = function(state, prop) {if (__DEV__ && isNaN(parseInt(prop as any))) die(13)
  return objectTraps.deleteProperty!.call(this, state[0], prop)
}
arrayTraps.set = function(state, prop, value) {if (__DEV__ && prop !== "length" && isNaN(parseInt(prop as any))) die(14)
  return objectTraps.set!.call(this, state[0], prop, value, state[0])
}

其余

开发过程中,咱们往往会在 React 函数中应用 useReducer 办法,然而 useReducer 实现较为简单,咱们能够用 useMethods 简化代码。useMethods 外部就是应用 immer (代码非常简略,咱们间接拷贝 index.ts 即可)。

不应用 useMethods 状况下:

const initialState = {
  nextId: 0,
  counters: []};

const reducer = (state, action) => {let { nextId, counters} = state;
  const replaceCount = (id, transform) => {const index = counters.findIndex(counter => counter.id === id);
    const counter = counters[index];
    return {
      ...state,
      counters: [...counters.slice(0, index),
        {...counter, count: transform(counter.count) },
        ...counters.slice(index + 1)
      ]
    };
  };

  switch (action.type) {
    case "ADD_COUNTER": {
      nextId = nextId + 1;
      return {
        nextId,
        counters: [...counters, { id: nextId, count: 0}]
      };
    }
    case "INCREMENT_COUNTER": {return replaceCount(action.id, count => count + 1);
    }
    case "RESET_COUNTER": {return replaceCount(action.id, () => 0);
    }
  }
};

比照应用 useMethods :

import useMethods from 'use-methods';    

const initialState = {
  nextId: 0,
  counters: []};

const methods = state => {const getCounter = id => state.counters.find(counter => counter.id === id);

  return {addCounter() {state.counters.push({ id: state.nextId++, count: 0});
    },
    incrementCounter(id) {getCounter(id).count++;
    },
    resetCounter(id) {getCounter(id).count = 0;
    }
  };
};

激励一下

如果你感觉这篇文章不错,心愿能够给与我一些激励,在我的 github 博客下帮忙 star 一下。

博客地址

参考资料

immutability-helper

Immer

useMethods

正文完
 0