通过(有时)不编写现代 JavaScript 来避免繁重的 Babel 转换

Avatar of Alex MacArthur
Alex MacArthur

DigitalOcean 为您的旅程的每个阶段提供云产品。 立即开始使用 $200 免费信用额度!

很难想象在没有像 Babel 这样的工具的情况下编写生产就绪的 JavaScript。 它一直是让现代代码为广大用户所用的改变游戏规则者。 随着这一挑战基本消除,没什么能阻止我们真正利用现代规范提供的功能。

但同时,我们也不想过度依赖它。 如果你偶尔看一下用户实际下载的代码,你会发现,有时,看似简单的 Babel 转换可能特别臃肿和复杂。 在很多情况下,你可以使用简单的“老派”方法执行相同的任务——无需预处理带来的沉重负担。

让我们使用 Babel 在线 REPL(一个用于快速测试转换的强大工具)来仔细看看我所指的内容。 针对不支持 ES2015+ 的浏览器,我们将使用它来突出显示一些你(和你的用户)可能最好选择 JavaScript 中“老派”方法的时机,尽管现代规范推广了一种“新”方法。

在进行的过程中,请记住,这与其说是“旧与新”,不如说是选择最的实现,既能完成工作,又能绕过构建过程中可能出现的任何预期副作用。

让我们开始构建吧!

预处理 for..of 循环

for..of 循环是一种灵活的现代循环遍历可迭代集合的方式。 它经常以与传统 for 循环非常相似的方式使用,这可能让你觉得 Babel 的转换会很简单,也很容易预测,尤其是当你只与数组一起使用它时。 并非如此。 我们编写的代码可能只有 98 字节

function getList() {
  return [1, 2, 3];
}

for (let value of getList()) {
  console.log(value);
}

但输出结果为 1.8kb(增长了 1736%)!


"use strict";

function _createForOfIteratorHelper(o) { if (typeof Symbol === "undefined" || o[Symbol.iterator] == null) { if (Array.isArray(o) || (o = _unsupportedIterableToArray(o))) { var i = 0; var F = function F() {}; return { s: F, n: function n() { if (i >= o.length) return { done: true }; return { done: false, value: o[i++] }; }, e: function e(_e) { throw _e; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var it, normalCompletion = true, didErr = false, err; return { s: function s() { it = o[Symbol.iterator](); }, n: function n() { var step = it.next(); normalCompletion = step.done; return step; }, e: function e(_e2) { didErr = true; err = _e2; }, f: function f() { try { if (!normalCompletion && it.return != null) it.return(); } finally { if (didErr) throw err; } } }; }

function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); }

function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; }

function getList() {
  return [1, 2, 3];
}

var _iterator = _createForOfIteratorHelper(getList()),
    _step;

try {
  for (_iterator.s(); !(_step = _iterator.n()).done;) {
    var value = _step.value;
    console.log(value);
  }
} catch (err) {
  _iterator.e(err);
} finally {
  _iterator.f();
}

为什么它不直接使用 for 循环? 这可是个数组! 显然,在这种情况下,Babel 并不知道它正在处理一个数组。 它只知道它正在处理一个可能返回任何可迭代对象(数组、字符串、对象、NodeList)的函数,它需要做好应对任何可能值的准备,根据 ECMAScript 规范 针对 for..of 循环。

我们可以通过明确地传递一个数组来大幅缩减转换,但这在实际应用中并不总是容易。 所以,为了利用循环的好处(比如 break 和 continue 语句),同时自信地保持包大小精简,我们可能只需使用 for 循环。 没错,它已经过时了,但它能完成工作。

function getList() {
  return [1, 2, 3];
}


for (var i = 0; i < getList().length; i++) {
  console.log(getList()[i]);
}

/解释 Dave Rupert 几年前 发布了一篇关于这种情况的博客文章,发现即使是 polyfilled 的 forEach 也是对他来说很好的解决方案。

预处理数组 […Spread]

这里也是类似的情况。 展开运算符可以与多个类别的对象一起使用(不仅仅是数组),所以当 Babel 不知道它正在处理的数据类型时,它需要采取预防措施。 不幸的是,这些预防措施可能会导致严重的字节膨胀。

这是输入,只有 81 字节

function getList () {
  return [4, 5, 6];
}


console.log([1, 2, 3, ...getList()]);

输出膨胀到 1.3kb

"use strict";

function _toConsumableArray(arr) { return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread(); }

function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); }

function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); }

function _iterableToArray(iter) { if (typeof Symbol !== "undefined" && Symbol.iterator in Object(iter)) return Array.from(iter); }

function _arrayWithoutHoles(arr) { if (Array.isArray(arr)) return _arrayLikeToArray(arr); }

function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; }

function getList() {
  return [4, 5, 6];
}

console.log([1, 2, 3].concat(_toConsumableArray(getList())));

相反,我们可以直接使用 concat() 你需要编写的代码量差异并不大,它完全做到了它应该做的事情,而且无需担心额外的膨胀。

function getList () {
  return [4, 5, 6];
}


console.log([1, 2, 3].concat(getList()));

更常见的示例:循环遍历 NodeList

你可能不止一次看到过这种情况。 我们经常需要查询多个 DOM 元素并循环遍历生成的 NodeList。 为了对该集合使用 forEach,通常需要将其扩展为数组。

[...document.querySelectorAll('.my-class')].forEach(function (node) {
  // do something
});

但正如我们所看到的,这会导致大量的输出。 作为替代方案,在 Array 原型上运行 NodeList 的方法,比如 slice,并没有什么问题。 结果相同,但负担轻得多。

[].slice.call(document.querySelectorAll('.my-class')).forEach(function(node) {
  // do something
});

关于“宽松”模式的说明

值得一提的是,一些与数组相关的膨胀也可以通过利用 @babel/preset-env宽松模式 来避免,这种模式在完全忠实于现代 ECMAScript 语义方面有所妥协,但提供了更精简输出的优势。 在许多情况下,这可能就足够了,但你也在你的应用中引入了风险,你以后可能会后悔。 毕竟,你是在告诉 Babel 对你如何使用代码做出一些相当大胆的假设。

这里的主要结论是,有时,更适合有意地选择要使用的功能,而不是投入更多时间来调整构建过程,并可能在以后与看不见的后果作斗争。

预处理默认参数

这是一个更可预测的操作,但当它在整个代码库中重复使用时,字节会累积起来。 ES2015 引入了默认参数值,这在函数接受可选参数时可以整理函数的签名。 这里有 75 字节

function getName(name = "my friend") {
  return `Hello, ${name}!`;
}

但 Babel 在转换时可能会比预期更冗长,导致 169 字节

"use strict";


function getName() {
  var name = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : "my friend";
  return "Hello, ".concat(name, "!");
}

作为替代方案,我们可以完全避免使用 arguments 对象,只需检查参数是否为 undefined。 我们失去了默认参数提供的自文档特性,但如果我们真的要压缩字节,这可能值得。 而且,根据用例,我们甚至可以尝试检查 falsey 以使其更精简。

function getName(name) {
  name = name || "my friend";
  return `Hello, ${name}!`;
}

预处理 async/await

async/awaitPromise API 上的语法糖是我最喜欢的 JavaScript 新增功能之一。 即使如此,默认情况下,Babel 可能会对其进行非常混乱的处理。

编写 157 字节

async function fetchSomething(url) {
  const response = await fetch(url);
  return await response.json();
}

fetchSomething("https://google.com");

编译后 1.5kb

"use strict";

function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } }

function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; }

function fetchSomething(_x) {
  return _fetchSomething.apply(this, arguments);
}

function _fetchSomething() {
  _fetchSomething = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee(url) {
    var response;
    return regeneratorRuntime.wrap(function _callee$(_context) {
      while (1) {
        switch (_context.prev = _context.next) {
          case 0:
            _context.next = 2;
            return fetch(url);

          case 2:
            response = _context.sent;
            _context.next = 5;
            return response.json();

          case 5:
            return _context.abrupt("return", _context.sent);

          case 6:
          case "end":
            return _context.stop();
        }
      }
    }, _callee);
  }));
  return _fetchSomething.apply(this, arguments);
}

fetchSomething("https://google.com");

你会注意到 Babel 不会默认将 async 代码转换为 promises。 相反,它们被转换为依赖于 regenerator-runtime 库的生成器,这比我们在 IDE 中编写的代码要多得多。 幸运的是,可以通过插件(比如 babel-plugin-transform-async-to-promises)来实现 Promise 路由。 与那 1.5kb 的输出相比,我们最终得到了更少的输出,只有 638 字节

"use strict";


function _await(value, then, direct) {
  if (direct) {
    return then ? then(value) : value;
  }


  if (!value || !value.then) {
    value = Promise.resolve(value);
  }


  return then ? value.then(then) : value;
}


var fetchSomething = _async(function (url) {
  return _await(fetch(url), function (response) {
    return _await(response.json());
  });
});


function _async(f) {
  return function () {
    for (var args = [], i = 0; i < arguments.length; i++) {
      args[i] = arguments[i];
    }


    try {
      return Promise.resolve(f.apply(this, args));
    } catch (e) {
      return Promise.reject(e);
    }
  };
}

但是,正如之前提到的,依赖插件来减轻这种痛苦存在风险。 这样做时,我们会在整个项目中影响转换,还会引入另一个构建依赖项。 相反,我们可以考虑坚持使用 Promise API。

function fetchSomething(url) {
  return fetch(url).then(function (response) {
    return response.json();
  }).then(function (data) {
    return resolve(data);
  });
}

预处理类

为了获得更多语法糖,ES2015 引入了 class 语法,它提供了一种简化的方式来利用 JavaScript 的原型继承。 但如果我们使用 Babel 为旧浏览器进行转译,输出就不会那么甜了。

输入只有 120 字节

class Robot {
  constructor(name) {
    this.name = name;
  }


  speak() {
     console.log(`I'm ${this.name}!`);
  }
}

但输出结果为 989 字节

"use strict";

function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }

function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }

function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }

var Robot = /*#__PURE__*/function () {
  function Robot(name) {
    _classCallCheck(this, Robot);

    this.name = name;
  }

  _createClass(Robot, [{
    key: "speak",
    value: function speak() {
      console.log("I'm ".concat(this.name, "!"));
    }
  }]);

  return Robot;
}();

大多数时候,除非你进行一些相当复杂的继承,否则使用 伪经典方法 会很简单。 它需要编写的代码稍微少一些,并且生成的接口与类几乎完全相同。

function Robot(name) {
  this.name = name;


  this.speak = function() {
    console.log(`I'm ${this.name}!`);
  }
}


const rob = new Robot("Bob");
rob.speak(); // "Bob"

战略考量

请记住,根据您的应用程序的受众,您在这里阅读的大量内容可能意味着 *您的* 保持捆绑包精简的策略可能会采取不同的形式。

例如,您的团队可能已经做出有意决定放弃对 Internet Explorer 和其他“遗留”浏览器的支持(鉴于绝大多数浏览器支持 ES2015+,这正变得越来越普遍)。如果是这样,您可能最好将时间花在审计构建系统所针对的浏览器列表上,或者确保您没有发布不必要的 polyfill。

即使您仍然有义务支持旧版浏览器(或者您可能太喜欢某些现代 API 而无法放弃它们),也有一些其他选择可以让您仅向需要它们的用户的计算机发布沉重的预处理捆绑包,例如差异化服务实现。

重要的是,您的团队选择优先考虑哪种策略(或策略)并不重要,更重要的是,在构建系统生成的代码的基础上,有意识地做出这些决定。而这一切都始于打开那个 dist 目录以窥视。

打开引擎盖

我非常喜欢现代 JavaScript 继续提供的各种新功能。它们使应用程序更易于编写、维护、扩展,尤其是阅读。但是,只要编写 JavaScript 意味着 *预处理* JavaScript,就必须确保我们掌握这些功能对我们最终要服务的用户的意义。

这意味着偶尔打开构建过程的引擎盖。在最好的情况下,您可能会通过使用更简单的“经典”替代方案来避免特别沉重的 Babel 转换。在最坏的情况下,您将更好地理解(并欣赏)Babel 所做的所有工作。