2024 构建你自己的webpack

1 什么是bundler

diff 复制代码
- 打包器

2 打包器有什么用?

现在的前端开发,文件多,多人开发。如何避免命名冲突和提高代码的可读性和可维护性就很重要,不是像以前script src 指定src值的方式引入不同的js代码了。就需要一个人能统一这个乱世, 打包器就横空出世了。解决了什么问题?

如图所示: 解决了 让我们开发的时候,可以写模块,可以拆分文件(esm commongjs 一个文件就是一个模块 私有的 同名变量在多个文件里面不冲突,其他模块想要使用属性或者方法 需要模块内通过当前是什么模块标准,按照对应的模块标准导出即可 ,其他使用的地方,按照模块导入语法导入使用即可,其他语言里面默认支持的功能, js es6才有),并且可以在浏览器里面运行。 就是这个目的。

3 bundler有哪些?

  • 这里我们只谈webpack

4 webpack里面怎么实现的?

  1. 解析一个文件,找出它的依赖文件
  2. 递归构建依赖图
  3. 根据依赖图谱,将所有的最终打包成一个文件

依赖图谱一般长这样:

对应的数据结构就是 图

这就是实现思路

根据实现思路,建好对应的文件

markdown 复制代码
/example
    - entry.js 入口文件 里面引入message.js
    - message.js 里面引入name.js 
    - name.js 里面导出name 变量
bundle.js 实现具体逻辑的地方    

entry.js

js 复制代码
import message from "./message.js";

console.log(message);

message.js

message.js
js 复制代码
import { name } from "./name.js";

export default `hello ${name}`;

name.js

js 复制代码
 export const name = "前端关宇";

bundle.js

  1. fs 有读取文件的能力, 调用方法读取entry.js里面的内容, utf-8 的形式展示
  2. 将读取的内容(就是字符串)转成 ast (抽象语法树) 本质就是一个js对象或者叫树
  3. 拿到ast 构建依赖图

1. 里面的实现

js 复制代码
const fs = require("fs");
function createAsset(filename) {
  const fileContent = fs.readFileSync(filename, "utf-8");
  console.log(fileContent);
  console.log(typeof fileContent); // string
}

createAsset("./example/entry.js");

根目录下执行 node bundle.js 查看结果 发现读取的内容是字符串

将读取的到的内容 复制到astexplorer.net/ 左侧是源码 右侧是解析好的ast 可以看到 引入的语句 对应的部分 顶部中间位置 显示@babel/parser 意思就是通过这个库,解析成的ast , 点击展开也可以选择不同的其他的库,解析出来的格式不太一样。无论用哪一个,目的就是解析字符串成ast对象。

里面可以找到从哪个文件引入的信息 这里就是 ./message.js 对应右侧 source 对象里面的value属性

左右是对应高亮的 , 当你hover对应部分的时候, 左右会对应起来。方便你看结果。

当我写两个引入语句的时候, 右侧同样会在ast里面解析出来有两个引入。

2. 里面的实现,code string to ast

很多库可以实现, 比如 代码字符串转ast 使用babel parser npm i @babel/parser

很多依赖大佬的 acorn 这个大佬的 作品 ProseMirror codemirror acorn js parser 还有看过他的书的话一定不陌生的:

遍历ast 使用 babel traverse npm i @babel/traverse

目前函数长这样, 大概就是拿到字符串 输入字符串输出ast 输入ast得到deps数组 里面是依赖的文件路径 函数放回一个对象, 有三个属性 id , filename ,deps

输入字符串输出ast 的处理

输入ast得到deps数组 的处理 这里面接口提供了hooks ,使我们传递对应的 hooks name 和 callback 就可以得到符合条件的node 节点。 我们需要的是导入申明 callback里面可以解构拿到node节点, 节点里面有属性可以拿到 依赖的文件的路径的值

整体代码

js 复制代码
const fs = require("fs");
const { parse } = require("@babel/parser");
const traverse = require("@babel/traverse").default;

function createAst(filename) {
  const fileContent = fs.readFileSync(filename, "utf-8");
  console.log(fileContent);
  return parse(
    fileContent,
    //https://babel.dev/docs/babel-parser search  sourceType
    { sourceType: "module" }
  );
}

function genDeps(ast) {
  //遍历ast 收集依赖
  let deps = [];
  traverse(ast, {
    ImportDeclaration: ({ node }) => {
      let depFileName = node.source.value;
      deps.push(depFileName);
    },
  });

  return deps;
}

let ID = 0;
function createAsset(filename) {
  const ast = createAst(filename);
  let deps = genDeps(ast);
  const id = ID++;

  return {
    id,
    filename,
    deps,
  };
}

const mainAsset = createAsset("./example/entry.js");

console.log(mainAsset); //{ id: 0, filename: './example/entry.js', deps: [ './message.js' ] }

构建队列 里面每一项是一个对象 包含一些属性

目前返回的队列长这样

数组对象里面 对象每一个里面有id 属性 filename 属性 和 deps 依赖 还多一个属性 mapping 记录 当前 文件名和依赖的id 上面createGraph 里面 给asset 添加的属性 mapping 在遍历的时候,填充的这个asset.mapping 里面的key 和value

下一步 实现bundle方法 里面输入是graph 输出是一个js

  • 实现之前有一个细节要处理, 我们写的代码 有的使用import 语法 有的使用require , 不统一, 我们需要实现统一,比如最后都使用require语法来引入 代码, 这里就需要使用babel 将语法统一转换成require使用
  • 需要安装 npm i @babel/core @babel/preset-env

里面加入 getCode 并且返回code

js 复制代码
function createAsset(filename) {
  const ast = createAst(filename);
  let deps = genDeps(ast);
  const id = ID++;
  let code = getCode(ast);

  console.log("code", code);
  return {
    id,
    filename,
    deps,
    code
  };
}

安装 @babel/core @babel/preset-env 以后 顶部引入babel

调用babel.transformFromAst方法 传入ast 和对应的参数 指定预设 presets 为 @babel/preset-env

调用后 输出code ,得到的code 就是都是require语法的了。

输出graph 可以看到 返回的队列是长这样: 每一个对象里面多了一个code属性,并且里面的模块导入和导出都是使用的require module.exports 语法了

为什么要统一成 require module.exports 语法?

一个是为了统一。 嗯 。好像没说一样

一个是为了 统一以后,使用commonjs 模块规范, 里面有require module module.exports . 方便对require函数进行重写, webpack内部也是自己实现了一个require函数 来负责模块的导入,现在就是达到了,不管用户用什么模块标准写, 来到我这里打包的时候,我都给你统一处理成require , 然后再用我自己写的require 巧妙吧!

完成bundle函数

因为浏览器里面只能识别es5 的js , es5里面实现模块化 可以通过自执行函数,利用函数作用域的方式, 这里也这么实现。

入参是graph 里面是一个数组对象

目前就是在构造 modules 字符串 , 从graph 里面取出需要的数据 , 构造完应该是一个对象, 作为入参, 传递给自执行函数里面

输出的结果是这样的

发现code里面的 require('./message.js') 这里面的参数'./message.js' 不能写死,要传入用户动态写的, 比如下次用户写 .a/b.js 这里就要变化, 其实我们之前在构建graph里面 有对每一个asset 添加了mapping 这里面记录了这个变化的值, 所以拿过来构造一下返回即可。

将asset里面的mapping对象,字符串化, 并且作为value数组的第二个参数返回即可

这是现在构建出来的结果:

modules实参就是刚才构建好的 入参对象 key是id value是一个数组,数组第一项是function(require){}那个函数, 第二项是变化的值 mapping

接下来就要实现require函数了,接收的参数是模块id, 然后调用require函数, 因为模块id是从0开始, 所以传入0

这就是webpack的精华了, 自己实现了require函数 里面将传入的fn调用, 并且在module.exports对象上添加了各个模块里面写的属性和方法。 最后导出module.exports

最后! 将构建好的结果(下面这段js) 放在浏览器控制台里面运行 , 你就可以看到结果了!

js 复制代码
 (function (modules) {
        function require(id){
            //获取传入对象里面的 一个模块 , 从模块数组里面取出fn 和 mapping
            const [fn, mapping] = modules[id]
            
            //定义自己的导入函数
            function localRequire(relativePath){
                let nextId = mapping[relativePath]
                return require(nextId)
            }

            //自己构造一个module 并且返回
            const module = {exports: {} };

            //调用传入的参数 并且
            //注入自己实现的localRequire module
            fn(localRequire, module, module.exports)

            //返回对象
            return module.exports
        }

        require(0)
     })({
      0 : 
     [
        function(require,module, exports){"use strict";

var _message = _interopRequireDefault(require("./message.js"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
console.log(_message["default"]);},
        {"./message.js":1}
     ],
    1 : 
     [
        function(require,module, exports){"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports["default"] = void 0;
var _name = require("./name.js");
var _default = exports["default"] = "hello ".concat(_name.name);},
        {"./name.js":2}
     ],
    2 : 
     [
        function(require,module, exports){"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.name = void 0;
var name = exports.name = "前端关宇";},
        {}
     ],
    
     })
     

结果是:

总结

这里就实现了一个简单的webpack

核心就三步:

  • 1 将用户写的代码字符串 读取成ast, 一维的代码变成内存里面嵌套的树
  • 2 根据ast 递归构建依赖图,这里使用了队列, 嵌套的树变成一维的数组对象
  • 3 将数组对象整合成一个大的js输出

涉及到的知识点有:

  • 转译器的使用 babel-parser
  • 如何遍历ast
  • 如何将代码转换成commonjs语法 使用babel/preset-env
  • nodejs基础api fs path 的使用
  • package.json scripts的使用
  • webpack本质是一个nodejs的包,解决了多模块打包成一个浏览器可以识别的大的模块,并且通过构建依赖图,解决了不同js模块之间的手动维护依赖关系的难题,为什么要打包成一个模块?从网络的角度,只需要建立一次连接,避免频繁的请求多个文件的时候,tcp 建立和断开连接
  • es6 语法的使用 包括模版字符串的使用 函数 iife自执行函数可以充当作用域等
  • 数组的使用, 解构赋值
  • 数据结构队列的使用
  • 递归的使用
  • 其实写了一遍这个,es6语法就掌握大部分了。
  • 推荐你自己实现一遍,知其然知其所以然。

happy coding!

完整代码

js 复制代码
const fs = require("fs");
const path = require("path");
const { parse } = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const babel = require("@babel/core");
function createAst(filename) {
  const fileContent = fs.readFileSync(filename, "utf-8");
  console.log(fileContent);

  return parse(
    fileContent,
    //https://babel.dev/docs/babel-parser search  sourceType
    { sourceType: "module" }
  );
}

function genDeps(ast) {
  //遍历ast 收集依赖
  let deps = [];
  traverse(ast, {
    ImportDeclaration: ({ node }) => {
      let depFileName = node.source.value;
      deps.push(depFileName);
    },
  });

  return deps;
}

function getCode(ast) {
  const { code } = babel.transformFromAst(ast, null, {
    presets: ["@babel/preset-env"],
  });
  return code;
}

let ID = 0;
function createAsset(filename) {
  const ast = createAst(filename);
  let deps = genDeps(ast);
  const id = ID++;
  let code = getCode(ast);

  return {
    id,
    filename,
    deps,
    code,
  };
}

function handledeps(deps, asset, dirname, queue) {
  //asset 的数据结构 是一个对象 里面有{id, filename, deps} 三个属性
  deps.forEach((relativePath) => {
    //获取绝对路径
    const absolutePath = path.join(dirname, relativePath);
    //   console.log(absolutePath, relativePath); //example/message.js ./message.js
    //创建子内容 返回一个对象 里面有{id, filename, deps} 三个属性
    const childAsset = createAsset(absolutePath);
    //构建映射
    asset.mapping[relativePath] = childAsset.id;
    //向队列里面添加子内容 继续处理 因为子内容里面 可能还有依赖其他模块
    queue.push(childAsset);
  });
}
//entry 就是上面的filename 值就是这样的 ./example/entry.js
function createGraph(entry) {
  const mainAsset = createAsset(entry);
  const queue = [mainAsset]; //声明一个队列
  //构建队列

  for (let asset of queue) {
    let deps = asset.deps;
    const dirname = path.dirname(asset.filename);
    //添加一个mapping属性 用于做映射 数据结构是 key: relativePath , value : childAsset.id
    asset.mapping = {};
    //deps的数据结构 string[]   ['./message.js'] 这种
    handledeps(deps, asset, dirname, queue);
  }
  //返回队列
  return queue;
}
const entry = "./example/entry.js";
const graph = createGraph(entry);
console.log(graph.length);
const result = bundle(graph);

function bundle(graph) {
  let modules = "";

  //moduleObject : {id,filename, code, deps, mapping }
  graph.forEach(({ id, code, mapping }) => {
    let key = `${id}`;
    let value = `
     [
        function(require,module, exports){${code}},
        ${JSON.stringify(mapping)}
     ],
    `;
    modules += `${key} : ${value}`;
  });

  const result = `
     (function (modules) {
        function require(id){
            //获取传入对象里面的 一个模块 , 从模块数组里面取出fn 和 mapping
            const [fn, mapping] = modules[id]
            
            //定义自己的导入函数
            function localRequire(relativePath){
                let nextId = mapping[relativePath]
                return require(nextId)
            }

            //自己构造一个module 并且返回
            const module = {exports: {} };

            //调用传入的参数 并且
            //注入自己实现的localRequire module
            fn(localRequire, module, module.exports)

            //返回对象
            return module.exports
        }

        require(0)
     })({
      ${modules}
     })
     `;

  return result;
}
console.log("result", result);
相关推荐
GIS程序媛—椰子24 分钟前
【Vue 全家桶】7、Vue UI组件库(更新中)
前端·vue.js
DogEgg_00130 分钟前
前端八股文(一)HTML 持续更新中。。。
前端·html
ZL不懂前端33 分钟前
Content Security Policy (CSP)
前端·javascript·面试
木舟100937 分钟前
ffmpeg重复回听音频流,时长叠加问题
前端
王大锤43911 小时前
golang通用后台管理系统07(后台与若依前端对接)
开发语言·前端·golang
我血条子呢1 小时前
[Vue]防止路由重复跳转
前端·javascript·vue.js
黎金安1 小时前
前端第二次作业
前端·css·css3
啦啦右一1 小时前
前端 | MYTED单篇TED词汇学习功能优化
前端·学习
半开半落1 小时前
nuxt3安装pinia报错500[vite-node] [ERR_LOAD_URL]问题解决
前端·javascript·vue.js·nuxt