從零實現簡易版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/...

本文同步分享在 博客「MudOnTire」(SegmentFault)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索