手写一个 mini 的 webpack 项目构建工具

726 阅读8分钟

这是我参与8月更文挑战的第8天,活动详情查看:8月更文挑战

这篇文章断断续续写了几个小时,为了是 bundler 这件事说清楚,主要是因为 bundle 这件事比较琐碎和复杂,打现在为止还不确定我是不是把这件事事情说清楚了,其实对于技术文档因为其内在逻辑性本来就比较严谨,即便这样想要恰当表达还是一件不易的事,想要把一个故事讲清楚清楚想必是一件更难的事情,突然对文学作品的作家产生一种敬畏之心。

002.jpeg

首先我们都知道今天对于任何一门语言来说,都少不了包(模块)管理工具,因为对于大型应用,都需要 code 按功能进行拆分来模块化,这种模块化的好处不言而喻,就仅看模块化在开发过程中的好处,模块化可以让大家协同分模块去一起并行开发。

001.jpeg

javascript 开始是没有模块的概念,早期 javascript 主要工作给网页添加动态效果和交互的 toy ,可能也没有必要引入模块的概念,不过今天则不同,随着 web 应用变得越来越复杂,更多事情交个前端去完成,而且 nodejs 的出现让 javascript 的领域从前端扩张到后端,这样一来,包管理引入到这门语言也就变得迫在眉睫了。我接触第一个 javascript 包管理工具有 require.js。到了今天 javascript 包管理工具主要有两种概念,大家都去实现这两种概念。

005.png

  • ES6 module
import _ from 'lodash'

export default someValue;

需要注意的是 ES module 形式需要启动服务,通过 http 请求方式来价值

  • Commonjs modules
const _ = require('lodash');
module.exports = someValue

动机

Webpack 是一个很好的 javascript 包管理工具,很多项目都是用 webpack 进行构建,可能最近好像 vue 项目放弃 webpack 而采用 rollup.js 构建工具。接触过早期的 require.js 也用过 webpack 来构建项目,只不过都是停留在使用这个层面上,具体内部是什么机制还不算了解。昨天看到了老外分享关于如何自己实现个 javascript 构建工具的视频,准备和大家一起实现一篇,当然不仅是 copy,对其分享还会就一些关键内容进行展开,并且是当地进行扩展。

006.jpeg

思路

要做一件事,就需要先思考怎么做,例如把大象放冰箱里一共分 3 部,那么我们来做项目构建应该如何做,想一想,首先我们需要将 js 文件读入,然后从文件中找到包含依赖的信息,

  • 读取 js 将文件进行解析出一个 AST
  • 通过 AST 将代码抽象后提取依赖相关信息
  • 利用相关信息生成可以遍历依赖关系图
  • 利用依赖关系图将代码有效组织在一起

007.png

entry.js - message.js - name.js

获取资源对象

我们将每个 javascript 文件看成一个资源,这里 createAsset 方法主要负责读取每一个 javascript 文件(看成资源),然后从资源提取所需信息。

读取 javascript 文件

引入 fs 模块,使用 readFileSync 读取文件内容,同步读取文件。

const fs = require("fs")
function createAsset(filename){
    const content = fs.readFileSync(filename,'utf-8');

    console.log(content);
}

creatAsset('./example/entry.js');

将文本解析为 AST

这里 createAsset获取资源,将文件以字符串形式读取后赋值给 content 对象。接下来我们需要读取的字符串进行解析,解析成为一个 AST,那么什么是 AST,全称是抽象语法树,也就是 javascript 代码形成一个结构化的语法树,其实 AST 就是普通的 Json 文件,用于描述。

这里我们引入 babylon 这个包,帮助我们将字符串解析成为一个 AST 结构数据。接下来我们需要通过解析文件获取到这个文件依赖了哪些文件,

  • File Program body ImportDeclaration
  • 每一个结点都有一个 type 类型
const fs = require("fs");
const babylon = require('babylon');

function creatAsset(filename){
    const content = fs.readFileSync(filename,'utf-8');

    const ast = babylon.parse(content,{
        sourceType:'module'
    });

    console.log(ast);
}

下面是我们解析输出 AST 对象,这就是一个普通 json 对象,我们可以简单阅读一下。每个结点都有一个 type 类型,我们来简单看一下每个结点都包含哪些内容,type 表示结点的类型,start 和 end 表示该结点在 code 中位置,我们来对照 code 来看一下解析出 AST 对象。

import message from './message.js';

console.log(message)

其中有两个集合 commentstokens,在 tokens 中存放着一个一个

Node {
  type: 'File',
  start: 0,
  end: 57,
  loc: SourceLocation {
    start: Position { line: 1, column: 0 },
    end: Position { line: 3, column: 20 }
  },
  program: Node {
    type: 'Program',
    start: 0,
    end: 57,
    loc: SourceLocation { start: [Position], end: [Position] },
    sourceType: 'module',
    body: [ [Node], [Node] ],
    directives: []
  },
  comments: [],
  tokens: [
    Token {
      type: [KeywordTokenType],
      value: 'import',
      start: 0,
      end: 6,
      loc: [SourceLocation]
    },
    Token {
      type: [TokenType],
      value: 'message',
      start: 7,
      end: 14,
      loc: [SourceLocation]
    },
    Token {
      type: [TokenType],
      value: 'from',
      start: 15,
      end: 19,
      loc: [SourceLocation]
    },
    Token {
      type: [TokenType],
      value: './message.js',
      start: 20,
      end: 34,
      loc: [SourceLocation]
    },
    Token {
      type: [TokenType],
      value: undefined,
      start: 34,
      end: 35,
      loc: [SourceLocation]
    },
    Token {
      type: [TokenType],
      value: 'console',
      start: 37,
      end: 44,
      loc: [SourceLocation]
    },
    Token {
      type: [TokenType],
      value: undefined,
      start: 44,
      end: 45,
      loc: [SourceLocation]
    },
    Token {
      type: [TokenType],
      value: 'log',
      start: 45,
      end: 48,
      loc: [SourceLocation]
    },
    Token {
      type: [TokenType],
      value: undefined,
      start: 48,
      end: 49,
      loc: [SourceLocation]
    },
    Token {
      type: [TokenType],
      value: 'message',
      start: 49,
      end: 56,
      loc: [SourceLocation]
    },
    Token {
      type: [TokenType],
      value: undefined,
      start: 56,
      end: 57,
      loc: [SourceLocation]
    },
    Token {
      type: [TokenType],
      value: undefined,
      start: 57,
      end: 57,
      loc: [SourceLocation]
    }
  ]
}

接下里的工作我们来完善一下 createAsset ,我们已经将文本解析为 AST 后,我们再引入 babel-traverse ,目的是在解析好的 AST 上获取到 ImportDeclaration,有关 import 的相关的语句。

/** */

const fs = require("fs");
const babylon = require('babylon');
const traverse = require('babel-traverse').default;

function creatAsset(filename){
    const content = fs.readFileSync(filename,'utf-8');

    const ast = babylon.parse(content,{
        sourceType:'module'
    });

    traverse(ast,{
        ImportDeclaration:({node})=>{
            console.log(node);
        }
    })

    // console.log(ast);
}

creatAsset('./example/entry.js');

通过 traverse 提取到了 import 内容,通过这个结点 source.value 可以获取到./message.js 这是我们想要,这里 ImportDeclaration 结点可以获取到文件的依赖关系。

Node {
  type: 'ImportDeclaration',
  start: 0,
  end: 35,
  loc: SourceLocation {
    start: Position { line: 1, column: 0 },
    end: Position { line: 1, column: 35 }
  },
  specifiers: [
    Node {
      type: 'ImportDefaultSpecifier',
      start: 7,
      end: 14,
      loc: [SourceLocation],
      local: [Node]
    }
  ],
  source: Node {
    type: 'StringLiteral',
    start: 20,
    end: 34,
    loc: SourceLocation { start: [Position], end: [Position] },
    extra: { rawValue: './message.js', raw: "'./message.js'" },
    value: './message.js'
  }
}

收集到的该文件的依赖信息,都保存在dependencies 数组里,

function creatAsset(filename){
    const content = fs.readFileSync(filename,'utf-8');

    const ast = babylon.parse(content,{
        sourceType:'module'
    });

    const dependencies = []

    traverse(ast,{
        ImportDeclaration:({node})=>{
            // console.log(node);
            dependencies.push(node.source.value);
        }
    });

    console.log(dependencies);
}
[ './message.js' ]

我们 createAsset 返回一个资源对象,这个资源对象包含几个属性,id 、filename 和 dependencies 依赖关系,其中 dependencies 保存该文件所依赖的文件。

let ID = 0;

function createAsset(filename){
    const content = fs.readFileSync(filename,'utf-8');

    const ast = babylon.parse(content,{
        sourceType:'module'
    });

    const dependencies = []

    traverse(ast,{
        ImportDeclaration:({node})=>{
            // console.log(node);
            dependencies.push(node.source.value);
        }
    });
    const id = ID++;
    return{
        id,
        filename,
        dependencies,
    };
    // console.log(dependencies);
}

const mainAsset = creatAsset('./example/entry.js');
console.log(mainAsset)
{
  id: 0,
  filename: './example/entry.js',
  dependencies: [ './message.js' ]
}

016.png

创建依赖图

构建文件依赖图,首先将口入资源放到一个队列 (queue),遍历依赖(dependencies) 其中存放着文件依赖的路径,但是这里依赖路径是是相对路径,但是 createAsset 需要接受一个绝对路径作为参数。我们在文件引用的库或者文件时用到路径相对于该文件的路径import message from './message.js';,

function createGraph(entry){
    const mainAsset = createAsset(entry);

    const queue = [mainAsset];
    for(const asset of queue){
        const dirname = path.dirname(asset.filename);

        asset.mapping = {}

        asset.dependencies.forEach(relativePath => {
            const absolutePath = path.join(dirname,relativePath);
            const child = createAsset(absolutePath);
						asset.mapping[relativePath] = child.id
            queue.push(child)
        });
    }
    return queue;
}

创建图,通过图形式来表示项目各个文件的依赖关系,这里创建一个方法createGraph(),介绍主入口的文件路径作为参数,然后调用 createAsset 获取包换 id,filenamedependencies的对象,接下来定义一个 queue,这个数组里存放所有,这里暂且叫资源对象吧,每次获取到资源对象后,我们根据资源对象中依赖数组来获取其依赖文件路径,因为依赖文件路径是相对于引用该文件的路径相对路径,而 creatAsset 读取文件时需要的是绝对路径。所以引入 path 包,通过 const absolutePath = path.join(dirname,relativePath);得到绝对路径。

[
  {
    id: 0,
    filename: './example/entry.js',
    dependencies: [ './message.js' ],
    mapping: { './message.js': 1 }
  },
  {
    id: 1,
    filename: 'example/message.js',
    dependencies: [ './name.js' ],
    mapping: { './name.js': 2 }
  },
  { id: 2, filename: 'example/name.js', dependencies: [], mapping: {} }
]

012.jpeg

基于依赖图进行 bundle

所谓 bundle 就是根据依赖图将这些模块文件组织在一起,

function bundle(graph){

    let modules = '';
    
    graph.forEach(mod => {
        modules + `${mod.i}:[

        ]`
    })

    const result = `
        (function(){

        })({${modules}})
		}`
const result = `
        (function(){

        })({${modules}})
		}`

这里返回一个 result 是立即执行函数,传入一个对象作为参数。

因不是所有浏览器对 EMSCscript module 形式,大多数都支持 commonjs 这样包引用的方式,bebel 可以看成代码转换工具,根据你给出presets:['env'] 将代码转换为其他形式。

    const {code} = babel.transformFromAst(ast,null,{presets:['env']});
    // install babel-core and npm install babel-preset-env --save

    return{
        id,
        filename,
        dependencies,
        code
    };

接下来给每个资源对象添加一个 code 属性和 mapping 属性,这里先给 mapping 埋下一个伏笔,

[
  {
    id: 0,
    filename: './example/entry.js',
    dependencies: [ './message.js' ],
    code: '"use strict";\n' +
      '\n' +
      'var _message = require("./message.js");\n' +
      '\n' +
      'var _message2 = _interopRequireDefault(_message);\n' +
      '\n' +
      'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n' +
      '\n' +
      'console.log(_message2.default);',
    mapping: { './message.js': 1 }
  },
  {
    id: 1,
    filename: 'example/message.js',
    dependencies: [ './name.js' ],
    code: '"use strict";\n' +
      '\n' +
      'Object.defineProperty(exports, "__esModule", {\n' +
      '  value: true\n' +
      '});\n' +
      '\n' +
      'var _name = require("./name.js");\n' +
      '\n' +
      'exports.default = "hello " + _name.name;',
    mapping: { './name.js': 2 }
  },
  {
    id: 2,
    filename: 'example/name.js',
    dependencies: [],
    code: '"use strict";\n' +
      '\n' +
      'Object.defineProperty(exports, "__esModule", {\n' +
      '  value: true\n' +
      '});\n' +
      "var name = exports.name = 'machine learning';",
    mapping: {}
  }
]

function bundle(graph){

    let modules = '';
    
    graph.forEach(mod => {
        modules +=`${mod.id}:[
            function(require, module, exports){
                ${mod.code}
            }
        ]`
    })

    const result = `
        (function(){

        })({${modules}})
    `
    return result;
}
const result = `
  (function(){

  })({${modules}})
`

这里简单介绍一下 result, {${moduels}} ,经过上面对字符串的拼接得到 module 的值类似于 "id:[function],id:[function]... 这种形式,然后我们将解析好字符串放到一个对象。

 (function(){

        })({0:[
            function(require, module, exports){
                "use strict";

            var _message = require("./message.js");

            var _message2 = _interopRequireDefault(_message);

            function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

            console.log(_message2.default);
            }
        ],1:[
            function(require, module, exports){
                "use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});

var _name = require("./name.js");

exports.default = "hello " + _name.name;
            }
        ],2:[
            function(require, module, exports){
                "use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
var name = exports.name = 'machine learning';
            }
        ]})

017.jpeg

分析构建文件

这里用到递归,对于递归是初学者提升一个标志,

  • 首先 module 是一个对象,key 是索引从 0 ,值是一个数组,数组中包含一个函数和对象

  • 在 modules 中每一个函数接受三个 require, modulesexports

    function localRequire(relativePath) {
      return require(mapping[relativePath])
    }
    

localRequire 会根据输入相对路径,对应到 module 中 id,这里就用到 mapping ,在 mapping 是一个对象,对象键值是模块存放的相对路径 ./messge.js 而值这是该模块对应 id。

这里我们先看定义一个 require 函数接受一个 id 然后调用函数并且传入0 作为参数,我们来看一看 require 里面都做了哪些事情,首先根据 id 从 modules 数组获取 fn 和 mapping,mapping 这是这样一个键值对。在 require内部定义一个localRequire这里根据该文件依赖相对路径所对应 id 来递归调用 require这也是开始我们定义mapping的缘故。定义 module,调用函数并且传入requiremodulemodule.exports

018.jpeg

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

        function localRequire(relativePath) {
            return require(mapping[relativePath])
        }

        const module = {
            exports: {}
        };

        fn(localRequire, module, module.exports)

        return module.exports
    }
    require(0);
})({
    0: [
        function (require, module, exports) {
            "use strict";

            var _message = require("./message.js");

            var _message2 = _interopRequireDefault(_message);

            function _interopRequireDefault(obj) {
                return obj && obj.__esModule ? obj : {
                    default: obj
                };
            }

            console.log(_message2.default);
        },
        {
            "./message.js": 1
        }
    ],
    1: [
        function (require, module, exports) {
            "use strict";

            Object.defineProperty(exports, "__esModule", {
                value: true
            });

            var _name = require("./name.js");

            exports.default = "hello " + _name.name;
        },
        {
            "./name.js": 2
        }
    ],
    2: [
        function (require, module, exports) {
            "use strict";

            Object.defineProperty(exports, "__esModule", {
                value: true
            });
            var name = exports.name = 'machine learning';
        },
        {}
    ],
})