最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 彻底搞懂webpack原理:手写webpack核心代码

    正文概述 掘金(Alaso)   2021-04-02   800

    面试经常问到的一个问题就是webapck的原理,要理解一个东西的原理,最深刻的方式就是自己去实现一次。下面我们就一起手写webpack核心代码,彻底搞懂webpack原理。

    webpack的基础使用: webpack由浅入深系列(一) 、 webpack由浅入深系列(二)、 用webpack打包按需加载的vue组件库

    功能预期

    我们将要手写的webpack有以下功能:

    1、能将模块化的js文件,打包生成一个js文件,模块中的ES6代码转换为ES5代码,在html文件中引入后能够直接运行在浏览器。

    2、模块化的js文件支持esm和cjs的导入导出方式。

    3、支持loader选项。

    4、支持plugin选项。

    基础功能的实现

    使用方式

    需要打包的文件:

    //src/index.js
    import { msg } from './hello.js'
    const worldModule = require('./world.js')
    console.log(msg)
    console.log('hi ' + worldModule.world)
    
    //src/hello.js
    import { world } from './world.js'
    export const msg = 'hello ' + world
    
    //src/world.js
    export const world = 'world'
    

    打包配置文件:

    const path = require('path')
    module.exports = {
      mode: 'none',
      entry: './src/index.js',
      output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, 'dist')
      }
    }
    

    package.json中添加script字段:

    "scripts": {
        "myPack": "node ./lib/my-pack.js --config webpack.config.js"
    },
    

    这样我们就能通过npm run myPack执行打包命令。最终结果将是在dist目录下生成一个bundle.js,将bundle.js引入html文件并在浏览器打开,将会输出:

    彻底搞懂webpack原理:手写webpack核心代码

    流程分析

    npm run myPack实际上执行的是node ./lib/my-pack.js --config webpack.config.js

    我们在项目下新建一个lib文件夹,用于存放将要实现的打包工具的代码。 彻底搞懂webpack原理:手写webpack核心代码

    首先,我们来看看webpack的打包流程,如下图所示,主要有这样几个步骤:

    1、解析config文件,获取配置选项,包括entry、output、loader、plugin等。

    2、从entry文件开始,解析entry文件,获取依赖文件列表。

    3、遍历依赖文件列表,逐个解析每个依赖文件,获取新的依赖,由此,递归地构建一个依赖关系图。在这个过程中,如果指定了loader,则先用loader处理匹配文件,再解析。

    4、根据依赖关系图生成打包后代码,输出到output指定的文件中。

    彻底搞懂webpack原理:手写webpack核心代码

    获取配置项

    我们支持在package.json中指定配置文件,如没指定,则默认用项目根目录下的webpack.config.js,hmmm,不是很重要,可以略过。

    确定配置文件后,获取配置项,并传入Compiler,然后执行Compiler的run方法,这是整个my-pack的流程控制方法。

    const path = require("path")
    const Compiler = require("./Compiler")
    
    let configPath = path.resolve(process.cwd(), './webpack.config.js')  //默认用项目根目录下的`webpack.config.js`
    
    //获取命令行参数,如指定了--config,则获取紧跟其后的配置文件名
    const argv = process.argv       
    const index = argv.findIndex((value) => value === '--config')
    if(index >= 0 && argv[index+1]){
      configPath = path.resolve(process.cwd(), argv[index+1])
    }
    
    const config = require(configPath)   //获取配置项
    const compiler = new Compiler(config)  //将配置项传入Compiler
    
    compiler.run()  //执行Compiler的run方法
    

    整体过程

    Compiler类是控制整个打包过程的类,它的结构如下:

    const parser = require('./parser')
    class Compiler{
      constructor (config) {  //解析配置选项
        this.config = config
        this.entry = config.entry
        this.output = config.output
        this.execPath = process.cwd()  //Node.js 进程的当前工作目录,在这里也就是项目根目录
        this.modules = Object.create(null)   //用于所有依赖文件生成的module的集合
      }
    
      run () {  //主要分为以下两个步骤
        this.buildModule(path.resolve(this.execPath, this.entry))  //构建依赖关系图
        this.emitFile()    //生成打包文件
      } 
    }
    module.exports = Compiler
    

    构建依赖关系图

    buildModule (filename) {
        let key = path.relative(this.execPath, filename)    //获取文件基于项目根目录的相对路径,作为它在module集合的key
        key = './' +  key.replace(/\\/g,'/')
        if(this.modules[key]) return  //如果模块已经存在于集合中,则返回
    
        //编译解析文件,得到转换成es5的文件源码和它的依赖数组
        const { dependencies, code } = parser.parse(filename,this.config)  
        this.modules[key] = {  //根据文件源码和它的依赖数组生成module,并加入到依赖集合中
          code: code,
          dependencies: dependencies
        }
        
        //遍历文件的依赖数组,递归执行buildModule方法,直到遍历完所有依赖文件,这时this.modules中将是项目所有依赖module的集合
        dependencies.forEach((dependency) => {  
          const absPath = path.resolve(this.execPath, dependency)
          this.buildModule(absPath)
        })
    }
    

    对于每个要解析的文件,通过parser.parse(filename,this.config)得到它的依赖数组和转换成es5的文件源码。

    parser.parse处理文件的过程:

    1、首先通过@babel/parser将文件源代码生成抽象语法树ast;

    彻底搞懂webpack原理:手写webpack核心代码

    2、然后使用@babel/traverse遍历这个抽象语法树,找到reuqire和import的文件,记录到依赖数组中;在这里统一将ast中文件的引用路径修改成基于项目根目录的路径,比如这里的'./hello.js'修改成'./src/hello.js',与上面modules集合的各个key值对应。

    3、最后通过@babel/core基于修改后的ast生成es5代码

    const babelParser = require("@babel/parser")
    const traverse = require("@babel/traverse").default
    const babel = require('@babel/core')
    
    parse (filename,config) {
        const ast = this.genAST(filename,config)   //生成ast抽象语法树
        const dependencies = []
        const dirname = path.dirname(filename)
        traverse(ast, {                           //遍历ast,获取依赖,并加入依赖数组
          CallExpression({node}){
            if(node.callee.name === 'require'){   //通过require导入
              let moduleFile = path.resolve(dirname, node.arguments[0].value)
              moduleFile = path.relative(root, moduleFile)
              moduleFile = './' + moduleFile.replace(/\\/g,'/')
              node.arguments[0].value = moduleFile   
              dependencies.push(moduleFile)      //加入依赖数组
            }
          },
          ImportDeclaration({node}){             //通过import导入
            let moduleFile = path.resolve(dirname, node.source.value)
            moduleFile = path.relative(root, moduleFile)
            moduleFile = './' + moduleFile.replace(/\\/g,'/')
            node.source.value = moduleFile
            dependencies.push(moduleFile)        //加入依赖数组
          }
        })
        const { code } = babel.transformFromAst(ast, null, {    //基于ast生成es5代码
          presets: ["@babel/preset-env"]
        })
        return {       //返回解析后的es5代码和依赖数组
          code,
          dependencies
        }
     },
     genAST (filename,config) {
        let sourceCode = fs.readFileSync(filename, 'utf8')
        const ast = babelParser.parse(sourceCode,{
          sourceType: 'module'   //解析es6模块
        })
        return ast.program
    },
    

    parse处理的每一个文件都生成一个module对象,该module对象至少包括codedependencies这两个字段,以./src/index.js为例,我们看看得到的codedependencies

    彻底搞懂webpack原理:手写webpack核心代码

    接下来,我们要做的就是遍历dependencies数组,对每一项执行buildModule方法,直到遍历完所有依赖文件,就得到了所有依赖module对象。

    dependencies.forEach((dependency) => {  
      const absPath = path.resolve(this.execPath, dependency)
      this.buildModule(absPath)
    })
    

    彻底搞懂webpack原理:手写webpack核心代码

    生成bundle文件

    生成bundle文件实质上是拼接出一个bundle.js文件内容的字符串,然后通过fs.writeFileSync写入到bundle.js文件。我们就仿照实际webpack的打包文件(webpack输出文件分析)来拼接字符串。整个输出文件是一个自执行函数。

    每一个module对象的code将作为一个匿名函数的代码块,通过调用这个匿名函数,执行模块代码:

    let modules = ''
    Object.keys(this.modules).forEach((key) => {  //遍历依赖模块集合,拼接字符串
      modules += `'${key}': function(require, module, exports){ //用匿名函数包裹 模块code
        ${this.modules[key].code}
      },`
    })
    

    从上面解析后的code中,我们可以看到,直接运行code有一个问题,就是requireexports没有定义,所以在生成的打包文件中定义require方法和exports对象,并作为参数传给包裹它的匿名函数。

    var installedModules = {}
    function require(filename){                   //定义require方法
       if(installedModules[filename]) {           //若是已经加载过的模块,直接从缓存中取
          return installedModules[filename].exports;   
       }
    
       var fn = modules[filename]    //获取模块包裹匿名函数
       var module = installedModules[filename] = {   //并缓存模块	
          exports: {}                                //定义exports对象
       }
       fn(require, module, module.exports)   //执行匿名函数
       return module.exports
    }
    

    完整代码如下:

    emitFile () {
        const output = path.resolve(this.output.path,this.output.filename)  //输出文件名
        let modules = '' 
        Object.keys(this.modules).forEach((key) => {  //
          modules += `'${key}': function(require, module, exports){
            ${this.modules[key].code}
          },`
        })
        const bundle = `(function(modules){     //自执行函数
          var installedModules = {}
          function require(filename){                   //定义require方法
            if(installedModules[filename]) {
    	  return installedModules[filename].exports;		
            }
    
            var fn = modules[filename]
            var module = installedModules[filename] = { 
              exports: {}                              //定义exports对象
            }
            fn(require, module, module.exports)        //执行模块包裹匿名函数,也就是执行模块代码
            return module.exports                 //返回模块导出对象
          }
          require('${this.entry}')      //加载入口文件
        })({
          ${modules}                    //所有模块拼接的字符串
        })`
        fs.writeFileSync(output, bundle, 'utf-8')   //将字符串写入output指定文件
      }
    

    生成的bundle.js如下:(屏幕装不下,就把./src/hello.js./src/world.js折叠了) 彻底搞懂webpack原理:手写webpack核心代码

    加上loader

    loader本身是一个函数,接受源文件作为参数,并返回转换的结果。在基础功能上,我们将加上loader的使用。这里我们使用两个loader,一个是去掉console,一个是在每个文件加上作者信息。

    module: {
        rules: [
          {
            test: /\.js$/,
            use: [
              path.resolve(__dirname, 'lib/loaders/remove-console-loader.js'),
              path.resolve(__dirname, 'lib/loaders/add-author-loader.js')
            ]
          }
        ]
    },
    

    修改也很简单,就是在用parser处理文件时,第一步就是判断文件是否被loader匹配,如匹配,调用loader处理文件内容,再返回处理后的结果,进行下一步的解析成ast,后面就和基础版一样了。

    initLoader (filename,config) {
        let source = fs.readFileSync(filename, 'utf8')
        const rules = config.module && config.module.rules   //获取rules数组
        rules && rules.forEach((rule) => {     //遍历rules
          const { test, use } = rule           //获取匹配规则和loader数组
          let l = use.length - 1
          if(test.test(filename)){             //文件名如匹配得上
            function execLoader(){
              const loader = require(use[l--])   //从最后一个loader开始执行 (loader的执行顺序是从右向左)
              source = loader(source)            //将执行结果重新赋给source
              if(l>=0){                //如果前面还有loader,执行loader,直到use数组里的所有loader执行完毕
                execLoader()             
              }
            }
            execLoader()
          }
        })
        return source
    },
    genAST (filename,config) {
      const sourceCode = this.initLoader(filename,config)  //先使用loader处理文件,返回处理后的结果
      const ast = babelParser.parse(sourceCode,{
        sourceType: 'module'  
      })
      return ast.program
    },
    

    重新打包执行之后,再运行,就会发现控制台没有console.log的内容了。并且每个module代码的结尾都加上了作者信息。

    彻底搞懂webpack原理:手写webpack核心代码 loader代码:

    //remove-console-loader.js
    function loader(source){
      const newSource = source.replace(/console\.log\([\s\S]*\)/g,'')
      return newSource
    }
    module.exports = loader
    
    //add-author-loader.js
    function loader(source){
      return source + '/**created by Alaso**/'
    }
    module.exports = loader
    

    加上plugin

    plugin的执行需要借助tapable,tapable是一个类似于 Node.js中的 EventEmitter的库,参考关于 tapable 你需要知道这些

    简单起见,这里我们就使用它的SyncHook(同步钩子),用起来比较像订阅发布。

    const hook = new SyncHook()
    hook.tap('hello', () => {   //订阅hook
        console.log('hello');
    })
    hook.tap('world', () => {   //订阅hook
        console.log('world');
    })
    hook.call()                //发布hook
    

    插件plugin的实现思路就是订阅webpack的各种钩子,和vue的生命周期钩子类似,webpack在整个打包流程中也有各种生命周期,如entryOption、run、compile、emit等,它们标志着打包流程的不同阶段。

    对于每一个生命周期,我们用SyncHook注册一个钩子,在插件中订阅相关的钩子,当程序运行到某个生命周期,就发布对应的钩子,订阅了这个钩子的插件就会执行。

    按照webpack官方约定,webpack 插件是一个具有 apply 方法的 JavaScript 对象。apply 方法会被 webpack compiler 调用,并且在 整个 编译生命周期都可以访问 compiler 对象

    我们模仿html-webpack-plugin(创建html文件去承载输出的bundle)写一个插件。

    首先,在webpack.config.js中添加:

    plugins: [
        new HtmlPlugin({
          template: './src/index.html',   //用到的模板文件
          filename: 'newIndex.html'       //生成的html文件命名
        })
    ]
    

    HtmlPlugin的实现:

    const fs = require('fs')
    const path = require('path')
    const cheerio = require('cheerio')
    class HtmlPlugin {  
      constructor (options) {
        this.template = options.template  //解析参数
        this.filename = options.filename
      }
    
      apply (compiler) {     //按照官方约定,定义apply方法,并将compiler作为参数传入
        compiler.hooks.afterEmit.tap('afterEmit',() => {   //订阅afterEmit钩子,在compiler.hooks.afterEmit.call()的时候执行下面的内容
          //读取模板内容
          const template = fs.readFileSync(path.resolve(process.cwd(),this.template), 'utf-8')    
          const $ = cheerio.load(template)  //通过cheerio解析template字符串
    
          //cheerio的api类似于jquery,这里我们生成一个script标签,并插入到body中
          const script = $(`<script src='./${compiler.output.filename}'></script>`)
          $('body').append(script)
          
          const htmlFile = $.html()   //基于修改后的dom重新生成字符串
          const output = path.resolve(compiler.output.path,this.filename)  
          fs.writeFileSync(output, htmlFile, 'utf-8') //将字符串写入到bundle.js所在的目录
        })
      }
    }
    
    module.exports = HtmlPlugin
    

    我们回到Compiler类,在基础功能上进行修改:

    class Compiler{
      constructor (config) {
        ...
        this.hooks = {  //添加各生命周期对应的hooks
          emit: new SyncHook(),       //生成资源到 output 文件之前触发
          afterEmit: new SyncHook()    //生成资源到 output 文件之前触发
        }
        const plugins = this.config.plugins   //获取配置文件中的plugins数组
        if(Array.isArray(plugins)){
          plugins.forEach((plugin) => {       //遍历数组,执行每一个plugin的apply方法,将compiler作为参数传入
            plugin.apply(this)         执行plugin的apply方法,订阅生命周期钩子
          })
        }
     }
     
     run () {  //在不同的生命周期,发布对应的钩子
        this.buildModule(path.resolve(this.execPath, this.entry)) 
        this.hooks.emit.call()   //生成资源到 output 文件之前,发布emit钩子
        this.emitFile()
        this.hooks.afterEmit.call() //生成资源到 output 文件之后,发布afterEmit钩子
     }
    }
    

    再次打包,在dist目录就是生成了一个newIndex.html,内容如下:

    彻底搞懂webpack原理:手写webpack核心代码 到此,我们的手写webpack核心代码就完成了,祝大家在面试的时候更加游刃有余,下次见。

    项目地址: github.com/magicsoso/m…

    参考资料

    实现一个简单的Webpack

    玩转webpack 全方位掌握webpack核心技能和优化策略

    通过 Webpack 的 compiler 对象的 Hooks 学会编写 Webpack 插件的编写


    起源地下载网 » 彻底搞懂webpack原理:手写webpack核心代码

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元