前端性能精进(七)——构建

简介: 前端性能精进(七)——构建

 前端构建是指通过工具自动化地处理那些繁琐、重复而有意义的任务。

  这些任务包括语言编译、文件压缩、模块打包、图像优化、单元测试等一切需要对源码进行处理的工作。

  在将这类任务交给工具后,开发人员被解放了生产力,得以集中精力去编写代码业务,提高工作效率。

  构建工具从早期基于流的 gulp,再到静态模块打包器 webpack,然后到现在炙手可热的 Vite,一直在追求更极致的性能和体验。

  构建工具的优化很大一部分其实就是对源码的优化,例如压缩、合并、Tree Shaking、Code Splitting 等。


一、减少尺寸


  减少文件尺寸的方法除了使用算法压缩文件之外,还有其他优化方式也可以减小文件尺寸,例如优化编译、打包等。

1)编译

  在现代前端业务开发中,对脚本的编译是必不可少的,例如 ES8 语法通过 Babel 编译成 ES5,Sass 语法编译成 CSS 等。

  在编译完成后,JavaScript 或 CSS 文件的尺寸可能就会有所增加。

  关于脚本文件,若不需要兼容古老的浏览器,那推荐直接使用新语法,不要再编译成 ES5 语法。

  例如 ES6 的 Symbol 类型编译成 ES5 语法,如下所示,代码量激增。

let func = () => {
  let value = Symbol();
  return typeof value;
};
// 经过 Babel 编译后的代码
function _typeof(obj) {
  "@babel/helpers - typeof";
  return (
    (_typeof =
      "function" == typeof Symbol && "symbol" == typeof Symbol.iterator
        ? function (obj) {
            return typeof obj;
          }
        : function (obj) {
            return obj && "function" == typeof Symbol &&
              obj.constructor === Symbol && obj !== Symbol.prototype
              ? "symbol" : typeof obj;
          }),
    _typeof(obj)
  );
}
var func = function func() {
  var value = Symbol();
  return _typeof(value);
};

  为了增加编译效率,需要将那些不需要编译的目录或文件排除在外。

  例如 node_modules 中所依赖的包,配置如下所示。

module.exports = {
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        use: "babel-loader",
        exclude: /node_modules/
      },
    ]
  }
};

2)打包

  在 webpack 打包生成的 bundle 文件中,除了业务代码和引用的第三方库之外,还会包含管理模块交互的 runtime。

  runtime 是一段辅助代码,在模块交互时,能连接它们所需的加载和解析逻辑,下面是通过 webpack 4.34 生成的 runtime。

/******/ (function(modules) { // webpackBootstrap
/******/     // The module cache
/******/     var installedModules = {};
/******/
/******/     // The require function
/******/     function __webpack_require__(moduleId) {
/******/
/******/         // Check if module is in cache
/******/         if(installedModules[moduleId]) {
/******/             return installedModules[moduleId].exports;
/******/         }
/******/         // Create a new module (and put it into the cache)
/******/         var module = installedModules[moduleId] = {
/******/             i: moduleId,
/******/             l: false,
/******/             exports: {}
/******/         };
/******/
/******/         // Execute the module function
/******/         modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/         // Flag the module as loaded
/******/         module.l = true;
/******/
/******/         // Return the exports of the module
/******/         return module.exports;
/******/     }
/******/
/******/
/******/     // expose the modules object (__webpack_modules__)
/******/     __webpack_require__.m = modules;
/******/
/******/     // expose the module cache
/******/     __webpack_require__.c = installedModules;
/******/
/******/     // define getter function for harmony exports
/******/     __webpack_require__.d = function(exports, name, getter) {
/******/         if(!__webpack_require__.o(exports, name)) {
/******/             Object.defineProperty(exports, name, { enumerable: true, get: getter });
/******/         }
/******/     };
/******/
/******/     // define __esModule on exports
/******/     __webpack_require__.r = function(exports) {
/******/         if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/             Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/         }
/******/         Object.defineProperty(exports, '__esModule', { value: true });
/******/     };
/******/
/******/     // create a fake namespace object
/******/     // mode & 1: value is a module id, require it
/******/     // mode & 2: merge all properties of value into the ns
/******/     // mode & 4: return value when already ns object
/******/     // mode & 8|1: behave like require
/******/     __webpack_require__.t = function(value, mode) {
/******/         if(mode & 1) value = __webpack_require__(value);
/******/         if(mode & 8) return value;
/******/         if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
/******/         var ns = Object.create(null);
/******/         __webpack_require__.r(ns);
/******/         Object.defineProperty(ns, 'default', { enumerable: true, value: value });
/******/         if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));
/******/         return ns;
/******/     };
/******/
/******/     // getDefaultExport function for compatibility with non-harmony modules
/******/     __webpack_require__.n = function(module) {
/******/         var getter = module && module.__esModule ?
/******/             function getDefault() { return module['default']; } :
/******/             function getModuleExports() { return module; };
/******/         __webpack_require__.d(getter, 'a', getter);
/******/         return getter;
/******/     };
/******/
/******/     // Object.prototype.hasOwnProperty.call
/******/     __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/     // __webpack_public_path__
/******/     __webpack_require__.p = "";
/******/
/******/
/******/     // Load entry module and return exports
/******/     return __webpack_require__(__webpack_require__.s = 0);
/******/ })
/************************************************************************/

  在代码中定义了一个加载模块的函数:__webpack_require__(),其参数是模块标识符,还为它添加了多个私有属性。

  在编写的源码中所使用的 import、define() 或 require() 等模块导入语法,都会被转换成 __webpack_require__() 函数。

  也就是说,webpack 自己编写 polyfill 来实现 CommonJS、ESM 等模块语法。

  这里推荐另一个模块打包工具:rollup,它默认使用 ESM 模块标准,而非 CommonJS 和 AMD。

  所以,rollup 打包出的脚本比较干净(如下所示),适合打包各类库,React、Vue 等项目都是用 rollup 打包。

import { age } from './maths.js';
console.log(age + 1)
console.log(1234)
// maths.js 文件中的代码
export const name = 'strick'
export const age = 30
// 经过 rollup 打包后的代码
const age = 30;
console.log(age + 1);
console.log(1234);

  目前,支持 ES6 语法的浏览器已达到 98.35%,如下图所示,若不需要兼容 IE6~IE10 等古老浏览器的话,rollup 是打包首选。

  

3)压缩

  目前市面上有许多成熟的库可对不同类型的文件进行压缩。

  例如压缩 HTML 的 html-minifier,压缩 JavaScript 的 uglify-js,压缩 CSS 的 cssnano,压缩图像的 imagemin

  压缩后的文件会被去除换行和空格,像脚本还会修改变量名,部分流程替换成三目预算,删除注释或打印语句等。

  webpack 和 rollup 都支持插件的扩展,在将上述压缩脚本封装到插件中后,就能在构建的过程中对文件进行自动压缩。

  以 webpack 的插件为例,已提供了 ImageMinimizerPluginOptimizeCssPluginUglifyjsPlugin 等压缩插件,生态圈非常丰富。

4)Tree Shaking

  Tree Shaking 是一个术语,用于移除 JavaScript 中未被引用的死代码,依赖 ES6 模块语法的静态结构特性。

  在执行 Tree Shaking 后,在文件中就不存在冗余的依赖和代码。在下面的示例中,ES 模块可以只导入所需的 func1() 函数。

export function func1() {
  console.log('strick')
}
export function func2() {
  console.log('freedom')
}
// maths.js 文件中的代码
import { func1 } from './maths.js';
func1();
// 经过 Tree Shaking 后的代码
function func1() {
  console.log('strick');
}
func1();

  Tree Shaking 最先在 rollup 中出现,webpack 在 2.0 版本中也引入了此概念。

5)Scope Hoisting

  Scope Hoisting 是指作用域提升,具体来说,就是在分析出模块之间的依赖关系后,将那些只被引用了一次的模块合并到一个函数中。

  下面是一个简单的示例,action() 函数直接被注入到引用它的模块中。

import action from './maths.js';
const value = action();
// 经过 Scope Hoisting 后的代码
(function() {
  var action = function() { };
  var value = action();
});

  注意,由于 Scope Hoisting 依赖静态分析,因此需要使用 ES6 模块语法。

  webpack 4 以上的版本可以在 optimization.concatenateModules 中配置 Scope Hoisting 的启用状态。

  比起常规的打包,在经过 Scope Hoisting 后,脚本尺寸将变得更小。


二、合并打包


  模块打包器最重要的一个功能就是将分散在各个文件中的代码合并到一起,组成一个文件。

1)Code Splitting

  在实际开发中,会引用各种第三方库,若将这些库全部合并在一起,那么这个文件很有可能非常庞大,产生性能问题。

  常用的优化手段是 Code Splitting,即代码分离,将代码拆成多块,分离到不同的文件中,这些文件既能按需加载,也能被浏览器缓存。

  不仅如此,代码分离还能去除重复代码,减少文件体积,优化加载时间。

  Vue 内置了一条命令,可以查看每个脚本的尺寸以及内部依赖包的尺寸。

  在下图中,vendors.js 的原始尺寸是 3.76M,gzipped 压缩后的尺寸是 442.02KB,比较大的包是 lottie、swiper、moment、lodash 等。

  

  这类比较大的包可以再单独剥离,不用全部聚合在 vendors.js 中。

  在 vue.config.js 中,配置 config.optimization.splitChunks(),如下所示,参数含义可参考 SplitChunksPlugin 插件。

config.optimization.splitChunks(
      {
        cacheGroups: {
          vendors: {
            name: 'chunk-vendors',
            test: /[\\/]node_modules[\\/]/,
            priority: -10,
            chunks: 'initial'
          },
          lottie: {
            name: 'chunk-lottie',
            test: /[\\/]node_modules[\\/]lottie-web[\\/]/,
            chunks: 'all',
            priority: 3,
            reuseExistingChunk: true,
            enforce: true
          },
          swiper: {
            name: 'chunk-swiper',
            test: /[\\/]node_modules[\\/]_swiper@3.4.2@swiper[\\/]/,
            chunks: 'all',
            priority: 3,
            reuseExistingChunk: true,
            enforce: true
          },
          lodash: {
            name: 'chunk-lodash',
            test: /[\\/]node_modules[\\/]lodash[\\/]/,
            chunks: 'all',
            priority: 3,
            reuseExistingChunk: true,
            enforce: true
          }
        }
      }
    )

  在经过一顿初步操作后,原始尺寸降到 2.4M,gzipped 压缩后的尺寸是 308.64KB,比之前少了 100 多 KB,如下图所示。

  

  其实有时候只是使用了开源库的一个小功能,若不复杂,那完全可以自己用代码实现,这样就不必依赖那个大包了。

  例如常用的 lodashunderscore,都是些短小而实用的工具方法,只要单独提取并修改成相应的代码(参考此处),就能避免将整个库引入。

2)资源内联

  资源内联会让文件尺寸变大,但是会减少网络通信。

  像移动端屏幕适配脚本,就比较适合内联到 HTML 中,因为这类脚本要最先运行,以免影响后面样式的计算。

  若是通过域名请求,当请求失败时,整个移动端页面的布局将是错位的。

  webpack 的 InlineSourcePlugin 就提供了 JavaScript 和 CSS 的内联功能。

  将小图像转换成 Data URI 格式,也是内联的一种应用,同样也是减少通信次数,但文件是肯定会大一点。

  还有一种内联是为资源增加破缓存的随机参数,以免读取到旧内容。

  随机参数既可以包含在文件名中,也可以包含在 URL 地址中,如下所示。

<script src="/js/chunk-vendors.e35b590f.js"></script>

  在 webpack.config.js 中,有个 output 字段,用于配置输出的信息。

  它的 filename 属性可声明输出的文件名,可以配置成唯一标识符,如下所示。

module.exports = {
  output: {
    filename: "[name].[hash].bundle.js"
  }
};


总结


  在构建之前,也可以做一些前置优化。

  例如对浏览器兼容性要求不高的场景,可以将编译脚本选择 ES6 语法,用 rollup 打包。

  还可以将一些库中的简单功能单独实现,以免引入整个库。这部分优化后,打包出来的尺寸肯定会比原先小。

  在构建的过程中,可以对文件进行压缩、Tree Shaking 和 Scope Hoisting,以此来减小文件尺寸。

  在合并时,可以将那些第三方库提取到一起,组成一个单独的文件,这些文件既能按需加载,也能被浏览器缓存。

  资源内联是另一种优化手段,虽然文件尺寸会变大,但是能得到通信次数变少,读取的文件是最新内容等收益。

相关文章
|
14天前
|
前端开发 机器人 API
前端大模型入门(一):用 js+langchain 构建基于 LLM 的应用
本文介绍了大语言模型(LLM)的HTTP API流式调用机制及其在前端的实现方法。通过流式调用,服务器可以逐步发送生成的文本内容,前端则实时处理并展示这些数据块,从而提升用户体验和实时性。文章详细讲解了如何使用`fetch`发起流式请求、处理响应流数据、逐步更新界面、处理中断和错误,以及优化用户交互。流式调用特别适用于聊天机器人、搜索建议等应用场景,能够显著减少用户的等待时间,增强交互性。
109 2
|
16天前
|
前端开发
深入解析React Hooks:构建高效且可维护的前端应用
本文将带你走进React Hooks的世界,探索这一革新特性如何改变我们构建React组件的方式。通过分析Hooks的核心概念、使用方法和最佳实践,文章旨在帮助你充分利用Hooks来提高开发效率,编写更简洁、更可维护的前端代码。我们将通过实际代码示例,深入了解useState、useEffect等常用Hooks的内部工作原理,并探讨如何自定义Hooks以复用逻辑。
|
21天前
|
前端开发 JavaScript 测试技术
前端工程化:构建高效、可维护的现代Web应用
【10月更文挑战第5天】前端工程化:构建高效、可维护的现代Web应用
|
21天前
|
前端开发 测试技术 持续交付
前端工程化:构建高效、可维护的现代Web应用
【10月更文挑战第5天】前端工程化:构建高效、可维护的现代Web应用
|
7天前
|
JavaScript 前端开发 Docker
前端全栈之路Deno篇(二):几行代码打包后接近100M?别慌,带你掌握Deno2.0的安装到项目构建全流程、剖析构建物并了解其好处
在使用 Deno 构建项目时,生成的可执行文件体积较大,通常接近 100 MB,而 Node.js 构建的项目体积则要小得多。这是由于 Deno 包含了完整的 V8 引擎和运行时,使其能够在目标设备上独立运行,无需额外安装依赖。尽管体积较大,但 Deno 提供了更好的安全性和部署便利性。通过裁剪功能、使用压缩工具等方法,可以优化可执行文件的体积。
前端全栈之路Deno篇(二):几行代码打包后接近100M?别慌,带你掌握Deno2.0的安装到项目构建全流程、剖析构建物并了解其好处
|
19天前
|
前端开发 JavaScript 编译器
前端研发链路之构建
本文首发于微信公众号“前端徐徐”,作者徐徐探讨了前端研发链路中的构建过程。文章介绍了构建器、JavaScript编译器和CSS编译器的作用及常见工具,详细解析了它们如何协同工作,提高开发效率和项目可维护性。适合前端开发者阅读,帮助理解现代前端构建体系。
13 1
前端研发链路之构建
|
1天前
|
前端开发 API UED
深入理解微前端架构:构建灵活、高效的前端应用
【10月更文挑战第23天】微前端架构是一种将前端应用分解为多个小型、独立、可复用的服务的方法。每个服务独立开发和部署,但共同提供一致的用户体验。本文探讨了微前端架构的核心概念、优势及实施方法,包括定义服务边界、建立通信机制、共享UI组件库和版本控制等。通过实际案例和职业心得,帮助读者更好地理解和应用微前端架构。
|
7天前
|
前端开发 API UED
拥抱微前端架构:构建灵活、高效的前端应用
【10月更文挑战第17天】微前端架构是一种将前端应用拆分为多个小型、独立、可复用的服务的方法,每个服务可以独立开发、部署和维护。本文介绍了微前端架构的核心概念、优势及实施步骤,并分享了业界应用案例和职业心得,帮助读者理解和应用这一新兴架构模式。
|
12天前
|
存储 监控 前端开发
掌握微前端架构:构建未来前端应用的基石
【10月更文挑战第12天】随着前端技术的发展,传统的单体应用架构已无法满足现代应用的需求。微前端架构通过将大型应用拆分为独立的小模块,提供了更高的灵活性、可维护性和快速迭代能力。本文介绍了微前端架构的概念、核心优势及实施步骤,并探讨了其在复杂应用中的应用及实战技巧。
|
15天前
|
前端开发 JavaScript
Vite 多种前端框架的构建
Vite 多种前端框架的构建