1 Star 3 Fork 0

Jay_Ohhh / front-end notes

加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
克隆/下载
前端工程化.md 17.66 KB
一键复制 编辑 原始数据 按行查看 历史
Jay_Ohhh 提交于 2022-05-25 10:26 . update

打包篇

第一章 打包器的资源处理

模块化规范

模块内具有作用域。

Node端

CommonJS
  • 运行时同步加载(动态加载)

    同步意味着阻塞加载,浏览器资源是异步加载的,因此有了AMD CMD解决方案

    CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。

  • 输出值的拷贝,会缓存第一次运行的结果

基本语法

  • 暴露模块:module.exports.xxx = valueexports.xxx = value

    exports指向的是module.exports

    // module.exports和exports必须指向同一个值
    exports = module.exports = {};
    
    // wrong: exports不再指向module.exports
    exports = {};

    保险起见,可使用以下方式:

    • modules.exports.foo = bar

    • export.foo = bar

  • 引入模块:require(xxx),如果是第三方模块,xxx为模块名;如果是自定义模块,xxx为模块文件路径

浏览器端

AMD
  • 在浏览器端中并行异步加载

  • 依赖前置,换句话说,在解析和执行当前模块之前,模块作者必须指明当前模块所依赖的模块

  • 提前执行依赖

实现:RequireJS

CMD
  • 在浏览器端中异步加载

  • 按需加载

  • 依赖就近,依赖可以就近书写,可以把依赖写进你的代码中的任意一行

  • 延迟执行依赖

实现:Sea.js

浏览器 和 Node 兼容端

UMD

统一AMD和CommonJS规范,解决跨平台问题。既可以在 node/webpack 环境中被 require 引用,也可以在浏览器中直接用 CDN 被 script.src 引入。

(function (root, factory) {
  if (typeof define === "function" && define.amd) {
    // AMD
    define(["jquery"], factory);
  } else if (typeof exports === "object") {
    // CommonJS
    module.exports = factory(require("jquery"));
  } else {
    // 全局变量
    root.returnExports = factory(root.jQuery);
  }
})(this, function ($) {
  // ...
});
ESM

Pure ESM package

浏览器和服务器通用的模块解决方案

ES6 模块设计思想:尽量的静态化、使得编译时就能确定模块的依赖关系,以及输入和输出的变量(CommonJS和AMD模块,都只能在运行时确定这些东西)。

  • 编译时加载(静态加载)

  • 动态引用,不会缓存值

  • 输出值的引用

    当ES6遇到import时,不会像CommonJS一样去执行模块,而是生成一个动态的只读引用(在代码静态解析阶段就会生成),当真正需要的时候再到模块里去取值,所以ES6模块是动态引用,并且不会缓存值。

    编译:类似翻译,就是将源代码翻译成机器能识别的代码。

    运行:就是将代码跑起来,被装载到内存中去了。

AST及其应用

ASTAbstract Syntax Tree 的简称,是前端工程化绕不过的一个名词。它涉及到工程化诸多环节的应用,比如:

  1. 如何将 Typescript 转化为 Javascript (typescript)
  2. 如何将 SASS/LESS 转化为 CSS (sass/less)
  3. 如何将 ES6+ 转化为 ES5 (babel)
  4. 如何将 Javascript 代码进行格式化 (eslint/prettier)
  5. 如何识别 React 项目中的 JSX (babel)
  6. GraphQL、MDX、Vue SFC 等等

而在语言转换的过程中,实质上就是对其 AST 的操作,核心步骤就是 AST 三步走

  1. Code -> AST (Parse)
  2. AST -> AST (Transform)
  3. AST -> Code (Generate)

以下是一段代码,及其对应的 AST

// Code
const a = 4

// AST
{
  "type": "Program",
  "start": 0,
  "end": 11,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 11,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 6,
          "end": 11,
          "id": {
            "type": "Identifier",
            "start": 6,
            "end": 7,
            "name": "a"
          },
          "init": {
            "type": "Literal",
            "start": 10,
            "end": 11,
            "value": 4,
            "raw": "4"
          }
        }
      ],
      "kind": "const"
    }
  ],
  "sourceType": "module"
}

AST 的生成

AST 的生成这一步骤被称为解析(Parser),而该步骤也有两个阶段: 词法分析(Lexical Analysis)和语法分析(Syntactic Analysis)

词法分析 (Lexical Analysis)

词法分析用以将代码转化为 Token 流,维护一个关于 Token 的数组

// Code
a = 3

// Token
[
  { type: { ... }, value: "a", start: 0, end: 1, loc: { ... } },
  { type: { ... }, value: "=", start: 2, end: 3, loc: { ... } },
  { type: { ... }, value: "3", start: 4, end: 5, loc: { ... } },
  ...
]

词法分析后的 Token 流也有诸多应用,如:

  1. 代码检查,如 eslint 判断是否以分号结尾,判断是否含有分号的 token
  2. 语法高亮,如 highlight/prism 使之代码高亮
  3. 模板语法,如 ejs 等模板也离不开
语法分析 (Syntactic Analysis)

语法分析将 Token 流转化为结构化的 AST,方便操作

{
  "type": "Program",
  "start": 0,
  "end": 5,
  "body": [
    {
      "type": "ExpressionStatement",
      "start": 0,
      "end": 5,
      "expression": {
        "type": "AssignmentExpression",
        "start": 0,
        "end": 5,
        "operator": "=",
        "left": {
          "type": "Identifier",
          "start": 0,
          "end": 1,
          "name": "a"
        },
        "right": {
          "type": "Literal",
          "start": 4,
          "end": 5,
          "value": 3,
          "raw": "3"
        }
      }
    }
  ],
  "sourceType": "module"
}

可通过自己写一个解析器,将语言 (DSL) 解析为 AST 进行练手,以下两个示例是不错的选择

  1. 解析简单的 HTML 为 AST
  2. 解析 Marktodwn List 为 AST

或可参考一个最简编译器的实现 the super tiny compiler

运行时分析

webpack runtime & manifest

https://webpack.docschina.org/concepts/manifest/#root

webpack runtime 和 manifest用来引导所有模块的加载和连接交互。

当webpack complier执行、解析和映射应用程序时,它把模块的详细的信息都记录到了manifest中。当模块被打包并运输到浏览器上时,runtime就会根据manifest来处理和加载模块。利用manifest就知道从哪里去获取模块代码。

manifest数据被包含在某个js文件中,可使用 optimization.runtimeChunk 选项将 runtime 代码(包含manifest数据)拆分为一个单独的 chunk。

Rollup

在 Rollup 中,并不会将所有模块置于 modules 中使用 Module Wrapper 进行维护,它仅仅将所有模块铺平展开

// index.js
import name from "./name";
console.log(name);
// name.js
const name = "shanyue";
export default name;

在打包后,直接把所有模块平铺展开即可,可见实时示例

// output.js
const name = "shanyue";
console.log(name);

webpack HMR

HMR,Hot Module Replacement,热模块替换,见名思意,即无需刷新在内存环境中即可替换掉过旧模块。与 Live Reload 相对应。

PS: Live Reload,当代码进行更新后,在浏览器自动刷新以获取最新前端代码。

在 webpack 的运行时中 __webpack__modules__ 用以维护所有的模块。

而热模块替换的原理,即通过 chunk 的方式加载最新的 modules,找到 __webpack__modules__ 中对应的模块逐一替换,并删除其上下缓存。

其精简数据结构用以下代码表示:

// webpack 运行时代码
const __webpack_modules = [
  (module, exports, __webpack_require__) => {
    __webpack_require__(0);
  },
  () => {
    console.log("这是一号模块");
  },
];

// HMR Chunk 代码
// JSONP 异步加载的所需要更新的 modules,并在 __webpack_modules__ 中进行替换
self["webpackHotUpdate"](0, {
  1: () => {
    console.log("这是最新的一号模块");
  },
});

其下为更具体更完整的流程,每一步都涉及众多,有兴趣的可阅读 webpack-dev-server 及开发环境 webpack 运行时的源码。

  1. webpack-dev-server 将打包输出 bundle 使用内存型文件系统控制,而非真实的文件系统。此时使用的是 memfs模拟 node.js fs API
  2. 每当文件发生变更时,webpack 将会重新编译,webpack-dev-server 将会监控到此时文件变更事件,并找到其对应的 module。此时使用的是 chokidar监控文件变更
  3. webpack-dev-server 将会把变更模块通知到浏览器端,此时使用 websocket 与浏览器进行交流。此时使用的是 ws
  4. 浏览器根据 websocket 接收到 hash,并通过 hash 以 JSONP 的方式请求更新模块的 chunk
  5. 浏览器加载 chunk,并使用新的模块对旧模块进行热替换,并删除其缓存

如何提升 webpack 构建资源的速度

使用 speed-measure-webpack-plugin可评估每个 loader/plugin 的执行耗时。

更快的 loader: swc

webpack 中耗时最久的当属负责 AST 转换的 loader。

当 loader 进行编译时的 AST 操作均为 CPU 密集型任务,使用 Javascript 性能低下,此时可采用高性能语言 rust 编写的 swc

比如 Javascript 转化由 babel 转化为更快的 swc

module: {
  rules: [
    {
      test: /\.m?js$/,
      exclude: /(node_modules)/,
      use: {
        loader: "swc-loader",
      },
    },
  ];
}

持久化缓存: cache

webpack5 内置了关于缓存的插件,可通过 cache 字段配置开启。

它将 ModuleChunkModuleChunk 等信息序列化到磁盘中,二次构建避免重复编译计算,编译速度得到很大提升。

{
  cache: {
    type: "filesystem";
  }
}

如对一个 JS 文件配置了 eslinttypescriptbabelloader,他将有可能执行五次编译,被五次解析为 AST

  1. acorn: 用以依赖分析,解析为 acorn 的 AST
  2. eslint-parser: 用以 lint,解析为 espree 的 AST
  3. typescript: 用以 ts,解析为 typescript 的 AST
  4. babel: 用以转化为低版本,解析为 @babel/parser 的 AST
  5. terser: 用以压缩混淆,解析为 acorn 的 AST

而当开启了持久化缓存功能,最耗时的 AST 解析将能够从磁盘的缓存中获取,再次编译时无需再次进行解析 AST。

得益于持久化缓存,二次编译甚至可得到与 Unbundle 的 vite 等相近的开发体验

在 webpack4 中,可使用 cache-loader 仅仅对 loader 进行缓存。需要注意的是该 loader 目前已是 @depcrated 状态。

module.exports = {
  module: {
    rules: [
      {
        test: /\.ext$/,
        use: ["cache-loader", ...loaders],
        include: path.resolve("src"),
      },
    ],
  },
};

多进程: thread-loader

thread-loader为官方推荐的开启多进程的 loader,可对 babel 解析 AST 时开启多线程处理,提升编译的性能。

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: "thread-loader",
            options: {
              workers: 8,
            },
          },
          "babel-loader",
        ],
      },
    ],
  },
};

第二章 如何分析前端打包体积

如何分析前端打包体积

在 webpack 中,可以使用 webpack-bundle-analyzer分析打包后体积分析。

其原理是根据 webpack 打包后的 Stats数据进行分析。

在默认配置下,webpack-bundle-analyzer 将会启动服务打开一个各个 chunk 下各个 module 占用体积的可视化图。

你可以通过它,找到在在打包中占用体积最大的模块,并进行优化。

在查看页面中,有三个体积选项:

  1. stat: 每个模块的原始体积
  2. parsed: 每个模块经 webpack 打包处理之后的体积,比如 terser 等做了压缩,便会体现在上边
  3. gzip: 经 gzip 压缩后的体积

JavaScript 压缩

通过 AST 分析,根据选项配置一些策略,来生成一颗更小体积的 AST 并生成代码。

目前前端工程化中使用 terserswc进行 JS 代码压缩,他们拥有相同的 API。

常见用以压缩 AST 的几种方案如下:

去除多余字符: 空格,换行及注释

// 对两个数求和
function sum (a, b) {
  return a + b;
}

此时文件大小是 62 Byte一般来说中文会占用更大的空间。

多余的空白字符会占用大量的体积,如空格,换行符,另外注释也会占用文件体积。当我们把所有的空白符合注释都去掉之后,代码体积会得到减少。

去掉多余字符之后,文件大小已经变为 30 Byte 压缩后代码如下:

function sum(a,b){return a+b}

替换掉多余字符后会有什么问题产生呢?

有,比如多行代码压缩到一行时要注意行尾分号。

压缩变量名:变量名,函数名及属性名

function sum (first, second) {
  return first + second;  
}

如以上 firstsecond 在函数的作用域中,在作用域外不会引用它,此时可以让它们的变量名称更短。但是如果这是一个 module 中,sum 这个函数也不会被导出呢?那可以把这个函数名也缩短。

// 压缩: 缩短变量名
function sum (x, y) {
  return x + y;  
}

// 再压缩: 去除空余字符
function s(x,y){return x+y}

在这个示例中,当完成代码压缩 (compress) 时,代码的混淆 (mangle) 也捎带完成。 但此时缩短变量的命名也需要 AST 支持,不至于在作用域中造成命名冲突。

解析程序逻辑:合并声明以及布尔值简化

通过分析代码逻辑,可对代码改写为更精简的形式。

合并声明的示例如下:

// 压缩前
const a = 3;
const b = 4;

// 压缩后
const a = 3, b = 4;

布尔值简化的示例如下:

// 压缩前
!b && !c && !d && !e

// 压缩后
!(b||c||d||e)

解析程序逻辑: 编译预计算

在编译期进行计算,减少运行时的计算量,如下示例:

// 压缩前
const ONE_YEAR = 365 * 24 * 60 * 60

// 压缩后
const ONE_YAAR = 31536000

以及一个更复杂的例子,简直是杀手锏级别的优化。

// 压缩前
function hello () {
  console.log('hello, world')
}

hello()

// 压缩后
console.log('hello, world')

Tree Shaking

Tree Shaking 基于 ES Module 进行静态分析,通过 AST 将用不到的函数进行移除,从而减小打包体积。

/* TREE-SHAKING */
import { sum } from "./maths.js";

console.log(sum(5, 5)); // 10
// maths.js

export function sum(x, y) {
  return x + y;
}

// 由于 sub 函数没有引用到,最终将不会对它进行打包
export function sub(x, y) {
  return x - y;
}

最终打包过程中,sub 没有被引用到,将不会对它进行打包。以下为打包后代码。

// maths.js

function sum(x, y) {
  return x + y;
}

/* TREE-SHAKING */

console.log(sum(5, 5));

import *

当使用语法 import * 时,Tree Shaking 依然生效。

import * as maths from "./maths";

// Tree Shaking 依然生效
maths.sum(3, 4);
maths["sum"](3, 4);

import * as maths,其中 maths 的数据结构是固定的,无复杂数据,通过 AST 分析可查知其引用关系。

const maths = {
  sum() {},
  sub() {},
};

JSON TreeShaking

Tree Shaking 甚至可对 JSON 进行优化。原理是因为 JSON 格式简单,通过 AST 容易预测结果,不像 JS 对象有复杂的类型与副作用。

{
  "a": 3,
  "b": 4
}
import obj from "./main.json";

// obj.b 由于未使用到,仍旧不会被打包
console.log(obj.a);

引入支持 Tree Shaking 的 Package

为了减小生产环境体积,我们可以使用一些支持 ES 的 package,比如使用 lodash-es 替代 lodash

我们可以在 npm.devtool.tech中查看某个库是否支持 Tree Shaking。

browserslist 的意义

https://github.com/browserslist/browserslist

关于前端打包体积与垫片关系,我们有以下几点共识:

  1. 由于低浏览器版本的存在,垫片是必不可少的
  2. 垫片越少,则打包体积越小
  3. 浏览器版本越新,则垫片越少

那在前端工程化实践中,当我们确认了浏览器版本号,那么它的垫片体积就会确认。

1
https://gitee.com/Jay_Ohhh/front-end-notes.git
git@gitee.com:Jay_Ohhh/front-end-notes.git
Jay_Ohhh
front-end-notes
front-end notes
master

搜索帮助