写点什么

前端 TypeError 错误永久消失术

  • 2025-02-13
    广东
  • 本文字数:5219 字

    阅读完需:约 17 分钟

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


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

一、背景介绍

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

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

let aa.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 插件 在打包时进行代码转换:

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

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

// ina.b.c.da['b']['c']['d']a && a.b && a.b.c && a.b.c.d
// outa?.b?.c?.d
复制代码

四、目标价值

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

五、功能实现

5.1 Babel 插件核心

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

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

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

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 参数配置

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

  • 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.dconst y = a['b']['c'].dconst z = a.b[c.d].eif(a.b.c.d){}switch (a.b.c.d){}
// 特殊操作(a).b // 括号运算const w = +a.b.c // 一元运算
// 方法调用a.b.c.d()a().ba.b().ca.b(c.d).efn(a.b.c.d)fn(a.b, 1)fn(...a)fn.a(...b).c(...d)
// 短路表达式优化// optional membera && a.ba && a.b && a.b.ca.b && a.b.c && a.b.c.dthis.a && this.a.bthis.a.b && this.a.b.c && this.a.b.c.dthis['a'] && this['a'].bthis['a'] && this['a']['b'] && this['a']['b']['c']this['a'] && this['a'].b && this['a'].b['c']
// optional methoda && a.b()a && a.b().ca.b && a.b.c()a && a.b && a.b.c()
// assign expressionlet a = a && a.blet b = a && a.b && a.b.c && a.b.c.dlet c = a && a.b && a.b.c()
// self is optional chaininga && a?.ba && a.b && a?.b?.ca && a?.b && a?.b?.ca && a?.b() && a?.b()?.c
// function argsfn(a && a.b)fn(a && a.b && a.b.c)
// only did option chaininga.b && b.ca.b && a.c.da.b && a.b.c && a.c.da.b.c && a.b.da.b.c && a.ba.b.c.d && a.b.c.e
// not handlea && ba && b && ca || ba || b || true
// 忽略赋值操作x.a = 1x.a.c = 2
// 忽略算术运算a.b++++a.ba.b----a.b
// 忽略指派赋值运算a.b += 1a.b -= 1
// 忽略 in/offor (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().bnew a.b().c.d
// 配置忽略项process.env.aprocess.env.a.b.c
// 忽略 ?. 本身a?.ba?.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 membera?.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 methoda?.b();a?.b()?.c;a?.b?.c();a?.b?.c();
// assign expressionlet a = a?.b;let b = a?.b?.c?.d;let c = a?.b?.c();
// self is optional chaininga?.b;a?.b?.c;a?.b?.c;a?.b()?.c;
// function argsfn(a?.b);fn(a?.b?.c);
// only did option chaininga?.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 handlea && 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/offor (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 的发生。

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

十一、参考资料


发布于: 3 小时前阅读数: 4
用户头像

官方公众号:vivo互联网技术,ID:vivoVMIC 2020-07-10 加入

分享 vivo 互联网技术干货与沙龙活动,推荐最新行业动态与热门会议。

评论

发布
暂无评论
前端 TypeError 错误永久消失术_前端_vivo互联网技术_InfoQ写作社区