浅谈前端工程化-面试常客

1,549 阅读15分钟

1.前端工程化概述

我们可以使用HTML、CSS、JavaScript编写网站,为什么需要工程化?

大型项目的几个痛点

  1. 需要区分各种代码环境,如开发环境、生产环境等等
  2. 对于图片、CSS、字体、第三方库等资源需要规范的管理
  3. 代码中使用了JS新特性或者CSS新特性但是需要向下兼容老设备或者其他浏览器
  4. 使用其他语言如TS、JSX,使用预处理器增强CSS等
  5. 发布时对于未使用的代码,将其剔除(Tree-shaking),并对最终的代码进行代码压缩(Tensor)

如果是在普通的项目中,对于上面这些特性需要配置复杂的环境,且难以做到统一标准,维护代码相当麻烦

于是出现了诸如Webpack、Vite一类的工程化工具。

本文不讨论实际的配置和代码,主要讲述工程化中的一些概念,帮助更好理解前端工程化。

2.模块化

自 2009 年 Node.js 诞生,前端先后出现了 CommonJSAMDCMDUMDES Module 等模块规范。

无模块化标准阶段

早在模块化标准还没有诞生的时候,前端界已经产生了一些模块化的开发手段,如文件划分命名空间IIFE 私有作用域

CommonJS

CommonJS 是业界最早正式提出的 JavaScript 模块规范,主要用于服务端,随着 Node.js 越来越普及,这个规范也被业界广泛应用。对于模块规范而言,一般会包含 2 方面内容:

  • 统一的模块化代码规范

  • 实现自动加载模块的加载器(也称loader)

代码中使用 require 来导入一个模块,用module.exports来导出一个模块。实际上 Node.js 内部会有相应的 loader 转译模块代码,最后模块代码会被处理成下面这样:

(function (exports, require, module, __filename, __dirname) {
  // 执行模块代码
  // 返回 exports 对象
});

AMD

AMD全称为Asynchronous Module Definition,即异步模块定义规范。模块根据这个规范,在浏览器环境中会被异步加载,而不会像 CommonJS 规范进行同步加载,也就不会产生同步请求导致的浏览器解析过程阻塞的问题了。我们先来看看这个模块规范是如何来使用的:

// main.js
define(["./print"], function (printModule) {
  printModule.print("main");
});

// print.js
define(function () {
  return {
    print: function (msg) {
      console.log("print " + msg);
    },
  };
});

由于没有得到浏览器的原生支持,AMD 规范需要由第三方的 loader 来实现,最经典的就是 requireJS 库了,它完整实现了 AMD 规范,至今仍然有不少项目在使用。

ES Module

ES6 Module 也被称作 ES Module(或 ESM), 是由 ECMAScript 官方提出的模块化规范,作为一个官方提出的规范,ES Module 已经得到了现代浏览器的内置支持。在现代浏览器中,如果在 HTML 中加入含有type="module"属性的 script 标签,那么浏览器会按照 ES Module 规范来进行依赖加载和模块解析,这也是 Vite 在开发阶段实现 no-bundle 的原因,由于模块加载的任务交给了浏览器,即使不打包也可以顺利运行模块代码。

几种模块化规范的对比

运行环境加载方式运行机制特点
服务器同步运行时第一次加载后会将结果缓存,再次加载会读取缓存的结构。CommonJS
浏览器异步运行时依赖前置,不管模块是否有用到,都会全量加载。AMD
浏览器异步运行时依赖就近,延迟加载CMD
浏览器/服务端异步编译时静态化,在编译时就确定模块之间的依赖关系,输入和输出。ESM

为什么Tree Shaking只能在ESM上使用

不同于其他几种标准,ESM加载模块是静态的,在编译时就确定了模块于模块的关系,在使用一些打包器如Webpack时,他会分析依赖图从而删除一些未使用的模块和代码,保证打包结果的轻量。像CommonJS这类,是动态加载的,无法确定依赖图,从而无法进行Tree Shaking。

3.Webpack

3.1 Webpack是什么

Webpack 是一种用于构建 JavaScript 应用程序的静态模块打包器,它能够以一种相对一致且开放的处理方式,加载应用中的所有资源文件(图片、CSS、视频、字体文件等),并将其合并打包成浏览器兼容的 Web 资源文件。

它会在内部从一个或多个入口点构建一个 依赖图(dependency graph),然后将你项目中所需的每一个模块组合成一个或多个 bundles,它们均为静态资源,用于展示你的内容。

image.png

3.2 Webpack中的一些概念

entry

入口起点(entry point) 指示 webpack 应该使用哪个模块,来作为构建其内部 依赖图(dependency graph) 的开始。进入入口起点后,webpack 会找出有哪些模块和库是入口起点(直接和间接)依赖的。

output

output 属性告诉 webpack 在哪里输出它所创建的 bundle,以及如何命名这些文件。主要输出文件的默认值是 ./dist/main.js,其他生成文件默认放置在 ./dist 文件夹中。

dependency graph

每当一个文件依赖另一个文件时,webpack 都会将文件视为直接存在 依赖关系。这使得 webpack 可以获取非代码资源,如 images 或 web 字体等。并会把它们作为 依赖 提供给应用程序。

当 webpack 处理应用程序时,它会根据命令行参数中或配置文件中定义的模块列表开始处理。 从 入口 开始,webpack 会递归的构建一个 依赖关系图,这个依赖图包含着应用程序中所需的每个模块,然后将所有模块打包为少量的 bundle —— 通常只有一个 —— 可由浏览器加载。

Chunk

Webpack在生成依赖图后,遍历Entry对象,创建中间产物Chunk,并根据Chunk生成最终的bundle

bundle

bundle 是 Webpack 打包后的最终输出文件。它包含了经过处理和优化的项目代码、依赖库以及各种资源。

sourcemap

Sourcemap 协议 最初由 Google 设计并率先在 Closure Inspector 实现,它的主要作用就是将经过压缩、混淆、合并的产物代码还原回未打包的原始形态,帮助开发者在生产环境中精确定位问题发生的行列位置

loader

webpack 只能理解 JavaScript 和 JSON 文件,这是 webpack 开箱可用的自带能力。loader 让 webpack 能够去处理其他类型的文件,并将它们转换为有效 模块,以供应用程序使用,以及被添加到依赖图中。

plugin

Plugin 是用于扩展 Webpack 功能的插件,可以在 Webpack 构建过程的不同阶段执行各种任务。Plugin 可以实现更复杂的功能,如代码压缩、优化、生成 HTML 文件、清理输出目录等。

Mode

通过选择 development, productionnone 之中的一个,来设置 mode 参数,你可以启用 webpack 内置在相应环境下的优化。其默认值为 production

3.3 Webpack的开发时和打包时

开发时

使用Webpack开发web应用时,启动开发服务器需要递归打包整个依赖树,然后将打包结果缓存到本地,供下次修改代码后快速更新,又因为Webpack使用JavaScript编写性能较弱,所以Webpack开发服务器的启动速度很慢。

打包时

Webpack的流程一般是,入口分析识别模块引用,遍历模块生成依赖图,根据遍历的结果使用相应的loader进行构建处理,同时也会在不同阶段运行plugin处理中间产物,最后生成产物。

3.4 Webpack的优化手段

持久化缓存

webpack5引入了持久化缓存特性,将首次构建的过程与结果数据持久化保存到本地文件系统,在下次执行构建时跳过解析、链接、编译等一系列非常消耗性能的操作,直接复用上次的 Module/ModuleGraph/Chunk 对象数据,迅速构建出最终产物。

动态加载

动态加载是 Webpack 内置能力之一,我们不需要做任何额外配置就可以通过动态导入语句(importrequire.ensure)轻易实现。但请 注意,这一特性有时候反而会带来一些新的性能问题:一是过度使用会使产物变得过度细碎,产物文件过多,运行时 HTTP 通讯次数也会变多,在 HTTP 1.x 环境下这可能反而会降低网络性能,得不偿失;二是使用时 Webpack 需要在客户端注入一大段用于支持动态加载特性的 Runtime

HTTP 缓存优化

通过给生成产物文件名加上一段由产物内容生成的Hash值,对所有产物设置强缓存,当内容更新时,文件名的Hash值改变,引起服务器重新加载新的产物文件。

使用外置依赖

使用 Webpack 的 externals 特性将部分模块排除在 Webpack 打包系统之外,然后可以使用CDN的方式引入,Webpack 会 预设 运行环境中已经内置这些库。

使用Tree-shaking删除多余模块导出

Tree-Shaking 较早前由 Rich Harris 在 Rollup 中率先实现,Webpack 自 2.0 版本开始接入,是一种基于 ES Module 规范的 Dead Code Elimination 技术,它会在运行过程中静态分析模块之间的导入导出,判断哪些模块导出值没有被其它模块使用 —— 相当于模块层面的 Dead Code,并将其删除。

4.Vite

4.1 Vite是什么

Vite和Webpack类似,都是一种打包器,Vite一般可用于Web应用的开发,得益于其中内置的ESBuild和Rollup,Vite在开发和打包时的体验都相当好。值得注意的是ESBuild使用GO语言编写因此拥有非常快的速度,而Rollup使用JavaScript编写的。

image.png

4.2 Vite 中的一些概念

4.2.1 开发时

no-bundle

no-bundle是vite重要的概念,vite的开发服务器借助浏览器支持ESM的性质,对于项目的源码不进行打包(no-bundle),只对第三方库进行打包(兼容ESM格式和解决递归依赖加载问题)。

可以说no-bundle是vite启动快的重要原因。

预构建

image.png

在启动开发服务器之前会先进行预构建,会将第三方依赖打包并转换为ESM(主要是解决ESM格式兼容性问题和Vite开发服务器按需加载导致的海量请求问题,如lodash-es库)

单文件编译

打包第三方依赖完成后,会对项目的源码进行单文件编译,如Vue、TS、JSX文件,都会被编译为ESM,在使用ESBuild以前,这种事情一般都需要使用Babel、TSC等工具,而使用ESBuild Transformer可以做到非常快速编译文件

ESBuild Transformer无法做到TS的类型检查,因此类型检查更多的需要依赖编辑器的提示。

4.2.2 打包时

vite打包使用了rollup进行打包,rollup轻量的特性比较适合vite集成用来打包。

其他性质类似Webpack。

5.Pollyfill

在前端领域中,用来为旧浏览器提供其没有的最新原生支持的代码片段,我们将其称之为“polyfill” ,翻译过来就是“垫片”,也就是打补丁的意思

5.1 手动补丁

最方便的解决兼容性问题,首先想到的应该是手动写一个转换函数,将新语法转换为旧语法实现降级。比如 Object.assign()

其优点是,直接简单,并且天然的支持“按需”使用,不会有其他冗余代码,在性能上比较友好,但是缺点就是这不是一种工程化的解决方案,不易管理和维护,且复用性低。

5.2 根据覆盖率自动打补丁

@babel/preset-env 会根据目标环境来进行编译和打补丁,如果想在最近 3个 浏览器版本和 安卓4.4 版本以及 iOS 9.0 以上版本运行我们的代码,那么我们可以这样配置 babel

...
   presets: [
     [
       '@babel/preset-env',
       {
         targets: {
           "browsers": [
             "last 3 versions",
             "Android >= 4.4",
             "iOS >= 9.0"
           ],
         }
       },
     ],
   ]
...

5.3 在线动态补丁

如果该浏览器支持该特性的话,那么针对该特性的 polyfill 就不需要引入。那么如何减少这种冗余呢?在线动态打补丁就是一个方案。

polyfill.io/v3/ 就是实现该方案的服务,其提供 CDN 资源,会根据浏览器的 UA 不同,返回不同的内容。

不过现在这个网站已经失效了,据说是中国公司收购后放了恶意代码,这种CDN服务确实容易存在这类问题。

6.babel

6.1 babel是什么

Babel 是一个 JavaScript 的编译器。

Babel 的主要功能有:

  • 语法转换:高级语言特性的降级
  • polyfill:上一节我们介绍过的打补丁
  • 源码转换:我们可以将 jsx、vue 代码转换为浏览器可识别的 JS 代码。

6.2 babel设计思想

Babel 的架构模式是“插件架构模式” 。插件架构模式的特点就是将扩展功能从核心模块中抽出为插件

6.3 babel转译过程

Babel 的转译过程主要可以分为三个步骤,解析(parse)、转换(transform)、生成(generate)

分别使用到@babel/parser@babel/traverse@babel/generator

7.pnpm和monorepo

当一个仓库规模逐渐升级并拆分为多个模块时,可以使用Monorepo的方式管理仓库。些模块通常在同一仓库中依赖其他不同模块, 同时不同模块间还会互相依赖。

项目依赖

monorepo有pnpm和lerna两种解决方案,本文主要讲pnpm,我们先了解pnpm。

7.1 pnpm

pnpm又称 performant npm,翻译过来就是高性能的npm。

pnpm通过使用硬链接符号链接(又称软链接)的方式来避免重复安装以及提高安装效率。

  • 硬链接:和原文件共用一个磁盘地址,相当于别名的作用,如果更改其中一个内容,另一个也会跟着改变
  • 符号链接(软链接):是一个新的文件,指向原文件路径地址,类似于快捷方式

7.1.1 安装依赖

下载流程

  1. 解析依赖树,生成pnpm-lock.yml,如果本地没有的依赖会进行下载
  2. 下载压缩包,这个阶段pnpm会开启并行下载
  3. 解压压缩包
  4. 通过硬连接的方式创建依赖目录,节省了大量存储空间,采用虚拟存储目录+软连接解决幽灵依赖

7.1.2 硬链接

硬链接: 电脑文件系统中的多个文件平等的共享同一个文件存储单元。

假如磁盘中有一个名为 data 的数据,C盘中的一个名为 hardlink1 的文件硬链接到磁盘 data 数据,另一个名为 hardlink2 的文件也硬链接到磁盘 data 数据,此时如果通过 hardlink1 文件改变磁盘 data 的数据内容,则通过 hardlink2 访问磁盘 data 数据内容是改变过后的内容。

硬链接可以有多条,它们可以指向同一块磁盘空间。

7.1.3 软链接

软链接(符号连接): 包含一条以绝对路径或相对路径的形式指向其他文件或者目录的引用。

最常见的就是桌面的快捷方式,其本质就是一个软链接,软链接所产生的文件是无法更改的,它只是存储了目标文件的路径,并根据该路径去访问对应的文件。

7.1.4 虚拟目录

pnpm 使用一个统一的内容可寻址存储目录来存放所有下载的包。这个存储目录通常位于用户主目录下的.pnpm-store文件夹。

工作原理

  • 当安装一个包时,pnpm 会将包的内容存储在这个全局存储目录中。每个包的文件都以哈希值命名,确保唯一性。
  • 对于不同项目中相同版本的包,pnpm 只会在存储目录中保存一份副本,然后通过硬链接或软链接的方式将其链接到各个项目的node_modules目录中。

7.1.5 幽灵依赖

幽灵依赖指的是那些没有在项目的package.json文件中明确声明,但却可以在项目代码中被引入和使用的依赖。

项目安装了包 A,而包 A 又依赖包 B。如果包 B 没有在项目的package.json中声明,那么包 B 就是幽灵依赖,B可以被使用。

7.2 monorepo(单仓多模块)

monorepo模式是让一个项目中的不同组件和依赖,以独立模块的方式存储在单个代码仓库中,模块与模块之前可能有依赖关系。

monilith、multirepo、monorepo的区别

img

7.3 在pnpm中使用monorepo

在pnpm 中使用 workspace(工作空间)以支持monorepo

需要在代码仓库根目录创建pnpm-workspace.yaml,指定哪些目录作为独立的工作空间,这个工作空间可以理解为一个子模块或者 npm 包。

例如,就表示以a文件夹为一个包,b文件夹为一个包,c文件夹下所有文件夹为都为包

packages:
  - a
  - b
  - c/*
📦my-project
 ┣ 📂a
 ┃ ┗ 📜package.json
 ┣ 📂b
 ┃ ┗ 📜package.json
 ┣ 📂c
 ┃ ┣ 📂c-1
 ┃ ┃ ┗ 📜package.json
 ┃ ┣ 📂c-2
 ┃ ┃ ┗ 📜package.json
 ┃ ┗ 📂c-3
 ┃   ┗ 📜package.json
 ┣ 📜package.json
 ┣ 📜pnpm-workspace.yaml

pnpm不是通过目录名称,而是通过目录下 package.json 文件的 name 字段来识别仓库内的包与模块的。

7.4 monorepo之间如何相互引用

如果a包需要使用b包中的一些函数,可以在package.json中配置依赖

{
  "name": "a",
  // ...
  "dependencies": {
    "b": "workspace:*"
  }
}

参考文章

  • pnpm官方文档

  • Webpack中文文档

  • Vite中文文档

  • 掘金小册《Webpack5 核心原理与应用实践》

  • 掘金小册《深入浅出 Vite》