前端 TypeError 错误永久消失术

作者:来自 vivo 互联网大前端团队- Sun Maobin

通过开发 Babel 插件,打包时自动为代码添加 可选链运算符(?.),从而有效避免 TypeError 的发生。

一、背景介绍

在 JS 中当获取引用对象为空值的属性时,程序会立即终止运行并报错:TypeError: Cannot read properties of ...

ECMAScript 2020 新增的 可选链运算符(?.),当属性值不存在时返回 undefined,从而可有效避免该错误的发生。

let a
a.b.c.d // Uncaught TypeError: Cannot read properties of undefined (reading 'b')
a?.b?.c?.d // undefined

本文将分享如何借助这一特性开发 Babel 插件,自动为代码添加 ?.,从而根治系统中的 TypeError 错误。

二、项目痛点

  1. 维护中的代码可能存在 TypeError 隐患,数量大维护成本高,比如:存在大量直接取值操作:a.b.c.d

  2. 在新代码中使用 ?. 书写起来太繁琐,同时也导致源码不易阅读,比如:a?.b?.c?.d

因此,如果我们只要在打包环节自动为代码添加 ?.,就可以很好解决这些问题。

三、解决思路

开发 Babel 插件 在打包时进行代码转换:

  • 将存在隐患的操作符 .[] 转换为 ?.

  • 将臃肿的短路表达式 && 转换为 ?.

// in
a.b.c.d
a['b']['c']['d']
a && a.b && a.b.c && a.b.c.d
// out
a?.b?.c?.d

四、目标价值

通用于任何基于 Babel 的 JS 项目,在源码 0 改动的情况下,彻底消灭 TypeError 错误。

五、功能实现

5.1 Babel 插件核心

  • 识别代码中可能存在 TypeError 的风险操作:属性获取方法调用

  • 支持自定义 Babel 参数配置,includesexcludes 代码转换规则

  • 短路表达式 && 自动优化

import { declare } from '@babel/helper-plugin-utils';
import * as t from '@babel/types';
export default declare((api, options) => {
  // 仅支持 Babel7 
  api.assertVersion(7);
  return {
    // Babel 插件名称
    name: 'babel-plugin-auto-optional-chaining',
    visitor: {
      /**
       * 通过 Babel AST 解析语法后,仅针对以下数据类型做处理
       * - MemberExpression:a.b 或 a['b']
       * - CallExpression:a.b() 或 a['b']()
       * - OptionalMemberExpression:a?.b 或 a?.['b']
       * - OptionalCallExpression:a.b?.() 或 a.['b']?.()
       */
      'MemberExpression|CallExpression|OptionalMemberExpression|OptionalCallExpression'(path) {
        // 避免重复处理
        if (path.node.extra.hasAoc) return;

        // isValidPath:通过 Babel 配置参数决定是否处理该节点
        const isMeCe = path.isMemberExpression() || path.isCallExpression();
        if (isMeCe && !isValidPath(path, options)) return;
        // 属性获取
        // shortCircuitOptimized:&& 短路表达式优化后再做替换处理
        if (path.isMemberExpression() || path.isOptionalMemberExpression()) {
          const ome = t.OptionalMemberExpression(path.node.object, path.node.property, path.node.computed, true);
          if (!shortCircuitOptimized(path, ome)) {
            path.replaceWith(ome);
          };
        };
        // 方法掉用
        // shortCircuitOptimized:&& 短路表达式优化后再做替换处理
        if (path.isCallExpression() || path.isOptionalCallExpression()) {
          const oce = t.OptionalCallExpression(path.node.callee, path.node.arguments, false);
          if (!shortCircuitOptimized(path, oce)) {
            path.replaceWith(oce);
          };
        };
        // 添加已处理标记
        path.node.extra.hasAoc = true;
      }
    }
  };
});

5.2 Babel 参数配置

支持 includesexcludes 两个参数,决定自动处理的代码 ?. 的策略。

  • includes - 仅处理指定代码片段

  • excludes - 排除指定代码片段不做处理

// includes 列表,支持正则
const isIncludePath = (path, includes: []) => {
  return includes.some(item => {
    let op = path.hub.file.code.substring(path.node.start, path.node.end);
    return new RegExp(`^${item}$`).test(op);
  })
};
// excludes 列表,支持正则
const isExcludePath = (path, excludes: []) => {
  // 忽略:excludes 列表,支持正则
  return excludes.some(item => {
    let op = path.hub.file.code.substring(path.node.start, path.node.end);
    return new RegExp(`^${item}$`).test(op);
  })
};
// 校验配置参数
const isValidPath = (path, {includes, excludes}) => {
  // 如果配置了 includes,仅处理 includes 匹配的节点
  if (includes?.length) {
    return isIncludePath(path, includes);
  }
  // 如果配置了 excludes,则不处理 excludes 匹配的节点
  if (includes?.length) {
    return !isExcludePath(path, includes);
  }
  // 默认全部处理
  return true;
}

5.3 短路表达式优化

支持添加参数 optimizer=false 关闭优化

const shortCircuitOptimized = (path, replaceNode) => {
  // 支持添加参数 optimizer=false 关闭优化
  if (options.optimizer === false) return false;
  const pc = path.container;
  // 判断是否逻辑操作 && 
  if (pc.type !== 'LogicalExpression') return false;
  // 只处理 a && a.b 中的 a.b
  if (pc.type === 'LogicalExpression' && path.key === 'left') return false;
  // 递归寻找上一级是否逻辑表达式,即:a && a.b && a.b.c
  const pp = path.parentPath;
  if (pp.isLogicalExpression() && path.parent.operator === '&&'){
    let ln = pp.node.left;
    let rn = pp.node.right?.object ?? pp.node.right?.callee ?? {};
    const isTypeId = type => 'Identifier' === type;
    const isValidType = type => [
      'MemberExpression',
      'OptionalMemberExpression',
      'CallExpression',
      'OptionalCallExpression'
    ].includes(type);
    const isEqName = (a, b) => {
      if ((a?.name ?? b?.name) === undefined) return false;
      return a?.name === b?.name;
    };
    // 递归处理并替换
    // 如:a && a.b && a.b.c ==> a?.b && a.b.c ==> a?.b?.c
    const getObj = (n, r = '') => {
      const reObj = obj => {
          r = r ? `${obj.name}.${r}` : obj.name;
      };
      isTypeId(n.property?.type) && reObj(n.property);
      isTypeId(n.object?.type) && reObj(n.object);
      isTypeId(n.callee?.type) && reObj(n.callee);
      if (isValidType(n.object?.type)) {
        return getObj(n.object, r);
      };
      if (isValidType(n.callee?.type)) {
        return getObj(n.callee, r);
      };
      return r;
    };
    // eg:a && a.b
    if (isTypeId(ln.type) && isTypeId(rn.type)) {
      if (isEqName(ln, rn)) {
        return pp.replaceWith(replaceNode);
      }
    };
    // eg:a && a.b | a && a.b.c...
    if (isTypeId(ln.type) && isValidType(rn.type)) {
      const rnObj = getObj(rn);
      if (rnObj.startsWith(ln.name)) {
        return pp.replaceWith(replaceNode);
      }
    };
    // eg:a.b && a.b.c | a.b && a.b.c...
    // 注意:a.b.c && a.b.d 不会被转换
    if (isValidType(ln.type) && isValidType(rn.type)) {
      const lnObj = getObj(ln);
      const rnObj = getObj(rn);
      if (rnObj.startsWith(lnObj)) {
        return pp.replaceWith(replaceNode);
      }
    };
  };
  return false;
};

六、插件应用

配置 babel.config.js 文件。

支持3个配置项:

  • includes - 仅处理指定代码片段(优先级高于 excludes

  • excludes - 排除指定代码片段不做处理

  • optimizer - 如果设置为 false 则关闭优化短路表达式 &&

module.exports = {
  plugins: [
    ['babel-plugin-auto-optional-chaining', {
      excludes: [
        'new .*',       // eg:new a.b() 不能转为 new a.b?.()
        'process.env.*' // 固定短语通过.链接,不做处理
      ],
      // includes: [],
      // optimizer: false
    }]
  ]
}

七、不足之处

自动为代码添加 ?. 可能会导致打包后文件体积略微增加,从而影响页面访问速度。

八、相关插件

对于不支持 可选链运算符 (?.) 的浏览器或者版本(如:Chrome<80),可以再使用插件 @babel/plugin-transform-optional-chaining 做反向降级。

使用后效果如下:

// 第1步:考虑健壮性,使用本文插件将代码自动转为可选链
a.b ===> a?.b
// 第2步:考虑兼容性,使用 @babel/plugin-transform-optional-chaining 再做反向降级
a?.b ==> a === null || a === void 0 ? void 0 : a.b;

九、插件测试

以下是一些测试用例仅供参考,使用 babel-plugin-tester 进行测试。

Input 输入用例

// 常规操作
const x = a.b.c.d
const y = a['b']['c'].d
const z = a.b[c.d].e
if(a.b.c.d){}
switch (a.b.c.d){}
// 特殊操作
(a).b // 括号运算
const w = +a.b.c // 一元运算
// 方法调用
a.b.c.d()
a().b
a.b().c
a.b(c.d).e
fn(a.b.c.d)
fn(a.b, 1)
fn(...a)
fn.a(...b).c(...d)
// 短路表达式优化
// optional member
a && a.b
a && a.b && a.b.c
a.b && a.b.c && a.b.c.d
this.a && this.a.b
this.a.b && this.a.b.c && this.a.b.c.d
this['a'] && this['a'].b
this['a'] && this['a']['b'] && this['a']['b']['c']
this['a'] && this['a'].b && this['a'].b['c']
// optional method
a && a.b()
a && a.b().c
a.b && a.b.c()
a && a.b && a.b.c()
// assign expression
let a = a && a.b
let b = a && a.b && a.b.c && a.b.c.d
let c = a && a.b && a.b.c()
// self is optional chaining
a && a?.b
a && a.b && a?.b?.c
a && a?.b && a?.b?.c
a && a?.b() && a?.b()?.c
// function args
fn(a && a.b)
fn(a && a.b && a.b.c)
// only did option chaining
a.b && b.c
a.b && a.c.d
a.b && a.b.c && a.c.d
a.b.c && a.b.d
a.b.c && a.b
a.b.c.d && a.b.c.e
// not handle
a && b
a && b && c
a || b
a || b || true
// 忽略赋值操作
x.a = 1
x.a.c = 2
// 忽略算术运算
a.b++
++a.b
a.b--
--a.b
// 忽略指派赋值运算
a.b += 1
a.b -= 1
// 忽略 in/of
for (a in b.c.d);
for (bar of b.c.d);
// 忽略 new 操作符
new a.b()
new a.b.c()
new a.b.c.d()
new a().b
new a.b().c.d
// 配置忽略项
process.env.a
process.env.a.b.c
// 忽略 ?. 本身
a?.b
a?.b?.c?.d

Out 结果输出:

// 常规操作
const x = a?.b?.c?.d;
const y = a?.["b"]?.["c"]?.d;
const z = a?.b?.[c?.d]?.e;
if (a?.b?.c?.d) {
}
switch (a?.b?.c?.d) {
}
// 特殊操作
a?.b; // 括号运算
const w = +a?.b?.c; // 一元运算
// 方法调用
a?.b?.c?.d();
a()?.b;
a?.b()?.c;
a?.b(c?.d)?.e;
fn(a?.b?.c?.d);
fn(a?.b, 1);
fn(...a);
fn?.a(...b)?.c(...d);
// 短路表达式优化
// optional member
a?.b;
a?.b?.c;
a?.b?.c?.d;
this.a?.b;
this.a?.b?.c?.d;
this["a"]?.b;
this["a"]?.["b"]?.["c"];
this["a"]?.b?.["c"];
// optional method
a?.b();
a?.b()?.c;
a?.b?.c();
a?.b?.c();
// assign expression
let a = a?.b;
let b = a?.b?.c?.d;
let c = a?.b?.c();
// self is optional chaining
a?.b;
a?.b?.c;
a?.b?.c;
a?.b()?.c;
// function args
fn(a?.b);
fn(a?.b?.c);
// only did option chaining
a?.b && b?.c;
a?.b && a?.c?.d;
a?.b?.c && a?.c?.d;
a?.b?.c && a?.b?.d;
a?.b?.c && a?.b;
a?.b?.c?.d && a?.b?.c?.e;
// not handle
a && b;
a && b && c;
a || b;
a || b || true;
// 忽略赋值操作
x.a = 1;
x.a.c = 2;
// 忽略算术运算
a.b++;
++a.b;
a.b--;
--a.b;
// 忽略指派赋值运算
a.b += 1;
a.b -= 1;
// 忽略 in/of
for (a in b.c.d);
for (bar of b.c.d);
// 忽略 new 操作符
new a.b();
new a.b.c();
new a.b.c.d();
new a().b;
new a.b().c.d;
// 配置忽略项
process.env.a;
process.env.a.b.c;
// 忽略 ?. 本身
a?.b;
a?.b?.c?.d;

十、写在最后

本文通过介绍如何开发一个 Babel 插件,在打包时自动为代码添加 可选链运算符(?.),从而有效避免 JS 项目 TypeError 的发生。

希望这个思路能够有效的提升大家项目的健壮性和稳定性。

十一、参考资料

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 220,295评论 6 512
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,928评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 166,682评论 0 357
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,209评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,237评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,965评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,586评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,487评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,016评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,136评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,271评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,948评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,619评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,139评论 0 23
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,252评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,598评论 3 375
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,267评论 2 358

推荐阅读更多精彩内容