Stories

Detail Return Return

前端 TypeError 錯誤永久消失術 - Stories Detail

作者:來自 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 參數配置,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.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 的發生。

希望這個思路能夠有效的提升大家項目的健壯性和穩定性。

十一、參考資料

  • tc39/proposal-optional-chaining
  • 可選鏈運算符(?.)
  • Babel插件手冊
  • Babel's optional chaining AST spec
  • ESTree
  • eslint/no-unsafe-optional-chaining
user avatar ting_61d6d9790dee8 Avatar ssbunny Avatar xw-01 Avatar xiao2 Avatar lizhiqianduan Avatar best_6455a509a2177 Avatar qifengliao_5e7f5b20ee3bd Avatar liudamao Avatar neronero Avatar niumingxin Avatar anran758 Avatar shenfq Avatar
Favorites 20 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.