预热面试季-webpack进阶篇(打包原理、手写loader、plugin)

2,596 阅读9分钟

阅读建议

  • 建议人群:想继续深入了解webpack原理
  • 本文目标:
    • 了解webpack打包原理,并自己实现一个bundler.js

    • 自己实现一个loader

    • 自己实现一个plugin

webpack打包原理

获取配置 根据配置信息启动webpack,执行构建

webpack的打包流程

1、从入口模块开始分析

- 有哪些依赖
- 转换代码

2、递归得分析其他依赖模块

- 有哪些依赖
- 转换代码

3、生成可以在浏览器端执行的bundle文件

分析入口文件

首先在根目录的src文件夹创建需要打包的文件:index.js、 add.js、 say.js

// index.js
import { say } from "./sya.js";
document.write("hello" + say("webpack")); //hello webpack
// add.js
export function add() {
  return "add";
}
// say.js
import { add } from "./add.js";
export function say(str) {
  return str + add();
}

上面已经创建好了要打包的文件,并且都互相导出、引入。接下来创建lib文件夹,用来存放自己实现的bundler.js相关文件,用来实现打包功能。

bundler.js的实现

怎么启动webpack的

// webpack.js
// 启动webpack node webpack.js
const options = require('./webpack.config.js') // 获取webpack配置
cosnt bundler = require('./lib/bundler.js') 
new Bundler(options).run() // 执行 bundler类的run函数

从上面的使用代码可以看出来:

  • bundler.js会导出一个bundler类。
  • 并且会接收webpack配置传进来的options参数。
  • 最后执行run()函数执行构建。

获取文件内容

要想分析件里的内容,首先我们要拿到文件的内容,因此这里我们需要引入node里面的核心模块fs.readFileSync()来读取文件内容

const fs = require("fs")

module.exports = class Budler {
 	//获取webpack配置
  constructor(options) {
    this.entry = options.entry;
    this.output = options.output;
  }
  run() {
    this.build(this.entry);
  }
  build(entryFile) {
    //entryFile ./src/index.js
    //1.分析入口,读取入口模块的内容
    let content = fs.readFileSync(entryFile, "utf-8");
    console.log(content)
}

拿到模块的依赖

上一个步骤已经成功读取到了文件的内容,接下来我们需要知道,文件中引入了那些依赖,并且提取出来。这里我们需要安装一个babel 插件@babel/parser,然后引入。

这个插件为我们提供了一个parser()方法,并且接受两个参数,第一个参数是要分析的代码 ,第二个参数是 配置项

const parser = require("@babel/parser")

module.exports = class Budler {
 	//获取webpack配置
  constructor(options) {
    this.entry = options.entry;
    this.output = options.output;
  }
  run() {
    this.build(this.entry);
  }
  build(entryFile) {
    //entryFile ./src/index.js
    //1.分析入口,读取入口模块的内容
    let content = fs.readFileSync(entryFile, "utf-8");
    const ast = parser.parse(content, {
      sourceType: "module"
    })
    console.log(ast)
}

上面打印出来的是AST抽象语法树

上面的AST对象太乱太多,我们真正需要的信息是引入了那些模块以及它的路径,所以再打印一下ast.program

可以看到body数组里面的对象,type分别是ImportDeclarationExpressionStatement:引入声明和表达式。

而我们的index.js也确实是第一行引入,第二行是表达式

// index.js
import { say } from "./sya.js"
document.write("hello" + say("webpack")) //hello webpack

所以我们用babel的parse方法,可以帮助我们分析出AST抽象语法树,通过AST又可以拿到声明的语句,声明的语句里放置了入口文件里对应的依赖关系,所以我们利用抽象语法树,把我们的js代码转换成了js对象。

@babel/traverse插件可以帮助我们快速找到import的节点,注意:引入的时候注意后面要加.default,然后使用traverse方法进行遍历

const path = require('path') // 可以获取文件路径

const denpendcies = {}; //可以保留相对路径和根路径两种信息
    tarverse(ast, {
      ImportDeclaration({ node }) {
        const dirname = path.dirname(entryFile);
        const newPath = path.join(dirname, node.source.value);
        denpendcies[node.source.value] = newPath;
      }
    })
    console.log(denpendcies) // { './say.js': 'src\\say.js' } // key引入的依赖,value是路径

接下来要做的事情,就是用babel对代码进行转换、编译,把代码转换成浏览器可以认识的es5,安装@babel/core,并且引入,这个工具提供给我们一个transformFromAst()进行转换,方法接收三个参数,第一个参数是ast,第二个参数可以填null,第三个参数是配置项(注意配置项里的"@babel/preset-env"也需要手动安装),这个方法会把ast抽象语法树转换成一个对象并且返回,对象里包含一个code,这个code就是编译生成的,可以在浏览器上直接运行的当前模块的代码。

const { transformFromAst } = require('@babel/core')

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

打印出来的就是index.js里面的内容,并且是进过翻译后的,那么恭喜你!我们对入口文件代码的分析就大功告成了!下面我们可以进行一些优化,让代码可以看起来优雅一点。在lib文件夹下面创建utils.js文件,把工具类的函数都放到这里

// utils.js
const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const tarverse = require('@babel/traverse').default
const { transformFromAst } = require('@babel/core')

module.exports = {
  //分析模块 获得AST
  getAst: fileName => {
    let content = fs.readFileSync(fileName, 'utf-8')
    return parser.parse(content, {
      sourceType: 'module'
    })
  },

  //获取依赖
  getDependcies: (ast, fileName) => {
    const dependcies = {} //可以保留相对路径和根路径两种信息
    tarverse(ast, {
      ImportDeclaration({ node }) {
        // denpendcies.push(node.source.value); 相对路径
        const dirname = path.dirname(fileName)
        const newPath = path.join(dirname, node.source.value)
        dependcies[node.source.value] = newPath
      }
    })
    return dependcies
  },

  //转换代码
  getCode: ast => {
    const { code } = transformFromAst(ast, null, {
      presets: ['@babel/preset-env']
    })
    return code
  }
}

然后在bundler.js中引入

// bundler.js
const { getAst, getDependcies, getCode } = require('./utils.js')

转换代码并生成可以在浏览器执行的文件

通过上面的步骤:我们已经可以获取到文件名称、依赖以及文件中的代码,所以我们下面要做的就是根据这些信息生成可以真正在浏览器端执行的代码

// bundler.js
module.exports = class Complier {
  constructor(options) {
    this.entry = options.entry
    this.output = options.output
    this.modules = [] // 存储模块
  }
  run() {
    const info = this.build(this.entry)
    this.modules.push(info)

    for (let i = 0; i < this.modules.length; i++) {
      const item = this.modules[i]
      const { dependencies } = item
      if (dependencies) {
        for (let j in dependencies) {
          this.modules.push(this.build(dependencies[j]))
        }
      }
    }
  console.log(this.modules)
  }
  build(fileName) {
    let ast = getAst(fileName)
    let dependencies = getDependcies(ast, fileName)
    let code = getCode(ast)
    // h获取到的信息
    return {
      fileName,
      dependencies,
      code
    }
  }
}

输出this.modules后,与我们预料的一样,是3个模块的数组,里面的对象内容也跟build()return出来的数值一样

但是下一步我们还要转换一下数据格式,让它变成fileName:{dependencies,code}的数据形式,把数组转换成对象,然后返回出去,以方便打包代码

//转换数据结构
    const obj = {}
    this.modules.forEach(item => {
      obj[item.fileName] = {
        dependencies: item.dependencies,
        code: item.code
      }
    })
    console.log(obj)

那么最后一步就是生成在浏览器中运行的file

// bundler.js

module.exports = class Complier{
	file(code) {
    //获取输出信息 .../dist/main.js
    const filePath = path.join(this.output.path, this.output.filename)
    const newCode = JSON.stringify(code)
    // 要写入到文件,所以返回字符串
    // 网页中的所有代码都应该放在一个大的闭包里面,避免勿扰全局环境,所以我们要写个闭包
    const bundle = `(function(graph){
        function require(module){
            function localRequire(relativePath){
               return require(graph[module].dependencies[relativePath])
            }
            var exports = {};
            (function(require,exports,code){
                eval(code)
            })(localRequire,exports,graph[module].code)
            return exports;
        }
        require('${this.entry}') //./src/index.js
    })(${newCode})`
	
    fs.writeFileSync(filePath, bundle, 'utf-8')
  }
}

自己编写一个loader

⾃⼰编写⼀个Loader的过程是⽐较简单的,

Loader就是一个函数,声明式函数 ,不能用箭头函数。

拿到源代码,作进一步的修饰处理理,再返回处理后的源码就可以了

遵循Webpack制定的设计规则和结构,输入与输出均为字符串,各个Loader完全独立,即插即用

同步loader

loader默认导出一个函数,接受匹配到的文件资源字符串和SourceMap,我们可以修改文件内容字符串后再返回给下一个loader进行处理。下面实现一个简单的loader案例:替换源码中字符串的loader

// index.js
console.log('我要替换 str')

// replaceLoader.js
//需要⽤声明式函数,因为要上到上下文的this, ⽤到this的数据,该函数接受⼀个
参数,是源码
module.exports = function(source) {
  console.log(source, this, this.query)
  return source.replace('str','被我替换了!')
}

然后去配置文件中使用这个loader

// webpack.config.js

//需要使⽤node核⼼模块path来处理路径
const path = require('path')
module: {
  rules: [
    {
      test: /\.js$/,
      use: path.resolve(__dirname, "./loader/replaceLoader.js")
    }
  ]
},

异步loader

我们是怎么给loader配置参数,以及是如何接受参数的?

// replaceLoader.js
const loaderUtils = require("loader-utils") // 官⽅推荐处理loader,query的工具

module.exports = function(source) {
  //this.query 通过this.query来接受配置文件传递进来的参数
  //return source.replace("str", this.query.name)
  const options = loaderUtils.getOptions(this)
  const result = source.replace("str", options.name)
  return source.replace("str", options.name)
}

通过上面的工具,可以接收外部传进来的参数进行替换字符串

// webpack.config.js

//需要使⽤node核⼼模块path来处理路径
const path = require('path')
module: {
  rules: [
    {
      test: /\.js$/,
      use: [
        {
        loader: path.resolve(__dirname, "./loader/replaceLoader.js"),
          options: {
              name: "我被替换了2"
          }
        }
     ]
    }
  ]
},

那么如果我们想不止返回处理好的源码呢,还要返回其他的多个信息的时候,我们可以使用this.callback来处理

//replaceLoader.js
const loaderUtils = require("loader-utils") // 官⽅推荐处理loader,query的⼯工具
module.exports = function(source) {
  const options = loaderUtils.getOptions(this)
  const result = source.replace("str", options.name)
  this.callback(null, result)
}

callback的详细传参方法如下:

this.callback({
    //当无法转换原内容时,给 Webpack 返回一个 Error
    error: Error | Null,
    //转换后的内容
    content: String | Buffer,
    //转换后的内容得出原内容的Source Map(可选)
    sourceMap?: SourceMap,
    //原内容生成 AST语法树(可选)
    abstractSyntaxTree?: AST 
})

如果loader里⾯有异步的事情要怎么处理呢?我们会使用this.async来处理,他会返回this.callback

const loaderUtils = require("loader-utils")
module.exports = function(source) {
  const options = loaderUtils.getOptions(this)
  //定义一个异步处理,告诉webpack,这个loader里有异步事件,在⾥面调⽤下这个异步
  //callback 就是 this.callback 注意参数的使⽤
  const callback = this.async()
  setTimeout(() => {
    const result = source.replace("str", options.name)
    callback(null, result)
  }, 3000)
};

手写plugin

plugin的作用:开始打包,在某个时刻,帮助我们处理一些什么事情的机制

plugin要比loader稍微复杂一些,在webpack的源码中,⽤plugin的机制还是占有非常大的场景,可以说plugin是webpack的灵魂

plugin的本质是一个类,里⾯包含⼀个apply函数,接受⼀个参数,compiler

我们写一个简单的plugin

// copy-plugin.js
class CopyPlugin {
  constructor() {
  }
  //compiler:webpack实例
  apply(compiler) {
  
  }
}
module.exports = CopyPlugin

在配置文件中使用

// webpack.config.js

const CopyPlugin = require("./plugin/copy-plugin")
plugins: [new CopyPlugin()]

如何传递参数

我们可以在constructor上接收一个参数options,在配置文件使用的时候传进来

// copy-plugin.js
class CopyPlugin {
  constructor(options) {
  	console.log(options)
  }
  //compiler:webpack实例
  apply(compiler) {
  
  }
}
module.exports = CopyPlugin

// webpack.config.js

const CopyPlugin = require("./plugin/copy-plugin")
plugins: [new CopyPlugin({title: '参数'})]

配置plugin在什么时刻进行

class CopyPlugin {
  //接收参数
  constructor(options) {
    console.log(options)
  }

  //compiler:webpack实例
  apply(compiler) {
    //emit 生成资源文件到输出目录之前
    compiler.hooks.emit.tapAsync(
      'CopyPlugin',
      (compilation, cb) => {
        console.log(compilation.assets)
        compilation.assets['copyright.txt'] = {
          // 文件内容
          source: function () {
            return 'hello copy'
          },
          // 文件大小
          size: function () {
            return 20
          }
        }
        // 完成之后 走回调,告诉compilation事情结束
        cb()
      }
    )
    // 同步的写法; 
    compiler.hooks.compile.tap('CopyPlugin', compilation => {
      console.log('开始了')
    })
  }
}
module.exports = CopyPlugin

compiler不仅有同步的钩子,通过tap函数来注册,还有异步的钩子,通过tapAsynctapPromise来注册

compiler.hooks.emit.tapPromise("CopyPlugin", (compilation) => {
      return new Promise((resolve, reject) => {
        setTimeout(()=>{
          console.log("compilation emit")
          resolve()
        }, 1000)
      })
    })

上面又出现了一个compilation,它和上面提到的compiler对象都是Plugin和webpack之间的桥梁

  • compiler对象包含了 Webpack 环境所有的的配置信息。这个对象在启动 webpack 时被一次性建立,并配置好所有可操作的设置,包括 options,loader 和 plugin。当在 webpack 环境中应用一个插件时,插件将收到此 compiler 对象的引用。可以使用它来访问 webpack 的主环境

  • compilation对象包含了当前的模块资源、编译生成资源、变化的文件等。当运行webpack 开发环境中间件时,每当检测到一个文件变化,就会创建一个新的 compilation,从而生成一组新的编译资源。compilation 对象也提供了很多关键时机的回调,以供插件做自定义处理时选择使用

至此,一个简易的webpack plugin就完成了!

如果觉得写的还不错就点个赞叭