前端性能的好壞是影響用户體驗的一個關鍵因素,因此進行前端相關的性能優化顯得十分重要。網絡上一些常見的優化手段,相信不少讀者也都瞭解過或實踐過,所以本文主要介紹一些比較容易被忽視的優化細節,當然前提都是在大規範計算的場景下。
Babel 編譯優化
本內容運行環境為 node v14.16.0,babel 版本為 @babel/preset-env@7.17.10,benchmark 版本為 benchmark@2.1.4
眾所周知 babel 有很多的配置項,不同的配置下編譯出來的結果也大不相同,有些編譯的結果會為了符合 ECMAScript 規範,而進行一些的額外檢查或實現一些特殊的能力,從而引起一些性能上的開銷,然而在多數情況下這些檢查和能力帶來的開銷是不必要的,因此下面會列舉一些常見的插件配置來進行優化。
-
@babel/plugin-proposal-object-rest-spread
在項目中有可能會使用 ... 運算符來進行進行克隆或者屬性拷貝,例子如下:const o1 = { a:1 ,b:2, c:3 }; const o2 = { x:1, y: 2, z:3 }; const o3 = { ...o1, ...o2 };當使用 babel 默認配置時,該代碼會編譯如下代碼:
"use strict"; function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } const o1 = { a: 1, b: 2, c: 3 }; const o2 = { x: 1, y: 2, z: 3 }; const o3 = _objectSpread(_objectSpread({}, o1), o2);可以看出一個簡單的屬性拷貝里用到了很多 Object 相關的函數調用。我們用 benchmark 來測試一下屬性拷貝的性能,同樣的添加一組使用原生的 Object.assign 作為對照,其代碼如下:
const Benchmark = require('benchmark'); const suite = new Benchmark.Suite(); // ... 省略處為上方代碼 3~18 行 suite .on('complete', (event) => { console.log(String(event.target)); }) .add('babel _objectSpread', () => { var o3 = _objectSpread(_objectSpread({}, o1), o2); }).run() .add('Object.assign', () => { var o3 = Object.assign({}, o1, o2); }) .run();輸出的結果如下:
babel _objectSpread x 1,512,926 ops/sec ±0.33% (90 runs sampled) Object.assign x 8,682,644 ops/sec ±0.33% (93 runs sampled)可以看出兩者性能上相差了接近 6 倍,如果項目中有大量屬性拷貝的使用(特別是在一些大數據的循環中使用),那麼在性能上會有很大的差距。既然如此 babel 為什麼不默認編譯成使用原生的 Object.assign 進行拷貝呢 ,具體原因可以參考 https://2ality.com/2016/10/re...() 鏈接中的描述,簡單概況就是在對有 Object.defineProperty 修飾過得對象來説,其屬性拷貝時存在一些小細節上的差異。
因此如果項目中不在乎上述鏈接中的細節差異,推薦在 babel.config.json 或 .babelrc 中添加如下配置,將其轉換為使用原生的 Object.assign,配置如下:"plugins": [ [ "@babel/plugin-proposal-object-rest-spread", { "loose": true, "useBuiltIns": true } ] ] -
@babel/plugin-transform-classes
同樣的在項目中可能會使用 class 來面向對象編程,並且也經常會使用到繼承來拓展基類的能力,例子如下:class BaseTest { constructor(a) { this.a = a; } x() {} y() {} z() {} } class Test extends BaseTest { constructor(a) { super(a); } e(){ super.x(); } f() {} }當使用 babel plugin 中配置了默認的 @babel/plugin-transform-classes 時,該代碼會編譯如下代碼:
"use strict"; function _get() { if (typeof Reflect !== "undefined" && Reflect.get) { _get = Reflect.get; } else { _get = function _get(target, property, receiver) { var base = _superPropBase(target, property); if (!base) return; var desc = Object.getOwnPropertyDescriptor(base, property); if (desc.get) { return desc.get.call(arguments.length < 3 ? target : receiver); } return desc.value; }; } return _get.apply(this, arguments); } function _superPropBase(object, property) { while (!Object.prototype.hasOwnProperty.call(object, property)) { object = _getPrototypeOf(object); if (object === null) break; } return object; } function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); Object.defineProperty(subClass, "prototype", { writable: false }); if (superClass) _setPrototypeOf(subClass, superClass); } function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); } function _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; } function _possibleConstructorReturn(self, call) { if (call && (typeof call === "object" || typeof call === "function")) { return call; } else if (call !== void 0) { throw new TypeError("Derived constructors may only return object or undefined"); } return _assertThisInitialized(self); } function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; } function _isNativeReflectConstruct() { if (typeof Reflect === "undefined" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === "function") return true; try { Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); return true; } catch (e) { return false; } } function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); } 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); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; } let BaseTest = /*#__PURE__*/function () { function BaseTest(a) { _classCallCheck(this, BaseTest); this.a = a; } _createClass(BaseTest, [{ key: "x", value: function x() {} }, { key: "y", value: function y() {} }, { key: "z", value: function z() {} }]); return BaseTest; }(); let Test = /*#__PURE__*/function (_BaseTest) { _inherits(Test, _BaseTest); var _super = _createSuper(Test); function Test(a) { _classCallCheck(this, Test); return _super.call(this, a); } _createClass(Test, [{ key: "e", value: function e() { _get(_getPrototypeOf(Test.prototype), "x", this).call(this); } }, { key: "f", value: function f() {} }]); return Test; }(BaseTest);可以看出 babel 編譯後的類繼承還是比較複雜的,涉及了比較多的函數調用,我們使用 benchmark 分別來測試一下構造函數和實例方法調用的性能,同時測試一下構造10萬個實例後內存上的開銷,測試代碼如下:
const Benchmark = require('benchmark'); const process = require('process'); const t = new Test(); const suite = new Benchmark.Suite(); suite .on('complete', (event) => { console.log(String(event.target)); }) .add('new Test', () => { const t = new Test(); }) .run() .add('t.e()', () => { t.e(); }) .run() const arr = []; const before = process.memoryUsage(); for (let i = 0; i < 100000; i++) { arr.push(new Test()); } console.log(`10w Test heapUsed diff: ${(process.memoryUsage().heapUsed - before.heapUsed) / 1024 / 1024}MB`);其測試結果如下:
new Test x 1,446,508 ops/sec ±1.21% (87 runs sampled) t.e() x 41,960,280 ops/sec ±0.36% (93 runs sampled) 10w Test heapUsed diff: 26.5MB如果不使用 @babel/plugin-transform-classes 則 babel 不會對 class 進行編譯,其測試結果如下:
new Test x 171,730,493 ops/sec ±0.46% (92 runs sampled) t.e() x 24,297,804 ops/sec ±0.21% (94 runs sampled) 10w Test heapUsed diff: 5.2MB當然如果使用 @babel/plugin-transform-classes 並且配置為寬鬆模式,則 babel 會編譯成一種簡單的繼承方式(複製原型鏈的方式),同樣的進行測試後其結果如下:
new Test x 826,371,067 ops/sec ±1.68% (84 runs sampled) t.e() x 833,356,353 ops/sec ±1.74% (87 runs sampled) 10w Test heapUsed diff: 5.2MB根據結果可以看出,使用寬鬆模式編譯後其運行的速度比前兩者會快幾倍甚至百倍,並且內存的開銷也是最小的,那寬鬆模式和嚴格模式上有什麼差別呢?這裏筆者沒有深入去查閲相關資料,目前知道的影響是在寬鬆模式一下,其基類上的 new.target 是 undefined ,也歡迎大家在評論區討論。
綜上所述這裏推薦配置如下:"plugins": [ [ "@babel/plugin-transform-classes", { "loose": true } ] ] - assumptions
在上面的鏈接中,可以發現 babel 在 7.13.0 之後新增了 assumptions 的配置,其取代了寬鬆模式的配置,便於更好的優化編譯結果。這裏就不再給出推薦配置了,建議大家動手嘗試靈活配置。
TypeScript 編譯優化
本內容運行環境為 node v14.16.0,benchmark 版本為 benchmark@2.1.4,typescript 版本為 typescript@4.6.4,webpack 版本為 webpack@5.72.0
同樣的 typescript 也有非常多的配置項,不過好在大多數配置並不會對性能造成很大的影響,這裏主要介紹 typescript 與 webpack 等編譯工具結合使用後,將多文件編譯成單文件引起的性能問題。
在項目中,我們通常會進行模塊劃分,將各個模塊拆分為單獨的文件,把相似的模塊歸類到同一個文件夾下,同時還會在對應文件夾下創建一個 index 文件,並將該目錄下的全部模塊進行一個導出,這樣做既方便了不同模塊間的引用方式,也方便了模塊管理和搖樹等等,簡單例子如下:
// 目錄結構
.
└─ src
├─ demo.ts
└─ lib
├─ constants
│ ├─ number.ts
│ └─ index.ts
└─ index.ts
// src/lib/constants/number.ts
export const One: number = 1;
// src/lib/constants/index.ts
export * from 'number';
// src/lib/index.ts
export * from 'constants';
// demo.ts
import { One } from './lib';
function demo() {
for (let i = 0; i < 100; i++) {
if (i === One) {
// do something
}
}
}
// 性能測試代碼
const Benchmark = require('benchmark');
const suite = new Benchmark.Suite();
suite
.on('complete', (event) => {
console.log(String(event.target));
})
.add('import benchmark test', demo)
.run();
假設我們使用 webpack 進行編譯並只配置一個 ts-loader,同時修改 tsconfig.json 中的配置將 compilerOptions.module 配置為非 esnext 的參數,比如為 commonjs,那麼當 demo.ts 作為入口文件,編譯輸出成單文件後,其內部每個導出的 index.ts 模塊都會被編譯成如下代碼:
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
可以看出每一層的導出的內容都會被 getter 包裹一次,那麼在外部訪問對應模塊時是層級越深,走過的 getter 次數越多,從而增加了性能的開銷,以上面內容為例其 benchmark 結果為:
import benchmark test x 874,452 ops/sec ±0.12% (95 runs sampled)
當 module 配置為 esnext 後,其 benchmark 結果為:
import benchmark test x 21,961,693 ops/sec ±1.48% (90 runs sampled)
可以看出在使用 esnext 的場景下性能快了 20 多倍。可能有讀者會問如果就是要使用 commonjs 的導出方式還有辦法優化嗎?答案是肯定的,這裏給出幾種解法:
- 修改引用路徑,直接引導最內部的文件,降低 getter 的次數
- 在使用的文件中定義一個變量將對應的值存儲起來,如將 demo 修改為如下代碼:
import { One } from './lib';
const SelfOne = One;
function demo() {
for (let i = 0; i < 100; i++) {
if (i === SelfOne) {
// do something
}
}
}
- 不使用 ts-loader,使用 @babel/preset-typescript + babel loader
JavaScript 邏輯優化
JavaScript 邏輯方面最好的優化手段還是通過 devtool 錄製 performance 來進行性能分析,這裏給出幾個優化思路:
-
當頻繁的使用同一個數組進行查找內容時,如果不需要考慮索引且該數組內容不重複,可用 Set 代替其時間複雜度
// 優化前 const arr = ['A', 'B', 'C']; function isIncludes(string) { return arr.includes(string); } // 優化後 const set = new Set(['A', 'B', 'C']); function isIncludes(string) { return set.has(string); } -
當 if else 特別多時一般會建議用 switch case,當然改用 switch case 後還有兩種優化方案,一是把容易匹配的 case 放在前面,不容易匹配的放後面;二是用 Map/Object 的形式把每種 case 當做一個函數來處理
// 優化前 if (type === 'A') { // do something } else if (type === 'B') { // do something } else if (type === 'C') { // do something } else { // do something } // 優化方案一 switch (type) { // 命中率高的放前面 case 'C': // do something break; // 命中率次高的放中間 case 'B': // do something break; // 命中率低的放後面 case 'A': // do something break; default: // do something break; } // 優化方案二 function A() { // do something } function B() { // do something } function C() { // do something } function defaultFn() { // do something } const map = { A, B, C }; if (map[type]) { map[type](); } else { defaultFn(); } -
高頻率使用的計算函數,如果頻繁的存在重複的輸入輸出時,可考慮使用緩存來減少計算,當然緩存也不能亂用,不然可能會產生大量的內存增長
// 優化前 const fibonacci = (n) => { if (n === 1) return 1; if (n === 2) return 1; return fibonacci(n-1) + fibonacci(n-2); }; // 優化後 import { memoize } from 'lodash-es'; const fibonacci = memoize((n) => { if (n === 1) return 1; if (n === 2) return 1; return fibonacci(n-1) + fibonacci(n-2); }); -
當要進行數組合並,且原數組不需要保留時,用 push.apply 代替 concat,前者的時間複雜度是 O(n) ,而後者因為是將數組A和數組B合併成一個新數組C,所以時間複雜度是 O(m+n),當然如果數組過長那麼 push.apply 可能會引起爆棧,可通過 for + push 解決
// 優化前 arrA = arrA.concat(arrB); // 優化後 arrA.push.apply(arrA, arrB); // 或 for (let i = 0, len = arrB.length; i < len; i++) { arrA.push(arrB[i]); } -
儘可能減少鏈式調用將邏輯放到一個函數內,一是可以減少調用棧的長度;二是可以減少一些鏈式調用上的隱式開銷
function square(v) { return v * v; } function isLessThan5000(v) { return v < 5000; } // 優化前 arr.map(square).filter(isLessThan5000).reduce((prev, curr) => prev + curr, 0); // 優化後 arr.reduce((prev, curr) => { curr = square(curr); if (isLessThan5000(curr)) { return prev + curr; } return prev; }, 0); -
當要等待多個異步任務結束後完成某個工作時,如果這些異步任務之間無關聯關係,用 Promise.all 代替一個個 await
// 優化前 await getPromise1(); await getPromise2(); // do something // 優化後 await Promise.all([getPromise1(), getPromise2()]); // do something
Canvas 優化
由於筆者工作中主要與 canvas2d 打交道,所以這裏的分享也主要是與 canvas2d 相關的:
-
當有 canvas 內容滾動或移動的需求時,如果本身 canvas 內容是非透明的背景色,則可以通過 drawImage 自己來減少繪製區域
// 假設例子為垂直方向每 10px 展示一個數字從 0 開始 // 當前頁面寬度 200 高度 100 向下滾動 20px const width = 200; const height = 100; const offset = 20; // 優化前 ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, width, height); // 設置白色背景 ctx.fillStyle = '#000'; for (let i = 0, len = height / 10; i < len; i++) { // 每 10 px 繪製一個數字 ctx.fillText(2 + i, width / 2, (i + 1) * 10); } // 優化後 ctx.drawImage( ctx.canvas, 0, offset, 200, height - offset, 0, 0, 200, height - offset ); // 繪製已有內容 ctx.fillStyle = '#fff'; ctx.fillRect(0, height - offset, width, offset); // 設置白色背景 ctx.fillStyle = '#000'; for (let i = 0, len = offset / 10; i < len; i++) { // 繪製剩餘的數字 ctx.fillText(10 + i, width / 2, (i + 1) * 10 + height - offset); } - 如果在 canvas 中有繪製圖標的需求,且圖標本身是用 SVG 描述的,那麼可以將 SVG 轉成 Path2D 來,通過用 Path2D 繪製替代 drawImage 繪製
- 減少 canvas2d 上下文的切換,儘可能保持相同上下文繪製完成後再切換,如需要交替展示紅黃綠,可以先把紅色部分全部繪製完,再繪製黃色以及綠色,而非每畫一個區域切換一個顏色
React 優化
React 的優化個人認為是最困難的,常見的有減少不必要的 state 更新或通過一些 api 來減少 render 次數、非必需的組件懶加載、狀態批量更新等等。它沒有快速優化的手段,只能通過一些工具去逐步分析優化,這裏就不做過多的描述了,簡單提供幾個分析工具的鏈接:
- 官方提供的 React Profiler 工具
- 開源的 why-did-you-render
結語
筆者在工作中做過很多性能優化相關的工作,但一直以來都沒有進行一些總結和分享,這次利用五一假期時間對之前的優化做了簡單的梳理和總結,算是完成了寫一篇分享的小目標。同時也希望這篇文章對大家有幫助,可以拓寬日常工作中的優化思路。
如果您對文章有疑問或者有更多的優化技巧,歡迎評論交流。
最後飛書表格團隊招人,座標深圳、上海、武漢,hc 充足,歡迎有興趣的朋友私信或發送簡歷至 dingyiwei@bytedance.com ,也可通過 https://job.toutiao.com/s/FkG... 鏈接進行投遞,期待您的加入,讓我們一起挑戰前端深水區。