Webpack打包流程

2,571 阅读8分钟

前言

学习webpack打包流程,跟着 大崔哥 的视频敲了一遍,结合 瓶子君 的一篇讲解教程,记点笔记,以便自己后续查看。

模块

模块是一组有特定功能的代码。它封装了实现细节,公开了公共API,并与其他模块结合以构建更大的应用程序; 模块化,就是为了实现更高级别的抽象,它将一类或者多种实现封装到一个模块里,使用者不必考虑模块内是怎样的依赖关系,仅仅调用它暴露出来的API即可。

依赖

模块之间的依赖关系,就是多个模块结合来构建出更大应用程序的相互引用关系;

简单的说,我们常常封装的工具函数是一个小模块,多个函数组成一个库,函数之间又可能相互调用,这种调用关系就是依赖关系,这个函数、库就是一个模块;

CJS/AMD/UMD/ESM

  • CJS(CommonJS):旨在用于服务器端 JavaScript 的同步定义,Node 的模块系统实际上基于 CJS; 但 CommonJS 是以同步方式导入,因为用于服务端,文件都在本地,同步导入即使卡住主线程影响也不大,但在浏览器端,如果在 UI 加载的过程中需要花费很多时间来等待脚本加载完成,这会造成用户体验的很大问题。 鉴于网络的原因, CommonJS 为后端 JavaScript 制定的规范并不完全适合与前端的应用场景,下面来介绍 JavaScript 前端的规范。

  • AMD(异步模块定义):被定义为用于浏览器中模块的异步模型,RequireJS 是 AMD 最受欢迎的实现;

  • UMD(通用模块定义):它本质上一段 JavaScript 代码,放置在库的顶部,可让任何加载程序、任何环境加载它们;

  • ES2015(ES6):定义了异步导入和导出模块的语义,会编译成 require/exports 来执行的,这也是我们现今最常用的模块定义;

CJS导出的是模块的拷贝,不会影响;ESM导出的是模块的引用,会影响;

打包器

将多个JS模块组合到一个可以在浏览器中运行的文件中的工具。 (模块化)打包器可以管理多个依赖项,将每个依赖项模块化,让每个依赖项能够在正确的时间、正确的地点被正确的引用。 (捆绑)并且可以减少http文件请求,只请求一个打包后的JS文件即可; 所以,模块化与捆绑是打包器需要实现的两个最主要功能。

项目目录

-src
- - mes  //二级文件夹
- - - message.js //依赖
- - entry.js //入口文件
- - hello.js  //依赖
- - name.js //依赖
- index.js //打包文件
- minipack.config.js //配置文件

entry.js

//入口文件
import message from "./mes/message.js";
import { name } from "./name.js";

message();
console.log("----name-----: ", name);

message.js

// 依赖项
import {hello} from '../hello.js'
import {name} from '../name.js'

export default function message() {
  console.log(`${hello} ${name}!`)
}

hello.js

// 依赖项
export const hello = 'hello'

name.js

// 依赖项
export const name = 'bottle'

minipack.config.js

const path = require("path");

module.exports = {
  entry: "./src/entry.js",
  output: {
    filename: "bundle.js",
    path: path.resolve(__dirname, "./dist")
  }
};

安装需要的包:

npm install @babel/core @babel/parser @babel/preset-env @babel/traverse --save-dev

  • @babel/core babel 集成包核心库,包含(parse/traverse/types/transfromFromAst...)
  • @babel/parser 解析文件内容为AST
  • @babel/traverse 遍历AST
  • @babel/preset-env 根据配置的浏览器的列表,自动加载当前浏览器所需要的插件,然后对ES语法做转换

打包流程:

  • 解析入口文件,生成AST树,遍历所有依赖项,收集依赖;
  • 递归解析所有的依赖项,生成依赖关系图;
  • 使用依赖关系图,生成一个可以在浏览器运行的JS文件;
  • 输出到指定文件夹下;

解析入口文件,生成AST树,遍历依赖项

//引入fs模块
const fs = require("fs");
// const babelParser = require("@babel/parser");
// const traverse = require("@babel/traverse").default;
const { parse, traverse, transformFromAst } = require("@babel/core"); //核心插件,是babel的整合
//获取配置文件
const config = require("./minipack.config");
//入口
const { entry } = config;
//出口
const { output } = config;


/**
 * @description 解析文件内容及其依赖,
 * {
 *   deps:文件依赖的模块,
 *   code:文件解析内容
 * }
 * @param filename 文件路径
 */
function createAsset(filename) {
  //1:读取文件内容
  const content = fs.readFileSync(filename, "utf-8");

  //2:解析代码,生成AST(抽象语法树)https://astexplorer.net/
  //sourceType:指示代码应解析的模式有三种参数,
  const ast = parse(content, {
    //script/module(使用ES6的import和require时)/unambiguous(parse去自己检测)
    sourceType: "module"
  });
  
  //3:遍历 AST 找到依赖关系
  //存放 ast 中解析出的所有依赖
  const deps = [];
  traverse(ast, {
    //// 遍历所有的 import 模块,并将相对路径放入 deps
    ImportDeclaration: ({ node }) => {
      // console.log("node", node.source.value);
      deps.push(node.source.value);
    }
  });
  
  //4:把 AST 转成浏览器可运行的代码
  //通过babel/core的 transformFromAst 方法转换 ast 成浏览器可以运行的东西
  const { code } = transformFromAst(ast, null, { 
    // 该`presets`选项是一组规则,告诉`babel`如何传输我们的代码.
    // 我们用`babel-preset-env``将我们的代码转换为浏览器可以运行的东西
    presets: ["@babel/env"]
  });
  // console.log("code:******************", code);

  // fs.writeFileSync("./dist/bundle.js", code);
  return {
    deps,
    code
  };
}

Snipaste_2022-03-20_19-27-10.png

递归解析依赖项,生成依赖关系

此时要考虑到可能出现重复打包(多处依赖同一个文件)的问题,所以解析依赖项时,要找到一个唯一标识,去除重复模块,可以参考webpack的打包结果,是以文件名为唯一标识;

/**
 * @description
 * 从入口文件开始,获取整个依赖图
 * @param entry 入口文件
 */
function createGraph(entry) {
  //从入口文件开始,解析每一个依赖资源,并将其依次放入队列
  const mainAsset = createAsset(entry);
  //队列
  const queue = {
    [entry]: mainAsset
  };

  /**
   * @description 递归遍历,获取所有依赖
   * @param filename 文件名
   * @param asset 入口文件
   */
  function recursionDep(filename, asset) {
    //跟踪所有依赖文件(模块唯一标识符)
    asset.mapping = {};

    // 模块 import 为相对路径,转成当前绝对路径
    //第一次参数是 entry.js,所以 absolutePath 是它的依赖相对于 entry.js
    const dirname = path.dirname(filename);
    // console.log("filename:", filename, "dirname", dirname);
    //循环依赖数组里面的相对路径,拼接上当前递归文件路径,等于它依赖的绝对路径,
    //因为源头entry路径确定了,依次都确定了
    asset.deps.forEach(relativePath => {
      // 获取绝对路径,以便于 createAsset 读取文件
      //!!教程此处没有做windows适配
      const absolutePath = path.join(dirname, relativePath).replace(/\\/g, "/");
      // console.log("relativePath:", relativePath, "absolutePath:", absolutePath);
      //与之前的 asset 关联
      asset.mapping[relativePath] = absolutePath;
      // console.log("mapping:", asset.mapping);
      //检测依赖文件,没有加入到依赖图中,才让其加入,避免模块重复打包
      if (!queue[absolutePath]) {
        //获取依赖模块内容
        const childAsset = createAsset(absolutePath);

        //将 childAsset 依赖关系放入 queue 以便于 for 继续解析依赖资源的依赖
        //直到所有依赖解析完成,就这构成了一个从入口文件开始的依赖图
        queue[absolutePath] = childAsset;
        //如果子的依赖关系 deps 数组还有依赖其他的,那就继续递归
        if (childAsset.deps.length > 0) {
          recursionDep(absolutePath, childAsset);
        }
      }
    });
  }

  //遍历 queue 队列,获取每一个 asset 及其依赖模块并将其加入到队列
  for (const filename in queue) {
    const asset = queue[filename];
    recursionDep(filename, asset);
  }

  //返回依赖图
  return queue;
}

Snipaste_2022-03-20_21-29-18.png

使用依赖图,返回一个可以在浏览器运行的 JavaScript 文件

这里可以看 大崔哥 视频,讲的比较容易理解,但他是用ejs模板解析去做的;

/**
 * @description 打包(使用依赖图,返回一个可以在浏览器运行的包)
 * 所以返回一个立即执行函数(function(){})(params)
 * 这个函数只接收一个参数,包含依赖图中的所有信息
 *
 * 遍历图 graph,将每个 mod 以'key:value'方式加入到 modules
 * key(filename),模块的唯一标识符,value 为一个数组,包含:
 * function(require, module, exports){ ${mod.code} }
 * ${JSON.stringify(mod.mapping)}
 *
 * 其中:function(require, module, exports){ ${mod.code} }
 * 使用函数包装每一个模块的代码 mode.code,防止 mode.code 污染全局变量或其它模块
 * 并且模块转化后运行在 common.js 系统,它们期望有 require, module, exports 可用
 *
 * 其中:${JSON.stringify(mod.mapping)} 是模块间的依赖关系,当依赖被 require 时调用
 * 例如:{ './message.js': 'src\message.js' }
 *
 * @param graph 依赖图
 */
function bundle(graph) {
  let modules = "";
  for (const filename in graph) {
    const mod = graph[filename]; //每一个模块
    modules += `'${filename}':[
        function(require,module,exports){
          ${mod.code}
        },
        ${JSON.stringify(mod.mapping)},
      ],`;
  }

  // 注意:modules 是一组 `key: value,`,所以我们将它放入 {} 中
  // 实现 立即执行函数
  // 首先实现一个 require 函数,require('${entry}') 执行入口文件,entry 为入口文件绝对路径,也为模块唯一标识符
  // require 函数接受一个moduleId(filename 绝对路径) 并在其中查找它模块我们之前构建的对象.
  // 通过解构 const [fn, mapping] = modules[id] 来获得我们的函数包装器和 mappings 对象.
  // 由于一般情况下 require 都是 require 相对路径,而不是moduleId(filename 绝对路径),所以 fn 函数需要将 require 相对路径转换成 require 绝对路径,即 localRequire
  // 注意:不同的模块 moduleId(filename 绝对路径)时唯一的,但相对路径可能存在相同的情况
  //
  // 将 module.exports 传入到 fn 中,将依赖模块内容暴露处理,当 require 某一依赖模块时,就可以直接通过 module.exports 将结果返回
  const result = `
  (function(modules){
    function require(moduleId){
      const [fn,mapping] = modules[moduleId];

      //获取map映射的值,即 key-value 的value 
      function localRequire(name){
        return require(mapping[name])
      }

      const module = {exports:{}};
      fn(localRequire,module,module.exports)
      return module.exports;
    }
    require('${entry}')
  })({${modules}})
  `;
  return result;
}

输出到指定目录

/**
 * @description 输出打包
 * @param path 路径
 * @param result 内容
 */
function writeFile(path, result) {
  //写入 ./dist/bundle.js
  fs.writeFile(path, result, err => {
    if (err) throw err;
    console.log("文件已被保存");
  });
}
// 获取依赖图
const graph = createGraph(entry);
// console.log("graph:", graph);
//打包
const result = bundle(graph);
//console.log("result", result);
//输出
let { path: outPath, filename: outFilename } = output;
fs.access(`${outPath}/${outFilename}`, err => {
  if (!err) {
    writeFile(`${outPath}/${outFilename}`, result);
  } else {
    fs.mkdir(outPath, { recursive: true }, err => {
      if (err) throw err;
      writeFile(`${outPath}/${outFilename}`, result);
    });
  }
});

BUG

  1. 瓶子君 createGraph方法的方法解析absolute时,windows下会出现反斜杠,后续mapping的key值错误,本文已做了适配;
  2. 2.大崔哥 视频教程的mainjs中引入foo方法不正确,导致打包时按照exports.default方式,引入bundlejs时,浏览器报错;

参考视频文章

大崔哥视频

瓶子君

【面试说】Javascript 中的 CJS, AMD, UMD 和 ESM是什么?

@babel/core入门