路客的文档

技术总结和生活记录

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

前言

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

我们来一起看一下。

性能优化版

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

/**
 * 解析路径为字段数组
 * @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 循环,while比for 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;
  // 替换reduce写法
  while (i < len) {
    const key = fileds[i];
    let ref = target[key];
    if (i + 1 === len) { // 当前键是被更新路径的最后一个字段, 如 'obj.a.b'中的b则直接赋值
      target[key] = value;
    } else { // 引用类型值
      // 下一个字段的形式决定当前字段对应的数据类型,例如,arr[0],0决定了arr字段是数组类型,如果字段为纯数字则判定为数组(忽略对象键为数字的情况),此处key不会为''
      const curKeyDataType = isNaN(Number(fileds[i + 1])) ? Object : Array;

      // 如果路径值不存在,或者存在但是数据结构不一致,则创建对应空对象
      // 使用 constructor 判断类型是因为它远比Object.prototype.toString.call()更快
      if (!ref || (ref && ref.constructor !== curKeyDataType)) {
        ref = curKeyDataType === Object ? {} : [];
        // 不存在时可以继续创建了,但是依旧给出不易维护提示
        warn(`updated field "${path}" does not exist in the data or datatype is inconsistent, may not be easy to maintain.`);
      }
    }

    target = ref;
    i++;
  }

}
复制代码

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

附上旧方法:

// 链式赋值
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"`);
}
复制代码

另外注意,能使用 typeof 、 constructor 、instanceof 判断类型的场景就不要用Object.prototype.toString.call(),尤其在长循环中,节省4倍左右的性能不香么。

总结

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

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

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