手写简单版的webpack打包工具

前言

webpack最近一直都有学习,但是一直都未能够很好的去了解它的一个实现方法以及实现过程做了什么,最近在某个培训机构看了一堂公开课,觉得还不错,今天照着来自己实现一个简单的webpack。

前提条件

希望在看这篇文章之前大家对webpack和node的一些api有着最基本的了解,这样子大家才能跟好的了解后面的内容,不熟悉的建议先官网学习一下。

webpack打包流程

  1. 初始化参数:从配置⽂件和 Shell 语句中读取与合并参数,得出最终的参数。
  2. 开始编译:⽤上⼀步得到的参数初始化 Compiler 对象,加载所有配置的插件,执⾏对象的 run
    ⽅法开始执⾏编译。
  3. 确定⼊⼝:根据配置中的 entry 找出所有的⼊⼝⽂件。
  4. 编译模块:从⼊⼝⽂件出发,调⽤所有配置的 Loader 对模块进⾏翻译,再找出该模块依赖的模块,再递归本步骤直到所有⼊⼝依赖的⽂件都经过了本步骤的处理。
  5. 完成模块编译:在经过第 4 步使⽤ Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系。
  6. 输出资源:根据⼊⼝和模块之间的依赖关系,组装成⼀个个包含多个模块的 Chunk,再把每个Chunk 转换成⼀个单独的⽂件加⼊到输出列表,这步是可以修改输出内容的最后机会。
  7. 输出完成:在确定好输出内容后,根据配置确定输出的路径和⽂件名,把⽂件内容写⼊到⽂件系统。
    以上几个步骤粗略的总结的打包过程webpack做的主要事情,接下来我们围绕这几点来一步步实现我们简易版的webpack,感受一下它的打包流程。

操作流程

  • 在自己电脑上任意一个地方新建一个文件夹,我这里新建了一个叫mywebpack的空文件夹,在空文件夹内新建一个source文件夹用于存放我们的js文件,有git bash的可以直接复制下面这条命令。
mkdir mywebpack && cd mywebpack && mkdir source

创建好文件夹后我们在source下面手动创建几个js文件,分别是entry.js,message.js,date.js。内容分别如下
entry.js

import message from './message.js';

console.log(message);

message.js

import {
    date
} from './date.js';

export default `today is ${date}`;

date.js

export const date = new Date().toLocaleString();

我们用箭头分析一下三个文件的依赖关系,entry.js是我们的入口文件,引入了message.js,message.js引入了date.js,所以三者的依赖关系为entry=>message=>date。

  • 上一步分析了依赖关系,接下来我们根据依赖关系和打包流程来编写打包文件,我在mywebpack的根目录下新建了一个index.js,首先,我们知道了入口文件,那我们就要从入口文件下手,去获取入口文件的内容,那么我们用的是node的fs模块的fs.readFileSync方法去读取entry.js的文件内容,并打印出来。
const fs = require('fs');

function createAsset(filename) {
    const content = fs.readFileSync(filename, 'utf-8');
    console.log(content);
}

createAsset('./source/entry.js');

写好后在终端执行文件,我用的是vscode的

node index.js

可以看到在终端已经输出了entry.js文件的内容
在这里插入图片描述
那么我们的第一步也算成功了。

  • 在我们获取到文件内容后接下来我们该获取entry.js的文件依赖了,虽然我们肉眼可以看出entry依赖文件有message,但是机器
    不行,我们需要分析整个文件的ast语法树来解析依赖,我们先用一个ast的在线分析工具试一下,https://astexplorer.net/,打开后我们把entry的文件内容复制过去。
    在这里插入图片描述

图中我们主要了解几个点就好,

  • 可以看到最上级是一个File, File中包含换一个program, 就是我们的程序;
  • 在program的body属性里, 就是我们各种语法的描述
  • 可以看到第一个就是 ImportDeclaration, 也就是引入的声明.
  • ImportDeclaration里有一个source属性, 它的value就是引入的文件地址 ‘./message.js’

那么有小伙伴可能会问,这也只是在线转换,有没有插件进行内部转换呢,答案肯定是有的,就是我们的babylon,一个基于babel的js解析工具,官方解释:Babylon is a JavaScript parser used in Babel,自行理解这句话的意思哈哈。

npm i babylon

装完插件发现没有init项目,没有packag.json文件,我们初始化一下项目

npm init

然后一直回车就完事了,这时候先看一下我们的项目目录
在这里插入图片描述
装完插件之后,我们在index.js文件内引入Babylon,并且使用它内置的方法解析我们的入口文件entry,最后打印看一下效果。

const fs = require('fs');
const babylon = require('babylon');

function createAsset(filename) {
    const content = fs.readFileSync(filename, 'utf-8');

    const ast = babylon.parse(content, {
        sourceType: 'module'
    });

    console.log(ast);
}

createAsset('./source/entry.js');

在这里插入图片描述
从输出来看没什么问题,可以看到输出了一个Object, 看起来和在线转换不大像,但这确实就是咱们entry.js的AST。

得到了ast后我们接着要拿它的ImportDeclaration,但是很明显打印出来的ast没有ImportDeclaration,那是因为我们还需要进行转换,这时候就是另一个插件的出场时间了,babel-traverse,官方解释:We can use it alongside Babylon to traverse and update nodes,老规矩,自行理解。

npm i babel-traverse --registry=https://registry.npm.taobao.org

安装完以后咱们利用它来遍历并获取到 ImportDeclaration 节点, 遍历到对应节点后, 可以提供一个函数来操作此节点。先引入,在调用traverse方法

const fs = require('fs');
const babylon = require('babylon');
const traverse = require('babel-traverse').default;

function createAsset(filename) {
    const content = fs.readFileSync(filename, 'utf-8');

    const ast = babylon.parse(content, {
        sourceType: 'module'
    });

    traverse(ast, {
        ImportDeclaration: ({
            node
        }) => {
            console.log(node)
        }
    })
}

createAsset('./source/entry.js');

终端运行输出
在这里插入图片描述
这不就有了么,想要啥有啥,美滋滋。

这里就又个问题,这里我们只手动写了一个依赖,单如果有多个依赖呢,是的,聪明的你可能已经想到了,我们用个数组接收这些traverse对象,我新建了一个dependencies数组

const fs = require('fs');
const babylon = require('babylon');
const traverse = require('babel-traverse').default;

function createAsset(filename) {
    const content = fs.readFileSync(filename, 'utf-8');

    const ast = babylon.parse(content, {
        sourceType: 'module'
    });

    const dependencies = [];

    traverse(ast, {
        ImportDeclaration: ({
            node
        }) => {
            dependencies.push(node.source.value);
        }
    })

    console.log(dependencies);
}

createAsset('./source/entry.js');

这里就不执行了,和上一步没区别,只是把节点放到了数组里面,但拗不过有些大兄弟,非得要康康,
在这里插入图片描述
那就给你康康吧,
在这里插入图片描述
这玩意就长这样,里面放的是每个依赖的path。

接下来我们就要优化函数 createAsset , 使其能够区分文件,因为要获取所有文件的依赖,所以咱们需要一个id来标识所有文件。这里咱们用一个简单的自增number,这样遍历的每一个文件的id就唯一了。同时咱们要先获取到入口文件的 id filename 以及 dependencies。

const fs = require('fs');
const babylon = require('babylon');
const traverse = require('babel-traverse').default;

let ID = 0;

function createAsset(filename) {
    const content = fs.readFileSync(filename, 'utf-8');

    const ast = babylon.parse(content, {
        sourceType: 'module'
    });

    const dependencies = [];

    traverse(ast, {
        ImportDeclaration: ({
            node
        }) => {
            dependencies.push(node.source.value);
        }
    })

    const id = ID++;

    return {
        id,
        filename,
        dependencies
    }
}

const mainAsset = createAsset('./source/entry.js');
console.log(mainAsset);

老规矩,终端输出康康效果!
在这里插入图片描述
OK,这样子我们单个文件的依赖就得到了,接下来我们还要获取整个项目的依赖,我们新写一个createGraph方法来操作,入参是我们的entry的路径,并把createAsset方法移到这个方法里面执行。

const fs = require('fs');
const babylon = require('babylon');
const traverse = require('babel-traverse').default;

let ID = 0;

function createAsset(filename) {
    const content = fs.readFileSync(filename, 'utf-8');

    const ast = babylon.parse(content, {
        sourceType: 'module'
    });

    const dependencies = [];

    traverse(ast, {
        ImportDeclaration: ({
            node
        }) => {
            dependencies.push(node.source.value);
        }
    })

    const id = ID++;

    return {
        id,
        filename,
        dependencies
    }
}

function createGraph(entry) {
    const mainAsset = createAsset(entry);
}

const graph = createGraph('./source/entry.js');
console.log(graph);

我们知道,之前单个文件的依赖是一个对象,就是mainAsset对象,我们需要用一个数组包裹起来方便我们遍历,不贴全部代码了,只贴一小部分,不要全部覆盖了噢,

function createGraph(entry) {
    const mainAsset = createAsset(entry);
    const allAsset = [mainAsset];

    for (let asset of allAsset) {
        asset.dependencies.forEach();
    }
}

其次就是,我们获取到的都是依赖文件的相对路径,我们需要转化成绝对路径才能拿到依赖文件,这时候就需要用到path.dirname方法了,记得在文件最上面

const path = require('path');
function createGraph(entry) {
    const mainAsset = createAsset(entry);
    const allAsset = [mainAsset];

    for (let asset of allAsset) {
        const dirname = path.dirname(asset.filename);
        asset.dependencies.forEach(relativePath => {
            const absoultePath = path.join(dirname, relativePath);
            const childAsset = createAsset(absoultePath);
        });
    }
}

这时候大兄弟们应该就有点懵了,谁跟谁啊,一大串的,
在这里插入图片描述
那我就来简单讲解一下吧,我们遍历的对象是这样的,asset对象,
在这里插入图片描述
然后大家对着来看是不是就好多了,考虑到依赖文件可能也会依赖其他文件,比如我们的message依赖date,因此我们需要递归调用createAsset方法去获取子依赖文件,接下来我们还需要一个map,来记录dependencies中的相对路径 和 childAsset的对应关系,方便后续做依赖的引入,最后把得到的子依赖push到集合中,并把结果返回出去。

function createGraph(entry) {
    const mainAsset = createAsset(entry);
    const allAsset = [mainAsset];

    for (let asset of allAsset) {
        const dirname = path.dirname(asset.filename);

        asset.mapping = {};

        asset.dependencies.forEach(relativePath => {
            const absoultePath = path.join(dirname, relativePath);

            const childAsset = createAsset(absoultePath);

            asset.mapping[relativePath] = childAsset.id;

			allAsset.push(childAsset);
        });
    }
    return allAsset;
}


const graph = createGraph('./source/entry.js');
console.log(666, graph);

在这里插入图片描述
就酱紫,我们收集到了所有的文件依赖,接下来就是最刺激,有多刺激?比游戏还刺激就是了,就是打包~
在这里插入图片描述
这里又要我们新建一个方法了,用于打包生成的graph,

function bundle(graph) {

}

const graph = createGraph('./source/entry.js');
const result = bundle(graph)
console.log(result);

那么bundle里面装的是什么呢?主要就是创建整体的代码结果,这么说大家可能不大清楚,具体就是我们需要把每个文件模块的modules传递给一个函数,但他要立即执行,因此这里我们会用到自执行函数,具体如下。

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

    const result = `
        (function() {
            
        })({${modules}})
    `;
}

那么modules哪里来呢?这个只要我们遍历一下graph就可以拿到了,

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

    graph.forEach(module => {
        modules += `${module.id}:[

        ],`;
    })

    const result = `
        (function() {
            
        })({${modules}})
    `;
}

在这里, 每一个module.id对应的value,应该有当前module的可执行代码, 也就是CommonJs规范的代码。(这里可以看一下babel 在线演示工具, 展示一下是什么样子的代码https://babeljs.io/repl#?browsers=defaults%2C%20not%20ie%2011%2C%20not%20ie_mob%2011&build=&builtIns=false&corejs=false&spec=false&loose=false&code_lz=Q&debug=false&forceAllTransforms=true&shippedProposals=false&circleciRepo=&evaluate=true&fileSize=false&timeTravel=false&sourceType=module&lineWrap=true&presets=env%2Creact%2Cstage-0%2Cstage-1%2Cstage-2%2Cstage-3&prettier=true&targets=&version=7.14.2&externalPlugins= )
在这里插入图片描述
我们把entry的代码copy进去可以看到进行了babel的转换,这些就是打包后的代码,但我们上面的过程仅仅是分析了依赖,并没有获取到每个依赖文件的源代码,所以咱们添加一下模块代码的记录,我们需要修改一下createAsset方法,在里面获取所有code并且编译。我们需要安装一下babel-core,还会用到babel-preset-env作为预设来编译代码。

npm i babel-core

npm i babel-preset-env

安装完记得在顶部引入,然后改造createAsset

const fs = require('fs');
const path = require('path');
const babylon = require('babylon');
const traverse = require('babel-traverse').default;
const babel = require('babel-core');

let ID = 0;

function createAsset(filename) {
    const content = fs.readFileSync(filename, 'utf-8');

    const ast = babylon.parse(content, {
        sourceType: 'module'
    });

    const dependencies = [];

    traverse(ast, {
        ImportDeclaration: ({
            node
        }) => {
            dependencies.push(node.source.value);
        }
    })

    const id = ID++;

    const {
        code
    } = babel.transformFromAst(ast, null, {
        // 告诉babel以什么方式编译我们的代码
        presets: ['env']
    })

    return {
        id,
        filename,
        dependencies,
        code
    }
}

然后我们再执行一遍文件,看看结果
在这里插入图片描述
可以看到经过babel的处理,我们拿到了编译后的文件代码,现在我们回到bundle方法,接下来我们就可以遍历我们graph来拿到我们的每个文件的module,拼接成我们的modules,

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

    graph.forEach(module => {
        modules += `${module.id}:[
            function(require, module, exports) {
                ${module.code}
            },
        ],`;
    })

    const result = `
        (function() {
            
        })({${modules}})
    `;

    return result;
}

有小伙伴会问为什么要传require,module,exports,其实我们再看一遍咱们bebal在线演示编译后的代码, 想一下咱们CommonJS的规范, 每个模块的代码函数其实要接收3个参数require,module,exports

CommonJS规范规定,每个模块内部:

  1. module变量代表当前模块。
    这个变量是一个对象,它的exports属性(即module.exports)是对外的接口。加载某个模块,其实是加载该模块的module.exports属性。

  2. require方法用于加载模块。

从上面的几点来看也不难理解我们为什么要传这几个参数了,但是这几个参数从哪里拿呢?其实module和exports其实咱们已经可以非常方便的拿到了,但是require去哪获取呢,没有方法获取,只能我们自己实现require函数,首先咱们要先把要引入需要的mapping给放到modules里。

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

    graph.forEach(module => {
        modules += `${module.id}:[
            function(require, module, exports) {
                ${module.code}
            },
            ${JSON.stringify(module.mapping)},
        ],`;
    })

    const result = `
        (function() {
            
        })({${modules}})
    `;

    return result;
}

const graph = createGraph('./source/entry.js');
const result = bundle(graph)
console.log(666, result);

有同学可能又忘了mapping是什么,我把上面的图放一下你就知道了

在这里插入图片描述
接着砸门再来输出一波
在这里插入图片描述
眉头一皱,发现事情并不简单,这东西也太丑了,大家将就着看吧,实在不行,终端:那我走?

接下来实现require方法,require方法应该接收一个参数, 来表示要引入哪些代码,那么咱们可以用id来实现,因为前面用一个mapping存了依赖的relativePath和模块id的映射关系。

    // 记住这里modules的数据结构, 取出来的fn和mapping分别是什么?
    const result = `
        (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}})
    `;

不知道走到这一步大家还记不记得fn和mapping分别是什么,其实就是他们两
在这里插入图片描述
webpack到这里基本就已经完成了, 咱们运行一下代码看看。
在这里插入图片描述
最终打包出来的模块代码就长这样子,我们复制到浏览器运行时试试看
在这里插入图片描述
成功!!!相信各位老铁到这里都对webpack的内部运作有了一定的了解,出去面试又能吹一波了,希望这篇文章对大家有所帮助,学不会的就是彬彬了,哈哈,拜拜。

有老铁私信说老是手动运行太麻烦,能不能打包到一个文件里,没问题


"scripts": {
    "build": "rm -rf dist && mkdir dist && node mywebpack.js > dist"
},

把这段加到你的package.json,然后就可以开心 npm run build了。

如有雷同,纯属巧合,侵权必删

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值