浅谈可选链在前端的应用及实现

1,958 阅读4分钟

一、前言

  业务开发过程中,避免不了操作复杂的嵌套对象,而对于这种场景通常会遇到两个痛点:

  • 操作嵌套对象时缺少健壮性处理,从而触发运行时错误,进而导致页面白屏
  • 健壮性处理的方式不够优雅,出现大量臃肿的代码块

二、可选链操作符

  ECMA 新的可选链操作符规范允许读取位于对象链深处的属性,而不必明确验证链中的每个引用是否有效。

const adventurer = {
  name: 'Alice',
  cat: {
    name: 'Dinah'
  }
};

adventurer.someNonExistentMethod?.(); // undefined
adventurer.dog?.name; // undefined

  而不采用可选链操作符的情况下,浏览器会直接抛出如下异常:

Uncaught TypeError: adventurer.someNonExistentMethod is not a function

Uncaught TypeError: Cannot read properties of undefined (reading 'name')

  可选链操作符在引用为 null 或者 undefined 的情况下不会引起错误,该表达式会短路返回 undefined。

let potentiallyNullObj = null;
let x = 0;
let prop = potentiallyNullObj?.[x++];

console.log(x); // x 将不会被递增,依旧输出 0

  例如上述代码中,x++ 表达式并不会被执行,这也算是 JavaScript 内部优化的一种方式。

  可选链操作符是可以与函数结合的,但是可能会出现这种情况:

const foo = {
    customMethod: () => {}
} 

foo.customMethod = 1;

foo?.customMethod?.(); // foo?.customMethod is not a function

  所以在和函数结合的场景下,要能够确保方法属性不会被篡改,否则还是需要使用 typeof 来保证代码的健壮性。

  在前端的开发场景下,大部分情况下都是需要将数据映射到 UI 上,直接显示一个 undefined 肯定是不合适的,所以规范也提供了空值合并操作符

let customer = {
  name: "Carl",
  details: { age: 82 }
};
let customerCity = customer?.city ?? "暗之城";
console.log(customerCity); // “暗之城”

  而残酷的开发现实中,服务端可能对于没有数据的字段会直接返回 null!!!所以这种不确定的场景下,使用逻辑或操作符(||)会更加稳妥。

三、babel 编译

  因为可选链操作符目前处于 Stage 4,所以只能通过 Babel 或者 TypeScript 来编译该特性代码。

const foo = {}
foo?.bar?.name

  采用 Babel 编译上述代码,可以得到如下内容:

var _foo$bar;

const foo = {};
foo === null || foo === void 0 ? void 0 : (_foo$bar = foo.bar) === null || _foo$bar === void 0 ? void 0 : _foo$bar.name;

  实现的重点如下:

  • 利用逻辑或操作符实现可选链操作符的短路计算特性
  • 利用临时变量模拟对象链的每个引用

  可选链操作符并不会创建临时变量。

四、设置对象链深处的属性值

  前文提到可选链操作符只能读取对象链深处的属性,但是在业务开发中,避免不了去设置对象链深处的属性。

  首先需要定义一个协议来约定对象链的引用方式:

dotProp.set(object, 'foo.baz', 'x');

  接下来只需要针对分隔符来解析出每一级引用的属性名即可:

const getPathSegments = path => path.split('.');

  为了增强代码的健壮性,可以针对解析后的路径再进行一次合法性校验:

const disallowedKeys = new Set([
	'__proto__',
	'prototype',
	'constructor'
]);

const isValidPath = pathSegments => !pathSegments.some(segment => disallowedKeys.has(segment));

const getPathSegments = path => {
    const paths = path.split('.');
    
    if (!isValidPath(paths)) {
        return [];
    }
    return paths;
};

  有了对象链的引用路径之后,只需要找到最终的赋值节点即可,特别需要注意的就是在遍历过程中确保上一级引用为对象,从而避免 TypeError 异常的发生。

set(object, path, value) {
  if (!isObject(object) || typeof path !== 'string') {
    return object;
  }

  const root = object;
  const pathArray = getPathSegments(path);

  for (let i = 0; i < pathArray.length; i++) {
    const p = pathArray[i];

    if (!isObject(object[p])) {
      object[p] = {};
    }

    if (i === pathArray.length - 1) {
      object[p] = value;
    }

    object = object[p];
  }

  return root;
}

  上述代码虽然实现了设置对象链深处属性值的功能,但是它的缺点也很明显,缺少 IDE 的智能提示。很有可能会出现由于拼写错误导致预期之外的运行结果。

  对于对象链协议的约定也可以参考 Ramda 库简单粗暴的方式:

const ramda = require('ramda');

const obj = {
  a: {
    b: [1, 2, 3]
  }
}

ramda.assocPath(['a', 'b', 1], 20, obj); // { a: { b: [ 1, 20, 3 ] } }

五、写在最后

  参考文献及相关源码:

  最后,如果本文对您有帮助,欢迎点赞、收藏、分享