今时今日,做前端不用个webpack好像都被时代抛弃了一样,每天开发的时候npm run dev,该上线了npm run build,反正执行个命令刷刷地就打包好了,你根本无需知道执行命令之后整个过程究竟干了什么。webpack就像个黑盒,你得小心翼翼遵循它的配置行事,配好了就万幸。这使得我很长一段时间以来,都对webpack毕恭毕敬,能跑起来的代码就是最好的代码,千万别乱动配置。
终于有一天,我忍不住要搞清楚webpack究竟做了什么。
我们为什么需要webpack
去搞清楚webpack做了什么之前,我觉得首先要思考一下我们为什么需要webpack,它究竟解决了什么痛点。想想我们日常搬砖的场景:
- 开发的时候需要一个开发环境,要是我们修改一下代码保存之后浏览器就自动展现最新的代码那就好了(热更新服务)
- 本地写代码的时候,要是调后端的接口不跨域就好了(代理服务)
- 为了跟上时代,要是能用上什么ES678N等等新东西就好了(翻译服务)
- 项目要上线了,要是能一键压缩代码啊图片什么的就好了(压缩打包服务)
- 我们平时的静态资源都是放到CDN上的,要是能自动帮我把这些搞好的静态资源怼到CDN去就好了(自动上传服务)
巴拉巴拉等等服务,那么多你需要的服务,如果你打一个响指,这些服务都有条不紊地执行好,岂不是美滋滋!所以我们需要webpack帮我们去整合那么多服务,而node的出现,赋予了我们去操作系统的能力,这才有了我们今天的幸福(kubi)生活(manong)。
所以我觉得要根据自己的需求来使用webpack,知道自己需要什么样的服务,webpack能不能提供这样的服务,如果可以,那么这个服务应该在构建中的哪个环节被处理。
- 如果与输入相关的需求,找entry(比如多页面就有多个入口)
- 如果与输出相关的需求,找output(比如你需要定义输出文件的路径、名字等等)
- 如果与模块寻址相关的需求,找resolve(比如定义别名alias)
- 如果与转译相关的需求,找loader(比如处理sass处理es678N)
- 如果与构建流程相关的需求,找plugin(比如我需要在打包完成后,将打包好的文件复制到某个目录,然后提交到git上)
抽丝剥茧之后,去理解这些的流程,你就能从webpack那一坨坨的配置中,定位到你需求被webpack处理的位置,最后加上相应的配置即可。
webpack打包出来的什么
webpack搞了很多东西,但最终产出的无非就是经过重重服务处理过的代码,那么这些代码是怎样的呢?
首先我们先来看看入口文件index.js:
console.log('index')
const one = require('./module/one.js')
const two = require('./module/two.js')
one()
two()
嗯,很简单,没什么特别,引入了两个模块,最后执行了它们一下。其中one.js和two.js的代码也很简单,就是导出了个函数:
// one.js
module.exports = function () {
console.log('one')
}
// two.js
module.exports = function () {
console.log('two')
}
好了,就是这么简单的代码,放到webpack打包出来的是什么呢?
/******/ (function(modules) { // webpackBootstrap
/******/ // The module cache
/******/ var installedModules = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/
/******/ // Check if module is in cache
/******/ if(installedModules[moduleId]) {
/******/ return installedModules[moduleId].exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = installedModules[moduleId] = {
/******/ i: moduleId,
/******/ l: false,
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
/******/
/******/ // Flag the module as loaded
/******/ module.l = true;
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = modules;
/******/
/******/ // expose the module cache
/******/ __webpack_require__.c = installedModules;
/******/
/******/ // define getter function for harmony exports
/******/ __webpack_require__.d = function(exports, name, getter) {
/******/ if(!__webpack_require__.o(exports, name)) {
/******/ Object.defineProperty(exports, name, {
/******/ configurable: false,
/******/ enumerable: true,
/******/ get: getter
/******/ });
/******/ }
/******/ };
/******/
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = function(module) {
/******/ var getter = module && module.__esModule ?
/******/ function getDefault() { return module['default']; } :
/******/ function getModuleExports() { return module; };
/******/ __webpack_require__.d(getter, 'a', getter);
/******/ return getter;
/******/ };
/******/
/******/ // Object.prototype.hasOwnProperty.call
/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
/******/
/******/ // __webpack_public_path__
/******/ __webpack_require__.p = "";
/******/
/******/ // Load entry module and return exports
/******/ return __webpack_require__(__webpack_require__.s = 0);
/******/ })
/************************************************************************/
/******/ ([
/* 0 */
/***/ (function(module, exports, __webpack_require__) {
console.log('index')
const one = __webpack_require__(1)
const two = __webpack_require__(2)
one()
two()
/***/ }),
/* 1 */
/***/ (function(module, exports) {
module.exports = function () {
console.log('one')
}
/***/ }),
/* 2 */
/***/ (function(module, exports) {
module.exports = function () {
console.log('two')
}
/***/ })
/******/ ]);
真是不忍直视……我写得这么简洁优雅的代码,经过webpack的处理后如此不堪入目!但为了搞清楚这坨东西究竟做了什么,我不得不忍丑去将它简化了一下。
简化webpack打包出来的代码
其实进过简化后就可以看到,这些代码意图十分明显,也是我们十分熟悉的套路。
(function (modules) {
const require = function (moduleId) {
const module = {}
module.exports = null
modules[moduleId].call(module, module, require)
return module.exports
}
require(0)
})([
function (module, require) {
console.log('index')
const one = require(1)
const two = require(2)
one()
two()
},
function (module, require) {
module.exports = function () {
console.log('one')
}
},
function (module, require) {
module.exports = function () {
console.log('two')
}
}])
你会看到这不就是我们挂在嘴边的自执行函数吗?然后参数是一个数组,这个数组就是我们的模块,当require(0)的时候就会执行这个数组索引为0的代码,以此类推而达到模块化的效果。这里有个关键点,就是我们明明写的时候是require(’./module/one.js’),怎么最后出来可以变成require(1)呢?
让我们自己来撸一个
没有什么比自己撸一个理解得更透彻了。我们根据上面的最终打包的结果来捋一捋要做一些什么事情。
1.观察一下,我们需要一个自执行函数,这里面需要控制的是这个自执行函数的传参,就是那个数组
2.这个数组是毋容置疑是根据依赖关系来形成的
3.我们要找到所有的require然后将require的路径替换成对应数组的索引
4.将这个处理好的文件输出出来
ok,上代码:
const fs = require('fs')
const path = require('path')
const esprima = require('esprima')
const estraverse = require('estraverse')
// 定义上下文 即所有的寻址都按照这个基准进行
const context = path.resolve(__dirname, '../')
// 处理路径
const pathResolve = (data) => path.resolve(context, data)
// 定义全局数据格式
const dataInfo = {
// 入口文件源码
source: '',
// 分析入口文件源码得出的依赖信息
requireInfo: null,
// 根据依赖信息得出的各个模块
modules: null
}
/**
* 读取文件
* @param {String} path
*/
const readFile = (path) => {
return new Promise((resolve, reject) => {
fs.readFile(path, function (err, data) {
if (err) {
console.log(err)
reject(err)
return
}
resolve(data)
})
})
}
/**
* 分析入口源码
*/
const getRequireInfo = () => {
// 各个依赖的id 从1开始是因为0是入口文件
let id = 1
const ret = []
// 使用esprima将入口源码解析成ast
const ast = esprima.parse(dataInfo.source, {range: true})
// 使用estraverse遍历ast
estraverse.traverse(ast, {
enter (node) {
// 筛选出require节点
if (node.type === 'CallExpression' && node.callee.name === 'require' && node.callee.type === 'Identifier') {
// require路径,如require('./index.js'),则requirePath = './index.js'
const requirePath = node.arguments[0]
// 将require路径转为绝对路径
const requirePathValue = pathResolve(requirePath.value)
// 如require('./index.js')中'./index.js'在源码的位置
const requirePathRange = requirePath.range
ret.push({requirePathValue, requirePathRange, id})
id++
}
}
})
return ret
}
/**
* 模块模板
* @param {String} content
*/
const moduleTemplate = (content) => `function (module, require) {\n${content}\n},`
/**
* 获取模块信息
*/
const getModules = async () => {
const requireInfo = dataInfo.requireInfo
const modules = []
for (let i = 0, len = requireInfo.length; i < len; i++) {
const file = await readFile(requireInfo[i].requirePathValue)
const content = moduleTemplate(file.toString())
modules.push(content)
}
return modules
}
/**
* 将入口文件如require('./module/one.js')等对应成require(1)模块id
*/
const replace = () => {
const requireInfo = dataInfo.requireInfo
// 需要倒序处理,因为比如第一个require('./module/one.js')中的路径是在源码字符串42-59这个区间
// 而第二个require('./module/two.js')中的路径是在源码字符串82-99这个区间,那么如果先替换位置较前的代码
// 则此时源码字符串已经少了一截(从'./module/one.js'变成1),那第二个require的位置就不对了
const sortRequireInfo = requireInfo.sort((item1, item2) => item1.requirePathRange[0] < item2.requirePathRange[0])
sortRequireInfo.forEach(({requirePathRange, id}) => {
const start = requirePathRange[0]
const end = requirePathRange[1]
const headerS = dataInfo.source.substr(0, start)
const endS = dataInfo.source.substr(end)
dataInfo.source = `${headerS}${id}${endS}`
})
}
/**
* 输出打包好的文件
*/
const output = async () => {
const data = await readFile(pathResolve('./template/indexTemplate.js'))
const indexModule = moduleTemplate(dataInfo.source)
const allModules = [indexModule, ...dataInfo.modules].join('')
const result = `${data.toString()}([\n${allModules}\n])`
fs.writeFile(pathResolve('./build/output.js'), result, function (err) {
if (err) {
throw err;
}
})
}
const main = async () => {
// 读取入口文件
const data = await readFile(pathResolve('./index.js'))
dataInfo.source = data.toString()
// 获取依赖信息
dataInfo.requireInfo = getRequireInfo()
// 获取模块信息
dataInfo.modules = await getModules()
// 将入口文件如require('./module/one.js')等对应成require(1)模块id
replace()
// 输出打包好的文件
output()
console.log(JSON.stringify(dataInfo))
}
main()
这里的关键是将入口源码转成ast从而分析出require的路径在源码字符串中所在的位置,我们这里用到了esprima去将源码转成ast,然后用estraverse去遍历ast从而筛选出我们感兴趣的节点,这时我们就可以对转化成ast的代码为所欲为了,babel就是这样的原理为我们转化代码的。
最后
到这里我们可以知道,除去其他杂七杂八的服务,webpack本质上就是一个将我们平时写的模块化代码转成现在浏览器可以直接执行的代码。当然上面的代码是非常简陋的,我们没有去递归处理依赖,没有去处理require的寻址(比如require(‘vue’)是怎样找到vue在哪里的)等等的细节处理,只为还原一个最简单易懂的结构。上面的源码可以在这里找到。