Js如何使用链式属性表达式取值和赋值

1,624 阅读5分钟

什么是链式属性表达式?

例如,有这样一个对象:

const obj = {a: {b: {c: 3} } };

我们要取到其中c的值,正常情况下我们有很多种方法可以取到值,例如直接点或者使用解构等:

const c = obj.a.b.c;
const {a: {b: {c} } } = obj;

但是有一些情况不是我们主动去取值的,而是由某个方法或某个类在其执行时去取值。举个例子:Vue2的响应式原理是劫持对象的 getter 和 setter ,在 getter 中收集依赖,在 setter 中触发依赖,那如何确定哪些属性是被依赖的呢,Watcher这个类就接受表达式为参数,它来获取属性值,属性被访问后触发getter,该属性的依赖就被收集了,在被更新时就会执行回调。

更具体的可以参考 Vue2响应式原理解析

const viewModel = {a: {n: { m } } };
new Watcher(viewModel, "a.n.m", (newVal, oldVal) => {
  console.log("新的值--》", newVal);
  console.log("老的值--》", oldVa1l);
});

那么上例中的 a.n.m 就是链式属性表达式了,通过链式属性表达式来告诉方法要获取对象的哪个属性。

还有微信小程序的observer监听器,setData方法等都应用到了链式属性表达式,道理都一样,通过链式属性表达式去取值或赋值。

链式取值

先看一下要支持的数据类型:

// 对象,使用 . 访问
obj.a.b.c

// 数组,使用下标访问,要支持连续访问
arr[0][1][2]

// 对象数组嵌套混合
obj.a[0].b

先把链式属性表达式(以下简称路径)解析为字段数组,便于后续操作,例如:把 路径obj.arr[0].a.b 解析成 ['obj', 'arr', 0, 'a', 'b']

// 解析路径为字段数组
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;
}

注意一个细节,数组合并的方法有性能差异,在写工具、框架等对性能要求高的情况下,更推荐使用pushArray.prototype.push.apply(array1, array2)array1.push(…array2) 都行,这俩差距很微小。

  • 数组元素量级大而合并次数少时,性能对比:
    concat() > push() > […array1,…array2]

  • 数组元素少但合并次数多时,性能对比:
    push() > concat() > […array1,…array2]

  • push()方法适合10万级以下元素的数组合并,次数越多越有优势,但push()怕数组元素多,超过12万左右就会报错,导致无法合并数组

  • concat()方法适合数组元素量级大,但是合并次数少的情况,当数组合并频繁的时候性能表现略差;

  • […array1, …array2]方法无论是大量级数组合并还是数组频繁合并,都不占优势,单从性能方面来说,是最差的一种,莫非是因为它要创建数组产生了较大开销。

综合对比来说: push() > concat() > […array1,…array2]

一般情况下,用push()方法合并数组是最快的方法,concat()方法可以支撑大量级数组合并,而[…array1,…array2]扩展运算符可读性较好,不考虑性能时可以用;

插播一下 String.prototype.matchAll()方法,大家可能有不理解的地方:

matchAll()  方法返回一个包含所有匹配正则表达式的结果及分组捕获组的迭代器

const arr = [...'arr[0][1]'.matchAll(/(\w*)\[(\w+)\]/g)];

//  arr[0]:  ["arr[0]""arr""0"index0, input: "arr[0][1]", groups: undefined]
//  arr[1]:  ["[1]""""1"index6, input: "arr[0][1]", groups: undefined]

把路径解析为字段数组之后,就可以用 reduce 方法来链式取值了:

// 链式取值
function getValByPath(target: object, path: string): any {
  if (!(/[\\.\\[]/.test(path))) return target[path]; // 如果没有 . 或 [ 符号说明非链式,直接返回属性值
  const fileds = getPathFileds(path);
  const val = fileds.reduce((pre, cur) => pre?.[cur], target); // 取不到的时候返回undefined
  return val;
}

上面方法没有做类型检查以及默认值等,根据你自己的需要来就行。

到这里,我们就可以用getValByPath方法根据链式属性表达式来获取对象的属性值了。

链式赋值

赋值相对来说更麻烦一些

// 链式赋值
function updateValByPath(target: object, path: string, value): 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"`);
}

大家已经发现了,上面的方法只能更新引用存在的情况,即被更新数据的父级对象存在,如果要支持更复杂的情况,需要在被更新属性没有父级属性时帮它创建父级对象,可能是一个对象类型也可能是一个数组类型,这将额外消耗很多内存和性能,而且本身随意操作一个对象没有的属性就不符合严谨的代码规范,不利于维护,大家感兴趣的话可以自己在此基础上扩展,支持设置不存在的属性。

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

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