实现一个简易的webpack打包过程

模块化与webpack

在实现一个简单的webpack之前,我们需要了解为什么需要webpack等打包器存在。在前端刚开始没有模块化工具的时候,会遇到什么问题呢?
你可能需要把js都依次引入html中。这样做有几个问题

  • 多标签引入意味这这么多次的资源请求,对性能是极大的挑战
  • 如果资源有依赖关系,那么需要确保资源的引入顺序,可能会出问题
  • 文件中的东西不一定都能用到,但是都会进行资源传输,无法通过依赖分析只传输用到的内容
  • 如果在两个文件中定义了同名函数会冲突,除非全都给一个命名空间,把函数作为对象方法来使用
<script src="a.js"></script>
<script src="b.js"></script>
<script src="main.js"></script>

在这种情况下,模块化方案应运而生。如CommonJS和浏览器端的模块化规范AMD、CMD等。以其中amd规范的require.js来看对这个问题的解决。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <script src="./js/require.js" data-main='js/main'></script>
</body>
</html>

main.js

// main.js
console.log('before require')
require(['a', 'b'], function (a, b) {
  a.outputa('hello')
  b.outputb('hello')
})
console.log('after require')
// a.js
define(function () {
  function outputa (message) {
      console.log(message+'a')
  }
  return {
    outputa: outputa
  }
})
define(function () {
  console.log('define a.js')
  function outputa (message) {
      console.log(message+'a')
  }
  return {
    outputa: outputa
  }
})

可以看到依赖关系相对直接引入就清晰了很多,也可以避免命名冲突问题,但仍然无法解决资源多次下载的问题。要知道网络请求是前端优化的瓶颈,页面渲染再快也得资源能加载过来。这是因为cmd/amd的模块化是相当于在线“编译”的。而webpack则可以实现预编译,让浏览器只引用打包后的资源。

实现简易版webpack

模块化工具主要都是为了解决本地开发与生产环境对代码的不同需求而出现的。在开发环境我们希望代码是易懂可维护的,也会通过模块划分来提升代码的可复用性。而对于生产环境运行而言资源的请求越少越好,资源的大小越小越好。既然是这样,我们完全可以按照便于开发的方式开发代码在发布到生产环境之前在按照生产环境打包一份用于生产环境的代码而不影响我们的本地开发。

而webpack作为模块打包器,则恰好可以解决我们的这个需求。因此webpack的核心功能是资源打包。那么webpack是如何实现资源打包的呢?

前期准备

首先准备一份需要打包的代码。这里模块暴露的方案分别采用default和对象形式暴露。为了便于理解这里采用一个简单的输出例子。

// main.js
import a from './a.js'
import { outputb } from './b.js'
a('hello')
outputb('hello')
// a.js
export default function outputa(message) {
    console.log(message+'a')
}
// b.js
function outputb(message) {
    console.log(message+'bbb')
}
module.exports = {
    outputb
} 
模块分析

因为打包是将浏览器看不懂的语法转换成浏览器可以看懂的语法。可以从入口文件进行分析,生成依赖图谱后将依赖的文件作为模块打包。
模块分析这里需要生成ast语法树,这个步骤我们使用@babel/parser进行打包。

 const parse = require('@babel/parser');
 const ast = parse.parse(content, {
    sourceType: 'module',
    tokens: true
  });

这里其实也可以使用Babylon。因为Babylon就是Babel中使用的JavaScript解析器。不过Babylon已经封仓了,转仓用@babel/parser继续维护。因此可以将Babylon看作@babel/parser前身。使用babylon会默认提供token。我最开始使用Babylon,不过考虑到仓库不在维护了就转用了@babel/parser

 const parse = require('babylon');
 const content = fs.readFileSync(filename, 'utf-8');
 const ast = parse.parse(content, {
    sourceType: 'module',
 })

我们可以打印看下ast树的结构,可以看到解析出的文件语法树描述了类型、位置、程序信息、注释、标记等信息。

在这里插入图片描述

其中token这个部分是标记拆解,这里并非以字母进行逐个拆解,而是以关键字进行拆解。接下来我们看program

在这里插入图片描述

可以看到其中body中是类型为ImportDeclaration的语法树。看上去好像也是对我们的代码部分进行拆解,为什么有俩个字段都对代码进行了拆解?

这是因为token是词法分析,拆解了具体的标记组成。而body部分则是语法分析,加入了文法特性。
例如,a = b + c,我们首先进行词法分析拆解为标签,此时需要五个对象描述这条语句的五个关键字。但是比如说我们解析a的时候,只能记录下值和位置等信息,无法判定其作用。等拆解完了,我们发现这是个赋值语句,分析a的位置发现他是左值。
如果我们想要语法含义进行操作,例如将赋值语句改成判断语句,则应该等到语法分析之后,而非去操作词法树将=直接改成“==”。

依赖收集

还记得刚刚我们说的词法分析和语法分析吗?如果我们要分析依赖这是个语法概念,因此需要用到语法树的遍历。此处我们会用到@babel/traverse进行语法树遍历。可以看到文档中提供了两种用法

  • 在遍历路径中通过断言更新节点
  • 选中语法树特定节点类型

此处我们使用节点类型命中节点并进行依赖收集

  const traverse = require('@babel/traverse').default;
  const dependencies = [];
  traverse(ast, {
    ImportDeclaration: ({ node }) => {
      dependencies.push(node.source.value);
    },
  });

到这里我们通过ast的依赖分析拿到了文件的依赖。

[ './a.js', './b.js' ]
语法转化

接下来我们对语法进行转换,这里的选项preset-env将所有ES2015-ES2020代码转换为与ES5兼容。

此处我们使用transformFromAstSync进行ast转换,在es8中将不支持transformFromAst,因此这里最好显示的指定为同步。

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

到了这里我们已经完成了单个文件的模块分析及依赖图谱生成,接下来怎么做呢?没错就是继续递归入口文件的模块。并将用到的模块都存储到队列中。所以我们将上面解析的函数封装起来。

let globalId = 0;
function createModule(filename) {
  const content = fs.readFileSync(filename, 'utf-8');
  // 模块分析
  const ast = parse.parse(content, {
    sourceType: 'module',
  });

  // 依赖收集
  const dependencies = [];
  traverse(ast, {
    ImportDeclaration: ({ node }) => {
      dependencies.push(node.source.value);
    },
  });

  // 语法转换后生成代码
  const code = babel.transformFromAstSync(ast, null, {
    presets:["@babel/preset-env"]
  })

  // 暴露模块
  return {
    id: globalId++,
    filename,
    dependencies,
    code,
  };
}
生成依赖图谱

接下来进行依赖分析并对依赖进行模块分析:

function createDependenceMap(filename) {
  // 创建入口模块
  const entryModule = createModule(filename);
  // 创建模块队列,并将入口模块放入
  const moduleQueue = [entryModule]
  
  for (const module of moduleQueue) {
    const dirname = path.dirname(module.filename);
    module.map = {}
    // 对入口模块的依赖进行模块分析及依赖收集
    module.dependence.forEach(dependencePath => {
      const absolutePath = path.join(dirname, dependencePath);
      // 如果依赖仍有依赖则不断重复该过程,创建子模块
      const child = createModule(absolutePath);
      // 创建子模块Id与路径的映射关系
      module.map[dependencePath] = child.id
      // 将依赖模块也放入模块队列
      moduleQueue.push(child);
    })
    console.log(module.map)
  }
  // 返回模块队列
  return moduleQueue
}

到了这一步我们完成了从文件到模块的转化
[(img-LQixJck9-1619355428790)(evernotecid://5C9EC4C2-3173-4E27-96E0-473FCB420905/appyinxiangcom/9154414/ENResource/p82)]

生成代码

因为浏览器是不识别requireexportsmodule等关键字的。而且实际上我们已经把文件转换成了模块,所以我们需要将require解释为依赖模块查询,将exports及module解释为依赖挂载。当入口模块引入依赖时,依赖会将暴露的内容挂载在module对象,将该对象返回给入口模块。而依赖模块有模块引入时依赖模块也同样递归的完成该步骤。

function moduleEnv(filename) {
  const [code, map] = modules[filename];
  
  // 提供模块编号取出相应模块的方法
  // 依赖编号是根据依赖文件与依赖模块编号的映射关系取得
  function require(name) {
    return moduleEnv(map[name])
  }

  // 提供依赖挂载对象
  const module = { exports: {} }

  // 为模块注入依赖引入和模块挂载解释器
  code(require, module, module.exports)
  
  // 返回挂载完成的依赖模块
  return module.exports
}

然后我们将模块传入。在传入之前我们需要为执行代码创建环境,将其放在函数中,这样就可以将解释器通过参数注入了。响应的挂载和引用都可以通过刚刚提供的方法完成相应的使命了。

const fs = require('fs');
const path = require('path');
const parse = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const babel = require('@babel/core');

let globalId = 0;
function createModule(filename) {
  console.log(filename, 'createmodule')
  const content = fs.readFileSync(filename, 'utf-8');
  // 模块分析
  const ast = parse.parse(content, {
    sourceType: 'module',
  });

  // 依赖收集
  const dependence = [];
  console.log(dependence)
  traverse(ast, {
    ImportDeclaration: ({ node }) => {
      dependence.push(node.source.value);
    },
  });

  // 语法转换后生成代码
  const { code }= babel.transformFromAstSync(ast, null, {
    presets:["@babel/preset-env"]
  })

  // 暴露模块
  return {
    id: globalId++,
    filename,
    dependence,
    code,
  };
}

function createDependenceMap(filename) {
  // 创建入口模块
  const entryModule = createModule(filename);
  // 创建模块队列,并将入口模块放入
  const moduleQueue = [entryModule]
  
  for (const module of moduleQueue) {
    const dirname = path.dirname(module.filename);
    module.map = {}
    // 对入口模块的依赖进行模块分析及依赖收集
    module.dependence.forEach(dependencePath => {
      const absolutePath = path.join(dirname, dependencePath);
      // 如果依赖仍有依赖则不断重复该过程,创建子模块
      const child = createModule(absolutePath);
      // 创建子模块Id与路径的映射关系
      module.map[dependencePath] = child.id
      // 将依赖模块也放入模块队列
      moduleQueue.push(child);
    })
    console.log(module.map)
  }
  // 返回模块队列
  return moduleQueue
}

function bundle(graph) {
  let modules = '';
  
  // 对依赖模块进行处理
  graph.forEach(mod => {
    modules += `${mod.id}: [
       function (require, module, exports) {
         ${mod.code}
       },
       ${JSON.stringify(mod.map)},
     ],`;
  });

  // 进行逻辑整合
   const result = `
     (function(modules) {
      function moduleEnv(filename) {
        const [code, map] = modules[filename];
        
        // 提供模块编号取出相应模块的方法
        // 依赖编号是根据依赖文件与依赖模块编号的映射关系取得
        function require(name) {
          return moduleEnv(map[name])
        }

        // 提供依赖挂载对象
        const module = { exports: {} }
      
        // 为模块注入依赖引入和模块挂载解释器
        code(require, module, module.exports)

        // 返回挂载完成的依赖模块
        return module.exports
      }
      moduleEnv(0)
     })({${modules}})
   `;
  return result;
}


const graph = createDependenceMap('./src/main.js')
const result = bundle(graph);

// 将文件写入index.html引入的入口文件,作为output
fs.writeFileSync('./main.js', result)
console.log('打包完成!');

此时我们来看打包后的入口文件
在这里插入图片描述

到了这里,我么初步的打包工作就完成了,来看看浏览器执行结果,确认可以成功执行
在这里插入图片描述

过程梳理

实际上的webpack打包流程要复杂得多,但这仍不妨碍我们通过一个简单的例子来了解webpack打包的大概原理。总的来说,webpack是根据文件间的依赖关系对其进行静态分析,将这些模块按指定规则生成静态资源,当webpack处理程序时,它会递归地构建一个依赖关系图,将所有这些模块打包成一个或多个预期环境可以执行的bundle。
在这里插入图片描述

参考

https://github.com/estree/estree
https://www.npmjs.com/package/@babel/traverse
https://babeljs.io/docs/en/babel-traverse
https://www.npmjs.com/package/babel-core
https://babeljs.io/docs/en/babel-preset-env
https://www.npmjs.com/package/babylon
https://github.com/ronami/minipack
https://stackoverflow.com/questions/32275135/why-does-babel-rewrite-imported-function-call-to-0-fn
https://stackoverflow.com/questions/36076794/does-the-comma-operator-influence-the-execution-context-in-javascript
https://github.com/babel/babel/blob/main/packages/babel-parser/ast/spec.md
https://babeljs.io/docs/en/babel-parser#output
https://github.com/jamiebuilds/babel-handbook/blob/master/translations/en/plugin-handbook.md#toc-paths-in-visitors

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值