从零实现简易版Webpack

什么是bundler

市面上如今有不少bundler,最著名的就是webpack,此外常见的还有 browserifyrollupparcel等。虽然如今的bundler进化出了各类各样的功能,但它们都有一个共同的初衷,就是能给前端引入模块化的开发方式,更好的管理依赖、更好的工程化。前端

Modules(模块)

目前最多见的模块系统有两种:node

ES6 Moduleswebpack

// 引入模块
import _ from 'lodash';

// 导出模块
export default someObject;

CommonJS Modulesgit

// 引入模块
const _ = require('lodash');

// 导出模块
module.exports = someObject;

Dependency Graph(依赖关系图)

通常项目须要一个入口文件(entry point),bundler从该入口文件进入,查找项目依赖的全部模块,造成一张依赖关系图,有了依赖关系图bundler进一步将全部模块打包成一个文件。github

依赖关系图:web

dependency graph

Bundler实现思路

要实现一个bundler,有三个主要步骤:npm

  1. 解析一个文件并提取它的依赖项
  2. 递归地提取依赖并生成依赖关系图
  3. 将全部被依赖的模块打包进一个文件

本文使用一个小例子展现如何实现bundler,以下图所示,有三个js文件:入口文件 entry.jsentry.js 的依赖文件 greeting.jsgreeting.js 的依赖文件 name.js数组

example

三个文件内容分别以下:浏览器

entry.js:babel

import greeting from './greeting.js';

console.log(greeting);

greeting.js:

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

export default `hello ${name}!`;

name.js:

export const name = 'MudOnTire';

实现bundler

首先咱们新建一个bundler.js文件,bundler的主要逻辑就写在里面。

1. 引入JS Parser

按照咱们的实现思路,首先须要可以解析JS文件的内容并提取其依赖项。咱们能够把文件内容读取为字符串,并用正则去获取其中的import, export语句,可是这种方式显然不够优雅高效。更好的方式是使用JS parser(解析器)去解析文件内容。JS parser能解析JS代码并将其转化成抽象语法树(AST)的高阶模型,抽象语法树是把JS代码拆解成树形结构,且从中能获取到更多代码的执行细节。

AST Explorer 这个网站上面能够查看JS代码解析成成抽象语法树以后的结果。好比,greeting.js 的内容用 acron parser 解析后的结果以下:

greeting AST

能够看到抽象语法树实际上是一个JSON对象,每一个节点有一个 type 属性和 importexport 语句解析后的结果等等。将代码转成抽象语法树以后更方便提取里面的关键信息。

接下来,咱们须要在项目里面引入一个JS Parser。咱们选择 babylon(babylon也是babel的内部使用的JS parser,目前以 @babel/parser 的身份存在于babel的主仓库)。

安装babylon:

npm install --save-dev @babel/parser

或者yarn:

yarn add @babel/parser --dev

bundler.js 中引入babylon:

bundler.js:

const parser = require('@babel/parser');

2. 生成抽象语法树

有了JS parser以后,生成抽象语法树就很简单了,咱们只须要获取到JS源文件的内容,传入parser解析就好了。

bundler.js:

const parser = require('@babel/parser');
const fs = require('fs');

/**
 * 获取JS源文件的抽象语法树
 * @param {String} filename 文件名称
 */
function getAST(filename) {
  const content = fs.readFileSync(filename, 'utf-8');
  const ast = parser.parse(content, {
    sourceType: 'module'
  });
  console.log(ast);
  return ast;
}

getAST('./example/greeting.js');

执行 node bundler.js 结果以下:

get ast

3. 依赖解析

生成抽象语法树后,即可以去查找代码中的依赖,咱们能够本身写查询方法递归的去查找,也可使用 @babel/traverse 进行查询,@babel/traverse 模块维护整个树的状态,并负责替换,删除和添加节点。

安装 @babel/traverse:

npm install --save-dev @babel/traverse

或者yarn:

yarn add @babel/traverse --dev

使用 @babel/traverse 能够很方便的获取 import 节点。

bundler.js:

const traverse = require('@babel/traverse').default;

/**
 * 获取ImportDeclaration
 */
function getImports(ast) {
  traverse(ast, {
    ImportDeclaration: ({ node }) => {
      console.log(node);
    }
  });
}

const ast = getAST('./example/entry.js');
getImports(ast);

执行 node bundler.js 执行结果以下:

get imports

由此咱们能够得到 entry.js 中依赖的模块和这些模块的路径。稍稍修改一下 getImports 方法获取全部的依赖:

bundler.js:

function getImports(ast) {
  const imports = [];
  traverse(ast, {
    ImportDeclaration: ({ node }) => {
      imports.push(node.source.value);
    }
  });
  console.log(imports);
  return imports;
}

执行结果:

dependencies

最后,咱们将方法封装一下,为每一个源文件生成惟一的依赖信息,包含依赖模块的id、模块的相对路径和模块的依赖项:

let ID = 0;

function getAsset(filename) {
  const ast = getAST(filename);
  const dependencies = getImports(ast);
  const id = ID++;
  return {
    id,
    filename,
    dependencies
  }
}

const mainAsset = getAsset('./example/entry.js');
console.log(mainAsset);

执行结果:

assets

4. 生成Dependency Graph

而后,咱们须要写一个方法生成依赖关系图,该方法应该接受入口文件路径做为参数,并返回一个包含全部依赖关系的数组。生成依赖关系图能够经过递归的方式,也能够经过队列的方式。本文使用队列,原理是不断遍历队列中的asset对象,若是asset对象的dependencies不为空,则继续为每一个dependency生成asset并加入队列,并为每一个asset增长mapping属性,记录依赖之间的关系。持续这一过程直到queue中的元素被彻底遍历。具体实现以下:

bundler.js

/**
 * 生成依赖关系图
 * @param {String} entry 入口文件路径
 */
function createGraph(entry) {
  const mainAsset = getAsset(entry);
  const queue = [mainAsset];

  for (const asset of queue) {
    const dirname = path.dirname(asset.filename);
    asset.mapping = {};
    asset.dependencies.forEach((relPath, index) => {
      const absPath = path.join(dirname, relPath);
      const child = getAsset(absPath);
      asset.mapping[relPath] = child.id;
      queue.push(child);
    });
  }

  return queue;
}

生成的依赖关系以下:

dependency graph

5. 打包

最后,咱们须要根据依赖关系图将全部文件打包成一个文件。这一步有几个关键点:

  1. 打包后的文件须要可以在浏览器运行,因此代码中的ES6语法须要先被babel编译
  2. 浏览器的运行环境中,编译后的代码依然须要实现模块间的引用
  3. 合并成一个文件后,不一样模块的做用域依然须要保持独立

(1). 编译源码

首先安装babel并引入:

npm install --save-dev @babel/core

或者yarn:

yarn add @babel/core --dev

bundler.js:

const babel = require('@babel/core');

而后对 getAsset 方法稍做修改,这里咱们使用 babel.transformFromAstSync() 方法对生成的抽象语法树进行编译,编译成浏览器能够执行的JS:

function getAsset(filename) {
  const ast = getAST(filename);
  const dependencies = getImports(ast);
  const id = ID++;
  // 编译
  const { code } = babel.transformFromAstSync(ast, null, {
    presets: ['@babel/env']
  });
  return {
    id,
    filename,
    dependencies,
    code
  }
}

源码编译后生成的依赖关系图内容以下:

compiled

能够看到编译后的代码中还有 require('./greeting.js') 语法,而浏览器中是不支持 require()方法的。因此咱们还须要实现 require() 方法从而实现模块间的引用。

(2). 模块引用

首先打包以后的代码须要本身独立的做用域,以避免污染其余JS文件,在此使用IIFE包裹。咱们能够先勾勒出打包方法的结构,在bundler.js中新增 bundle() 方法:

bundler.js:

/**
 * 打包
 * @param {Array} graph 依赖关系图
 */
function bundle(graph) {
  let modules = '';

  // 将依赖关系图中模块编译后的代码、模块路径和id的映射关系传入IIFE
  graph.forEach(mod => {
    modules += `${mod.id}:[
      function (require, module, exports) { ${mod.code}},
      ${JSON.stringify(mod.mapping)}
    ],`
  })

  // 
  return `
    (function(){})({${modules}})
  `;
}

咱们先看一下执行 bundle() 方法以后的结果(为方便阅读使用 js-beautifycli-highlight 进行了美化 ):

bundled

如今,咱们须要实现模块之间的引用,咱们须要实现 require() 方法。实现思路是:当调用 require('./greeting.js') 时,去mapping里面查找 ./greeting.js 对应的模块id,经过id找到对应的模块,调用模块代码将 exports 返回,最后打包生成 main.js 文件。bundle() 方法的完整实现以下:

bundler.js:

/**
 * 打包
 * @param {Array} graph 依赖关系图
 */
function bundle(graph) {
  let modules = '';

  // 将依赖关系图中模块编译后的代码、模块路径和id的映射关系传入IIFE
  graph.forEach(mod => {
    modules += `${mod.id}:[
      function (require, module, exports) { ${mod.code}},
      ${JSON.stringify(mod.mapping)}
    ],`
  })

  const bundledCode = `
    (function (modules) {

      function require(id) {
        const [fn, mapping] = modules[id];

        function localRequire(relPath) {
          return require(mapping[relPath]);
        }

        const localModule = { exports : {} };
        
        fn(localRequire, localModule, localModule.exports);

        return localModule.exports;
      }

      require(0);

    })({${modules}})
  `;
  fs.writeFileSync('./main.js', bundledCode);
}

最后,咱们在浏览器中运行一下 main.js 的内容看一下最后的结果:

result

一个简易版本的Webpack大功告成!

本文源码:https://github.com/MudOnTire/...

相关文章
相关标签/搜索