webpack从零开始

448 阅读8分钟

webpack是什么

一图胜千言,官网这张图很好地描述了webpack的作用。

image-20210714132211464.png

由图可知,webpack把依赖复杂的文件们打包成了.js,.css,image这几类文件。

官网描述为:

本质上,webpack 是一个用于现代 JavaScript 应用程序的 静态模块打包工具。当 webpack 处理应用程序时,它会在内部构建一个 依赖图(dependency graph),此依赖图对应映射到项目所需的每个模块,并生成一个或多个 bundle

webpack顾名思义,web为网页,pack为打包,webpack就是应用于前端的打包(bundle)工具。目前前端发展到框架阶段,在开发过程中会存在很多文件,js,css,png等等,这些会存在相互依赖,相互引用错综复杂,而webpack将这些“鸡零狗碎”的较多的文件进行打包,成为较少的有序简单的一些静态资源,可以把这些资源(dist文件夹)直接放在服务器上进行分发。

为何需要webpack

为啥要用webpack?直接把那堆文件放到服务器上不行吗?webpack配置那么麻烦,岂不是徒增烦恼?

不用webpack这类打包工具,两种方法在浏览器中执行javascript:

  1. 写很多个.js脚本,然后在html里引用。

    ------毒点:加载n多个小脚本,然后网页卡疯了。一个一个发get请求,这谁等得了。

  2. 写一个大的.js。

    ------毒点:那么变量名,作用域,如何协作,怎么维护呢?

这两种方法明显不太行🙅‍♂️,随着前端发展,node出现了,node带来的带来了前端模块化,我们不用直接写script标签里引东西了,导入某个模块,我们就获得了“开袋即食”的功能。也不用担心作用域,变量名什么问题,这便是模块化的意义。

当然,node里模块化是CommonJS,使用require语句,这个没法直接被浏览器识别。现在呢,ECMAScript 模块(ESM)已经推出,用import语句也能愉快地引用模块,但这种还没普及。

webpack可以支持ESM 和 CommonJS两种模块语法,然后将这些模块打包成bundle.js,打包后就可以在html里用script标签引这些文件了。同时还能能处理各种资源,如样式,图片等等。还有十分丰富的插件可供使用,以提高网页性能,降低加载时间。

所以webpck三大功能(非权威总结):

  1. 打包。不打包会很卡。
  2. 打包不同语法引入的的模块,require的和import的。
  3. loaderplugin提供的种种功能。这俩稍后介绍。

上文讲的更多的是为什么我们需要打包器(bundler),至于webpack的特性:代码分割,智能解析,loader和plugin,可以参考这里

webpack咋用

一般而言,我们只需要配置webpack(因为写loader和plugin超纲我不会😯),所以基本所有操作都在webpack.config.js这个文件里。虽然只是这个文件的配置,但其仍然比较复杂。细究其细节,堪称烧脑,这里指路一篇文章《webpack 最佳实践》,基本涵盖了webpack的基础用法和常用loader和plugin(虽然好像有些bug)。

所以这里就不讲具体使用了,讲讲对于webpack中一些核心概念的理解,在官方文档介绍的基础上加上点自己的解释。

入口(entry)

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

module.exports = {
  entry: './path/to/my/entry/file.js',
};

入口不用解释。

输出(output)

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

const path = require('path');

module.exports = {
  entry: './path/to/my/entry/file.js',
  output: {
    path: path.resolve(__dirname, 'dist'), // 文件夹
    filename: 'my-first-webpack.bundle.js',
  },
};

入口出口基本都得引path模块,虽然前面没引。

loader

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

loader翻译作加载器,其实上就是个翻译官。因为webpack原生只能处理js,所以css,图片(这里没说html,因为这个得用plugin处理)等都是用loader来处理一下,才能把这些模块也打包。所谓loader就是一个个npm包,下载后再在webpack.config.js中进行配置。这里不用require引入,而plugin需要

在更高层面,在 webpack 的配置中,loader 有两个属性:

  1. test 属性,识别出哪些文件会被转换。用正则表达式来匹配。
  2. use 属性,定义出在进行转换时,应该使用哪个 loader。
const path = require('path');

module.exports = {
  output: {
    filename: 'my-first-webpack.bundle.js',
  },
  module: {
    rules: [{ test: /\.txt$/, use: 'raw-loader' }],
    // 面对.txt结尾的,都用raw-loader处理
  },
};

插件(plugin)

loader 用于转换某些类型的模块,而插件则可以用于执行范围更广的任务。包括:打包优化,资源管理,注入环境变量

插件,先引用,再使用,放在plugins数组里,是new出来的一个个对象。可以理解为工具包,干loader干不了的事。

const HtmlWebpackPlugin = require('html-webpack-plugin'); // 通过 npm 安装
const webpack = require('webpack'); // 用于访问内置插件

module.exports = {
  module: {
    rules: [{ test: /\.txt$/, use: 'raw-loader' }],
  },
  plugins: [new HtmlWebpackPlugin({ template: './src/index.html' })],
};

上面这个插件就是自动生成html文件,然后引用打包后的文件。

造个webpack

制造一个简单的webpack,是为了理解webpack的基本原理和其存在的最大意义。webpack本质是一个javasript打包工具,所以说:

“如果webpack只有一个功能,那就是将多个js文件打包成一个这个功能。”

我们可以手动模拟这个功能,即从一个入口文件开始,收集这个文件所依赖的全部js文件,并将这些文件打包。引用文件的方法有import,require。我们要做的事就是读取这些依赖将其解析为依赖图(其实是个数组)。

webpack官网提供有minipack项目,这个项目解析了import的依赖。

微医前端团队有篇文章《90 行代码的webpack,你确定不学吗?》,里面建了个myPack项目,可以解析require的依赖。

其他大牛写过的,我就不写了。本文将以myPack项目为蓝本,加入解析import依赖的能力,用120行代码写一个功能稍全一些的打包工具。

话不多说,开冲!!!

建立文件夹myPack,在文件夹打开终端输入:

npm init -y

初始化项目后,打开myPack/package.json,增加开发依赖:

  "devDependencies": {
    "babel-core": "^6.26.0",
    "@babel/parser": "^7.14.7",
    "@babel/traverse": "^7.14.7",
    "webpack": "^5.44.0",
    "webpack-cli": "^4.7.2",
    "babel-preset-env": "^1.6.1"
  }

终端中安装依赖:

npm install

下面建一些我们要处理的文件。在myPack目录下新建一个src文件夹,然后在src下依次新建这些文件。

myPack/src/a.js

const printB = require('./b')

module.exports = function printA() {
  console.log('module aa!')
  printB()
}

myPack/src/b.js

module.exports = function printB() {
    console.log('module b!')
  }

myPack/src/c.js:

export default function() {
    console.log('module ccc');
}

myPack/src/index.js

const printA = require('./a')
import fnC from './c'

fnC();
printA();

可以看到,我们在index.js中使用了require和import两种引入依赖的方式。接下来是重头戏,我们将通过一个myPack.js文件,将index的所有依赖,打包成一个dist.js文件。

话不多说,看代码,解释都在注释里。

myPack/myPack.js

const fs = require("fs");
const path = require("path");
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const { transformFromAst } = require("babel-core");

// 三个步骤:1.解析模块,2.建立依赖图,3.生成打包文件

// 1.解析模块在:对某一个模块(也就是某个js文件)进行解析,并产出其模块信息,包括:模块路径、模块依赖、模块转换后代码

// 保存根路径,所有模块根据根路径产出相对路径
let root = process.cwd();

function readModuleInfo(filePath) {
  // 准备好相对路径作为 module 的 key
  filePath =
    "./" + path.relative(root, path.resolve(filePath)).replace(/\\+/g, "/");
  // 读取源码
  const content = fs.readFileSync(filePath, "utf-8");
  // 转换出 AST: 抽象语法树(Abstract Syntax Tree),可以吧js代码抽象成对象,方便代码进行操作。
  const ast = parser.parse(content, {
    sourceType: "module",
  });
  // 遍历模块 AST,将依赖收集到 deps 数组中
  const deps = [];
  traverse(ast, {
    CallExpression: ({ node }) => {
      // 如果是 require 语句,则收集依赖
      if (node.callee.name === "require") {
        let moduleName = node.arguments[0].value;
        moduleName += path.extname(moduleName) ? "" : ".js";
        moduleName = path.join(path.dirname(filePath), moduleName);
        moduleName =
          "./" + path.relative(root, moduleName).replace(/\\+/g, "/");
        deps.push(moduleName);
        // 改造依赖的路径
        node.arguments[0].value = moduleName;
      }
    },
    // 下面收集使用import引入的依赖
    ImportDeclaration: ({ node }) => {
      // 我们将依赖关系数组推入我们导入的值.
      let moduleName = node.source.value;
      moduleName += path.extname(moduleName) ? "" : ".js";
      moduleName = path.join(path.dirname(filePath), moduleName);
      moduleName = "./" + path.relative(root, moduleName).replace(/\\+/g, "/");
      deps.push(moduleName);
      // 改造依赖的路径
      node.source.value = moduleName;
    },
  });
  // 编译回代码, 这一步会将import语法转换为require的语法
  let { code } = transformFromAst(ast, null, {
    presets: ["env"],
  });
  return {
    filePath,
    deps,
    code,
  };
}

// 2.建立依赖图:从入口出发递归地找到所有被依赖的模块,并构建成依赖树
function buildDependencyGraph(entry) {
  // 获取入口模块信息
  const entryInfo = readModuleInfo(entry);
  // 项目依赖树
  const graphArr = [];
  graphArr.push(entryInfo);
  // 从入口模块触发,递归地找每个模块的依赖,并将每个模块信息保存到 graphArr
  for (const module of graphArr) {
    module.deps.forEach((depPath) => {
      const moduleInfo = readModuleInfo(path.resolve(depPath));
      graphArr.push(moduleInfo);
    });
  }
  console.log("graphArr", graphArr);
  return graphArr;
}

// 3.生成打包文件:经过上面一步,我们已经得到依赖树能够描述整个应用的依赖情况,最后我们只需要按照目标格式进行打包输出即可
function pack(graph, entry) {
  const moduleArr = graph.map((module) => {
    return (
      `"${module.filePath}": function(module, exports, require) {
          eval(\`` +
      module.code +
      `\`)
        }`
    );
  });
  const output = `(() => {
      var modules = {
        ${moduleArr.join(",\n")}
      }
      var modules_cache = {}
      var require = function(moduleId) {
        if (modules_cache[moduleId]) return modules_cache[moduleId].exports
  
        var module = modules_cache[moduleId] = {
          exports: {}
        }
        modules[moduleId](module, module.exports, require)
        return module.exports
      }
  
      require('${entry}')
    })();`;
  return output;
}

// 编写一个入口函数 main 用以启动打包过程
function main(entry = "./src/index.js", output = "./dist.js") {
  fs.writeFileSync(output, pack(buildDependencyGraph(entry), entry));
}

main();

运行该文件

node myPack.js

可以得到,dist.js文件,这里也亮个相。

myPack/dist.js

(() => {
  var modules = {
    "./src/index.js": function (module, exports, require) {
      eval(`"use strict";

var _c = require("./src/c.js");

var _c2 = _interopRequireDefault(_c);

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

var printA = require("./src/a.js");

(0, _c2.default)();
printA();`);
    },
    "./src/a.js": function (module, exports, require) {
      eval(`"use strict";

var printB = require("./src/b.js");

module.exports = function printA() {
  console.log('module aa!');
  printB();
};`);
    },
    "./src/c.js": function (module, exports, require) {
      eval(`"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});

exports.default = function () {
  console.log('module ccc');
};`);
    },
    "./src/b.js": function (module, exports, require) {
      eval(`"use strict";

module.exports = function printB() {
  console.log('module b!');
};`);
    },
  };
  var modules_cache = {};
  var require = function (moduleId) {
    if (modules_cache[moduleId]) return modules_cache[moduleId].exports;

    var module = (modules_cache[moduleId] = {
      exports: {},
    });
    modules[moduleId](module, module.exports, require);
    return module.exports;
  };

  require("./src/index.js");
})();

为了验证打包成果,建个html文件跑一下(其实也可以直接运行dist文件)。

myPack/index.html

<script src="./dist.js"></script>

打开这个文件,看看结果:

image-20210716105345423.png

成了!!!

附上文件目录,以供参考:

.
├── dist.js
├── index.html
├── myPack.js
├── package-lock.json
├── package.json
└── src
    ├── a.js
    ├── b.js
    ├── c.js
    └── index.js

1 directory, 9 files

引用

  1. webpack官网 webpack.docschina.org/

  2. Webpack是什么?chenyiqiao.gitbooks.io/webpack/con…

  3. minipack中文版 github.com/chinanf-boy…

  4. 90 行代码的webpack,你确定不学吗?juejin.cn/post/696382…