如何优雅实现链式属性表达式取值和赋值(高性能版)

955 阅读6分钟

前言

继上一篇 如何使用链式属性表达式取值和赋值理论篇完结后,我把这些真正应用到了我的小程序优化项目当中,但是感觉效果比预期的还差点,遂在实践过程中不断地优化,总结出了更优雅健壮性能更好的方案。

我们来一起看一下。

性能优化版

这个方法可以将一个链式属性表达式处理为一个字段数组。

/**
 * 解析路径为字段数组
 * @example
 * // returns ['arr', '0', 'a', 'b']
 * getPathFileds('arr[0].a.b');
 * @param {string} path 解析路径
 * @returns {string[]}
 */
export function getPathFileds(path: string): string[] {
  // 取缓存解析过的路径
  if (filedsCacheMap.has(path)) return filedsCacheMap.get(path);
  const segments: string[] | number[] = path.split('.'); // 分割字段片段, 如 a[5].b[4][0].c
  let fileds = segments; // 保存字段名

  // 处理包含数组的情况,例如 a[5].b[4][0].c 路径,要把b[4][0]这样的格式处理成[b, 4, 0]
  if (path.includes('[')) {
    fileds = [];
    let i = 0;
    const len = segments.length;
    while (i < len) {
      const segment = segments[i];
      if (segment.includes('[')) {
        const arrFileds = segment.split(/[\[\]]/); // ["b", "4", "", "0", ""]
        // for循环比push(...arrFileds)更快,而且加入判断非必要不push会更快
        for (let i = 0, len = arrFileds.length; i < len; i++) {
          if (arrFileds[i] !== '') fileds[fileds.length] = arrFileds[i]; // 比 fileds.push(arrFileds[i])更快
        }
        // fileds.push(...arrFileds); // push(...arr)比concat效率更高,push直接操作原数组,concat会创建新数组
      } else { // 如果是被'.'分割完的字段直接push
        fileds.push(segment);
      }
      i++;
    }
  }
  filedsCacheMap.set(path, fileds); // 缓存解析过的路径
  return fileds;
}

原写法:

// 解析路径为字段数组
function parsePath(path: string) {
  const segments: string[] = path.split('.'); // 分割字段片段
  let fileds: Array<number | string> = []; // 保存字段名
  if (path.includes('[')) { // 如果包含数组下标,收集数组索引 类似arr[0]这样的格式
    for (const segment of segments) {
      if (/^(\w+)(\[(\w+)\])+/.test(segment)) { // 匹配 类似 arr[0][1] 这种格式
        const arrIndexs: number[] = [];
        for (const item of segment.matchAll(/(\w*)\[(\w+)\]/g)) {
          if (item[1]) fileds.push(item[1]); // 第一个匹配的括号,即数组字段名
          arrIndexs.push(~~item[2]); // 第二个匹配的括号,即数组索引
        }

        fileds.push(...arrIndexs);
      } else { // 如果是被'.'分割完的字段直接push
        fileds.push(segment);
      }
    }
  } else { // 无数组值时无需遍历,提高性能
    fileds = segments;
  }
  return fileds;
}

该版本做了如下优化:

  • while 替代for of 循环,whilefor of更快。

    更多性能对比案例,可以看我的另一篇文章JS遍历13种循环方法性能大比拼:for/while/for in/for of/map/foreach...

  • /^(\w+)(\[(\w+)\])+/.test(segment),正则判断替换为includes方法,相对于includes,正则性能极慢。

  • matchAll 方法需要进行正则匹配,尤其是贪婪模式,性能消耗更大,此处使用 split 方法直接将字段分为数组,省掉了一个循环和push操作。

    这里提醒一下,能用原生方法的情况尽量不要使用正则,即使你深谙正则优化之道,但百密也终有一疏

  • push(...arr) 替换为优化for循环内使用length属性直接给数组赋值,首先 ... 扩展运算符实际上内部做的是遍历迭代器的操作,有一定性能消耗,另外直接使用数组下标赋值在多数场景下是比push更快的。

  • 加入了缓存,已经处理过的路径直接返回上次结果。

综上,该方法经过一系列细节优化之后,除了带来了3-5倍的性能提升外,还让本人心情愉悦了一下午🐶,把一件事情做到极致的感觉真的很爽。

链式取值

自然,取值方法也进行了优化

/**
 * 链式取值
 *
 * @export
 * @param {object} target
 * @param {string} path
 * @returns {*}
 */
function getValByPath(target: object, path: string): any {
  // 比 !(/[\\.\\[]/.test(path)) 性能高约15倍,比 !(path.includes('.') || path.includes('[')) 高约6倍
  if (!path.includes('.') && !path.includes('[')) return target[path];

  const fileds = getPathFileds(path);
  // const val = fileds.reduce((pre, cur) => pre?.[cur], target);
  // while 比 reduce快(2-3倍)
  let i = 0;
  let val = target;
  const len = fileds.length;
  while (i < len) {
    val = val?.[fileds[i]];
    i++;
  }
  return val;
}

优化了几点:

  • 正则判断链式属性改成了 includes 方法

    另外在性能测试时发现了一个有趣的现象,在判断不包含 ‘.’ 或者‘[’时,不加括号竟然比加括号写法快了大约6倍,即 !path.includes('.') && !path.includes('[') 远比 !(path.includes('.') || path.includes('['))这种写法要快,这里大家可以留意一下。

  • reduce 方法使用while循环代替,原因毫无疑问, while 必然 比reduce快,虽然代码行数多了一些,但是对于写工具框架的场景性能往往是更需要注重的点。

    感兴趣的同学可以去看下,循环方法性能对比

链式更新值


/**
 * 链式更新值
 *
 * @param {*} target
 * @param {string} path
 * @param {*} value
 */
export function updateValByPath(target: any, path: string, value: any): void {
  // 非链式属性直接赋值
  if (!path.includes('.') && !path.includes('[')) return target[path] = value;
  const fileds = getPathFileds(path);

  let i = 0;
  const len = fileds.length;
  while (i < len) {
    const key = fileds[i];
    if (i + 1 === len) { // 当前键是被更新路径的最后一个字段, 如 'obj.a.b'中的b则直接赋值
      target[key] = value;
      return;
    }

    // 创建对象或数组

    // 下一个字段的形式决定当前字段对应的数据类型,例如,arr[0],0决定了arr字段是数组类型,如果字段为纯数字则判定为数组(忽略对象键为数字的情况),key不会为''
    const curKeyDataType = isNaN(Number(fileds[i + 1])) ? 'object' : 'array';
    let typeMutation = false;
    const val = target[key];
    if (val) {
      const oriDataType = isArray(val) ? 'array' : 'object';
      typeMutation = oriDataType !== curKeyDataType || !isPlainObjectOrArray(val);
    }
    // 如果路径值不存在,或者存在但是数据类型变了,则创建对应数据类型
    if (!val || typeMutation) {
      target[key] = curKeyDataType === 'object' ? {} : [];
      // !更新 data 中不存在引用的属性或随意变更数据类型,理论上工具不会阻止这种行为,但是并不推荐,因为这种写法可能不利于维护
      warn(`updated field "${path}" does not exist in the data or datatype is inconsistent, may not be easy to maintain.`);
    }

    target = target[key];
    i++;
  }
}

除性能优化外,还对功能进行了增强,旧方法不能更新没有父对象的数据, 例如 updateValByPath({obj:{}, 'obj.a.b'})b属性没有父级对象 a,会静默失败,并给出提示,优化后的方法则支持在没有父对象时为其创建新的对象或数组,b属性可以成功赋值,obj对象成功添加b属性及其父对象a

注意一个细节,有一处代码 const oriDataType = Array.isArray(ref) ? 'array' : 'object';用来判断是数组还是对象,这里起初想用constructor属性,会比调用一个isArray方法快一丢丢,但是constructor属性不稳定,容易被更改,而且没有原型的对象(如Object.create(null)创建的纯净对象)是没有constructor属性的,所以用isArray更好,为什么没有用Object.prototype.toString.call()呢?因为该方法调用了两个函数,一个call,一个toString,再加上toString方法要将传入参数转为字符串,性能消耗就更大了,所以,推荐能使用 typeofconstructorinstanceof 判断类型的场景就不要用Object.prototype.toString.call(),尤其在循环中,节省4倍左右的性能不香么,最好优先使用另外三种方式,来用toString方法兜底。

关于constructor属性这部分有不理解的同学可以去看一下另一篇讲原型链的文章从prototype的设计初衷剖析JS原型和原型链,有收获后别忘了点个赞哦~

最后附上未改造前的旧方法:

// 链式赋值
function updateValByPath(target: object, path: string, value: any): void {
  if (!(/[\\.\\[]/.test(path))) return target[path] = value; // 如果没有 . 或 [ 符号说明非链式,直接赋值
  const fileds = getPathFileds(path);
  // cosnt obj = {a: {b: {c: 6}}};
  // 获取值引用 ,例如更新obj对象的c值,需要获取{c: 6}对象的引用,即obj.a.d = {c: 6},拿到引用后 ref.c = 8,就 {c: 6} 更新成 {c: 8} 了
  const ref = fileds.slice(0, -1).reduce((pre, cur) => pre[cur], target); // 只遍历到倒数第二个字段,因为这个字段就是被修改对象的引用

  if (ref) return ref[`${fileds.at(-1)}`] = value; // 拿到引用后,更新最后一个字段
  // 如果引用对象不存在,提醒开发者不要更新不存在的属性
  console.warn(`updated property "${path}" is not registered in data, you will not be able to get the value synchronously through "this.data"`);
}

以及类型判断常用函数:

// typeUtil.ts

// 获取数据类型 如:[object Array]
export const getTypeString = (val: unknown): string => Object.prototype.toString.call(val);

// 获取原始数据类型 如:array
export const getRawType = (val: unknown) => getTypeString(val).slice(8, -1)
  .toLowerCase();

export const isObject = (val: unknown): val is Record<any, any> => val !== null && typeof val === 'object';

export const { isArray } = Array;

/**
 * 判断是否是普通对象
 *
 * 直接调用constructor属性判断对象,替代调用函数转成字符串,减少性能开销,
 * 一般情况都可以通过constructor来判断,但是constructor属性不稳定,容易被更改,而且没有原型的对象(如Object.create(null)创建的纯净对象)是没有constructor属性的,
 * 此时仍然需要使用toString()方法来判断。
 *
 * @param {unknown} val
 * @returns {val is object}
 */
export const isPlainObject = (val: unknown): val is object => {
  if (val?.constructor) return val.constructor === Object;
  return getTypeString(val) === '[object Object]';
};

export const isPlainObjectOrArray = (val: unknown): boolean => isPlainObject(val) || isArray(val);



总结

以上文中所提到的一些优化的点,其实我们日常开发中很常见的,只是大家都没有那么关注,确实我们在写业务需求时往往不会涉及会造成很大性能差异的场景,没有太大必要死磕这些性能开销问题,对于不是那么复杂的项目来说,得到的收益微乎其微,但是重点是我们的开发习惯和思想,你是否会在某些场景下思考性能问题,而不是只是实现就行呢?

至少你需要考虑首屏代码是应该性能优先的吧,例如首屏被加载的逻辑包含大量循环,本来可以用优化for循环while,那你非用 for in,结果首屏仅处理数据格式化就消耗了大量性能,又或者你在首屏代码的循环中执行多次JSON.stringfy()方法,也会导致主线程被占用过久,首屏加载慢,再比如你写了一个框架或者开源包,搞了一大堆性能差的方法上去,开发者引入了你的包虽然功能增强了但是却拖累了人家的运行性能,诸如此类场景都需要我们在开发时带入对js代码的运行性能的思考。

具体哪些写法性能好,哪些性能偏低,可以简单参考下js遍历方法性能对比,然后结合实际场景,有选择地去应用。

后续会写一篇js代码性能优化实践总结,尽可能全地去收拢实际开发中的有用的优化技巧,敬请期待。

感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,还请三连支持一下,点赞、关注、收藏,作者会持续与大家分享更多干货