窥探原理:手写一个 JavaScript 打包器

前言

以前好友但愿能介绍一下 webapck 相关的内容,因此最近花费了两个多月的准备,终于完成了 webapck 系列,它包括一下几部分:html

  • webapck 系列一:手写一个 JavaScript 打包器
  • webpack 系列二:最佳配置指北
  • webpack 系列三:优化 90% 打包速度
  • webpack 系列四:优化包体积
  • webapck 系列五:优化首屏加载时间与页面流畅度
  • webapck 系列六:构建包分析
  • webapck 系列七:详细配置
  • webapck 系列八:手写一个 webapck 插件(模拟 HtmlWebpackPlugin 的实现)
  • webapck 系列九:webapck4 核心源码解读
  • webapck 系列十:webapck5 展望

全部的内容以后会陆续放出,若是你有任何想要了解的内容或者有任何疑问,关注公众号【前端瓶子君】回复【123】添加好友,我会解答你的疑问。前端

做为一个前端开发人员,咱们花费大量的时间去处理 webpack、gulp 等打包工具,将高级 JavaScript 项目打包成更复杂、更难以解读的文件包,运行在浏览器中,那么理解 JavaScript 打包机制就很必要,它帮助你更好的调试项目、更快的定位问题产生的问题,而且帮助你更好的理解、使用 webpack 等打包工具。 在这章你将会深刻理解 JavaScript 打包器是什么,它的打包机制是什么?解决了什么问题?若是你理解了这些,接下来的 webpack 优化就会很简单。node

1、什么是模块

一个模块能够有不少定义,但我认为:模块是一组与特定功能相关的代码。它封装了实现细节,公开了一个公共API,并与其余模块结合以构建更大的应用程序。webpack

所谓模块化,就是为了实现更高级别的抽象,它将一类或多种实现封装到一个模块中,咱们没必要考虑模块内是怎样的依赖关系,仅仅调用它暴露出来的 API 便可。git

例如在一个项目中:github

<html>
  <script src="/src/man.js"></script>
  <script src="/src/person.js"></script>
</html>
复制代码

其中 person.js 中依赖 man.js ,在引用时若是你把它们的引用顺序颠倒就会报错。在大型项目中,这种依赖关系就显得尤为重要,并且极难维护,除此以外,它还有如下问题:web

  • 一切都加载到全局上下文中,致使名称冲突和覆盖
  • 涉及开发人员的大量手动工做,以找出依赖关系和包含顺序

因此,模块就尤为重要。shell

因为先后端 JavaScript 分别搁置在 HTTP 的两端,它们扮演的角色不一样,侧重点也不同。 浏览器端的 JavaScript 须要经历从一个服务器端分发到多个客户端执行,而服务器端 JS 则是相同的代码须要屡次执行。前者的瓶颈在于宽带,后者的瓶颈则在于 CPU 等内存资源。前者须要经过网络加载代码,后者则须要从磁盘中加载, 二者的加载速度也不是在一个数量级上的。 因此先后端的模块定义不是一致的,其中服务器端的模块定义为:npm

  • CJS(CommonJS):旨在用于服务器端 JavaScript 的同步定义,Node 的模块系统实际上基于 CJS;

但 CommonJS 是以同步方式导入,由于用于服务端,文件都在本地,同步导入即便卡住主线程影响也不大,但在浏览器端,若是在 UI 加载的过程当中须要花费不少时间来等待脚本加载完成,这会形成用户体验的很大问题。 鉴于网络的缘由, CommonJS 为后端 JavaScript 制定的规范并不彻底适合与前端的应用场景,下面来介绍 JavaScript 前端的规范。json

  • AMD(异步模块定义):被定义为用于浏览器中模块的异步模型,RequireJS 是 AMD 最受欢迎的实现;
  • UMD(通用模块定义):它本质上一段 JavaScript 代码,放置在库的顶部,可以让任何加载程序、任何环境加载它们;
  • ES2015(ES6):定义了异步导入和导出模块的语义,会编译成 require/exports 来执行的,这也是咱们现今最经常使用的模块定义;

2、什么是打包器

所谓打包器,就是前端开发人员用来将 JavaScript 模块打包到一个能够在浏览器中运行的优化的 JavaScript 文件的工具,例如 webapck、rollup、gulp 等。

举个例子,你在一个 html 文件中引入多个 JavaScript 文件:

<html>
  <script src="/src/entry.js"></script>
  <script src="/src/message.js"></script>
  <script src="/src/hello.js"></script>
  <script src="/src/name.js"></script>
</html>
复制代码

这四个引入文件之间存在以下依赖关系:

1. 模块化

在 HTML 引入时,咱们须要注意这 4 个文件的引入顺序(若是顺序出错,项目就会报错),若是将其扩展到具备实际功能的可用的 web 项目中,那么可能须要引入几十个文件,依赖关系更是复杂。

因此,咱们须要将每一个依赖项模块化,让打包器帮助咱们管理这些依赖项,让每一个依赖项可以在正确的时间、正确的地点被正确的引用。

2. 捆绑

另外,当浏览器打开该网页时,每一个 js 文件都须要一个单独的 http 请求,即 4 个往返请求,才能正确的启动你的项目。

咱们知道浏览器加载模块很慢,即便是 HTTP/2 支持有效的加载许多小文件,但其性能都不如加载一个更加有效(即便不作任何优化)。

所以,最好将全部 4 个文件合并为1个:

<html>
  <script src="/dist/bundle.js"></script>
</html>
复制代码

这样只须要一次 http 请求便可。

因此,模块化与捆绑是打包器须要实现的两个最主要功能。

3、如何打包

如何打包到一个文件喃?它一般有一个入口文件,从入口文件开始,获取全部的依赖项,并打包到一个文件 bundle.js 中。例如上例,咱们能够以 /src/entry.js 做为入口文件,进行合并其他的 3 个 JavaScript 文件。

固然合并不能是简单的将 4 个文件全部内容放入一个 bundle.js 中。咱们先思考一下,它具体该怎么实现喃?

1. 解析入口文件,获取全部的依赖项

首先咱们惟一肯定的是入口文件的地址,经过入口文件的地址能够

  • 获取其文件内容
  • 获取其依赖模块的相对地址

因为依赖模块的引入是经过相对路径(import './message.js'),因此,咱们须要保存入口文件的路径,结合依赖模块的相对地址,就能够肯定依赖模块绝对地址,读取它的内容。

如何在依赖关系中去表示一个模块,以方便在依赖图中引用

因此咱们能够模块表示为:

  • code: 文件解析内容,注意解析后代码可以在当前以及旧浏览器或环境中运行;
  • dependencies: 依赖数组,为全部依赖模块路径(相对)路径;
  • filename: 文件绝对路径,当 import 依赖模块为相对路径,结合当前绝对路径,获取依赖模块路径;

其中 filename(绝对路径) 能够做为每一个模块的惟一标识符,经过 key: value 形式,直接获取文件的内容一依赖模块:

// 模块
'src/entry': {
  code: '', // 文件解析后内容
  dependencies: ["./message.js"], // 依赖项
}
复制代码

2. 递归解析全部的依赖项,生成一个依赖关系图

咱们已经肯定了模块的表示,那怎么才能将这全部的模块关联起来,生成一个依赖关系图,经过这个依赖关系能够直接获取全部模块的依赖模块、依赖模块的代码、依赖模块的来源、依赖模块的依赖模块。

如何去维护依赖文件间的关系

如今对于每个模块,能够惟一表示的就是 filename ,而咱们在由入口文件递归解析时,咱们能够获取到每一个文件的依赖数组 dependencies ,也就是每一个依赖项的相对路径,因此咱们须要定义一个:

// 关联关系
let mapping = {}
复制代码

用来在运行代码时,由 import 相对路径映射到 import 绝对路径。

因此咱们模块能够定义为[filename: {}]:

// 模块
'src/entry': {
  code: '', // 文件解析后内容
  dependencies: ["./message.js"], // 依赖项
  mapping:{
    "./message.js": "src/message.js"       
  }
}
复制代码

则依赖关系图为:

// graph 依赖关系图
let graph = {
  // entry 模块
  "src/entry.js": {
    code: '',
    dependencies: ["./src/message.js"],
    mapping:{
      "./message.js": "src/message.js"       
    }
  },
  // message 模块
  "src/message.js": {
    code: '',
    dependencies: [],
    mapping:{},
  }
}
复制代码

当项目运行时,经过入口文件成功获取入口文件代码内容,运行其代码,当遇到 import 依赖模块时,经过 mapping 映射其为绝对路径,就能够成功读取模块内容。

而且每一个模块的绝对路径 filename 是惟一的,当咱们将模块接入到依赖图 graph 时,仅仅须要判断 graph[filename] 是否存在,若是存在就不须要二次加入,剔除掉了模块的重复打包。

3. 使用依赖图,返回一个能够在浏览器运行的 JavaScript 文件

现今,可当即执行的代码形式,最流行的就是 IIFE(当即执行函数),它同时可以解决全局变量污染的问题。

IIFE

所谓 IIFE,就是在声明市被直接调用的匿名函数,因为 JavaScript 变量的做用域仅限于函数内部,因此你没必要考虑它会污染全局变量。

(function(man){
  function log(name) {
    console.log(`hello ${name}`);
  }
  log(man.name)
})({name: 'bottle'});
// hello bottle
复制代码

4. 输出到 dist/bundle.js

fs.writeFile 写入 dist/bundle.js 便可。

至此,打包流程与实现方案已肯定,接下来就实践一遍吧!

4、建立一个minipack项目

新建一个 minipack 文件夹,并 npm init ,建立如下文件:

- src
- - entry.js // 入口 js
- - message.js // 依赖项
- - hello.js // 依赖项
- - name.js // 依赖项
- index.js // 打包 js
- minipack.config.js // minipack 打包配置文件
- package.json 
- .gitignore
复制代码

其中 entry.js

import message from './message.js'
import {name} from './name.js'

message()
console.log('----name-----: ', name)
复制代码

message.js

import {hello} from './hello.js'
import {name} from './name.js'

export default function message() {
  console.log(`${hello} ${name}!`)
}

复制代码

hello.js

export const hello = 'hello'
复制代码

name.js

export const name = 'bottle'
复制代码

minipack.config.js

const path = require('path')
module.exports = {
    entry: 'src/entry.js',
    output: {
        filename: "bundle.js",
        path: path.resolve(__dirname, './dist'),
    }
}
复制代码

并安装文件

npm install @babel/core @babel/parser @babel/preset-env @babel/traverse --save-dev
复制代码

至此,整个项目建立完成。接下来就是打包了:

  • 解析入口文件,遍历全部依赖项
  • 递归解析全部的依赖项,生成一个依赖关系图
  • 使用依赖图,返回一个能够在浏览器运行的 JavaScript 文件
  • 输出到 /dist/bundle.js

5、解析入口文件,遍历全部依赖项

1. @babel/parser 解析入口文件,获取 AST

在 ./index.js 文件中,咱们建立一个打包器,首先解析入口文件,咱们使用 @babel/parser 解析器进行解析:

步骤一:读取入口文件内容

// 获取配置文件
const config = require('./minipack.config');
// 入口
const entry = config.entry;
const content = fs.readFileSync(entry, 'utf-8');
复制代码

步骤二:使用 @babel/parser(JavaScript解析器)解析代码,生成 ast(抽象语法树)

const babelParser = require('@babel/parser')
const ast = babelParser.parse(content, {
  sourceType: "module"
})
复制代码

其中,sourceType 指示代码应解析的模式。能够是"script""module""unambiguous" 之一,其中 "unambiguous" 是让 @babel/parser 去猜想,若是使用 ES6 importexport 的话就是 "module" ,不然为 "script" 。这里使用 ES6 importexport ,因此就是 "module"

因为 ast 树较复杂,因此这里咱们能够经过 astexplorer.net/ 查看:

咱们已经获取了入口文件全部的 ast,接下来咱们要作什么喃?

  • 解析 ast,解析入口文件内容(可在当前和旧浏览器或环境中向后兼容的 JavaScript 版本)
  • 获取它全部的依赖模块 dependencies

2. 获取入口文件内容

咱们已经知道了入口文件的 ast,能够经过 @babel/coretransformFromAst 方法,来解析入口文件内容:

const {transformFromAst} = require('@babel/core');
const {code} = transformFromAst(ast, null, {
  presets: ['@babel/preset-env'],
})
复制代码

3. 获取它全部的依赖模块

就须要经过 ast 获取全部的依赖模块,也就是咱们须要获取 ast 中全部的 node.source.value ,也就是 import 模块的相对路径,经过这个相对路径能够寻找到依赖模块。

步骤一:定义一个依赖数组,用来存放 ast 中解析出的全部依赖

const dependencies = []
复制代码

步骤二:使用 @babel/traverse ,它和 babel 解析器配合使用,能够用来遍历及更新每个子节点

traverse 函数是一个遍历 AST 的方法,由 babel-traverse 提供,他的遍历模式是经典的 visitor 模式 ,visitor 模式就是定义一系列的 visitor ,当碰到 AST 的 type === visitor 名字时,就会进入这个 visitor 的函数。类型为 ImportDeclaration 的 AST 节点,其实就是咱们的 import xxx from xxxx,最后将地址 pushdependencies 中.

const traverse = require('@babel/traverse').default
traverse(ast, {
  // 遍历全部的 import 模块,并将相对路径放入 dependencies
  ImportDeclaration: ({node}) => {
    dependencies.push(node.source.value)
  }
})
复制代码

3. 有效返回

{
  dependencies,
  code,
}
复制代码

完整代码:

/** * 解析文件内容及其依赖, * 指望返回: * dependencies: 文件依赖模块 * code: 文件解析内容 * @param {string} filename 文件路径 */
function createAsset(filename) {
  // 读取文件内容
  const content = fs.readFileSync(filename, 'utf-8')
  // 使用 @babel/parser(JavaScript解析器)解析代码,生成 ast(抽象语法树)
  const ast = babelParser.parse(content, {
    sourceType: "module"
  })

  // 从 ast 中获取全部依赖模块(import),并放入 dependencies 中
  const dependencies = []
  traverse(ast, {
    // 遍历全部的 import 模块,并将相对路径放入 dependencies
    ImportDeclaration: ({
      node
    }) => {
      dependencies.push(node.source.value)
    }
  })
  // 获取文件内容
  const {
    code
  } = transformFromAst(ast, null, {
    presets: ['@babel/preset-env'],
  })
  // 返回结果
  return {
    dependencies,
    code,
  }
}
复制代码

6、递归解析全部的依赖项,生成一个依赖关系图

步骤一:获取入口文件:

const mainAssert = createAsset(entry)
复制代码

步骤二:建立依赖关系图:

因为每一个模块都是 key: value 形式,因此定义依赖图为:

// entry: 入口文件绝对地址
const graph = {
  [entry]: mainAssert
}
复制代码

步骤三:递归搜索全部的依赖模块,加入到依赖关系图中:

定义一个递归搜索函数:

/** * 递归遍历,获取全部的依赖 * @param {*} assert 入口文件 */
function recursionDep(filename, assert) {
  // 跟踪全部依赖文件(模块惟一标识符)
  assert.mapping = {}
  // 因为全部依赖模块的 import 路径为相对路径,因此获取当前绝对路径
  const dirname = path.dirname(filename)
  assert.dependencies.forEach(relativePath => {
    // 获取绝对路径,以便于 createAsset 读取文件
    const absolutePath = path.join(dirname, relativePath)
    // 与当前 assert 关联
    assert.mapping[relativePath] = absolutePath
    // 依赖文件没有加入到依赖图中,才让其加入,避免模块重复打包
    if (!queue[absolutePath]) {
      // 获取依赖模块内容
      const child = createAsset(absolutePath)
      // 将依赖放入 queue,以便于继续调用 recursionDep 解析依赖资源的依赖,
      // 直到全部依赖解析完成,这就构成了一个从入口文件开始的依赖图
      queue[absolutePath] = child
      if(child.dependencies.length > 0) {
        // 继续递归
        recursionDep(absolutePath, child)
      }
    }
  })
}
复制代码

从入口文件开始递归:

// 遍历 queue,获取每个 asset 及其因此依赖模块并将其加入到队列中,直至全部依赖模块遍历完成
for (let filename in queue) {
  let assert = queue[filename]
  recursionDep(filename, assert)
}
复制代码

7、使用依赖图,返回一个能够在浏览器运行的 JavaScript 文件

步骤一:建立一个了当即执行函数,用于在浏览器上直接运行

const result = ` (function() { })() `
复制代码

步骤二:将依赖关系图做为参数传递给当即执行函数

定义传递参数 modules:

let modules = ''
复制代码

遍历 graph,将每一个 modkey: value, 的方式加入到 modules

注意:因为依赖关系图要传入以上当即执行函数中,而后写入到 dist/bundle.js 运行,因此,code 须要放在 function(require, module, exports){${mod.code}} 中,避免污染全局变量或其它模块,同时,代码在转换成 code 后,使用的是 commonJS 系统,而浏览器不支持 commonJS(浏览器没有 module 、exports、require、global),因此这里咱们须要实现它们,并注入到包装器函数内。

for (let filename in graph) {
  let mod = graph[filename]
  modules += `'${filename}': [ function(require, module, exports) { ${mod.code} }, ${JSON.stringify(mod.mapping)}, ],`
}
复制代码

步骤三:将参数传入当即执行函数,并当即执行入口文件:

首先实现一个 require 函数,require('${entry}') 执行入口文件,entry 为入口文件绝对路径,也为模块惟一标识符

const result = ` (function(modules) { require('${entry}') })({${modules}}) `
复制代码

注意:modules 是一组 key: value,,因此咱们将它放入 {}

步骤四:重写浏览器 require 方法,当代码运行 require('./message.js') 转换成 require(src/message.js)

const result = ` (function(modules) { function require(moduleId) { const [fn, mapping] = modules[moduleId] function localRequire(name) { return require(mapping[name]) } const module = {exports: {}} fn(localRequire, module, module.exports) return module.exports } require('${entry}') })({${modules}}) `
复制代码

注意:

  • moduleId 为传入的 filename ,为模块的惟一标识符
  • 经过解构 const [fn, mapping] = modules[id] 来得到咱们的函数包装(function(require, module, exports) {${mod.code}})和 mappings 对象
  • 因为通常状况下 require 都是 require 相对路径,而不是绝对路径,因此重写 fnrequire 方法,将 require 相对路径转换成 require 绝对路径,即 localRequire 函数
  • module.exports 传入到 fn 中,将依赖模块内容须要输出给其它模块使用时,当 require 某一依赖模块时,就能够直接经过 module.exports 将结果返回

8、输出到 dist/bundle.js

// 打包
const result = bundle(graph)
// 写入 ./dist/bundle.js
fs.writeFile(`${output.path}/${output.filename}`, result, (err) => {
  if (err) throw err;
  console.log('文件已被保存');
})
复制代码

9、总结及源码

原本想简单的写写,结果修修改改又那么多🤦‍♀️🤦‍♀️🤦‍♀️,但总要吃透才好。

源码地址:github.com/sisterAn/mi…

参考了minipack,解决了它会出现模块被重复打包的问题,同时借鉴了webpack以filename为惟一标识符进行模块定义。

想看往期更过系列文章,点击前往 github 博客主页

10、走在最后

  1. ❤️玩得开心,不断学习,并始终保持编码。👨💻

  2. 若有任何问题或更独特的看法,欢迎评论或直接联系瓶子君(公众号回复 123 便可)!👀👇

  3. 👇欢迎关注:前端瓶子君,每日更新!👇

相关文章
相关标签/搜索