前端构建工具进化历程

4,420 阅读7分钟

作者:buzhanhua

序言

现在前端项目的开发过程离不开构建工具帮助,面对琳琅满目的构建工具我们该如何选择最合适自己场景的构建工具是一个问题。在研究各种配置之余,我们去研究一下构建工具发展过程、底层原理,面对一些问题的时候往往事半功倍。

通过本文你可以了解到:

  1. 前端构建工具的进化历程
  2. 前端构建工具技术方案对比
  3. 常用构建工具核心实现原理

什么是构建?

构建简单的说就是将我们开发环境的代码,转化成生产环境可用来部署的代码。

市面上存在很多构建工具,但是最终的目标都是转化开发环境的代码为生产环境中可用的代码。在不同的前端项目中使用的技术栈是不一样的,比如:不同的框架、不同的样式处理方案等,为了生产出生产环境可用的 JS、CSS,构建工具实现了诸如:代码转换、代码压缩、tree shaking、code spliting 等。

前端构建工具可以做什么?

前端构建工具进化史

无模块化时代

YUI Tool + Ant

YUI Tool 是 07 年左右出现的一个构建工具,可以实现压缩混淆前端代码,依赖于 javaant 使用。

在早期 web 应用开发主要采用 JSP,处于混合开发的状态,不像是现在的前后端分离开发。通常由 java 开发人员来编写 js、css 代码。此时出现的构建工具依赖于别的语言实现。

JS 内联外联

前端代码是否必须通过构建才可以在浏览器中运行呢?当然不是。如下:

<html>
  <head>
    <title>Hello World</title>
  </head>
  <body>
    <div id="root"/>
    <script type="text/javascript">
      document.getElementById('root').innerText = 'Hello World'
    </script>
  </body>
</html>

上述代码,我们只需要按格式写几个 HTML 标签,插入简单的 JS 脚本,打开浏览器,一个 Hello World 的前端页面就呈现在我们面前了。但是当项目进入真正的实战开发,代码规模开始急速扩张后,大量逻辑混杂在一个文件之中就变得难以维护起来。早期的前端项目一般如下组织:

<html>
  <head>
    <title>JQuery</title>
  </head>
  <body>
    <div id="root"/>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <script type="text/javascript">
      $(document).ready(function(){
        $('#root')[0].innerText = 'Hello World'
      })
    </script>
  </body>
</html>

通过 JS 的内联外联组织代码,将不同的代码放在不同的文件中,但是这也仅仅解决了代码组织混乱的问题,还存在很多问题:

  • 大量的全局变量,代码之间的依赖是不透明的,任何代码都可能悄悄的改变了全局变量。
  • 脚本的引入需要依赖特定的顺序。

后续出现过一些 IIFE、命名空间等解决方案,但是从本质上都没有解决依赖全局变量通信的问题。在这样的背景下,前端工程化成为解决此类问题的正轨。

社区模块化时代

AMD/CMD - 异步模块加载

为了解决浏览器端 JS 模块化的问题,出现了通过引入相关工具库的方式来解决这一问题。出现了两种应用比较广的规范及其相关库:AMD(RequireJs) 和 CMD(Sea.js)。AMD 推崇依赖前置、提前执行,CMD 推崇依赖就近、延迟执行。下面领略下相关写法:

Require.js

// 加载完jquery后,将执行结果 $ 作为参数传入了回调函数
define(["jquery"], function ($) {
    $(document).ready(function(){
        $('#root')[0].innerText = 'Hello World';
    })
    return $
})

Sea.js

// 预加载jquery
define(function(require, exports, module) {
    // 执行jquery模块,并得到结果赋值给 $
    var $ = require('jquery');
    // 调用jquery.js模块提供的方法
    $('#header').hide();
});

两种模块化规范实现的原理基本上是一致的,只不过各自坚持的理念不同。两者都是以异步的方式获取当前模块所需的模块,不同的地方在于 AMD 在获取到相关模块后立即执行,CMD 则是在用到相关模块的位置再执行的。

AMD/CMD 解决问题:

  1. 手动维护代码引用顺序。从此不再需要手动调整 HTML 文件中的脚本顺序,依赖数组会自动侦测模块间的依赖关系,并自动化的插入页面。
  2. 全局变量污染问题。将模块内容在函数内实现,利用闭包导出的变量通信,不会存在全局变量污染的问题。

Grunt/Gulp

在 Google Chrome 推出 V8 引擎后,基于其高性能和平台独立的特性,Nodejs 这个 JS 运行时也现世了。至此,JS 打破了浏览器的限制,拥有了文件读写的能力。Nodejs 不仅在服务器领域占据一席之地,也将前端工程化带进了正轨。

在这个背景下,第一批基于 Node.js 的构建工具出现了。

Grunt

Grunt 主要能够帮助我们自动化的处理一些反复重复的任务,例如压缩、编译、单元测试、linting 等。

 // Gruntfile.js
module.exports = function(grunt) {
   // 功能配置
  grunt.initConfig({
    // 定义任务
    jshint: {
      files: ['Gruntfile.js', 'src/**/*.js', 'test/**/*.js'],
      options: {
        globals: {
          jQuery: true
        }
      }
    },
    // 时时侦听文件变化所执行的任务
    watch: {
      files: ['<%= jshint.files %>'],
      tasks: ['jshint']
    }
  });
  // 加载任务所需要的插件
  grunt.loadNpmTasks('grunt-contrib-jshint');
  grunt.loadNpmTasks('grunt-contrib-watch');
  // 默认执行的任务
  grunt.registerTask('default', ['jshint']);

};

Gulp

Grunt 的 I/O 操作比较“呆板”,每个任务执行结束后都会将文件写入磁盘,下个任务执行时再将文件从磁盘中读出,这样的操作会产生一些问题:

  1. 运行速度较慢
  2. 硬件压力大

Gulp 最大特点是引入了流的概念,同时提供了一系列常用的插件去处理流,流可以在插件之间传递。同时 Gulp 设计简单,既可以单独使用,也可以结合别的工具一起使用。

// gulpfile.js
const { src, dest } = require('gulp');
// gulp提供的一系列api
// src 读取文件
// dest 写入文件
const babel = require('gulp-babel');

exports.default = function() {
  // 将src文件夹下的所有js文件取出,经过Babel转换后放入output文件夹之中
  return src('src/*.js')
    .pipe(babel())
    .pipe(dest('output/'));
}

Browserify

随着 Node.js 的兴起,CommonJS 模块化规范成为了当时的主流规范。但是我们知道 CommonJS 所使用的 require 语法是同步的,当代码执行到 require 方法的时候,必须要等这个模块加载完后,才会执行后面的代码。这种方式在服务端是可行的,这是因为服务器只需要从本地磁盘中读取文件,速度还是很快的,但是在浏览器端,我们通过网络请求获取文件,网络环境以及文件大小都可能使页面无响应。

browserify 致力于打包产出在浏览器端可以运行的 CommonJS 规范的 JS 代码。

var browserify = require('browserify')
var b = browserify()
var fs = require('fs')

// 添加入口文件
b.add('./src/browserifyIndex.js')
// 打包所有模块至一个文件之中并输出bundle
b.bundle().pipe(fs.createWriteStream('./output/bundle.js'))

browserify 怎么实现的呢?

browserify 在运行时会通过进行 AST 语法树分析,确定各个模块之间的依赖关系,生成一个依赖字典。之后包装每个模块,传入依赖字典以及自己实现的 export 和 require 函数,最终生成一个可以在浏览器环境中执行的 JS 文件。

browserify 专注于 JS 打包,功能单一,一般配合 Gulp 一起使用。

ESM 规范出现

在 2015 年 JavaScript 官方的模块化终于出现了,但是官方只阐述如何实现该规范,浏览器少有支持。

Webpack

其实在 ESM 标准出现之前, webpack已经诞生了,只是没有火起来。webpack 的理念更偏向于工程化,伴随着 MVC 框架以及 ESM 的出现与兴起,webpack2 顺势发布,宣布支持 AMD\CommonJS\ESM、css/less/sass/stylus、babel、TypeScript、JSX、Angular 2 组件和 vue 组件。从来没有一个如此大而全的工具支持如此多的功能,几乎能够解决目前所有构建相关的问题。至此 webpack 真正成为了前端工程化的核心。

webpack 是基于配置。

module.exports = {
    // SPA入口文件
    entry: 'src/js/index.js',
    // 出口
    output: {
      filename: 'bundle.js'
    }
    // 模块匹配和处理 大部分都是做编译处理
    module: {
        rules: [
                    // babel转换语法
            { test: /.js$/, use: 'babel-loader' },
            //...
        ]
    },
    // 插件
    plugins: [
        // 根据模版创建html文件
        new HtmlWebpackPlugin({ template: './src/index.html' }),
    ],
}

webpack 要兼顾各种方案的支持,也暴露出其缺点:

  • 配置往往非常繁琐,开发人员心智负担大。
  • webpack 为了支持 cjs 和 esm,自己做了 polyfill,导致产物代码很“丑”。

在 webpack 出现两年后,rollup 诞生了~

Rollup

rollup 是一款面向未来的构建工具,完全基于 ESM 模块规范进行打包,率先提出了 Tree-Shaking 的概念。并且配置简单,易于上手,成为了目前最流行的 JS 库打包工具。

import resolve from 'rollup-plugin-node-resolve';
import babel from 'rollup-plugin-babel';

export default {
  // 入口文件
  input: 'src/main.js',
  output: {
    file: 'bundle.js',
    // 输出模块规范
    format: 'esm'
  },
  plugins: [
    // 转换commonjs模块为ESM
    resolve(),
    // babel转换语法
    babel({
      exclude: 'node_modules/**'
    })
  ]
}

rollup 基于 esm,实现了强大的 Tree-Shaking 功能,使得构建产物足够的简洁、体积足够的小。但是要考虑浏览器的兼容性问题的话,往往需要配合额外的 polyfill 库,或者结合 webpack 使用。

ESM 规范原生支持

Esbuild

在实际开发过程中,随着项目规模逐渐庞大,前端工程的启动和打包的时间也不断上升,一些工程动辄几分钟甚至十几分钟,漫长的等待,真的让人绝望。这使得打包工具的性能被越来越多的人关注。

esbuild是一个非常新的模块打包工具,它提供了类似 webpack 资源打包的能力,但是拥有着超高的性能。

esbuild 支持 ES6/CommonJS 规范、Tree Shaking、TypeScript、JSX 等功能特性。提供了 JS API/Go API/CLI 多种调用方式。

// JS API调用
require('esbuild').build({
    entryPoints: ['app.jsx'],
    bundle: true,
    outfile: 'out.js',
 }).catch(() => process.exit(1))

根据官方提供的性能对比,我们可以看到性能足有百倍的提升,为什么会这么快?

语言优势

  • esBuild 是选择 Go 语言编写的,而在 esBuild 之前,前端构建工具都是基于 Node,使用 JS 进行编写。JavaScript 是一门解释性脚本语言,即使 V8 引擎做了大量优化(JWT 及时编译),本质上还是无法打破性能的瓶颈。而 Go 是一种编译型语言,在编译阶段就已经将源码转译为机器码,启动时只需要直接执行这些机器码即可。
  • Go 天生具有多线程运行能力,而 JavaScript 本质上是一门单线程语言。esBuild 经过精心的设计,将代码 parse、代码生成等过程实现完全并行处理。

性能至上原则

  • esBuild 只提供现代 Web 应用最小的功能集合,所以其架构复杂度相对较小,更容易将性能做到极致
  • 在 webpack、rollup 这类工具中, 我们习惯于使用多种第三方工作来增强工程能力。比如:babel、eslint、less 等。在代码经过多个工具流转的过程中,存在着很多性能上的浪费,比如:多次进行代码 -> AST、AST -> 代码的转换。esBuild 对此类工具完全进行了定制化重写,舍弃部分可维护性,追求极致的编译性能。

虽然 esBuild 性能非常高,但是其提供的功能很基础,不适合直接用到生产环境,更适合作为底层的模块构建工具,在它基础上进行二次封装。

Vite

vite 是下一代前端开发与构建工具,提供 noBundle 的开发服务,并内置丰富的功能,无需复杂配置。

vite 在开发环境和生产环境分别做了不同的处理,在开发环境中底层基于 esBuild 进行提速,在生产环境中使用 rollup 进行打包。

为什么 vite 开发服务这么快?

传统 bundle based 服务:

  • 无论是 webpack 还是 rollup 提供给开发者使用的服务,都是基于构建结果的。
  • 基于构建结果提供服务,意味着提供服务前一定要构建结束,随着项目膨胀,等待时间也会逐渐变长。

noBundle 服务:

  • 对于 vite、snowpack 这类工具,提供的都是 noBundle 服务,无需等待构建,直接提供服务。
  • 对于项目中的第三方依赖,仅在初次启动和依赖变化时重构建,会执行一个依赖预构建的过程。由于是基于 esBuild 做的构建,所以非常快。
  • 对于项目代码,则会依赖于浏览器的 ESM 的支持,直接按需访问,不必全量构建。

为什么在生产环境中构建使用 rollup?

  • 由于浏览器的兼容性问题以及实际网络中使用 ESM 可能会造成 RTT 时间过长,所以仍然需要打包构建。
  • esbuild 虽然快,但是它还没有发布 1.0 稳定版本,另外 esbuild 对代码分割和 css 处理等支持较弱,所以生产环境仍然使用 rollup。

目前 vite 发布了 3.0 版本,相对于 2.0,修复了 400+issue,已经比较稳定,可以用于生产了。Vite 决定每年发布一个新的版本。

vite.config.js:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import {resolve} from 'path';
// defineConfig 这个方法没有什么实际的含义, 主要是可以提供语法提示
export default defineConfig({
  resolve:{
    alias:{
     '@':resolve('src')
    }
  },
  plugins: [vue()]
})

技术方案对比

前端构建工具实在是琳琅满目,以工程化的视角对社区仍然比较流行的构建工具进行简单对比,一些功能专一、特定场景下的工具不在考虑范围内~。

2021 前端构建工具排行

一些值得思考的问题

为什么 webpack 构建产物看着很丑?

我们在使用 webpack 构建项目后,会发现打包出来的代码非常的“丑”,这是为什么?原因就是:webpack 支持多种模块规范,但是最后都会变成commonJS规范(webpack5 对纯 esm 做了一定的优化),但是浏览器不支持commonJS规范,于是 webpack 自己实现了requiremodule.exports,所以会有很多 polyfill 代码的注入。

针对·common.js 加载 common.js 这种情况分析一下构建产物。

源代码:

// src/index.js
let title = require('./title.js')
console.log(title);

// src/title.js
module.exports = 'bu';

产物代码:

 (() => {
    //把所有模块定义全部存放到modules对象里
    //属性名是模块的ID,也就是相对于根目录的相对路径,包括文件扩展名
    //值是此模块的定义函数,函数体就是原来的模块内的代码
   var modules = ({
     "./src/title.js": ((module) => {
       module.exports = 'bu';
     })
   });
   // 缓存对象
   var cache = {};

   // webpack 打包后的代码能够运行, 是因为webpack根据commonJS规范实现了一个require方法
   function require(moduleId) {
     var cachedModule = cache[moduleId];
     if (cachedModule !== undefined) {
       return cachedModule.exports;
     }
     // 缓存和创建模块对象
     var module = cache[moduleId] = {
       exports: {}
     };
     // 运行模块代码
     modules[moduleId](module, module.exports, require);
     return module.exports;
   }
   var exports = {};
   (() => {
     // 入口相关的代码
     let title = require("./src/title.js")
     console.log(title);
   })();
 })();

webpack 按需加载的模块怎么在浏览器中运行?

在实际项目开发中,随着代码越写越多,构建后的 bundle 文件也会越来越大,我们往往按照种种策略对代码进行按需加载,将某部分代码在用户事件触发后再进行加载,那么 webpack 在运行时是怎么实现的呢?

其实原理很简单,就是以 JSONP 的方式加载按需的脚本,但是如何将这些异步模块使用起来就比较有意思了~

对一个简单的 case 进行分析。

源代码:

// index.js
import("./hello").then((result) => {
    console.log(result.default);
});

// hello.js
export default 'hello';

产物代码:

main.js

 // PS: 对代码做了部分简化及优化, 否则太难读了~~~
 // 定一个模块对象
var modules = ({});
// webpack在浏览器里实现require方法
function require(moduleId) {xxx}

/**
 * chunkIds 代码块的ID数组
 * moreModules 代码块的模块定义
*/
function webpackJsonpCallback([chunkIds, moreModules]) {
  const result = [];
  for(let i = 0 ; i < chunkIds.length ; i++){
    const chunkId = chunkIds[i];
    result.push(installedChunks[chunkId][0]);
    installedChunks[chunkId] = 0; // 表示此代码块已经下载完毕
  }

  // 将代码块合并到 modules 对象中去
  for(const moduleId in moreModules){
    modules[moduleId] = moreModules[moduleId];
  }
  //依次将require.e方法中的promise变为成功态
  while(result.length){
    result.shift()();
  }
}

// 用来存放代码块的加载状态, key是代码块的名字
// 每次打包至少产生main的代码块
// 0 表示已经加载就绪
var installedChunks = {
  "main": 0
}

require.d = (exports, definition) => {
  for (var key in definition) {
    Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
  }
};
require.r = (exports) => {
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
  Object.defineProperty(exports, '__esModule', { value: true });
};

// 给require方法定义一个m属性, 指向模块定义对象
require.m = modules;

require.f = {};

// 利用JSONP加载一个按需引入的模块
require.l = function (url) {
  let script = document.createElement("script");
  script.src = url;
  document.head.appendChild(script);
}

// 用于通过JSONP异步加载一个chunkId对应的代码块文件, 其实就是hello.main.js
require.f.j = function(chunkId, promises){
  let installedChunkData;
  // 当前代码块的数据
  const promise = new Promise((resolve, reject) => {
    installedChunkData = installedChunks[chunkId] = [resolve, reject];
  });
  promises.push(installedChunkData[2] = promise);
  // 获取模块的访问路径
  const url = chunkId + '.main.js';

  require.l(url);
}

require.e = function(chunkId) {
  let promises = [];
  require.f.j(chunkId, promises);
  console.log(promises);
  return Promise.all(promises);
}

var chunkLoadingGlobal = window['webpack'] = [];
// 由于按需加载的模块, 会在加载成功后调用此模块,所以这是JSONP的成功后的回掉
chunkLoadingGlobal.push = webpackJsonpCallback;

/**
 * require.e异步加载hello代码块文件 hello.main.js
 * promise成功后会把 hello.main.js里面的代码定义合并到require.m对象上,也就是modules上
 * 调用require方法加载./src/hello.js模块,获取 模块的导出对象,进行打印
 */
require.e('hello').then(require.bind(require, './src/hello.js')).then(result => console.log(result));

hello.main.js

"use strict";
(self["webpack"] = self["webpack"] || []).push([
  ["hello"], {
    "./src/hello.js": ((module, exports, require) => {
      require.r(exports);
      require.d(exports, {
        "default": () => (_DEFAULT_EXPORT__)
      });
      const _DEFAULT_EXPORT__ = ("hello");
    })
  }
]);

webpack 在产物代码中声明了一个全局变量 webpack 并赋值为一个数组,然后改写了这个数组的 push 方法。在异步代码加载完成后执行时,会调用这个 push 方法,在重写的方法内会将异步模块放到全局模块中然后等待使用。

白话版 webpack 构建流程

时至今日,webpack 的功能集已经非常庞大了,代码量更是非常惊人,源码的学习成本非常高,但是了解 webpack 构建流程又十分有必要,可以帮我们了解构建产物是怎么产生的,以及遇到实际问题时如何下手解决问题。

思路实现

简单模拟下 webpack 实现思路:

class Compilation {
    constructor(options) {
        this.options = options;
        // 本次编译所有生成出来的模块
        this.modules = [];
        // 本次编译产出的所有代码块, 入口模块和依赖模块打包在一起成为代码块
        this.chunks = [];
        // 本次编译产出的资源文件
        this.assets = {};
    }
    build(callback) {
        //5.根据配置文件中的`entry`配置项找到所有的入口
        let entry = {xxx: 'xxx'};

        //6.从入口文件出发,调用所有配置的loader规则,比如说loader对模块进行编译
        for(let entryName in entry){
            // 6. 从入口文件出发,调用所有配置的Loader对模块进行编译
            const entryModule = this.buildModule(entryName, entryFilePath);
            this.modules.push(entryModule);

            //8.等把所有的模块编译完成后,根据模块之间的依赖关系,组装成一个个包含多个模块的chunk
            let chunk = {
                name: entryName, // 代码块的名称就是入口的名称
                entryModule, // 此代码块对应的入口模块
                modules: this.modules.filter((module) => module.names.includes(entryName)) // 此代码块包含的依赖模块
            };
            this.chunks.push(chunk);
        }

        //9.再把各个代码块chunk转换成一个一个的文件(asset)加入到输出列表
        this.chunks.forEach((chunk) => {
            const filename = this.options.output.filename.replace('[name]', chunk.name); // 获取输出文件名称
            this.assets[filename] = getSource(chunk);
        });
        // 调用编译结束的回掉
        callback(null, {
            modules: this.modules,
            chunks: this.chunks,
            assets: this.assets
        }, this.fileDependencies);

    }

    //当你编译 模块的时候,需要传递你这个模块是属于哪个代码块的,传入代码块的名称
    buildModule(name, modulePath) {
         // 6. 从入口文件出发,调用所有配置的Loader对模块进行编译, loader 只会在编译过程中使用, plugin则会贯穿整个流程
         // 读取模块内容
         let sourceCode = fs.readFileSync(modulePath, 'utf8');
         //创建一个模块对象
         let module = {
             id: moduleId, // 模块ID =》 相对于工作目录的相对路径
             names: [name], // 表示当前的模块属于哪个代码块(chunk)
             dependencies: [], // 表示当前模块依赖的模块
         }

         // 查找所有匹配的loader,自右向左读取loader, 进行转译, 通过loader翻译后的内容一定是JS内容
         sourceCode = loaders.reduceRight((sourceCode, loader) => {
             return require(loader)(sourceCode);
         }, sourceCode);

         // 7. 再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理
         // 创建语法树, 遍历语法树,在此过程进行依赖收集, 绘制依赖图
         let ast = parser.parse(sourceCode, { sourceType: 'module' });
         traverse(ast, {});
         let { code } = generator(ast);

         // 把转译后的源代码放到module._source上
         module._source = code;
         // 再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理
         module.dependencies.forEach(({ depModuleId, depModulePath }) => {
            const depModule = this.buildModule(name, depModulePath);
            this.modules.push(depModule)
         });

         return module;
    }
}

function getSource(chunk) {
    return `
     (() => {
      var modules = {
        ${chunk.modules.map(
          (module) => `
          "${module.id}": (module) => {
            ${module._source}
          }
        `
      )}
      };
      var cache = {};
      function require(moduleId) {
        var cachedModule = cache[moduleId];
        if (cachedModule !== undefined) {
          return cachedModule.exports;
        }
        var module = (cache[moduleId] = {
          exports: {},
        });
        modules[moduleId](module, module.exports, require);
        return module.exports;
      }
      var exports ={};
      ${chunk.entryModule._source}
    })();
     `;
}

class Compiler {
    constructor(options) {
        this.options = options;
        this.hooks = {
            run: new SyncHook(),  //会在编译刚开始的时候触发此run钩子
            done: new SyncHook(), //会在编译 结束的时候触发此done钩子
        }
    }

    //4.执行`Compiler`对象的`run`方法开始执行编译
    run() {
        // 在编译前触发run钩子执行, 表示开始启动编译了
        this.hooks.run.call();
        // 编译成功之后的回掉
        const onCompiled = (err, stats, fileDependencies) => {
            // 10. 在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
            for(let filename in stats.assets) {
                fs.writeFileSync(filePath,stats.assets[filename], 'utf8' );
            }
            //当编译成功后会触发done这个钩子执行
            this.hooks.done.call();
        }
        //开始编译,编译 成功之后调用onCompiled方法
        this.compile(onCompiled);
    }

    compile(callback) {
        // webpack虽然只有一个Compiler, 但是每次编译都会产出一个新的Compilation, 用来存放本次编译产出的 文件、chunk、和模块
        // 比如:监听模式会触发多次编译
        let compilation = new Compilation(this.options);
        //执行compilation的build方法进行编译 ,编译 成功之后执行回调
        compilation.build(callback);
    }

}

function webpack(options) {
    //1.初始化参数,从配置文件和shell语句中读取并合并参数,并得到最终的配置对象
    let finalOptions = {...options, ...shellOptions};

    // 2.用上一步的配置对象初始化Compiler对象, 整个编译流程只有一个complier对象
    const compiler = new Compiler(finalOptions);

    // 3.加载所有在配置文件中配置的插件
    const { plugins } = finalOptions;
    for(let plugin of plugins){
        plugin.apply(compiler);
    }

    return compiler;
}

// webpackOptions webpack的配置项
const compiler = webpack(webpackOptions);
//4.执行对象的run方法开始执行编译
compiler.run();

为什么 Rollup 构建产物很干净?

  • rollup 只对 ESM 模块进行打包,对于 cjs 模块也会通过插件将其转化为 ESM 模块进行打包。所以不会像 webpack 有很多的代码注入。
  • rollup 对打包结果也支持多种 format 的输出,比如:esm、cjs、am 等等,但是 rollup 并不保证代码可靠运行,需要运行环境可靠支持。比如我们输出 esm 规范代码,代码运行时完全依赖高版本浏览器原生去支持 esm,rollup 不会像 webpack 一样注入一系列兼容代码。
  • rollup 实现了强大的 tree-shaking 能力。

为什么 Vite 可以让代码直接运行在浏览器上?

前文我们提到,在开发环境时,我们使用 vite 开发,是无需打包的,直接利用浏览器对 ESM 的支持,就可以访问我们写的组件代码,但是一些组件代码文件往往不是 JS 文件,而是 .ts.tsx.vue 等类型的文件。这些文件浏览器肯定直接是识别不了的,vite 在这个过程中做了些什么?

我们以一个简单的 vue 组件访问分析一下:

// index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + Vue</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

// /src/main.js
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app');

// src/App.vue
<template>
  <h1>Hello</h1>
</template>

在浏览器中打开页面后,会发现浏览器对入口文件发起了请求:

我们可以观察到 vue 这个第三方包的访问路径改变了,变成了node_modules/.vite下的一个 vue 文件,这里真正访问的文件就是前面我们提到的,vite 会对第三方依赖进行依赖预构建所生成的缓存文件。

另外浏览器也对 App.vue 发起了访问,相应内容是 JS:

返回内容(做了部分简化,移除了一些热更新的代码):

 const _sfc_main = {
    name: 'App'
}
// vue 提供的一些API,用于生成block、虚拟DOM
import { openBlock as _openBlock, createElementBlock as _createElementBlock } from "/node_modules/.vite/vue.js?v=b618a526"

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
    return (_openBlock(), _createElementBlock("h1", null, "App"))
}
// 组件的render方法
_sfc_main.render = _sfc_render;
export default _sfc_main;

总结:当用户访问 vite 提供的开发服务器时,对于浏览器不能直接识别的文件,服务器的一些中间件会将此类文件转换成浏览器认识的文件,从而保证正常访问。

参考资料

前端构建工具简史

ESM 实现原理

Webpack 核心原理


尾部关注.gif

扫码关注公众号 👆 追更不迷路