手写一个简易的Webpack
栏目: JavaScript · 发布时间: 5年前
内容简介:当然我不可能实现全部功能, 因为能力有限, 我只挑几个重要的实现创建两个项目, 一个为项目1)
鲁迅说: 当我们会用一样东西的时候,就要适当地去了解一下这个东西是怎么运转的。
一. 什么是Webpack
二. 写一个简单的Webpack
1. 看一下Webpack的流程图
当然我不可能实现全部功能, 因为能力有限, 我只挑几个重要的实现
2. 准备工作
创建两个项目, 一个为项目 juejin-webpack
, 一个为我们自己写的打包工具, 名字为 xydpack
1) juejin-webpack
项目主入口文件内容和打包配置内容为 :
// webpack.config.js const path = require('path') const root = path.join(__dirname, './') const config = { mode : 'development', entry : path.join(root, 'src/app.js'), output : { path : path.join(root, 'dist'), filename : 'bundle.js' } } module.exports = config 复制代码
// app.js /* // moduleA.js let name = 'xuyede' module.exports = name */ const name = require('./js/moduleA.js') const oH1 = document.createElement('h1') oH1.innerHTML = 'Hello ' + name document.body.appendChild(oH1) 复制代码
2)为了方便调试,我们需要把自己的 xydpack
包 link
到本地, 然后引入到 juejin-webpack
中, 具体操作如下
// 1. 在xydpack项目的 package.json文件中加上 bin属性, 并配置对应的命令和执行文件 { "name": "xydpack", "version": "1.0.0", "main": "index.js", "license": "MIT", "bin": { "xydpack" : "./bin/xydpack.js" } } // 2. 在xydpack项目中添加相应路径的xydpack.js文件, 并在顶部加上该文件的运行方式 #! /usr/bin/env node console.log('this is xydpack') // 3. 在 xydpack项目的命令行上输入 npm link // 4. 在 juejin-webpack项目的命令行上输入 npm link xydpack // 5. 在 juejin-webpack项目的命令行上输入 npx xydpack后, 会输出 this is xydpack 就成功了 复制代码
3. 编写 xydpack.js
从第一步的流程图中我们可以看出, webpack
打包文件的第一步是获取打包配置文件的内容, 然后去实例化一个 Compiler
类, 再通过 run
去开启编译, 所以我可以把 xydpack.js
修改为
#! /usr/bin/env node const path = require('path') const Compiler = require('../lib/compiler.js') const config = require(path.resolve('webpack.config.js')) const compiler = new Compiler(config) compiler.run() 复制代码
然后去编写 compiler.js
的内容
ps : 编写 xydpack
可以通过在 juejin-webpack
项目中使用 npx xydpack
去调试
4. 编写 compiler.js
1. Compiler
根据上面的调用我们可以知道, Compiler
为一个类, 并且有 run
方法去开启编译
class Compiler { constructor (config) { this.config = config } run () {} } module.exports = Compiler 复制代码
2. buildModule
在流程图中有一个 buildModule
的方法去实现构建模块的依赖和获取主入口的路径, 所以我们也加上这个方法
const path = require('path') class Compiler { constructor (config) { this.config = config this.modules = {} this.entryPath = '' this.root = process.cwd() } buildModule (modulePath, isEntry) { // modulePath : 模块路径 (绝对路径) // isEntry : 是否是主入口 } run () { const { entry } = this.config this.buildModule(path.resolve(this.root, entry), true) } } module.exports = Compiler 复制代码
在 buildModule
方法中, 我们需要从主入口出发, 分别获取模块的路径以及对应的代码块, 并把代码块中的 require
方法改为 __webpack_require__
方法
const path = require('path') const fs = require('fs') class Compiler { constructor (config) { //... } getSource (modulePath) { const content = fs.readFileSync(modulePath, 'utf-8') return content } buildModule (modulePath, isEntry) { // 模块的源代码 let source = this.getSource(modulePath) // 模块的路径 let moduleName = './' + path.relative(this.root, modulePath).replace(/\\/g, '/') if (isEntry) this.entryPath = moduleName } run () { const { entry } = this.config this.buildModule(path.resolve(this.root, entry), true) } } module.exports = Compiler 复制代码
3. parse
得到模块的源码后, 需要去解析,替换源码和获取模块的依赖项, 所以添加一个 parse
方法去操作, 而解析代码需要以下两个步骤 :
- 使用AST抽象语法树去解析源码
- 需要几个包辅助
@babel/parser -> 把源码生成AST @babel/traverse -> 遍历AST的结点 @babel/types -> 替换AST的内容 @babel/generator -> 根据AST生成新的源码 复制代码
注意 : @babel/traverse
和 @babel/generator
是 ES6
的包, 需要使用 default
导出
const path = require('path') const fs = require('fs') const parser = require('@babel/parser') const t = require('@babel/types') const traverse = require('@babel/traverse').default const generator = require('@babel/generator').default class Compiler { constructor (config) { //... } getSource (modulePath) { //... } parse (source, dirname) { // 生成AST let ast = parser.parse(source) // 遍历AST结点 traverse(ast, { }) // 生成新的代码 let sourceCode = generator(ast).code } buildModule (modulePath, isEntry) { let source = this.getSource(modulePath) let moduleName = './' + path.relative(this.root, modulePath).replace(/\\/g, '/') if (isEntry) this.entryPath = moduleName this.parse(source, path.dirname(moduleName)) } run () { const { entry } = this.config this.buildModule(path.resolve(this.root, entry), true) } } module.exports = Compiler 复制代码
那么得到的 ast
是什么呢, 大家可以去AST Explorer 查看代码解析成 ast
后是什么样子。
当有函数调用的语句类似 require()/ document.createElement()/ document.body.appendChild()
, 会有一个 CallExpression
的属性保存这些信息, 所以接下来要干的事为 :
require path
const path = require('path') const fs = require('fs') const parser = require('@babel/parser') const t = require('@babel/types') const traverse = require('@babel/traverse').default const generator = require('@babel/generator').default class Compiler { constructor (config) { //... } getSource (modulePath) { //... } parse (source, dirname) { // 生成AST let ast = parser.parse(source) // 模块依赖项列表 let dependencies = [] // 遍历AST结点 traverse(ast, { CallExpression (p) { const node = p.node if (node.callee.name === 'require') { // 函数名替换 node.callee.name = '__webpack_require__' // 路径替换 let modulePath = node.arguments[0].value if (!path.extname(modulePath)) { // require('./js/moduleA') throw new Error(`没有找到文件 : ${modulePath} , 检查是否加上正确的文件后缀`) } modulePath = './' + path.join(dirname, modulePath).replace(/\\/g, '/') node.arguments = [t.stringLiteral(modulePath)] // 保存模块依赖项 dependencies.push(modulePath) } } }) // 生成新的代码 let sourceCode = generator(ast).code return { sourceCode, dependencies } } buildModule (modulePath, isEntry) { let source = this.getSource(modulePath) let moduleName = './' + path.relative(this.root, modulePath).replace(/\\/g, '/') if (isEntry) this.entryPath = moduleName let { sourceCode, dependencies } = this.parse(source, path.dirname(moduleName)) } run () { const { entry } = this.config this.buildModule(path.resolve(this.root, entry), true) } } module.exports = Compiler 复制代码
递归获取所有的模块依赖, 并保存所有的路径与依赖的模块
const path = require('path') const fs = require('fs') const parser = require('@babel/parser') const t = require('@babel/types') const traverse = require('@babel/traverse').default const generator = require('@babel/generator').default class Compiler { constructor (config) { //... } getSource (modulePath) { //... } parse (source, dirname) { //... } buildModule (modulePath, isEntry) { let source = this.getSource(modulePath) let moduleName = './' + path.relative(this.root, modulePath).replace(/\\/g, '/') if (isEntry) this.entryPath = moduleName let { sourceCode, dependencies } = this.parse(source, path.dirname(moduleName)) this.modules[moduleName] = JSON.stringify(sourceCode) dependencies.forEach(d => this.buildModule(path.join(this.root, d)), false) } run () { const { entry } = this.config this.buildModule(path.resolve(this.root, entry), true) } } module.exports = Compiler 复制代码
4. emit
在获取了所有的模块依赖关系和主入口后, 接下来要把数据插入模板并写入配置项中的 output.path
因为需要一个模板, 所以借用一下 webpack
的模板, 使用 EJS
去生成模板, 不了解 EJS
的点这里, 模板的内容为 :
// lib/template.ejs (function (modules) { var installedModules = {}; function __webpack_require__(moduleId) { if (installedModules[moduleId]) { return installedModules[moduleId].exports; } var module = installedModules[moduleId] = { i: moduleId, l: false, exports: {} }; modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); module.l = true; return module.exports; } return __webpack_require__(__webpack_require__.s = "<%-entryPath%>"); }) ({ <%for (const key in modules) {%> "<%-key%>": (function (module, exports, __webpack_require__) { eval(<%-modules[key]%>); }), <%}%> }); 复制代码
下面我们编写 emit
函数
const path = require('path') const fs = require('fs') const parser = require('@babel/parser') const t = require('@babel/types') const traverse = require('@babel/traverse').default const generator = require('@babel/generator').default const ejs = require('ejs') class Compiler { constructor (config) { //... } getSource (modulePath) { //... } parse (source, dirname) { //... } buildModule (modulePath, isEntry) { //... } emit () { const { modules, entryPath } = this const outputPath = path.resolve(this.root, this.config.output.path) const filePath = path.resolve(outputPath, this.config.output.filename) if (!fs.readdirSync(outputPath)) { fs.mkdirSync(outputPath) } ejs.renderFile(path.join(__dirname, 'template.ejs'), { modules, entryPath }) .then(code => { fs.writeFileSync(filePath, code) }) } run () { const { entry } = this.config this.buildModule(path.resolve(this.root, entry), true) this.emit() } } module.exports = Compiler 复制代码
如果写到这, 在 juejin-webpack
项目里输入 npx xydpack
就会生成一个 dist
目录, 里面有一个 bundle.js
文件, 可运行在浏览器中,演示
三. 加上 loader
经过二之后, 只是单纯地转了一下代码, 好像没啥意义~
所以我们要加上 loader
, 对 loader
不熟悉的点这里 , 因为是手写嘛, 所以我们 loader
也自己写一下
注意 : 因为这个东西相当简易, 所以只能玩一下样式的 loader
, 其他的玩不了, 所以只演示写一下样式的 loader
1. 样式的loader
我个人习惯使用 stylus
去编写样式, 所以样式就写 stylus-loader
和 style-loader
首先, 在配置项上加上 loader
, 然后在 app.js
中引入 init.styl
// webpack.config.js const path = require('path') const root = path.join(__dirname, './') const config = { mode : 'development', entry : path.join(root, 'src/app.js'), output : { path : path.join(root, 'dist'), filename : 'bundle.js' }, module : { rules : [ { test : /\.styl(us)?$/, use : [ path.join(root, 'loaders', 'style-loader.js'), path.join(root, 'loaders', 'stylus-loader.js') ] } ] } } module.exports = config ----------------------------------------------------------------------------------------- // app.js const name = require('./js/moduleA.js') require('./style/init.styl') const oH1 = document.createElement('h1') oH1.innerHTML = 'Hello ' + name document.body.appendChild(oH1) 复制代码
在根目录创建一个 loaders
目录去编写我们的 loader
// stylus-loader const stylus = require('stylus') function loader (source) { let css = '' stylus.render(source, (err, data) => { if (!err) { css = data } else { throw new Error(error) } }) return css } module.exports = loader ----------------------------------------------------------------------------------------- // style-loader function loader (source) { let script = ` let style = document.createElement('style') style.innerHTML = ${JSON.stringify(source)} document.body.appendChild(style) ` return script } module.exports = loader 复制代码
loader
是在读取文件的时候进行操作的, 因此修改 compiler.js
, 在 getSource
函数加上对应的操作
const path = require('path') const fs = require('fs') const parser = require('@babel/parser') const t = require('@babel/types') const traverse = require('@babel/traverse').default const generator = require('@babel/generator').default const ejs = require('ejs') class Compiler { constructor (config) { //... } getSource (modulePath) { try { let rules = this.config.module.rules let content = fs.readFileSync(modulePath, 'utf-8') for (let i = 0; i < rules.length; i ++) { let { test, use } = rules[i] let len = use.length - 1 if (test.test(modulePath)) { // 递归处理所有loader function loopLoader () { let loader = require(use[len--]) content = loader(content) if (len >= 0) { loopLoader() } } loopLoader() } } return content } catch (error) { throw new Error(`获取数据错误 : ${modulePath}`) } } parse (source, dirname) { //... } buildModule (modulePath, isEntry) { //... } emit () { //... } run () { //... } } module.exports = Compiler 复制代码
然后运行 npx xydpack
打包, 会添加一段这样的代码
"./src/style/init.styl": (function (module, exports, __webpack_require__) { eval("let style = document.createElement('style');\nstyle.innerHTML = \"* {\\n padding: 0;\\n margin: 0;\\n}\\nbody {\\n color: #f40;\\n}\\n\";\ndocument.head.appendChild(style);"); }), 复制代码
然后运行就可以了,演示
*2. 脚本的loader
脚本的 loader
, 第一个想到的就是 babel-loader
, 我们自己写一个 babel-loader
, 但是需要使用 webpack
去打包, 修改配置文件为
// webpack.config.js resolveLoader : { modules : ['node_modules', path.join(root, 'loaders')] }, module : { rules : [ { test : /\.js$/, use : { loader : 'babel-loader.js', options : { presets : [ '@babel/preset-env' ] } } } ] } 复制代码
使用 babel
需要三个包: @babel/core | @babel/preset-env | loader-utils
安装后, 然后编写 babel-loader
const babel = require('@babel/core') const loaderUtils = require('loader-utils') function loader (source) { let options = loaderUtils.getOptions(this) let cb = this.async(); babel.transform(source, { ...options, sourceMap : true, filename : this.resourcePath.split('/').pop(), }, (err, result) => { // 错误, 返回的值, sourceMap的内容 cb(err, result.code, result.map) }) } module.exports = loader 复制代码
然后使用 webpack
打包就行了
四. 总结
到这里, 我们就可以大概猜一下 webpack
的运作流程是这样的 :
- 获取配置参数
- 实例化Compiler, 通过run方法开启编译
- 根据入口文件, 创建依赖项, 并递归获取所有模块的依赖模块
- 通过loader去解析匹配到的模块
- 获取模板, 把解析好的数据套进不同的模板
- 输出文件到指定路径
注意 : 我这个只是自己闹着玩的, 要学 webpack
, 点这里
ps : 马上毕业然后失业了, 有没有哪家公司缺页面仔的请联系我, 切图也行的, 我很耐造
邮箱 : will3virgo@163.com
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:- 手写简易IOC容器
- 如何手写实现简易的Dubbo?
- 搞懂依赖注入, 用 PHP 手写简易 IOC 容器
- Java多线程之Executor框架和手写简易的线程池
- 记一次手写简易MVC框架的过程 附源代码
- 简易RPC框架实现
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。