别再去网上找前端资料了,大厂前端工程师教你手写一个webpack

关注我,学前端,不迷路,文末有福利!

Webpack 是前端很火的打包工具,它本质上是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。当 Webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序需要的每个模块,然后将所有模块打包成一个或多个bundle

其实就是:Webpack 是一个 JS 代码打包器。

至于图片、CSS、Less、TS等其他文件,就需要 Webpack 配合 loader 或者 plugin 功能来实现~

一、Webpack 构建流程分析

1. Webpack 构建过程

首先先简单了解下 Webpack 构建过程:

根据配置,识别入口文件;

逐层识别模块依赖(包括 Commonjs、AMD、或 ES6 的 import 等,都会被识别和分析);

Webpack 主要工作内容就是分析代码,转换代码,编译代码,最后输出代码;

输出最后打包后的代码。

2. Webpack 构建原理

看完上面的构建流程的简单介绍,相信你已经简单了解了这个过程,那么接下来开始详细介绍 Webpack 构建原理,包括从启动构建到输出结果一系列过程:

(1)初始化参数

解析 Webpack 配置参数,合并 Shell 传入和webpack.config.js文件配置的参数,形成最后的配置结果。

(2)开始编译

上一步得到的参数初始化compiler对象,注册所有配置的插件,插件监听 Webpack 构建生命周期的事件节点,做出相应的反应,执行对象的run方法开始执行编译。

(3)确定入口

从配置文件(webpack.config.js)中指定的entry入口,开始解析文件构建 AST 语法树,找出依赖,递归下去。

(4)编译模块

递归中根据文件类型和 *loader* 配置,调用所有配置的 loader 对文件进行转换,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理。

(5)完成模块编译并输出

递归完后,得到每个文件结果,包含每个模块以及他们之间的依赖关系,根据entry配置生成代码块chunk

(6)输出完成

输出所有的chunk到文件系统。

注意:在构建生命周期中有一系列插件在做合适的时机做合适事情,比如UglifyPlugin会在 loader 转换递归完对结果使用UglifyJs压缩覆盖之前的结果。

二、手写 Webpack 构建工具

到这里,相信大家对 Webpack 构建流程已经有所了解,但是这还不够,我们再来试着手写 Webpack 构建工具,来将上面文字介绍的内容,应用于实际代码,那么开始吧~

1. 初始化项目

在手写构建工具前,我们先初始化一个项目:

$ yarn init -y

并安装下面四个依赖包:

@babel/parser : 用于分析通过 fs.readFileSync读取的文件内容,并返回 AST (抽象语法树) ;

@babel/traverse : 用于遍历 AST, 获取必要的数据;

@babel/core : babel 核心模块,提供 transformFromAst 方法,用于将 AST 转化为浏览器可运行的代码;

@babel/preset-env : 将转换后代码转化成 ES5 代码;

$ yarn add @babel/parser @babel/traverse @babel/core @babel/preset-env

初始化项目目录及文件:

代码存放在仓库:https://github.com/pingan8787/Leo-JavaScript/tree/master/Cute-Webpack/Write-Webpack

由于本部分核心内容是实现 Webpack 构建工具,所以会从《2. Webpack 构建原理》的“(3)确定入口”步骤开始下面介绍。

大致代码实现流程如下:

从图中可以看出,手写 Webpack 的核心是实现以下三个方法:

createAssets : 收集和处理文件的代码;

createGraph :根据入口文件,返回所有文件依赖图;

bundle : 根据依赖图整个代码并输出;

2. 实现 createAssets 函数

2.1 读取通过入口文件,并转为 AST

首先在./src/index文件中写点简单代码:

// src/index.jsimport info from "./info.js";console.log(info);

实现createAssets方法中的 文件读取 和 AST转换 操作:

// leo_webpack.jsconst fs = require("fs");const path = require("path");const parser = require("@babel/parser");const traverse = require("@babel/traverse").default;// 由于 traverse 采用的 ES Module 导出,我们通过 requier 引入的话就加个 .defaultconst babel = require("@babel/core");let moduleId = 0;const createAssets = filename => { const content = fs.readFileSync(filename, "utf-8"); // 根据文件名,同步读取文件流  // 将读取文件流 buffer 转换为 AST const ast = parser.parse(content, { sourceType: "module" // 指定源码类型    }) console.log(ast);}createAssets('./src/index.js');

上面代码:

通过fs.readFileSync()方法,以同步方式读取指定路径下的文件流,并通过parser依赖包提供的parse()方法,将读取到的文件流 buffer 转换为浏览器可以认识的代码(AST),AST 输出如下:

另外需要注意,这里我们声明了一个moduleId变量,来区分当前操作的模块。

在这里,不仅将读取到的文件流 buffer 转换为 AST 的同时,也将 ES6 代码转换为 ES5 代码了。

2.2 收集每个模块的依赖

接下来声明dependencies变量来保存收集到的文件依赖路径,通过traverse()方法遍历ast,获取每个节点依赖路径,并pushdependencies数组中。

// leo_webpack.jsfunction createAssets(filename){ // ... const dependencies = []; // 用于收集文件依赖的路径 // 通过 traverse 提供的操作 AST 的方法,获取每个节点的依赖路径    traverse(ast, { ImportDeclaration: ({node}) => {            dependencies.push(node.source.value);        }    });}

2.3 将 AST 转换为浏览器可运行代码

在收集依赖的同时,我们可以将 AST 代码转换为浏览器可运行代码,这就需要使用到babel,这个万能的小家伙,为我们提供了非常好用的transformFromAstSync()方法,同步的将 AST 转换为浏览器可运行代码:

// leo_webpack.jsfunction createAssets(filename){ // ... const { code } = babel.transformFromAstSync(ast,null, { presets: ["@babel/preset-env"]    }); let id = moduleId++; // 设置当前处理的模块ID return {        id,        filename,        code,        dependencies    }}

到这一步,我们在执行node leo_webpack.js,输出如下内容,包含了入口文件的路径filename、浏览器可执行代码code和文件依赖的路径dependencies数组:

$ node leo_webpack.js{   filename: './src/index.js',  code: '"use strict";

var _info = _interopRequireDefault(require("./info.js"));

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

console.log(_info["default"]);',   dependencies: [ './info.js' ] }

2.4 代码小结

// leo_webpack.jsconst fs = require("fs");const path = require("path");const parser = require("@babel/parser");const traverse = require("@babel/traverse").default;// 由于 traverse 采用的 ES Module 导出,我们通过 requier 引入的话就加个 .defaultconst babel = require("@babel/core");let moduleId = 0;function createAssets(filename){ const content = fs.readFileSync(filename, "utf-8"); // 根据文件名,同步读取文件流  // 将读取文件流 buffer 转换为 AST const ast = parser.parse(content, { sourceType: "module" // 指定源码类型    }) const dependencies = []; // 用于收集文件依赖的路径 // 通过 traverse 提供的操作 AST 的方法,获取每个节点的依赖路径    traverse(ast, { ImportDeclaration: ({node}) => {            dependencies.push(node.source.value);        }    }); // 通过 AST 将 ES6 代码转换成 ES5 代码 const { code } = babel.transformFromAstSync(ast,null, { presets: ["@babel/preset-env"]    });  let id = moduleId++; // 设置当前处理的模块ID return {       id,        filename,        code,        dependencies    }}

3. 实现 createGraph 函数

createGraph()函数中,我们将递归所有依赖模块,循环分析每个依赖模块依赖,生成一份依赖图谱。

为了方便测试,我们补充下consts.jsinfo.js文件的代码,增加一些依赖关系:

// src/consts.jsexport const company = "平安";// src/info.jsimport { company } from "./consts.js";export default `你好,${company}`;

接下来开始实现createGraph()函数,它需要接收一个入口文件的路径(entry)作为参数:

// leo_webpack.jsfunction createGraph(entry) { const mainAsset = createAssets(entry); // 获取入口文件下的内容 const queue = [mainAsset]; // 入口文件的结果作为第一项 for(const asset of queue){ const dirname = path.dirname(asset.filename);        asset.mapping = {};        asset.dependencies.forEach(relativePath => { const absolutePath = path.join(dirname, relativePath); // 转换文件路径为绝对路径 const child = createAssets(absolutePath); asset.mapping[relativePath] = child.id; // 保存模块ID  queue.push(child); // 递归去遍历所有子节点的文件        })    } return queue;}

上面代码:

首先通过createAssets()函数读取入口文件的内容,并作为依赖关系的队列(依赖图谱)queue数组的第一项,接着遍历依赖图谱queue每一项,再遍历将每一项中的依赖dependencies依赖数组,将依赖中的每一项拼接成依赖的绝对路径(absolutePath),作为createAssets()函数调用的参数,递归去遍历所有子节点的文件,并将结果都保存在依赖图谱queue中。

注意,mapping对象是用来保存文件的相对路径和模块 ID 的对应关系,在mapping对象中,我们使用依赖文件的相对路径作为key,来存储保存模块 ID。

然后我们修改启动函数:

// leo_webpack.js- const result = createAssets('./src/index.js');+ const graph = createGraph("./src/index.js");+ console.log(graph);

这时我们将得到一份包含所有文件依赖关系的依赖图谱:

这个依赖图谱,包含了所有文件模块的依赖,以及模块的代码内容。下一步只要实现bundle()函数,将结果输出即可。

4. 实现 bundle 函数

从前面介绍,我们知道,函数createGraph()会返回一个包含每个依赖相关信息(id / filename / code / dependencies)的依赖图谱queue,这一步就将使用到它了。

bundle()函数中,接收一个依赖图谱graph作为参数,最后输出编译后的结果。

4.1 读取所有模块信息

我们首先声明一个变量modules,值为字符串类型,然后对参数graph进行遍历,将每一项中的id属性作为key,值为一个数组,包括一个用来执行代码code的方法和序列化后的mapping,最后拼接到modules中。

// leo_webpack.jsfunction bundle(graph) { let modules = "";    graph.forEach(item => {        modules += ` ${item.id}: [                function (require, module, exports){ ${item.code}                }, ${JSON.stringify(item.mapping)}            ],        `    })}

上面代码:

modules中每一项的值中,下标为 0 的元素是个函数,接收三个参数require/module/exports,为什么会需要这三个参数呢?

原因是:构建工具无法判断是否支持require/module/exports这三种模块方法,所以需要自己实现(后面步骤会实现),然后方法内的code才能正常执行。

4.2 返回最终结果

接着,我们来实现bundle()函数返回值的处理:

// leo_webpack.jsfunction bundle(graph) { //... return `        (function(modules){            function require(id){                const [fn, mapping] = modules[id];                function localRequire(relativePath){                    return require(mapping[relativePath]);                }                const module = {                    exports: {}                }                fn(localRequire, module, module.exports);                return module.exports;            }            require(0);        })({${modules}})    `}

上面代码:

最终bundle函数返回值是一个字符串,包含一个自执行函数(IIFE),其中函数参数是一个对象,keymodulesvalue为前面拼接好的modules字符串,即{modules: modules字符串}

在这个自执行函数中,实现了require方法,接收一个id作为参数,在方法内部,分别实现了localRequire/module/modules.exports三个方法,并作为参数,传到modules[id]中的fn方法中,最后初始化require()函数(require(0);)。

4.3 代码小结

// leo_webpack.jsfunction bundle(graph) { let modules = "";    graph.forEach(item => {        modules += ` ${item.id}: [                function (require, module, exports){ ${item.code}                }, ${JSON.stringify(item.mapping)}            ],        `    }) return `        (function(modules){            function require(id){                const [fn, mapping] = modules[id];                function localRequire(relativePath){                    return require(mapping[relativePath]);                }                const module = {                    exports: {}                }                fn(localRequire, module, module.exports);                return module.exports;            }            require(0);        })({${modules}})    `}

5. 执行代码

当我们上面方法都实现以后,就开始试试吧:

// leo_webpack.jsconst graph = createGraph("./src/index.js");const result = bundle(graph);console.log(result)

这时候可以看到终端输出类似这样的代码,是字符串,这里为了方便查看而复制到控制台了:

这就是打包后的代码咯~

那么如何让这些代码执行呢?用eval()方法咯:

// leo_webpack.jsconst graph = createGraph("./src/index.js");const result = bundle(graph);eval(result);

这时候就能看到控制台输出你好,平安。那么我们就完成一个简单的 Webpack 构建工具啦~

能看到这里的朋友,为你点个赞~

三、总结

本文主要介绍了 Webpack 的构建流程和构建原理,并在此基础上,和大家分享了手写 Webpack 的实现过程,希望大家对 Webpack 构建流程能有更深了解,毕竟面试贼喜欢问啦~最后给大家分享一个web前端学习交流群,大家QQ扫描下方二维码即可免费进群。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,524评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,869评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,813评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,210评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,085评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,117评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,533评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,219评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,487评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,582评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,362评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,218评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,589评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,899评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,176评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,503评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,707评论 2 335