你能手写webpack的打包功能嘛

2,406 阅读9分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情

hello 大家好,🙎🏻‍♀️🙋🏻‍♀️🙆🏻‍♀️

我是一个热爱知识传递,正在学习写作的作者,ClyingDeng 凳凳!

好久不见哈!

今天我要给大家带来一个手写webpack的打包功能!

🏳‍🌈前方硬核,请注意!

简易webpack使用

webpack想必大家都挺熟悉的吧,我们先来完成webpack打包的一个简单例子。

初始化一个空项目npm init -y,安装依赖webpackwebpack-cli

创建一个src目录,存放自己代码的文件夹。内部新建一个index.js文件作为入口文件。在src目录下,index.js引入了add(两数相加)和minus(两数相减)两个方法。入口文件内容:

import add from './add.js'
import minus from './minus.js'

console.log(add(1, 2))
console.log(minus(1, 2))

创建一个webpack.config.js文件,作为webpack配置。目前webpack 5可以只需要指定它的环境模式就可以进行打包。默认入口就是src下的index.js文件,出口就是dist目录下的main.js文件。配置如下:

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

具体目录结构如下: image.png

最后执行webopack命令,进行webpack打包。

index.html文件中引入打包后的文件,在浏览器运行,控制台如果可以正常输出打包后的结果(1和2加减的结果),那么这就证明使用webpack打包成功。 image.png

webpack工作流程

  • 初始化Compiler:new Compiler(config) 得到Compiler对象
  • 开始编译:调用Compiler 对象 run 方法开始执行编译
  • 确定入口: 根据配置中的 entry 找出所有的入口文件
  • 编译模块:从入口文件出发,调用所有配置的Loader 对模块进行编译,再找出该模块依赖的模块,递归知道所有模块被加载进来
  • 完成模块编译:在经过第4步使用Loader 编译完成所有模块后,得到了每个模块被编译后的最终内容以及它们之间的依赖关系
  • 输出资源:根据入口模块之间的依赖关系,组装成一个个包含多个模块的Chunk。再把每个Chunk 转换成一个单独的文件加入到输出列表。(注意:这步是可以修改输出内容的最后机会)
  • 输出完成:在确定好的输出内容后,根据配置确定的路径和文件名,把文件内容写入到文件系统

手写webpack

根据webpack的工作流程,这次我们涉及到的没有loader和plugin。但是存在文件的依赖,至此我们需要在编译文件模块的时候需要进行一个模块的递归遍历收集的过程。

很好,上面就是我们今天要实现的打包功能:获取webpack配置,执行Compiler.run()方法,

第一层入口文件处理:根据传入配置的入口路径,读取文件读取资源转成utf-8格式,通过@babel/parser将资源转成ast抽象语法树;利用@babel/traverse去遍历ast中program.body节点,获取依赖路径,并将其收集到deps中;通过@babel/core将ast编译成浏览器能够识别的js。 经过一系列处理,然后输出到配置指定的目录和文件夹中。

第一层处理好之后,定义一个modules总的依赖收集器,将第一层文件依赖push到modules中。遍历modules,去处理入口文件的deps依赖,将其每个依赖都转成和第一层入口文件的格式。再push到modules,如果依赖中还存在依赖,那么就需要继续递归遍历,直到每个deps中为空。

处理modules,将其变成一个关系图,类似于:

 {
      'index.js':{
        code:'xx',
        deps:{
          'add.js':"xxx"
        },
        'add.js':{
          code:'xxx',
          deps:{}
        }
      }
  }

最后生成bundle,输出资源。

初始环境搭建

在上述环境的基础上,我们已经有了webpack的配置,那么我们就需要写一个我们自己的webpack方法,去接收配置信息。

首先我们知道会有一个Compiler类,内部存在run方法去执行打包。新建webpack功能处理文件:lib文件夹下Compiler.js文件。

class Compiler{
    constructor(options){
        this.options = options
    }
    run(){
        console.log('执行run方法',this.options)
    }
}
module.exports = Compiler

新建webpack功能处理文件:lib文件夹下index.js文件。接收config配置,通过new Compiler将配置传入Compiler类中。

const Compiler = require('./Compiler')

const myWebpack = (config) => {
  return new Compiler(config)
}
module.exports = myWebpack

新建一个打包的脚本文件:scripts文件夹--build.js文件。根据上面的原理阐述,不难发现webpack打包是执行其中的run方法。

const config = require('../webpack.config')
const myWebpack = require('../lib/index')
myWebpack(config).run()

在package.json文件中配置打包命令:"build": "node ./scripts/build.js"。执行npm run build可以看到run方法中已经获取到相关配置信息。

处理第一层入口文件

接下来的步骤主要就是去完善Compiler类。

处理第一层入口文件,就需要读取文件资源将其转成ast,并对ast进行遍历收集第一层文件依赖文件的信息,最后还需要根据ast编译成浏览器可识别的js语言。

获取ast

根据配置拿到入口文件路径,通过fs.readFileSync读取文件资源,再通过@babel/parser将其转成ast。

const fs = require('fs')
const path = require('path')
const babelPaser = require('@babel/parser')

 const filePath = this.options.entry
 const file = fs.readFileSync(filePath, 'utf-8')
 // 转成ast
 const ast = babelPaser.parse(file, {
   sourceType: 'module',
 }) 
 return ast

浏览器调试方法

大多情况下,我们都习惯于直接在终端输出结果,但是遇到输出像ast这样的对象结构,通常会展示很长一段,层级嵌套过深,很难分清子属性属于哪个父属性。 在此,我们使用浏览器调试方式,就可以很清楚的看到ast的层级结构。 在我们获取ast的地方打上debugger,并在package.json配置命令:"debug": "node --inspect-brk ./scripts/build.js"。执行命令。 在浏览器端任意页面打开控制台,会发现多出一个node.js图标:

image.png

点击图标,会弹出一个新的控制台窗口。

image.png 点击下一个断点就会跳转到我们打断点的代码文件中。

image.png

在控制台中监听ast,我们就可以很清楚的看到ast中的结构。当然也可以使用ast工具网站:astexplorer.net/

编译ast

拿到ast后我们需要将ast语法树编译成浏览器能够识别的js。直接通过@babel/core中的transformFromAst方法即可。js如果存在高级语法,就加一个@babel/preset-env去解析js。

const {transformFromAst} = require('@babel/core')
getCode(ast){
   const { code } = transformFromAst(ast, null, {
    presets: ['@babel/preset-env'],
   })
   return code
 }

遍历ast收集依赖

我们需要收集入口文件的依赖,就需要去遍历ast的program.body中的节点,找到类型为ImportDeclaration的引入声明节点。 image.png

再找到节点下的source.vlaue(入口文件引入依赖的相对路径),根据相对路径生成绝对路径。

const traverse = require('@babel/traverse').default
  // 获取依赖
getDeps(ast, filePath) {
  // 获取文件夹路径
  const dirname = path.dirname(filePath)
  // 收集ast语法树依赖: 通过ast上的program.body.source.value收集依赖
  //  定义收集存储依赖的容器
  const deps = {}
  traverse(ast, {
    // 内部遍历ast中的program.body 判断里面语句类型
    // 如果 type:ImportDeclaration 就会触发当前函数
    ImportDeclaration({ node }) {
      // 引入声明
      // code.node.source.value 引入文件的相对路径  './add.js'
      const relativePath = node.source.value
      // 生成基于入口文件的绝对路径
      const absolutePath = path.resolve(dirname, relativePath)
      // 添加依赖
      deps[relativePath] = absolutePath
    },
  })
  return deps
}

收集相对路径与绝对路径有映射关系的依赖deps。

image.png

递归收集所有依赖

收集依赖

将上面对第一层入口文件处理的三大方法提取封装成公共方法。作为构建基础方法build。

  // 开始构建
  build(filePath) {
    // 生成ast语法树
    const ast = getAst(filePath)
    // 收集依赖
    const deps = getDeps(ast, filePath)
    // 编译
    const code = getCode(ast)
    return {
      // 文件路径
      filePath,
      // 当前依赖
      deps,
      // 解析后的代码
      code,
    }
  }

在Compiler类中,创建一个收集多层文件总的依赖modules数组。 在执行方法run:

需要收集全部文件的依赖,我们就需要去递归遍历入口文件中的deps,根据deps中的依赖找到相应文件,再对相应文件进行转ast,编译,收集依赖。

   // 1.读取入口文件内容
    const filePath = this.options.entry
    const fileInfo = this.build(filePath)

    this.modules.push(fileInfo)
    // 遍历所有依赖
    this.modules.forEach((_) => {
      const deps = _.deps
      for (const path in deps) {
        // 获取绝对路径,然后收集依赖
        const absolutePath = deps[path]
        // 对依赖文件进行处理,并添加到modules中
        this.modules.push(this.build(absolutePath))
      }
    })

整理依赖

进一步处理依赖,每一个引入的相对路径文件下存在自己的依赖文件路径、编译code。将依赖整理成如下的依赖关系图:

 {
   'index.js':{
     code:'xx',
     deps:{
       'add.js':"xxx"
     },
     'add.js':{
       code:'xxx',
       deps:{}
     }
   }
 }

此时,我们就需要一个空对象来收集处理后的依赖关系。遍历modules依赖,根据上述结构进行组织。

const depsGraph = this.modules.reduce((graph, module) => {
     return {
        ...graph,
       [module.filePath]: {
         code: module.code,
         deps: module.deps,
       },
     }
   }, {})

打包输出资源

拿到处理后的依赖关系图,我们就可以生成bundle并打包输出资源。

生成bundle

webpack打包后的bundle是一个立即执行函数,内部是通过执行依赖关系图中的code,再次对发起相应依赖文件的请求。bundle内容如下:

(function (depsGraph) {
function require(modulePath){
  const module ={
    id:modulePath,
    exports:{}
  }
  // 依赖文件的require 
  // relativePath:./add.js
  function localRequire(relativePath){
const absolutePath = depsGraph[modulePath].deps[relativePath]
return require(absolutePath)
  }
// 执行第一层code
(function (exports,code,require) {
eval(code)
})(module.exports,depsGraph[modulePath].code,localRequire)

  return module.exports
}
require('${this.options.entry}')
})(${JSON.stringify(depsGraph)})

立即执行函数传入关系图,require第一层入口文件。在第一个入口文件中我们需要执行内部的code,就需要再加一个立即执行函数去执行code。在第一个入口文件执行时,会遇到依赖的add.js文件。执行的时候会将其转成require请求,所以我们还需要写一个请求内部文件依赖的方法localRequire,递归调用require。

可能会有人不理解在eval(code)这个函数中参数意义,是这样的:

  • exports:对外暴露的模块
  • code:需要执行的文件内容
  • require:依赖再次请求时,会触发require函数,所以入参的require名称不能改变。否则不会触发立即执行函数中的localRequire方法,会直接执行外层的require函数,这样会导致请求时,传入的是依赖文件的相对路径,获取不到相关文件内容,执行错误。

输出资源

通过options获取输出文件的绝对路径:

const filePath = path.resolve(this.options.output.path, this.options.output.filename)

创建打包输出后的文件夹和文件资源。由于我们只是实现简单版本的webpack,所以只考虑了打包文件夹下的一个输出文件。

// 判断是否存在相同文件夹名称,存在删除后再写入
if (fs.existsSync(this.options.output.path)) {
  fs.unlinkSync(filePath);
  fs.rmdirSync(this.options.output.path)
}
fs.mkdirSync(this.options.output.path)
fs.writeFileSync(filePath, bundle, 'utf-8')

验证功能

执行打包命令后,在html文件中引入。运行html文件: image.png

可以看到与使用webpack打包输出结果一致。

这样,一个简单的webpack打包流程功能就算完成啦🎉🎉🎉