阅读 7687

源码篇(三):手写webpack版mini源码分析项目构建过程。附送简版webpack源码

适合人群

本文适合2~4年的前端开发人员的进阶,且对vue或者react搭配webpack有一定的经验。如果webpack的基本使用都未了解,建议实践后再看本文。

本文是原创,均为本人手写。部分思维, 借鉴了“宫小白”的webpack文章。文章结尾标注了“感谢”。

再谈谈博客源码的进度

此前有人关心博客源码的进度问题。笔者是打算利用额外的时间,把脑袋中的源码都撸一份。即源码篇,具体可参考如下:

| 序号 | 博客主题 | 相关链接 | |-----|------|------------|- | 1 | 手写vue_mini源码解析 | juejin.im/post/684790… | | 2 | 手写react_mini源码解析 | juejin.im/post/685457… | | 3 | 手写webpack_mini源码解析(即本文) | juejin.im/post/685457… | | 4 | 手写jquery_mini源码解析| juejin.im/post/685457… | | 5 | 手写vuex_mini源码解析 | juejin.im/post/685705… | | 6 | 手写vue_router源码解析 | 预计8月 | | 7 | 手写diff算法源码解析 | 预计8月 | | 8 | 手写promis源码解析 | 预计8月 | | 9 | 手写原生js源码解析(手动实现常见api) | 预计8月 | | 10 | 手写react_redux,fiberd源码解析等 | 待定,本计划先出该文,整理有些难度 | | 11 | 手写koa2_mini | 预计9月,前端优先 | 期间除了除了源码篇,可能会写一两篇优化篇,基础篇等。有兴趣的欢迎持续关注。

讲讲废话

当前主流的前端,无论angular,vue还是react,都离不开的构建工具的编译与协助。比较有名气是有Webpack、Gulp 和 Grunt。

Grunt笔者没有实践经验,且相对另外两者比较偏冷门些,本文不做比较。

来个简单的高频面试题:

gulp与webpack有什么区别?

讲述一下个人理解(仅供参考):

gulp官方原话:gulp 将开发流程中让人痛苦或耗时的任务自动化,从而减少你所浪费的时间、创造更大价值。他更像是一个流水线上的过滤器,定义多task(即使多个过滤条件),讲我们的文件进行转译。比如我们将sass统一转译成css,或者文件的合并压缩等,专注于task的执行。笔者开发一个门户网站的时候,就将起引入,他精简的 API 集,只需一个文件代码,即帮我们完成了一个项目针对某些文件的改造,且使用于多页面的构建。

但单页面的构建,不得不用webpack。webpack有人称模块打包机,更侧重于模块打包。本质是为了打包js设计的,但是后期因为要针对整个前端项目,即支持loader对其他文件进行编译。

写源码之前的必备知识点

手写loader

写webpack的源码,你首先得懂得loader跟plugins是如何去加载的。本文写一个简单的栗子,让大家理解loader是个什么东西。

那我们就先看看大神写的loader是个什么东西。我们以less-loader为栗子:

npm install less-loader安装后,我们直接进入node_moudels/less-loader/package.json

查看main变量 我们可以看到: "main": "dist/cjs.js",

那么我们再看dist/cjs.js又指向index。他的源码在index.js文件中,我们来看index.js的代码:

图解中,我们可以分析到,他的首个参数,即是接受到文本内容。loaderContext即是将less转换为css的过程。但是整个loader,其实可以很清晰到看到。接受到source,然后通过转换,再暴露出去。按着这个思路,我们手写一个loader应该不难。

我们定义一个简单的需求:

  • 正式环境中,去除项目中所有的console.log()

  • 最后再打印出一个,关注博主博客

    const join = require('path').join; const fs = require('fs');

    function wzJsLoader(source) {

      /*过滤输出*/
      const mode = process.env.NODE_ENV === 'production'
      if( mode ){//正式环境
          source = source.replace(/console.log\(.*?\);/ig, value => "" );
      }
    
      /* 打上个人标记 */
      const tip = "关注博主博客:https://juejin.im/user/4195392104696519/posts ";
      source  =  source + `console.log("${tip}");`
      return source;
    复制代码

    };

    module.exports = wzJsLoader;

然后再我们的配置文件中,配置上上述的loader。

{
    test: /\.js$/, //js文件加载器
    //loader: 'happypack/loader?id=happyBabel',
    exclude: /node_modules/,
    use: [
        {
          loader: 'babel-loader?cacheDirectory=ture',
          options: {
            presets: ['@babel/preset-env']
          }
        },
        {
            loader: require.resolve("./myWebPackConfig/wz-js-loader"),
            options: { name: __dirname  },
        },
    ]
  },
复制代码

那么,我们就完成我们的第一个手写loader。

其实plugins也一样的原理,这里不做重复讲解,本文demo中会提到plugins。

tapable

tapable一些前端朋友(没了解过的webpack)可能很陌生,包括笔者之前也是,直到了解到webpack时才去理解这个概念。我们首先要了解他是个什么东西,再去写webpack可能更好了解一下。这里分为简版说明,跟代码解释。如知识想了解webpack的执行机制,可能看简版说明即可。

简版说明

同步:

名称解释
SyncHook同步执行,无需返回值
SyncBailHook同步执行,无需返回值,返回undefined终止
SyncWaterfallHook同步执行,上一个处理函数的返回值是下一个的输入,返回undefined终止
SyncLoopHook同步执行, 订阅的处理函数有一个的返回值不是undefined就一直循环它

异步:

名称解释
AsyncSeriesHook异步执行,无需返回值
AsyncParallelHook
AsyncSeriesBailHook异步执行,无需返回值,返回undefined终止
AsyncSeriesWaterfallHook异步执行,上一个处理函数的返回值是下一个的输入,返回undefined终止

看了tapable具体的几个方法的作用,大概了解,那么为什么webpack要扯上他呢? 因为webpack的构建流程,很多的跟着tapable的执行顺序方案有关。

SyncHook就最好理解,同步执行,我们不同的plugins之间,只需要同步执行下去即可。这时候我们可以利用SyncHook按顺序同步执行SyncHook执行我们的plugins即可。那么另外的场景是什么?

HappyPack知道是什么麽?例如开启多个线程,同步打包。是不是符合AsyncSeriesHook的场景。

再举个最简单的栗子: 我们生成文件时候,是不是有一个hash值呢?假设这个hash值,有多个插件用到,我们是不是应该在第一个方法生成一个hash,然后后续用到的方法,读取到这个值。那么,这个场景,SyncWaterfallHook是否派上用场?此时的hash一直传递下去,就完成了不同plugins之间的hash值传递。

还是不明白?案例实现会根据这个栗子实现。 如果简版本的介绍,还是一脸懵逼,建议看一下下列源码。

代码解释

代码的解释,是为了让你更了解tapable。如果你只想知道webpack,可以直接忽略。

tapable这里,他本身是暴露了8个方法(上述)。但是我们简版没有那么复杂,我们讲解一下下述用到SyncHook跟AsyncSeriesWaterfallHook。

SyncHook

SyncHook我们大致理解,他有一个tap将任务添加的内部的执行队列中。此时,如果调用call方法,将执行同步执行所有tap过的方法。具体可以通过下述代码理解:

class SyncHook {
	constructor(args){//args => ['name']
		this.tasks = [];
	}
	call(...args){
		this.tasks.forEach((task) => task(...args))
	}
	tap(name,task){
		this.tasks.push(task);
	}
}

let hook = new SyncHook(['name']);
hook.tap('plugins_0',function(name){
	console.log('plugins_0',name)
})
hook.tap('plugins_1',function(name){
	console.log('plugins_1',name)
})
hook.call('hello word');
复制代码
AsyncSeriesWaterfallHook

AsyncSeriesWaterfallHook我们大致理解,他有一个tap将任务添加的内部的执行队列中。此时,如果调用call方法,将执行同步执行所有tap过的方法,且需上一个返回值,给下一个函数队列。具体可以通过下述代码理解:

class SyncWaterfallHook {
    //一般是可以接收一个数组参数的,但是下面没有用到
    constructor(args) {
        this.tasks = []
    }
    tap(name, cb) {
        let obj = {}
        obj.name = name
        obj.cb = cb
        this.tasks.push(obj)
    }
    call(...arg) {
        let [first, ...others] = this.tasks
        let ret = first.cb(...arg)
        others.reduce((pre, next) => {
            return next.cb(pre)
        }, ret)
    }
}
复制代码

AST语法树

该章节,建议移步文章:https://www.jianshu.com/p/019d449a9282

手写webpack过程

1)基本架子的搭建

手动新建

新建一个package.json,入口文件我们制定了bin/index.js

    {
        "name": "zwzpack",
        "version": "0.1.0",
        "private": true,
        "scripts": {
            "start": "node  ./bin/index.js"
        },
        "devDependencies": {},
        "dependencies": {
        }
    }
    
复制代码

再新建bin/index.js表示我们的入口文件,参考我们webpack常规写法,引入自写的webpack的lib包,再引入我们的配置文件webpack.config.js。启动他

    const path = require('path')
    
    const config = require(path.resolve('webpack.config.js'))
    const WebpackCompiler = require('../lib/WebpackCompiler.js')
    
    const webpackCompiler = new WebpackCompiler(config)
    webpackCompiler.run();
复制代码

根目录定义webpack.config.js,我们首先定义一个webpack的基本配置文件。

    module.exports = {
        mode: 'development',
        entry: './src/index.js',
        output: {
            filename: 'main.js',
            path: path.join(__dirname, './dist')
        },
        module: {
            rules: []
        },
        plugins: [
        ]
    }
    
复制代码

public/index.html,作为我们测试webpack是否打包成功测试Html,引入打包后的js:

<html>
    <head>
        <meta charset="UTF-8">
        <title>个人webpack</title>
        <meta name="viewport" content="width=device-width,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no" />    
    </head>
    <script type="text/javascript" src="./main.js"  ></script>
    <body>
        <div class="my_page" >
            <h1 class="">欢迎小伙子小姑凉!</h1>
            <div class="">欢迎关注掘金:<a target="_blank" href="https://juejin.im/user/4195392104696519">点击</a></div>
            <div class="">手写mini_webpack教程:<a  target="_blank"  href="https://juejin.im/editor/drafts/5f1793716fb9a07e8b215a62">点击</a></div>
        </div>
    </body>
    <style>
        
    </style>
</html>
复制代码

WebpackCompiler是我们的核心编译类,下方重点讲解。

    class WebpackCompiler {
        constructor(config) {
            this.config = config;
        }
        
        run() {
            //编译开始
        }
    }

module.exports = WebpackCompiler;
复制代码

就这样,完成我们基本的架子,此部分暂时未涉及wepback的编译过程。

快速拷贝

直接拷贝:https://github.com/zhuangweizhan/codeShare/tree/master/19.zWebpack%EF%BC%88%E7%AE%80%E7%89%88webpack%E6%BA%90%E7%A0%81%EF%BC%89

2) 新建编译模板

webpack的最终目的,是为了生成js文件。(暂不考虑其他文件因素) 我们要自己手写一个webpack生成的js文件,那么首先,我们需要了解官方webpack生成的js文件到底是个啥?

我们新建一个官方webpack项目(百度一下很简单)

新建test.js,跟testChildren.js进行编译:

test.js:

const obj = require("./testChildren.js");
module.exports = { name: "weizhan",  obj }
复制代码

testChildren.js

module.exports = { age: 18}
复制代码

我们来观察编译结果:

图1:

图2:

截图大家还看不懂生成了什么的话,我再简化一下官方最后生成的文件,其中有变化的部分为:

((function(modules) {
...
//固定代码
    return __webpack_require__(__webpack_require__.s = "<%-文件路径 %>");
})

 "./src/js/test.js": (function (module, exports,__webpack_require__) {
    eval(` const obj = __webpack_require__("./testChildren.js");  test1.js 剩余文件代码`);
}),
"./src/js/testChildren.js": (function (module, exports,__webpack_require__) {
    eval(`testChildren.js 文件代码`);
}),
    
复制代码

我们可以观察到,生成的js文件中,将生成原来的js文件,套入一个模板中,而我们自己js代码给嵌入到结尾部分。 观察我简化后的文件,我们可以看到,webpack官方生成的js文件,他的生成规律:

  • 1.先快速拷贝一个编译模板,中间嵌入我们自己写的代码
  • 2.需要暴露我们的文件路径
  • 3.会将我们的js文件中的require转化为__webpack_require__,然后将所有require的js(包含自己)都用嵌入到function中的eval中。

了解完官方的生成规矩后,我们来模拟这个过程:

  • 根据规则1,首先有一个固定的编译模板,我们可以将模板内容先写在一个js中,但是为方便渲染嵌入,我们借用ejs的快速渲染模板
  • 根据规则2,我们需要知道我们的文件路径,ok,我们定义一个变量entryPath来标识文件路径
  • 根据规则3,需要把每个模块的代码到嵌入文件中,同样的我们要以路径为Key。那我们用modules变量来作为数组。(且原文件还有require转化为__webpack_require__)

根据规则,我们新建lib/main.ejs文件:

 (function(modules) {
...
//固定代码
    return __webpack_require__(__webpack_require__.s = "<%- entryPath %>");
})

({
    <% for(let key in modules){ %>
        "<%- key %>": (function (module, exports,__webpack_require__) {
            eval(`<%-modules[key] %>`);
    }),
     <% } %>
});
复制代码

3) 获取模板参数

生成JS的模板有了,那么我们剩余的问题就是,怎么拿到modules跟entryPath的问题。

entryPath好处理,用过webpack的都知道,入口文件即是webpack.config.js的entry变量。 那么modules呢?首先我们需要读取原来的文件内容。上文提到,两个重点:

  • 1.将require替换成__webpack_require__
  • 2.再所有的页面路径,跟页面内容封装一个数组。

需求即是如此,我首先想到的时候,写一个正则表达式,将require替换成__webpack_require__,且用node的fs,将文件内容读取。但是后续发现,兼容性实在是太烂了,空白符,注释等,都会成为头疼的地方。

后续发现大神的解析方法非常赞,用的是babylon 转换 AST转换的方法,我们来看看代码:

// babylon主要把源码转成ast。Babylon 是 Babel 中使用的 JavaScript 解析器。
// @babel/traverse 对ast解析遍历语法树 负责替换,删除和添加节点
// @babel/types 用于AST节点的Lodash-esque实用程序库
// @babel/generator 结果生成

const babylon = require('babylon')
const traverse = require('@babel/traverse').default;
const type = require('@babel/types');
const generator = require('@babel/generator').default

// 根据路径解析源码
parse(source, parentPath) {
    let ast = babylon.parse(source)//
    // 用于存取依赖
    let dependencies = []
    traverse(ast, {//对ast解析遍历语法树 负责替换,删除和添加节点
        CallExpression(p) {
            let node = p.node
            if (node.callee.name === 'require') {
                node.callee.name = '__webpack_require__';//将require替换成__webpack_require__
                const moduledName = './' + path.join(parentPath, node.arguments[0].value )
                dependencies.push(moduledName);//记录包含的requeir的名称,后边需要遍历替换成源码
                node.arguments = [type.stringLiteral(moduledName)] // 源码替换
            }
        }
    })
    let sourceCode = generator(ast).code
    return { sourceCode, dependencies };
}
复制代码

AST的内容如果不清楚,上述已经给了链接,先继续步骤继续下去,后续可以根据链接再补充一下自己。 js转换的方法已经抒写完毕,我们来写转换的入口。

// 编译生成完成的main文件,完成递归
buildMoudle(modulePath, isEntry) {
    const source = this.getSourceByPath(modulePath);//根据路径拿到源码
    const moduleName = './' + path.relative(this.root, modulePath);//转换一下路径名称
    const { sourceCode, dependencies } = this.parse(source, path.dirname(moduleName))//根据路径拿到源码,以及源码中已经require的文件名称数组
    this.modules[moduleName] = sourceCode;// 每个模块的代码都通过路径为Key,存入到modules对象中
    dependencies.forEach(item => {  // 递归需要转换的文件名称
        this.buildMoudle(path.resolve(this.root, item));////再对应的文件名称,替换成对应的源码
    })
}

getSourceByPath(modulePath){
    let content = fs.readFileSync(modulePath, 'utf8')
    return content
}
复制代码

此时,如果程序执行完毕,我们就可以拿到我们的modules参数。

4) 将模板生成js文件输出

这个章节很好理解,根据文件路径,拿到模板内容,模板变量的值,输出到指定位置即可。

//输出文件
outputFile() {
    let templateStr = this.getSourceByPath(path.join(__dirname, 'main.ejs'));  // 拿到步骤1写好的模板
    let code = ejs.render(templateStr, {
        entryPath: this.entryPath,
        modules: this.modules,
    })// 填充模板数据
    let outPath = path.join(this.config.output.path, this.config.output.filename)// 拿到输出地址
    fs.writeFileSync(outPath, code )// 写入
}
复制代码

我们改一下我们的编译类入口,在run方法执行他们:

class WebpackCompiler {
        constructor(config) {
            this.config = config;
            this.modules = {}
            this.root = process.cwd() //当前项目地址      
            this.entryPath = './' + path.relative(this.root, this.config.entry);
        }
        
        run() {
            this.buildMoudle( this.entryPath )
            this.outputFile();
        }
}
复制代码

此时,执行我们我们的基本架子,已经可以输出webpack的编译后的js文件。

5)嵌入loader

普通的js文件,已经抒写完毕。那我们来看看怎么嵌入我们的loader呢?看完了"写源码之前的必备知识点",我们应该明白,loader即是将我们的对应的文件后缀,通过loader的转移成我们的js文件支持的语法。

我们做一下前期工作。

  • 新建一个新的loader文件:

    const less = require('less') function loader(source) { let css = '' less.render(source, function(err, output) { css = output.css })

      css = css.replace(/\n/g, '\\n')
      let style = `
      let style = document.createElement('style')
      style.innerHTML = \n${JSON.stringify(css)}
      document.head.appendChild(style)
      `
      return style
    复制代码

    } module.exports = loader;

  • 具体less的转换规则比较复杂我就不折腾了。这里借用一下官方的less,赋值到js中。修改一下入口:webpack.config.js的module:

     module: {
          rules: [{
              test: /\.less$/,
              use: [path.join(__dirname, './lib/loader/less-loader.js')]
          }]
      },
    复制代码
  • 再在我们的src文件中新建test.less: body{ text-align: center; .my_page{ margin-top: 50px; line-height: 50px; } }

  • 在测试文件(src/index.js)引入他:

      require("./test.less");
      const index2Obj = require("./index2.js");
      alert("index文件告诉你:小伙子很帅"  );
      alert("index2文件告诉你:" + index2Obj.value );
      module.exports = {}
    复制代码

做完前期工作,我们来实现编译过程:

我们上述获取源文件代码用了:

getSourceByPath(modulePath){
    let content = fs.readFileSync(modulePath, 'utf8')
    return content
}
复制代码

loader的实现,就是在拿到源文件后,通过rules的规则匹配后缀,多做一层转换:

getSourceByPath(modulePath) {
    let content = fs.readFileSync(modulePath, 'utf8')
    // 事先拿module中的匹配规则与路径进行匹配
    const rules = this.config.module.rules
    for (let i = 0; i < rules.length; i++) {
        let { test, use } = rules[i]
        let len = use.length
        if (test.test(modulePath)) {
            function changeLoader() {
                // 先拿最后一个
                let loader = require(use[--len])//倒叙执行
                content = loader(content)
                if (len > 0) {
                    changeLoader()
                }
            }
            changeLoader()
        }
    }
    return content
}
复制代码

用了闭包,是因为一个后缀可能对应多个loader,所以这里写了循环执行。 再执行一次npm run start,此时你会发现,js中已经包含了less文件的内容。此时我们完成了loader的嵌入。

6)嵌入webpack的生命周期

理论上我们应该讲plugins的手写,但是plugins有一个问题,plugins可以设置在不同的编译阶段。例如在编译前,做什么?编译中,需做什么?这涉及到webpack的生命周期。我们来处理一下生命周期。

我们上述描述了tapable,SyncHook我们大致理解,他有一个tap将任务添加的内部的执行队列中,然后最后通过执行call方法,一次执行他们。

我们的plugins的执行也是如何,我们可以将我们的每个plugin通过tap任务放置在SyncHook中,等时机到了,调用call方法即可。

我们给我们的webpack定义五个生命周期,并在run方法适当的时机嵌入他们:

class WebpackCompiler {
        constructor(config) {
            this.config = config;
            this.modules = {}
            this.root = process.cwd() //当前项目地址      
            this.entryPath = './' + path.relative(this.root, this.config.entry);
            this.hooks = {
                entryInit: new tapable.SyncHook(),
                beforeCompile: new tapable.SyncHook(),
                afterCompile: new tapable.SyncHook(),
                afterPlugins: new tapable.SyncHook(),
                afteremit: new tapable.SyncWaterfallHook(['hash']),
            }
        }
        
        run() {
            this.hooks.entryInit.call(); //启动项目
            this.hooks.beforeCompile.call();  //编译前运行
            this.buildMoudle( this.entryPath )
            this.hooks.afterCompile.call( ); //编译后运行
            this.outputFile();
            this.hooks.afterPlugins.call( );//执行完plugins后运行
            this.hooks.afteremit.call( );//结束后运行
        }
}
复制代码

该webpack生命周期定义结束,下边我们手写plugins嵌入到生命周期中。

7)嵌入plugins

定义一个需求: 1)启动时,添加打印:前端小伙子,编译开始咯。 2)编译前:清空原来的dist文件夹内容 3)输出文件后:将我们的生成文件main.js,改名main.${hash}.js,并在index.html正确引入该文件。

  • 我们手写一个InitPlugin,该plugin是在启动时执行,即tap到entryInit周期中:

class InitPlugin { run(compiler) { // 将的在执行期放到刚开始解析入口前 compiler.hooks.entryInit.tap('Init', function(res) { console.log(前端小伙子,编译开始咯。); }) } }

  • 清空dist,在编译前:

class CleanDistPlugins { run(compiler) { // 将自身方法订阅到hook以备使用 //假设它的运行期在编译完成之后 compiler.hooks.beforeCompile.tap('CleanDistPlugins', function(res) { delFileFolderByName('./dist/'); }) } }

  • 编译后:

重命名文件:

class JsCopyPlugins {
    run(compiler) {
        compiler.hooks.afterPlugins.tap('JsCopyPlugins', function(res) {
            const ranNum = parseInt( Math.random() * 100000000 );
            fs.copyFile('./dist/main.js',`./dist/main.${ranNum}.js`,function(err){
                if(err) console.log('获取文件失败');
                delFileByName('./dist/main.js');
            })
            console.log("重新生成js成功" );
            return ranNum;
        })
    }
}
复制代码

修改html的js引入:

class HtmlReloadPlugins {
    run(compiler) {
        compiler.hooks.afterPlugins.tap('HtmlReloadPlugins', function(res) {
            let content = fs.readFileSync('./public/index.html', 'utf8')
            content = content.replace('main.js', `main.${res}.js`);
            fs.writeFileSync( './dist/index.html', content)
        })
    }
}
复制代码

由于“重命名文件”“修改html的js引入”需要用到同一个hash值,上述SyncWaterfallHook可以传递值,SyncHook则无传递值,这就是我为什么将最后一步改为SyncWaterfallHook的原因

所有的pulgins都写好了,我们来写webpack如何执行pulgins的过程:

其实生命周期帮我们实现了SyncHook的start函数。我们只需要将plugins注册到编译类中接口。plugins中已经将方法 tap到SyncHook中,我们只需要执行plugins即可。方法很简单:

class WebpackCompiler {
    constructor(config) {
        this.config = config
        //...省略
        
        const plugins = this.config.plugins
        if (Array.isArray(plugins)) {
            plugins.forEach(item => {
                // 每个均是实例,调用实例上的一个方法即可,传入当前Compiler实例
                item.run(this)
            })
        }

    }
}
复制代码

至此,我们已经完成了webpack_mini的手写。

简版webpack源码

给于核心类WebpackCompiler的完整代码,如还不清晰,建议导下项目查看,链接:https://github.com/zhuangweizhan/codeShare/tree/master/19.zWebpack%EF%BC%88%E7%AE%80%E7%89%88webpack%E6%BA%90%E7%A0%81%EF%BC%89

WebpackCompiler源码:

const path = require('path')
const fs = require('fs')
const { assert } = require('console')
    // babylon  将源码转成ast Babylon 是 Babel 中使用的 JavaScript 解析器。
    // @babel/traverse 对ast解析遍历语法树
    // @babel/types 用于AST节点的Lodash-esque实用程序库
    // @babel/generator 结果生成

const babylon = require('babylon')
const traverse = require('@babel/traverse').default;
const type = require('@babel/types');
const generator = require('@babel/generator').default
const ejs = require('ejs')
const tapable = require('tapable')

class WebpackCompiler {
    constructor(config) {
        this.config = config
        this.modules = {}
        this.root = process.cwd() //当前项目地址      
        this.entryPath = './' + path.relative(this.root, this.config.entry);
        this.hooks = {
            entryInit: new tapable.SyncHook(),
            beforeCompile: new tapable.SyncHook(),
            afterCompile: new tapable.SyncHook(),
            afterPlugins: new tapable.SyncHook(),
            afteremit: new tapable.SyncWaterfallHook(['hash']),
        }
        const plugins = this.config.plugins
        if (Array.isArray(plugins)) {
            plugins.forEach(item => {
                // 每个均是实例,调用实例上的一个方法即可,传入当前Compiler实例
                item.run(this)
            })
        }

    }

    // 获取源码
    getSourceByPath(modulePath) {
        // 事先拿module中的匹配规则与路径进行匹配
        const rules = this.config.module.rules
        let content = fs.readFileSync(modulePath, 'utf8')
        for (let i = 0; i < rules.length; i++) {
            let { test, use } = rules[i]
            let len = use.length

            // 匹配到了开始走loader,特点从后往前
            if (test.test(modulePath)) {
                function changeLoader() {
                    // 先拿最后一个
                    let loader = require(use[--len])
                    content = loader(content)
                    if (len > 0) {
                        changeLoader()
                    }
                }
                changeLoader()
            }
        }
        return content
    }

    // 根据路径解析源码
    parse(source, parentPath) {
        let ast = babylon.parse(source)//
        // 用于存取依赖
        let dependencies = []
        traverse(ast, {//对ast解析遍历语法树 负责替换,删除和添加节点
            CallExpression(p) {
                let node = p.node
                if (node.callee.name === 'require') {
                    node.callee.name = '__webpack_require__';//将require替换成__webpack_require__
                    const moduledName = './' + path.join(parentPath, node.arguments[0].value )
                    dependencies.push(moduledName);//记录包含的requeir的名称,后边需要遍历替换成源码
                    node.arguments = [type.stringLiteral(moduledName)] // 源码替换
                }
            }
        })
        let sourceCode = generator(ast).code
        return { sourceCode, dependencies };
    }

    // 构建模块
    buildMoudle(modulePath) {
        const source = this.getSourceByPath(modulePath);//根据路径拿到源码
        const moduleName = './' + path.relative(this.root, modulePath);//转换一下路径名称
        const { sourceCode, dependencies } = this.parse(source, path.dirname(moduleName))//根据路径拿到源码,以及源码中已经require的文件名称数组
        this.modules[moduleName] = sourceCode;// 每个模块的代码都通过路径为Key,存入到modules对象中
        dependencies.forEach(item => {  // 递归需要转换的文件名称
            this.buildMoudle(path.resolve(this.root, item));////再对应的文件名称,替换成对应的源码
        })
    }

    //输出文件
    outputFile() {
        let templateStr = this.getSourceByPath(path.join(__dirname, 'main.ejs'));  // 拿到步骤1写好的模板
        let code = ejs.render(templateStr, {
            entryPath: this.entryPath,
            modules: this.modules,
        })// 填充模板数据
        let outPath = path.join(this.config.output.path, this.config.output.filename)// 拿到输出地址
        fs.writeFileSync(outPath, code )// 写入
    }

    run() {
        this.hooks.entryInit.call(); //启动项目
        this.hooks.beforeCompile.call();  //编译前运行
        this.buildMoudle( this.entryPath )
        this.hooks.afterCompile.call( ); //编译后运行
        this.outputFile();
        this.hooks.afterPlugins.call( );//执行完plugins后运行
        this.hooks.afteremit.call( );//结束后运行
    }

}

module.exports = WebpackCompiler;
复制代码

文章结尾

感谢

笔者原本即将完成mini_webpack源码,后续发现有人的源码核心原理的实现,写的比我原本的要好一些,所以后期我做了改动。本文部分原理跟部分源码借鉴了“宫小白”,他的原文链接是:https://juejin.im/post/6847009773448069128。

此外, AST语法树不清晰建议文章:https://www.jianshu.com/p/019d449a9282

计划

本计划将webpack如何优化,写在本文中。发现文章已经差不多6K+字,太长小伙伴们也看不完,后续会单独一篇文章基于本文描述。

笔者也会继续写自己的mini框架源码,有兴趣继续关注:

| 序号 | 博客主题 | 相关链接 | |-----|------|------------|- | 1 | 手写vue_mini源码解析 | juejin.im/post/684790… | | 2 | 手写react_mini源码解析 | juejin.im/post/685457… | | 3 | 手写webpack_mini源码解析(即本文) | juejin.im/post/685457… | | 4 | 手写jquery_mini源码解析 | juejin.im/post/685457… | | 5 | 手写vuex_mini源码解析 | 预计下周 | | 6 | 手写vue_router源码解析 | 预计8月 | | 7 | 手写diff算法源码解析 | 预计8月 | | 8 | 手写promis源码解析 | 预计8月 | | 9 | 手写原生js源码解析(手动实现常见api) | 预计8月 | | 10 | 手写react_redux,fiberd源码解析等 | 待定,本计划先出该文,整理有些难度 | | 11 | 手写koa2_mini | 预计9月,前端优先 |