对比Webpack,使用Babel+Node实现一个100行的小型打包工具

前言

Webpack很强大,做为前端开发人员咱们必须熟练掌握。但它的原理其实并不难理解,甚至很简单。毕竟全部复杂的事物都是由简单的事物组合造成的。不光是Webpack,像Vue,React这样成熟的前端框架亦是如此。css

读完本文,你会认识到:html

  1. Webpack打包本质仍是使用fs模块读写文件,加以组合。
  2. Babel真的很强大,方便咱们分析源代码,提取有用的信息。
  3. 若是你了解过loader,你就会知道读取源代码以后能够如何操做,而不是仅仅进行简单的字符串匹配。

另外,但愿你能跟着本身实现一遍,代码量真的不大前端

源码,能够clone下来写一写node

预备知识

先看一个例子,也许你还不知道,node其实还有这样一个彩蛋:webpack

新建test.js输入一行代码:git

/* test.js */
console.log(arguments.callee.toString())
复制代码

在命令行中输入node test.js运行结果以下:es6

function (exports, require, module, __filename, __dirname) {
    console.log(arguments.callee.toString())
}
复制代码

注意这是控制台输出的代码,也就是console.log()的输出结果github

因为arguments.callee这个属性指向函数的调用者,咱们使用toString()转化后发现这竟然是一个函数,由此说明,node的代码实际上是运行在一个函数中的。咱们写的代码最终会被这样一个函数包裹,经常使用的require,module,exports,__dirname, __filename都是这个函数的参数,因此咱们才随处可用。web

进入正题

代码结构

  • message.js:定义了两个变量,并导出
export const message = 'qin'
export const weather = 'sunny day'
复制代码
  • say.js: 定义一个函数并导出
export default function (name) {
    console.log(`hello ${name}`)
}
复制代码
  • main.css: 样式文件
#app {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    animation: breath 2s ease infinite;
}
@keyframes breath {
    from, to {
        width: 100px;
        height: 100px;
        background-color: black;
    }
    50% {
        width: 200px;
        height: 200px;
        background-color: red;
    }
}
复制代码
  • main.js:入口文件
import hello from './js/say.js'
import { message, weather } from './js/message.js'
import './css/main.css'

hello(message)

hello(`今天的天气是:${weather}`)
复制代码

打包思路

  1. 首先咱们要从入口文件main.js开始,递归解析依并读取文件内容。可使用@babel/parser来实现。
  2. 获取文件内容以后作相应的处理,例如css咱们须要使用一点js代码构建style节点,并插入页面中,也就是常说的CSS in JS这个概念。
  3. 将全部资源合并成一个文件,实现打包。打包后的代码要运行在浏览器环境中,因此为了不产生全局污染,咱们须要将打包后的代码放进闭包中运行,并为其传递所运行须要的参数,因此,打包后的代码总体结构以下:
(function (参数) {
    /* 函数体 */
})(传参)
复制代码

面临的问题

  1. 浏览器不认import语法,咱们须要使用babel转换为ES5
  2. 咱们的打包工具运行在node环境中,打包过程当中势必使用CommonJs的模块规范,即便用require和module.exports来组织模块之间的引用关系。但问题是浏览器中没有require,没有module,没有exports。

聪明的你应该想到了,开篇提到的例子就是为了解决这个问题。npm

借鉴webpack

配置webpack进行打包,具体配置很是简单这里就不贴代码了。若是你还不会配置的话,或许须要先学习webpack的基础知识。

我删剪了部分代码,那不属于咱们讨论的范畴,最后生成的bundle.js内容以下:

(function(modules) {
	// webpack中用来模拟node环境下的require函数
 	function __webpack_require__(path) {
        // 构造一个模块
        var module = { exports: {} };
		// 执行模块对应的函数
        modules[path].call(module.exports, module, module.exports,__webpack_require__);
        
		// 返回模块加载的的结果
        return module.exports;
 	}

 	__webpack_require__("./src/main.js");
 }) ({
    "./src/css/main.css": (function(module, exports, __webpack_require__) {
        eval("var api = __webpack_require__(/*! ../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js */ \"./node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js\");\n var content = __webpack_require__(/*! !../../node_modules/css-loader/dist/cjs.js!./main.css */ \"./node_modules/css-loader/dist/cjs.js!./src/css/main.css\");\n\n content = content.__esModule ? content.default : content;\n\n if (typeof content === 'string') {\n content = [[module.i, content, '']];\n }\n\nvar options = {};\n\noptions.insert = \"head\";\noptions.singleton = false;\n\nvar update = api(content, options);\n\nvar exported = content.locals ? content.locals : {};\n\n\n\nmodule.exports = exported;\n\n//# sourceURL=webpack:///./src/css/main.css?");
    }),

    "./src/js/message.js": (function(module, __webpack_exports__, __webpack_require__) {
        "use strict";
        eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"message\", function() { return message; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"weather\", function() { return weather; });\nconst message = 'qin';\nconst weather = 'sunny day';\n\n//# sourceURL=webpack:///./src/js/message.js?");
    }),

    "./src/js/say.js": (function(module, __webpack_exports__, __webpack_require__) {
        "use strict";
        eval("__webpack_require__.r(__webpack_exports__);\n/* harmony default export */ __webpack_exports__[\"default\"] = (function (name) {\n console.log(`hello ${name}`);\n});\n\n//# sourceURL=webpack:///./src/js/say.js?");
    }),

    "./src/main.js": (function(module, __webpack_exports__, __webpack_require__) {
        "use strict";
        eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _js_say_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./js/say.js */ \"./src/js/say.js\");\n/* harmony import */ var _js_message_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./js/message.js */ \"./src/js/message.js\");\n/* harmony import */ var _css_main_css__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./css/main.css */ \"./src/css/main.css\");\n/* harmony import */ var _css_main_css__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(_css_main_css__WEBPACK_IMPORTED_MODULE_2__);\n\n\n\nObject(_js_say_js__WEBPACK_IMPORTED_MODULE_0__[\"default\"])(_js_message_js__WEBPACK_IMPORTED_MODULE_1__[\"message\"]);\nObject(_js_say_js__WEBPACK_IMPORTED_MODULE_0__[\"default\"])(`今天的天气是:${_js_message_js__WEBPACK_IMPORTED_MODULE_1__[\"weather\"]}`);\n\n//# sourceURL=webpack:///./src/main.js?");
    })

});
复制代码

能够看到,总体是一个闭包函数,传递的参数为已加载的全部的模块组成的对象。这里能够看到,模块就是一个个的函数,即开篇提到的例子。闭包函数的主体是,经过模拟的require函数找到对应模块并调用。至于eavl,不用多说了吧?传入代码内容字符串就会执行了。

开始实现

须要用到的工具以下

const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser') // 生成抽象语法树
const { transformFromAst } = require('@babel/core') // 转换es6语法
const { default: traverse } = require('@babel/traverse') // 抽象语法树分析

复制代码

注意,traverse模块是ES6 Module,因此使用CommonJs引入时须要加上default

快速安装:

npm install @babel/parser @babel/core @babel/traverse @babel/parser -D
复制代码

解析文件内容

好的,如今在根目录下新建bundler.js用来打包,咱们的打包流程将写在这里。首先实现analyze函数:

/**
 * 经过路径读取文件并解析
 * @param {String} filePath 
 * @return {Object} 解析结果
 */
const analyze = function (filePath) {
    const content = fs.readFileSync(filePath, 'utf-8')
    const ast = parser.parse(content, { sourceType: 'module' })
    const dependencies = [] 
    // 转换es6语法,并获得转换后的源代码
    const { code } = transformFromAst(ast, null, {
        presets: ['@babel/env']
    })
    // 分析依赖
    traverse(ast, {
        // 分析依赖的钩子
        ImportDeclaration ({ node }) {
            dependencies.push(node.source.value) // 得到全部依赖
        }
    })
    return {
        filePath,
        dependencies,
        code
    }
}
复制代码

这里解释下traverse函数的做用。咱们使用@babel/parser生成抽象语法树AST,就是一个描述代码结构的JSON对象,这个对象中包含了语法信息。咱们能够打印下

console.log(ast.program.body)
复制代码

结果是一个数组,我截取了数组中的一个元素,以下:

Node {
    type: 'ImportDeclaration',
    start: 0,
    end: 31,
    loc: SourceLocation { start: [Position], end: [Position] },
    specifiers: [ [Node] ],
    source: Node {
      type: 'StringLiteral',
      start: 18,
      end: 31,
      loc: [SourceLocation],
      extra: [Object],
      value: './js/say.js'
    }
}
复制代码

能够看到type: 'ImportDeclaration'说明这是一个import引入语法,如此一来,咱们就能够轻松的拿到对应的依赖,如上例是./js/say.js

traverse中的ImportDeclaration钩子,参数中包含node属性,这就是咱们须要找的依赖文件,咱们将它保存起来用于下面的分析。

递归解析依赖

经过对代码的依赖分析,获取全部资源,用于最后的打包

/**
 * 经过入口文件递归解析依赖,并返回全部的依赖
 * @param {String} entryFile 入口文件
 * @return 依赖的全部代码
 */
const getAssets = function (entryFile) {
    const entry = analyze(entryFile)
    const dependencies = [entry] // 起初依赖只包含入口,随着遍历不断加入
    for (const asset of dependencies) {
        // 获取目录名
        const dirname = path.dirname(asset.filePath)
        asset.dependencies.forEach(relPath => {
            // 将相对路径转换为绝对路径,相对路径是基于dirname的
            const absolutePath = path.join(dirname, relPath)
            // 处理css文件
            if (/\.css$/.test(absolutePath)) {
                const content = fs.readFileSync(absolutePath, 'utf-8')
                // 使用js插入style节点
                const cssInsertCode = `
                    const stylesheet = document.createElement('style');
                    stylesheet.innerText = ${JSON.stringify(content)};
                    document.head.appendChild(stylesheet);
                `
                dependencies.push({
                    filePath: absolutePath,
                    relPath, // 记得保存相对路径,由于require的时候须要用到
                    dependencies: [],
                    code: cssInsertCode
                })
            } else {
                const child = analyze(absolutePath)
                child.relPath = relPath // 同上
                dependencies.push(child) // 递归解析
            }            
        })
    }
    return dependencies
}
复制代码

开始打包

打包的目的是将文件合并,因为浏览器环境限制,咱们须要构造闭包,还要模拟node的环境变量。

/**
 * 打包流程主函数
 * @param {String} entry 入口文件
 * @return void
 */
const bundle = function (entry) {
    const dependencies = getAssets(entry)
    // 将依赖构建成对象
    const deps = dependencies.map(dep => {
        const filePath = dep.relPath || entry
        // 路径和模块造成映射
        return `'${filePath}':function (exports, require, module) { ${dep.code} }`
    })

    // 构造require函数,babel解析后的代码是node环境下的,咱们须要构造相应的函数
    // 来模拟原生require,从咱们构建的deps对象中获取相应模块函数
    const result = `(function(deps){
        function require(path){
            // 构造一个模块,表示当前模块
            const module = { exports: {} }
            // 执行对应的模块,并传入参数
            deps[path](module.exports, require, module)
            // 返回模块导出的内容,也就是require函数获取到的内容
            return module.exports
        }
        require('${entry}') // 从入口文件开始执行
    })({${deps.join(',')}})`

    // 若是你想压缩成一行能够加上这个,可是相应的要安装babel-preset-minify
    // const ast = parser.parse(result, { sourceType: 'script' })
    // const { code } = transformFromAst(ast, null, {
    //     presets: ['minify']
    // })
    
    // 写入文件
    fs.writeFileSync('./public/vendors.js', result) // 若是你压缩了,这里填code
}

// 运行打包
bundle('./src/main.js')
复制代码

须要注意的是,咱们要将代码觉得本的形式拼接在一块儿,不然代码将会直接运行生成结果,这不是咱们想要的。牢记,咱们是在拼接代码。

${deps.join(',')}获得的内容是一个字符串,咱们用一个大括号括起来,在运行时就至关因而一个对象了,即{${deps.join(',')}}

也许你会想直接构造一个对象而后使用JSON.stringify不就行了吗。实际上不行,由于咱们的这个对象的键值对中,key能够是字符串,可是value不行,value是咱们模拟的一个node模块,是一个函数,JSON.stringify会致使咱们最终获取到的是函数的字符串,而不是函数。

验收成果

打包后的vendors.js内容以下:

(function (deps) {
  function require(path) {
    const module = {
      exports: {}
    }
    deps[path](module.exports, require, module)
    return module.exports
  }
  require('./src/main.js')
})({
  './src/main.js': function (exports, require, module) {
    "use strict";

    var _say = _interopRequireDefault(require("./js/say.js"));

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

    require("./css/main.css");

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

    (0, _say["default"])(_message.message);
    (0, _say["default"])("\u4ECA\u5929\u7684\u5929\u6C14\u662F\uFF1A".concat(_message.weather));
  },
  './js/say.js': function (exports, require, module) {
    "use strict";

    Object.defineProperty(exports, "__esModule", {
      value: true
    });
    exports["default"] = _default;

    function _default(name) {
      console.log("hello ".concat(name));
    }
  },
  './js/message.js': function (exports, require, module) {
    "use strict";

    Object.defineProperty(exports, "__esModule", {
      value: true
    });
    exports.weather = exports.message = void 0;
    var message = 'qin';
    exports.message = message;
    var weather = 'sunny day';
    exports.weather = weather;
  },
  './css/main.css': function (exports, require, module) {
    const stylesheet = document.createElement('style');
    stylesheet.innerText = "#app {\r\n position: absolute;\r\n top: 50%;\r\n left: 50%;\r\n transform: translate(-50%, -50%);\r\n animation: breath 2s ease infinite;\r\n}\r\n@keyframes breath {\r\n from, to {\r\n width: 100px;\r\n height: 100px;\r\n background-color: black;\r\n }\r\n 50% {\r\n width: 200px;\r\n height: 200px;\r\n background-color: red;\r\n }\r\n}";
    document.head.appendChild(stylesheet);
  }
})
复制代码

对比webpack的结果,是否是很类似?只不过咱们没有使用eval函数,而是将代码直接写在函数体中。

新建html文件并引入vendors.js

<div id="app"></div>
<script src="./vendors.js"></script>
复制代码

结果以下:

动画效果

控制台

生效了,没问题。

这就是咱们自制的一个小型打包工具啦~喜欢点个赞哈😊

补充

在webpack打包代码结果展现那里,我删除的代码是关于webpack的一些更高级的功能的。例如webpack内置了缓存机制,一个模块加载事后就会缓存起来,并赋予id值,而后标记为已加载。之后再加载这个模块的时候经过标记判断,加载过的话就直接读缓存。

咱们构建的module是这样的:

const module = { exports: {} }
复制代码

__webpack_require__中构建的module是这样的:

// installedModules就是缓存
var module = installedModules[moduleId] = {
    i: moduleId, // 经过id来获取
    l: false, // loaded:标识是否加载过
    exports: {}
};
复制代码

咱们再去node中打印一下module的值:

console.log(module, module.exports === exports)
// 结果以下:
Module {
  id: '.',
  path: 'c:\\Users\\Administrator\\Desktop',
  exports: {},
  parent: null,
  filename: 'c:\\Users\\Administrator\\Desktop\\a.js',
  loaded: false,
  children: [],
  paths: [
    'c:\\Users\\Administrator\\Desktop\\node_modules',
    'c:\\Users\\Administrator\\node_modules',
    'c:\\Users\\node_modules',
    'c:\\node_modules'
  ]
} true
复制代码

看这结构模,是这么的类似~文件名,模块id,路径等。是否是有种尽在掌握的感受?😄

相比之下咱们构建的module就很简陋了,不过仍是能说明问题的,至少证实,node的模块化机制也没有那么难理解嘛。

咱们还能够看到,module.exports和exports是同一个对象,指向同一块内存,所以咱们既能够经过exports.a = 1这种属性的方式导出,也能够经过module.exports = {a:1}这种字面量的方式导出。

可是使用exports时,不能直接赋值,如:exports = {a:1},这是没法正常导出的,涉及js中引用类型的存储问题,这里再也不赘述。

参考

【掘金】实现小型打包工具

相关文章
相关标签/搜索