likes
comments
collection
share

从0实现一个简易的打包工具

作者站长头像
站长
· 阅读数 12
从0实现一个简易的打包工具

前言

打包工具是现代前端开发不可或缺的工具之一,它可以将多个文件和资源打包成一个单独的文件,以便于网页的部署和使用。在这个快速发展的前端生态中,有很多成熟的打包工具,例如Webpack、Rollup、Parcel等。但是,对于初学者来说,了解这些庞大而复杂的工具可能会感到非常困难。

因此,本文将从零开始实现一个简易的打包工具,了解打包工具的核心概念和原理,包括如何解析代码,如何处理依赖关系,如何生成打包结果等。通过实际操作,可以更深入地了解打包工具的实现细节,并为进一步学习现代打包工具打下坚实的基础。

基本步骤

  • 确定打包的入口文件以及其依赖关系,构建一个依赖图
  • 分析每个模块的内容,确定引用关系,生成可执行代码
  • 打包所有模块以及依赖形成一个单独的文件

具体实现

首先建立一个项目,目录及文件内容如下:

mini-bundler
├── src
│   ├── one.js
│   ├── two.js
│   └── main.js
├── bundler.js
└── index.html
// one.js
export const one = 1
// two.js
import { one } from './one.js'
export const two =  one + 2
// main.js
import { two } from './two.js'
console.log(two)
// index.html
<!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>mini-bundler</title>
  <script src="./dist/bundle.js"></script>
</head>
<body>
  <div id="root"></div>
</body>
</html>

模块分析

模块分析是从文件的引用情况分析,可以利用node的fs模块获取文件,将获取到的文件内容用babel处理成浏览器能运行的代码,另外再拿到文件的引用关系

const path = require('path')
// bundler.js
const fs = require('fs')
// js解析器
const parser = require('@babel/parser')
// 用于遍历ast节点
const traverse = require('@babel/traverse').default
const babel = require('@babel/core')

const moduleAnalyzer = (filename) => {
  // 获取到文件内容,'utf8'形式
  const content = fs.readFileSync(filename, 'utf8')
  // 将拿到的文件内容解析为ast,由于我们写的代码一般使用ESModule,所以设置sourceType: 'module'以获支持,详见https://www.babeljs.cn/docs/babel-parser
  const ast = babel.parse(content, {
    sourceType: 'module'
  })
  // 用于储存路径映射
  const dependencies = {}
  // 遍历ast节点
  traverse(ast, {
    // 解析import语句,获取到导入路径
    ImportDeclaration({ node }){
      const dirname = path.dirname(filename)
      const newFile = './' + path.join(dirname, node.source.value)
      // 得到{'./one.js': './src/one.js'}的映射
      dependencies[node.source.value] = newFile
    }
  })
  // 将ast经过预设转换为执行代码
  const { code } = babel.transformFromAst(ast, null, {
    presets: ['@babel/preset-env']
  })

  return {
    filename,
    dependencies,
    code
  }
}

建立依赖图扑

入口文件通常是应用程序的主文件(一般是src目录下的index.js或者main.js),依赖关系可以通过解析入口文件来确定。这里不断地去分析模块,构建出项目的整个依赖关系,比如main.js里引用了two.js,two.js里又引用了one.js

// 根据入口文件建立依赖图扑
const buildDependencyGraph = (entry) => {
  const entryModule = moduleAnalyzer(entry)
  // 不断地分析模块的引用情况
  const graph = [ entryModule ]
  for(let i = 0; i < graph.length; i++) {
    const { dependencies } = graph[i]
    for(let j in dependencies) {
      graph.push(moduleAnalyzer(dependencies[j]))
    }
  }
  // 转换为对象形式,方便获取
  const dependGraph = {}
  graph.forEach(item => {
    graph[item.filename] = {
      dependencies: item.dependencies,
      code: item.code
    }
  })
  return graph
}

生成可执行的代码

先将之前构建好的依赖关系序列化,这里用eval函数执行之前babel生成的代码,可以打印之前生成的代码,这里需要一个require函数和一个exports对象,require函数用来获取引用的模块内容,export用于储存模块导出的内容

const generateCode = (entry) => {
  const graph = JSON.stringify(buildDependencyGraph(entry))
  const bundleCode =  `(function(graph){
    function require(module){
      function newRequire(relativePath) {
        return require(graph[module.dependencies[relativePath])
      }
      var exports = {};
      (function(require, exports, code){
        eval(code)
      })(newRequire, exports, graph[module].code)
      return exports
    }
    require('${entry}')
  })(${graph})
  ` 
  return bundleCode
}
fs.writeFileSync('bundle.js', generateCode('./src/main.js'))

运行node bundler.js,项目目录下生成了dist文件夹,浏览器查看index.html,可以看到控制台输出了3

完整代码

// bundler.js
const ENTRY = './src/main.js'

const fs = require('fs');
const path = require('path');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const babel = require('@babel/core');
const uglifyjs = require('uglify-js');

const moduleAnalyzer = (filename) => {
  const content = fs.readFileSync(filename, 'utf-8');
  const ast = parser.parse(content, {
    sourceType: 'module'
  })
  const dependencies = {}
  traverse(ast, {
    ImportDeclaration({ node }){
      const dirname = path.dirname(filename)
      const newFile = './' + path.join(dirname,node.source.value)
      dependencies[node.source.value] = newFile
    }
  })
  const { code } = babel.transformFromAst(ast, null, {
    presets: ['@babel/preset-env']
  })
  return {
    filename,
    dependencies,
    code
  }
}

const buildDependencyGraph = (entry) => {
  const entryModule = moduleAnalyzer(entry)
  const graphArr = [ entryModule ]
  for(let i = 0; i < graphArr.length; i++) {
    const item = graphArr[i]
    const { dependencies } = item
    if(dependencies) {
      for(let j in dependencies) {
        graphArr.push(moduleAnalyzer(dependencies[j]))
      }
    }
  }
  const graph = {}
  graphArr.forEach(item => {
    graph[item.filename] = {
      dependencies: item.dependencies,
      code: item.code
    }
  })
  console.log(graph, 'graph')
  return graph
}

const generateCode = (entry) => {
  const graph = JSON.stringify(buildDependencyGraph(entry))
  return `(function(graph) {
    function require(module){
      function newRequire(relativePath){
        return require(graph[module].dependencies[relativePath])
      }
      var exports = {};
      (function(require, exports, code){
        eval(code)
      })(newRequire, exports, graph[module].code)
      return exports
    }
    require('${entry}')
  })(${graph})`
}
const code = uglifyjs.minify(generateCode(ENTRY)).code

if(!fs.existsSync('./dist')) {
  fs.mkdir('./dist', () =>{})
}

fs.writeFileSync('./dist/bundle.js', code)

扩展

Babel的traverse函数用于遍历抽象语法树(AST)并执行特定的操作,traverse函数的参数如下:

  • ast(必填):需要遍历的抽象语法树(AST)。
  • visitor(必填):一个对象,该对象的属性对应于AST节点的类型,值为一个函数,用于在遍历过程中对相应类型的节点执行特定的操作。
  • scope(可选):当前作用域的引用。
  • state(可选):一个对象,用于在遍历过程中共享状态。
  • path(可选):当前节点的路径,由一系列父节点组成的数组。
  • parentPath(可选):当前节点的父节点的路径。 这些参数在Babel插件的开发中非常有用。插件可以通过定义visitor对象中的函数来定义要执行的操作,并可以通过state对象在这些函数之间共享状态。在遍历过程中,插件可以使用pathparentPath来访问和修改节点和其父节点的信息, visitor对象是一个包含各种节点类型作为属性的对象,每个节点类型的属性都是一个函数,这些函数被称为“访问者”,因为它们访问抽象语法树中的特定类型的节点,并在遍历过程中执行特定的操作,详见>>babeljs.io/docs/babel-…

ImportDeclaration是一个类,用于在AST中表示已有的import语句。ImportDeclaration类的构造函数需要传递以下参数:

  • specifiers(必填):一个包含 ImportSpecifier、ImportDefaultSpecifier 或 ImportNamespaceSpecifier 节点的数组,用于表示导入的内容。
  • source(必填):一个字符串字面量,用于表示从哪个模块导入。

总结

通过这个实现过程,我们深入了解了打包工具的实现原理,并掌握了打包工具的核心技术。通过这个基础,我们可以更加深入地学习现代前端打包工具,如Webpack、Rollup、Parcel等,并应用于实际项目中。