ES2015 的高性能及其改进方向

1,112 阅读7分钟
原文链接: segmentfault.com

英文原文:v8project.blogspot.sg...

过去几个月 V8 团队聚焦于提升新增的 ES2015 的一些性能、提升最近一些其他 JavaScript 新特性的性能,使其能够达到或超越相应的 ES5 的性能。

出发点

在我们讨论这些不同的改进之前,要先了解在当前的 Web 开发中,已经有了广为使用的 Babel 作为编译器,为什么还要考虑 ES2015+ 的性能问题:

  1. 首先,有一些新的 ES2015 特性是只有 polyfill 时需要的。例如 Object.assign 函数。当 Babel 转译 “object spread property” 的时候(在 React 和 Redux 中经常碰到),就会依赖 Object.assign 来替代 ES5 中相应的函数(如果VM环境支持的话)。

  2. polyfill ES2015 的新特性往往会增加代码的 size,这些 ES2015 特性却有助于缓解当前的 web 性能危机,尤其像在手机设备这样的新兴市场上。在这样一种情况下,代码的解析和的成本将会很高。

  3. 最后,客户端的 JavaScript 运行环境只是依赖于 V8 引擎的环境之一,还有服务端的 Node.js 应用和工具等,它们都不需要转译成 ES5 代码,而直接使用最新的 V8 版本就可以使用这些新特性了。

一起来看一下下面这段 Redux 文档中的代码:

function todoApp(state = initialState, action) {
  switch (action.type) {
    case SET_VISIBILITY_FILTER:
      return { ...state, visibilityFilter: action.filter }
    default:
      return state
  }
}

有两个地方需要转译:默认参数 statestate 作为实例化对象进行返回。Babel 将生成如下 ES5 代码:


"use strict";

var _extends = Object.assign || function (target) { 
    for (var i = 1; i < arguments.length; i++) { 
        var source = arguments[i]; 
            for (var key in source) { 
                if (Object.prototype.hasOwnProperty.call(source, key)){ 
                    target[key] = source[key]; 
                } 
            } 
    } 
    return target; 
};

function todoApp() {
  var state = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : initialState;
  var action = arguments[1];

  switch (action.type) {
    case SET_VISIBILITY_FILTER:
      return _extends({}, state, { visibilityFilter: action.filter });
    default:
      return state;
  }
}

假设 Object.assign 要比用 Babel polyfill 生成的代码要慢一个数量级。这样的情况下,要将一个本不支持 Object.assign 的浏览器优化到使它具有 ES2015 能力,会引起很严重的性能问题。

这个例子同时也指出了转译的另一个缺点:转译生成的代码,要比直接用 ES2015+ 写的代码体积更大。在上面的例子中,源代码有 203 个字符(gzip 压缩后有 176 字节),而转译生成的代码有 588 个字符(gzip 压缩后有 367 字节)。代码大小是原来的两倍。下面来看关于 “JavaScript 异步迭代器”的一个例子:


async function* readLines(path) {
  let file = await fileOpen(path);

  try {
    while (!file.EOF) {
      yield await file.readLine();
    }
  } finally {
    await file.close();
  }
}

Babel 转译这段 187 个字符(gzip 压缩后 150 字节),会生成一段有 2987 个字符(gzip 压缩后 971 字节)的 ES5 代码,这还不包括再生器运行时需要加载的额外依赖:


"use strict";

var _asyncGenerator = function () { function AwaitValue(value) { this.value = value; } function AsyncGenerator(gen) { var front, back; function send(key, arg) { return new Promise(function (resolve, reject) { var request = { key: key, arg: arg, resolve: resolve, reject: reject, next: null }; if (back) { back = back.next = request; } else { front = back = request; resume(key, arg); } }); } function resume(key, arg) { try { var result = gen[key](arg); var value = result.value; if (value instanceof AwaitValue) { Promise.resolve(value.value).then(function (arg) { resume("next", arg); }, function (arg) { resume("throw", arg); }); } else { settle(result.done ? "return" : "normal", result.value); } } catch (err) { settle("throw", err); } } function settle(type, value) { switch (type) { case "return": front.resolve({ value: value, done: true }); break; case "throw": front.reject(value); break; default: front.resolve({ value: value, done: false }); break; } front = front.next; if (front) { resume(front.key, front.arg); } else { back = null; } } this._invoke = send; if (typeof gen.return !== "function") { this.return = undefined; } } if (typeof Symbol === "function" && Symbol.asyncIterator) { AsyncGenerator.prototype[Symbol.asyncIterator] = function () { return this; }; } AsyncGenerator.prototype.next = function (arg) { return this._invoke("next", arg); }; AsyncGenerator.prototype.throw = function (arg) { return this._invoke("throw", arg); }; AsyncGenerator.prototype.return = function (arg) { return this._invoke("return", arg); }; return { wrap: function wrap(fn) { return function () { return new AsyncGenerator(fn.apply(this, arguments)); }; }, await: function await(value) { return new AwaitValue(value); } }; }();

var readLines = function () {
  var _ref = _asyncGenerator.wrap(regeneratorRuntime.mark(function _callee(path) {
    var file;
    return regeneratorRuntime.wrap(function _callee$(_context) {
      while (1) {
        switch (_context.prev = _context.next) {
          case 0:
            _context.next = 2;
            return _asyncGenerator.await(fileOpen(path));

          case 2:
            file = _context.sent;
            _context.prev = 3;

          case 4:
            if (file.EOF) {
              _context.next = 11;
              break;
            }

            _context.next = 7;
            return _asyncGenerator.await(file.readLine());

          case 7:
            _context.next = 9;
            return _context.sent;

          case 9:
            _context.next = 4;
            break;

          case 11:
            _context.prev = 11;
            _context.next = 14;
            return _asyncGenerator.await(file.close());

          case 14:
            return _context.finish(11);

          case 15:
          case "end":
            return _context.stop();
        }
      }
    }, _callee, this, [[3,, 11, 15]]);
  }));

  return function readLines(_x) {
    return _ref.apply(this, arguments);
  };
}();

这段代码的大小是原来的 6.5 倍,也就是说增长了 650% (生成的 _asyncGenerator 函数也可能被共享,不过这依赖于你如何打包你的代码。如果被共享的话,多个异步迭代器共用会分摊代码大小带来的成本)。我们认为长远来看一直通过转译的方式来支持 ES5 是不可行的,代码 size 的增加不仅仅会使下载的时间变长,而且也会增加解析和编译的开销。如果我们想要彻底改善页面加载速度,和移动互联网应用的反应速度(尤其在手机设备上),那么一定要鼓励开发者使用 ES2015+ 来开发,而不是开发完以后转译成 ES5。对于不支持 ES2015 的旧浏览器,只有给它们完全转译以后的代码去执行了,而对于 VM 系统,上面所说的这个愿景也要求我们不断地提升 ES2015 的性能。

评估方法

正如上面所说的,ES2015+ 自身的绝对性能现在已经不是关键了。当前的关键是首先一定要确保 ES2015+ 的性能要比纯 ES5 高,第二更重要的是一定要比用 Babel 转译以后的版本性能高。目前已经有了一个由 Kevin Decker 开发的 six-speed 项目,这个项目多多少少实现了我们的需求:ES2015 特性 vs 纯 ES5 vs 转译生成代码三者之间的比较。

因此我们现在把提升相对性能作为我们做 ES2015+ 性能提升的基础。首先将会把注意力聚焦于那些最严重的问题上,即上面图中所列出的,从纯 ES5 所对应的 ES2015+ 版本性能下降 2 倍的那些项。之所以这么说是因为有个前提假设,假设纯 ES5 的版本至少会和相应 Babel 生成的版本速度一样快。

为现代语言而生的现代架构

以前版本的 V8 优化像 ES2015+ 这样的语言是比较困难的。比如想要加一个异常处理(即 try/chtch/finally)到 Crankshaft (V8 以前版本的优化编译器)是不可能的。就是说以 V8 的能力去优化 ES6 中的 for...of (这里面隐含有 finally 语句)都是有问题的。Crankshaft 在增加新的语言特性到编译器方面有很多局限性和实现的复杂性,这就使得 V8 框架的更新优化速度很难跟得上 ES 标准化的速度。拖慢了 V8 发展的节奏。

幸运的是,lgnition 和 TurboFan (V8 的新版解释器和编译器)在设计之初就考虑支持整个 JavaScript 语言体系。包括先进的控制流、异常处理、最近的 for...of 特性和 ES2015 的重构等。lgnition 和 TurboFan 的密集组合架构使得对于新特性的整体优化和增量式优化成为可能。

许多我们已经在现代语言特性上所取得的成功只有在 lgnition/TurboFan 上才可能实现。 lgnition/TurboFan 在优化生成器和异步函数方面的设计尤其关键。V8 一直以来都支持生成器,但是由于 Crankshaft 的限制,对其优化会极其受限。新的编译器利用 lgnition 生成字节码,这可以使复杂的生成器控制流转化为简单的本地字节控制流。TurboFan 也可以更容易实现基于字节流的优化,因为它不要知道生成器控制流的特殊细节,只需要知道如何保存和恢复函数声明就可以了。

联合声明

我们短期目标是尽快实现少于 2 倍的性能改善。首先从最差情况的实验开始,从 Chrome M54 到 Chrome M58 我们成功的把慢于 2 倍的测试集从 16 个降到了 8 个。同时也显著地使缓慢程度的中位数和平均数得以降低。

从下图中我们可以清晰地看到变化趋势,已经实现了平均性能超过了 ES5 大概 47%,这里列出的是在 M54 上的一些典型数据。

另外我们显著提高了基于迭代的新语言的性能,例如传递操作符和 for...of 循环等。下面是一个数组的重构情况:


function fn() {
  var [c] = data;
  return c;
}

比纯 ES5 版本还要快。ES5:

function fn() {
  var c = data[0];
  return c;
}

比 Babel 生成的代码要快的更多。Babel:


"use strict";

var _slicedToArray = function () { function sliceIterator(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"]) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } return function (arr, i) { if (Array.isArray(arr)) { return arr; } else if (Symbol.iterator in Object(arr)) { return sliceIterator(arr, i); } else { throw new TypeError("Invalid attempt to destructure non-iterable instance"); } }; }();

function fn() {
  var _data = data,
      _data2 = _slicedToArray(_data, 1),
      c = _data2[0];

  return c;
}

你可以到“高速 ES2015” 来了解更多细节的信息。下面这里是我们在 2017 年 1 月 12 日发出的视频连接

我们会继续针对 ES2015+ 的特性提升其性能。如果你对这一问题感兴趣,请看我们 V8 的“ES2015 and beyond performance plan

如果大家对文章感兴趣,欢迎关注我的知乎专栏-前端大哈。定期发布高质量文章。