很难想象在没有像 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/await
在 Promise 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 所做的所有工作。
但是输出结果为 1.8kb(增加了 1736%!)应该有一个免责声明,说明您并不是每次编写 for..off 循环都会添加 1.8kb 的 JavaScript。
了解我们的工具在后台的工作原理始终是一件好事,但如果有人告诉我他们不会使用 async/await,因为在编译代码时它会添加 1.5kb 的 JavaScript,那么我可能会自杀了。
有趣。我的意思是,当然,我一直都知道将现代代码转换为旧版浏览器会付出代价,但我不知道成本有多高。
再说一遍,我对这个话题的通常做法可以称为:“通过(尽可能)不写 JavaScript 来避免沉重的 Babel 转换”。
我总是倾向于在我参与的项目中尽可能地删除 JavaScript。我的意思是,最快的代码总是永远不存在的代码,对吧?
但是,结合本文中的提示,当然可以进一步提高性能,因此我对这种见解表示感谢。
这篇文章确实令人大开眼界,但值得注意的是,如果您进行多次转换,它们可以共享转换后的代码。
不过,总体主题是正确的:了解您的工具以及使用这些工具所产生的成本!
有趣的阅读,谢谢!
最后一个关于类的示例中有一个拼写错误,输出结果为 989 *字节*,而不是千字节(谢天谢地!)。
我想 989kb 是字节。
感谢您提供这篇文章,它信息量很大。随着我们进入 2020 年代,我希望能很快到来的一天,几乎所有开发人员都可以放弃对非常旧的遗留浏览器的支持。这将帮助我们为绝大多数使用 2017 年或之后发布的浏览器的用户提供更轻量级的代码库……
关于“循环遍历 NodeList”。我认为您指的是“HTMLCollection”。NodeList 集合默认包含“forEach()”方法。因此,您无需将“querySelectorAll()”选择项扩展到数组中,而只需使用“forEach”方法,因为它已包含在内。
但是,对于“HTMLCollection”,您没有可用的“forEach()”,因此,如果您想使用“forEach()”,则需要将它们扩展到数组中。“HTMLCollection”是在您使用较旧的“getElementsByClassName()”和“getElementsByTagName()”时创建的。
我认为在
for-of
案例中建议的解决方案实际上不正确?它可能应该是这样的所以你的意思是,我应该只使用
var
,对吧?精彩的阅读!非凡的见解,我从来没有考虑过使用新功能的重量,即使我不需要它们。
您的 promise 示例可以简化一些
当
fetch
已经返回一个 promise 时,不需要显式Promise
包装器。好文章!一个可能的缓解因素:本文中描述的大量膨胀来自 Babel 添加的 polyfill 函数,用于支持 ES6 结构。由于这些函数定义只会被添加一次,并且其中许多函数在多个 ES6 结构中共享,因此随着使用这些结构的次数增加,膨胀的比例会迅速下降。
非常好的观点。
不过,这仍然是一篇好文章。
太棒了!干得好!有没有什么在线工具可以检查生成的捆绑包或文件的大小?
DevTools 可以作为文件大小的不错选择。除了它还有其他选择吗?
谢谢 :)
随你吧……我会坚持使用 es2015。我们不再使用 64mb 内存了。
尽管信息量很大,但这篇文章的措辞对新手开发者来说具有误导性。对于任何实际应用来说,尺寸差异微不足道。
除此之外,我想知道 for..of 案例与 TypeScript 编译器相比如何,因为类型是已知的。