Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

【webpack进阶系列】手撸一个mini-webpack(二) : 打包依赖代码 #98

Open
amandakelake opened this issue Jan 6, 2020 · 0 comments

Comments

@amandakelake
Copy link
Owner

amandakelake commented Jan 6, 2020

Ready

先看4段代码

// src/index.js
import sayHello from './bundler-utils/say-hello.js';
import message from './bundler-utils/message.js';

sayHello(message);
// src/bundler-utils/say-hello.js
function sayHello(word) {
	console.log(word);
}

export default sayHello;
// src/bundler-utils/message.js
import { name } from './name.js';

export default `hello ${name}`;
// src/bundler-utils/name.js
const name = 'LGC';

export { name };

以上代码其实很简单,文件结构如图,最后的输出是hell LGC
F8E12362-905F-4E26-A210-8FEEB75BB43C

上一篇我们已经讲过怎么根据入口文件生成项目的JS依赖图,完整代码如下
【webpack进阶系列】简易Bundler -> 生成依赖图

const fs = require('fs');
const path = require('path');
// 把JS代码转为AST(抽象语法树,可以简单google一下概念,先不用太深入)
const parser = require('@babel/parser');
// 帮助我们解析AST的内容,最直接的就是通过 ImportDeclaration 点位找到文件的依赖入口
const traverse = require('@babel/traverse').default;
// babel.transformFromAst(AST, code, options) 可以帮助我们把AST转换成ES5代码
const babel = require('@babel/core');


// 入口文件模块分析
const moduleAnalyse = 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);
            // node.source.value就是获取到的模块路径名,带有相对于当前文件的路径
            // 比如import sleep from './utils/sleep.js'里面的'./utils/sleep.js'
            dependencies[node.source.value] = `./${path.join(dirname, node.source.value)}`;
        },
    });
    // babel翻译AST为浏览器可以识别的代码
    const { code } = babel.transformFromAst(ast, null, {
        presets: ['@babel/preset-env'],
    });
    return {
        filename,
        dependencies,
        code,
    };
};

const makeDependenciesGraph = entry => {
    // 先拿到入口文件的模块分析对象
    const entryModule = moduleAnalyse(entry);
    // 将通过递归遍历,把所有的模块依赖收集到这里
    const graphArray = [entryModule];
    for (let i = 0; i < graphArray.length; i++) {
        const item = graphArray[i];
        const { dependencies } = item;
        // 判断dependencies对象是否为空,即item是否还有依赖
        if (Object.keys(dependencies).length > 0) {
            for (let j in dependencies) {
                // 把得到的子依赖添加进graphArray,长度发生变化,for循环继续,形成了递归
                graphArray.push(moduleAnalyse(dependencies[j]));
            }
        }
    }
    // 数组转换为对象  方便后续操作
    const graph = {};
    graphArray.forEach(item => {
        graph[item.filename] = {
            dependencies: item.dependencies,
            code: item.code,
        };
    });

    return graph;
};

const graphInfo = makeDependenciesGraph('./src/index.js');
console.log('graphInfo', graphInfo);

先跑一发node bundler.js,已经能成功得到依赖图
D1DC6FE1-E621-4E21-9FD6-53DBD6CBBA50

现在就差最后一步了: 让代码跑起来

下面我们要做的事情是把这几个文件的code打包成一段能在浏览器成功运行的代码

Go

先改造一下入口代码

const generateCode = entry => {
	const graph = makeDependenciesGraph(entry);
	const finalCode = '';
	// TODO 把graph里所有的code合并起来,生成最后的code
	return finalCode;
};

const code = generateCode('./src/index.js');
console.log('code', code);

接下来是实现generateCode方法,返回一段代码字符串

首先浏览器里的代码都要通过闭包来执行,避免污染全局环境
把graph当做参数传入这个闭包函数

const generateCode = entry => {
    const graph = makeDependenciesGraph(entry);
    // 1、通过闭包执行,避免污染全局环境
    const finalCode = `
        (function(graph) {
			// 闭包接收处理graph
        })(${graph})
    `;
    return finalCode;
};

这里有个小坑,通过模板字符串直接传入${graph}得到的是[object object]
A4924021-3864-46A4-AD41-4FC032DC3735

所以需要 JSON.stringify(graph)

const generateCode = entry => {
    const graph = makeDependenciesGraph(entry);
    // 1、通过闭包执行,避免污染全局环境
    const finalCode = `
        (function(graph) {

        })(${JSON.stringify(graph)})
    `;
    return finalCode;
};

再跑一下,OK,正确传入了
7C3BAEC5-11ED-41CF-9A5A-3BB9E51E50D9

接下来看一下我们要转换什么东西,把graph再打印出来看一眼
E55B6F48-B540-4E5B-BEE7-81F01B35CBFD

index.jsmessage.js这两段代码复制出来看一下,把字符串和换行符都去掉,得到的是下面的代码
18ABF86E-796C-479A-9847-A02E948297EC

// ./src/index.js
"use strict";
var _sayHello = _interopRequireDefault(require("./bundler-utils/say-hello.js"));
var _message = _interopRequireDefault(require("./bundler-utils/message.js"));
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }
(0, _sayHello["default"])(_message["default"]);

// ./src/bundler-utils/message.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true});
exports["default"] = void 0;
var _name = require("./name.js");
var _default = "hello ".concat(_name.name);
exports["default"] = _default;

先分析一下这两段代码做了啥
index.js里自定义了一个_interopRequireDefault方法并立即执行,参数是require(模块路径),然后返回编译后的文件内容obj,如果__esModule属性存在,直接返回obj,否则返回{ 'default': obj }

再看message.js,给exports对象新增了一个__esModule属性,然后还默认它有一个default属性,最后通过exports["default"]导出该模块的内容

分析下来,这里有两个东西是陌生的,一个是require方法,另外一个是exports对象(未曾定义过),这两是浏览器不认识或者说无法处理的,那么就需要我们在上面的闭包里额外的实现

下面这个问题很有意思,估计问出了很多前端新手的心中疑问,本质上也是我们正在以及接下来要讨论的核心
require 和 import 怎么会变成浏览器认识的呢? - 知乎

两个目的
* 实现require方法
* 定义exports对象

1、实现require方法

由于是用模板字符串写的,而且有点复杂,我们一步一步来

先定一个require方法,然后立即执行,并传入entry,注意在模板字符串里面要加引号处理成字符串的形式

const generateCode = entry => {
    const graph = makeDependenciesGraph(entry);
    // 1、通过闭包执行,避免污染全局环境
    // 2、定义require方法,并立即执行,传入entry字符串
    const finalCode = `
        (function(graph) {
            function require(module){
                
            }
            require('${entry}')
        })(${JSON.stringify(graph)})
    `;
    return finalCode;
};

require里再执行一个闭包(不污染上一个闭包的作用域),用来执行依赖模块的代码,参数就是该模块的code,然后直接用eval(code)来执行

const generateCode = entry => {
    const graph = makeDependenciesGraph(entry);
    // 1、通过闭包执行,避免污染全局环境
    // 2、定义require方法,并立即执行,传入entry字符串
    // 3、require里再执行一个闭包(不污染上一个闭包的作用域),用来执行依赖模块的代码,
    //    参数就是该模块的code,然后直接用eval(code)来执行
    const finalCode = `
        (function(graph) {
            function require(module){
                (function(code){
                    eval(code)
                })(graph[module].code)
            }
            require('${entry}')
        })(${JSON.stringify(graph)})
    `;
    return finalCode;
};

到这里,入口文件index.js的代码其实已经可以执行完了,然后递归到message.js的时候

// ./src/bundler-utils/message.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true});
exports["default"] = void 0;
var _name = require("./name.js");
var _default = "hello ".concat(_name.name);
exports["default"] = _default;

它的代码也使用了require方法,然后参数是./name.js,这是相对于message.js的相对路径
但是,我们的依赖图graph对象里面收集的依赖,全部是相对于bundler.js的路径,也就是./src/bundler-utils/name.js,就是说只有这个key才能正确映射,依赖图graph的4个key如下
4BECCEF9-88F7-44D1-B258-0271A37CB40D

我们给执行子模块代码的闭包里再增加一个localRequire方法,专门用于处理这种路径问题,拿到真正的路径后,再调外面闭包的require方法,先看代码

const generateCode = entry => {
    const graph = makeDependenciesGraph(entry);
    // 1、通过闭包执行,避免污染全局环境
    // 2、定义require方法,并立即执行,传入entry字符串
    // 3、require里再执行一个闭包(不污染上一个闭包的作用域),用来执行依赖模块的代码,
    //    参数就是该模块的code,然后直接用eval(code)来执行
    // 4、执行到子模块时,定义localRequire把相对当前模块的依赖路径转变成相对于bundler.js的路径,
    const finalCode = `
        (function(graph) {
            function require(module){
                function localRequire(relativePath){
                    return require(graph[module].dependencies[relativePath])
                }
                (function(require, code){
                    eval(code)
                })(localRequire, graph[module].code)
            }
            require('${entry}')
        })(${JSON.stringify(graph)})
    `;
    return finalCode;
};

这里有点绕,我们把require方法拆开来,再根据依赖图来对比来看

先看里面这个闭包,多加了一个require参数,用实参localRequire来替代里面var _name = require("./name.js");中有问题的require方法

(function(require, code){
    eval(code)
})(localRequire, graph[module].code)

实际上的localRequire我们在外面再定义一次,记住一点,它的功能是把相对当前模块的依赖路径转变成相对于bundler.js的路径

function localRequire(relativePath){
    return require(graph[module].dependencies[relativePath])
}

对于message.js这个模块来说,上面的路径是这么走的graph[‘./bundler-utils/message.js’].dependencies[‘./name.js’]
对应的就是图中的依赖关系,那么拿到的就是./src/bundler-utils/name.js,就是我们真正的require方法所需要的路径了
6E86C38B-BD86-438F-BF7C-A49408566939

这块用文字写起来实在是有点绕,辛苦各位能坚持看到这里的你,但相信你一定会有收获

再回顾一下,我们自定义了一个require方法,传入入口文件后,就会去graph寻找当前文件的code并通过eval方法立即执行
然后递归到依赖的子模块时,子模块也会使用require方法寻找自身的依赖的code并执行,但此require非彼require,由于路径不完整,所以需要自定义一个localRequire方法来替代子模块使用的require,把真正的完整路径传递给最外面定义的那个require方法,拿到code后也立即执行,如此依赖,通过递归关系,整个依赖的所有code都会被执行一遍

2、定义exports对象

require搞定后,就剩下一个exports对象,这个比较简单,我们定义一个exports空对象,然后把它传入eval里,执行完后,再把exports导出即可

const generateCode = entry => {
    const graph = makeDependenciesGraph(entry);
    // 1、通过闭包执行,避免污染全局环境
    // 2、定义require方法,并立即执行,传入entry字符串
    // 3、require里再执行一个闭包(不污染上一个闭包的作用域),用来执行依赖模块的代码,
    //    参数就是该模块的code,然后直接用eval(code)来执行
    // 4、执行到子模块时,定义localRequire把相对当前模块的依赖路径转变成相对于bundler.js的路径
    // 5、定一个exports对象,传入执行函数,最后并导出该对象
    return `
        (function(graph) {
            function require(module){
                function localRequire(relativePath){
                    return require(graph[module].dependencies[relativePath])
                }
                var exports = {};
                (function(require, exports, code){
                    eval(code)
                })(localRequire, exports, graph[module].code);
                return exports;
            }
            require('${entry}')
        })(${JSON.stringify(graph)})
    `;
};

3、浏览器运行

然后我们运行node bundler.js,并把得到的结果拿到浏览器运行一下
得到了hello LGC, bingo!
911093A6-B252-48CB-8669-136DD773D919

总结

结合上一篇生成依赖图,我们再来回顾总结一下核心流程

  1. 入口模块分析,得到当前文件依赖图
  • 通过fs.readFileSync读取文件二进制字符串
  • 借助@babel/parser将JS代码转为AST抽象语法树
  • 借助@babel/traverse解析AST,通过ImportDeclaration得到文件的所有依赖dependencies
  • 借助babel.transformFromAst方法把AST代码转换成ES代码code
  • 将该文件的filenamedependenciescode打包成一个对象传出去
  1. 借助上面的模块分析方法,通过递归查找所有的模块依赖,得到项目的总依赖图对象,并以模块完整路径为key
  2. 合并生成所有依赖的code
  • 自定义require方法,用于根据文件路径得到模块的code
  • 自定义exports对象,用于导出当前模块的内容
  1. 将上面生成的code直接拿到浏览器控制台运行

最后的完整代码,可以直接用来运行打包JS的简易bundler

const fs = require('fs');
const path = require('path');
// 把JS代码转为AST(抽象语法树,可以简单google一下概念,先不用太深入)
const parser = require('@babel/parser');
// 帮助我们解析AST的内容,最直接的就是通过 ImportDeclaration 点位找到文件的依赖入口
const traverse = require('@babel/traverse').default;
// babel.transformFromAst(AST, code, options) 可以帮助我们把AST转换成ES5代码
const babel = require('@babel/core');

// 入口文件模块分析
const moduleAnalyse = 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);
            // node.source.value就是获取到的模块路径名,带有相对于当前文件的路径
            // 比如import sleep from './utils/sleep.js'里面的'./utils/sleep.js'
            dependencies[node.source.value] = `./${path.join(dirname, node.source.value)}`;
        },
    });
    // babel翻译AST为浏览器可以识别的代码
    const { code } = babel.transformFromAst(ast, null, {
        presets: ['@babel/preset-env'],
    });
    return {
        filename,
        dependencies,
        code,
    };
};

const makeDependenciesGraph = entry => {
    // 先拿到入口文件的模块分析对象
    const entryModule = moduleAnalyse(entry);
    // 将通过递归遍历,把所有的模块依赖收集到这里
    const graphArray = [entryModule];
    for (let i = 0; i < graphArray.length; i++) {
        const item = graphArray[i];
        const { dependencies } = item;
        // 判断dependencies对象是否为空,即item是否还有依赖
        if (Object.keys(dependencies).length > 0) {
            for (let j in dependencies) {
                // 把得到的子依赖添加进graphArray,长度发生变化,for循环继续,形成了递归
                graphArray.push(moduleAnalyse(dependencies[j]));
            }
        }
    }
    // 数组转换为对象  方便后续操作
    const graph = {};
    graphArray.forEach(item => {
        graph[item.filename] = {
            dependencies: item.dependencies,
            code: item.code,
        };
    });

    return graph;
};

const generateCode = entry => {
    const graph = makeDependenciesGraph(entry);
    // 1、通过闭包执行,避免污染全局环境
    // 2、定义require方法,并立即执行,传入entry字符串
    // 3、require里再执行一个闭包(不污染上一个闭包的作用域),用来执行依赖模块的代码,
    //    参数就是该模块的code,然后直接用eval(code)来执行
    // 4、执行到子模块时,定义localRequire把相对当前模块的依赖路径转变成相对于bundler.js的路径
    // 5、定一个exports对象,传入执行函数,最后并导出该对象
    return `
        (function(graph) {
            function require(module){
                function localRequire(relativePath){
                    return require(graph[module].dependencies[relativePath])
                }
                var exports = {};
                (function(require, exports, code){
                    eval(code)
                })(localRequire, exports, graph[module].code);
                return exports;
            }
            require('${entry}')
        })(${JSON.stringify(graph)})
    `;
};

const code = generateCode('./src/index.js');
console.log('code', code);
@amandakelake amandakelake changed the title 【webpack进阶系列】简易Bundler -> 根据依赖图打包代码 【webpack进阶系列】简易Bundler -> 打包依赖图代码 Jan 7, 2020
@amandakelake amandakelake changed the title 【webpack进阶系列】简易Bundler -> 打包依赖图代码 【webpack进阶系列】手撸一个mini-webpack Jan 20, 2020
@amandakelake amandakelake changed the title 【webpack进阶系列】手撸一个mini-webpack 【webpack进阶系列】手撸一个mini-webpack(二) Jan 21, 2020
@amandakelake amandakelake changed the title 【webpack进阶系列】手撸一个mini-webpack(二) 【webpack进阶系列】手撸一个mini-webpack(二) : 打包依赖代码 Jan 21, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant