前言
在现在的前端大环境下,由从前的html
、css
、js
,逐渐衍生出来了前端的工程化,由简到繁,越来越复杂,最复杂的要属我们的webpack
了,已经出现了webpack
工程师,用来专门配置webpack
。
前端工程化打包工具千千万,谁又是你的NO.One
。
本篇文章实现的是一款简单的javaScript
打包工具,不涉及非javaScript
的打包,如:css
、html
、静态文件
等。
环境
我们的电脑上需要配备node
环境。
所需部件工具
fs
fs
模块是用来操作文件的,该模块只能在node
环境中使用,不可以在浏览器中使用。
path
path
模块是用来处理文件及文件路径的一个模块。
@babel/parser
@babel/parser
模块用于接收源码,进⾏词法分析、语法分析,⽣成抽象语法树(Abstract Syntax Tree)
,简称ast
。
@babel/traverse
@babel/traverse
模块用于遍历更新我们使用@babel/parser
生成的AST
。对其中特定的节点进行操作。
@babel/core
@babel/core
模块中的transform
用于编译我们的代码,可以转编为第版本的代码,让它的兼容性更强。本文使用的是transformFromAstSync
,其效果都是一样的。
@babel/preset-env
@babel/preset-env
模块是一个智能环境预设的工具模块,允许我们使用最新的es规范
进行编写代码,无需对目标环境需要哪些语法转换进行各种繁琐细节的管理。
编写打包器
我们将结合上面的工具模块编写出一款自己的js
打包工具,如需打包非js
内容还需其他模块工具。
本文实现的仅能打包js
,让我们一起动手吧。
有个小细节提醒下各位朋友
在mac
系统下在终端
中打包出来的内容和window
终端打印出来是一样的,只是mac
下是隐视的。
环境搭建
首先我们需要新建一个文件夹,然后执行npm init
/ pnpm init
,生成package.json
文件。然乎安装上面的模块。
1 2 3 4 5 6 7
|
npm i @babel/core @babel/parser @babel/preset-env @babel/traverse
pnpm i @babel/core @babel/parser @babel/preset-env @babel/traverse
|
新建main.js
文件
我们新建一个main.js
文件,用来编写我们的代码,当然你也可以使用其他的文件名。
新建src
目录
这里我们需要新建一个src
目录,用来装我们写的代码。
在src
目录里面我们新建两个js
文件,如下:
1 2 3 4 5 6 7 8 9
|
const foo = () => { console.log('我是foo'); }
export { foo }
|
我们再新建一个index.js
文件,并引入foo.js
,并在index.js
的方法里面执行foo
方法。然后我们执行index
防范。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
import { foo } from "./foo.js"
const index = () => { foo() console.log('我是index'); for(let item of [1, 2]){ console.log(item); } }
index()
|
编写main.js
现在到了我们来开始编写main.js
的内容。
引入我们刚刚需要的工具模块,这里我们需要使用require
的形式进行引用,
1 2 3 4 5 6 7 8
| const fs = require('fs') const path = require('path') const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default const babel = require('@babel/core')
|
读取文件内容
我们添加readFile
方法读取我们编写的js
文件内容,这里我们使用fs
模块中的readFileSync
方法,并设置内容格式为utf-8
。
然后我们传入index.js
文件的路径并执行该方法。
1 2 3 4 5 6 7 8
| const readFile = (fileName) => { const content = fs.readFileSync(fileName, 'utf-8')
console.log(content); }
readFile('./src/index.js')
|
我们在终端执行node main.js
。
我们看到终端打印出来了我们index.js
文件中的内容。和我们index.js
中的内容一模一样,不一样的地方在于,打印出来的是字符串,里面加了\n
换行符。
1 2 3 4 5 6 7 8 9 10 11
| import { foo } from "./foo.js"
const index = () => { foo() console.log('我是index'); for(let item of [1, 2]){ console.log(item); } }
index()
|
将拿到的文件内容生成ast
语法树
上面我们已经拿到了我们写的代码,现在我们要通过@babel/parser
工具生成我们的ast
。
我们在readFile
的方法中添加@babel/parser
,并设置sourceType
为module
。并依旧在终端执行node main.js
。
1 2 3 4 5
| const ast = parser.parse(content, { sourceType: 'module' })
console.log(ast);
|
打印结果如下,是一个node
格式的节点,我们的代码内容在program
-> body
中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| Node { type: 'File', start: 0, end: 165, loc: SourceLocation { start: Position { line: 1, column: 0, index: 0 }, end: Position { line: 12, column: 7, index: 165 }, filename: undefined, identifierName: undefined }, errors: [], program: Node { type: 'Program', start: 0, end: 165, loc: SourceLocation { start: [Position], end: [Position], filename: undefined, identifierName: undefined }, sourceType: 'module', interpreter: null, body: [ [Node], [Node], [Node] ], directives: [] }, comments: [] }
|
使用@babel/traverse
遍历更新我们的ast
这里我们使用@babel/traverse
工具遍历我们刚刚生成的ast
。
此环境我们需要新建一个名为dependencies
的对象,用来装我们处理好的ast
的依赖关系。
我们将刚刚的ast
传进去,并对其option
中的ImportDeclaration
等于一个函数,添加一个形参接收每个文件的路径。
1 2 3 4 5 6 7
| const dependencies = {}
traverse(ast, { ImportDeclaration: ({ node }) => { } })
|
我们通过path
模块来处理我们文件的路径。
1
| const dirName = path.dirname(fileName)
|
我们需要对我们的文件名和路径进行进一步的处理。并对其进行正则替换反斜杠。
1
| const dir = './' + path.join(dirName, node.source.value).replace('\\', '/')
|
上面代码中的node.source.value
就是我们根据ast
中获取到的所有的文件名和路径。
我们将我们拿到的文件路径存入dependencies
对象中。
1
| dependencies[node.source.value] = dir
|
最终我们在终端执行node main.js
并打印我们的dependencies
对象。打印内容和我们需要编译的文件路径一致。
1
| { './foo.js': './src/foo.js' }
|
使用@babel/core
转编我们的代码
这里我们需要使用@babel/core
工具中的transform
方案转编我们的代码,让我们的代码在第版本的浏览器中也可以正常运行。
这里我们使用最新的api
transformFromAstSync
来转编我们的代码。
transformFromAstSync
的作用:将我们刚刚修改的ast
转编回我们的代码。
我们只需要它转换后的代码,其他的我们不需要,所以我们对其结果进行解构,只获取其代码。
1
| const { code } = babel.transformFromAstSync(ast, null, {})
|
我们这里需要使用到@babel/preset-env
,对我们的代码进行降级处理,也是就是说我们使用的新版规范编写,我们要转它转回老版本规范。如果这里我们不处理,我们的代码也将不会处理,原模原样的输出。
所以我们需要给其添加一个presets
属性并放入我们的@babel/preset-env
工具。这里我们将modules
属性设为false
,让它输出为esm
格式的代码。
其他属性扩展:commonjs
、amd
、umd
、systemjs
、auto
1 2 3 4 5 6 7 8 9 10
| const { code } = babel.transformFromAstSync(ast, null, { presets: [ [ "@babel/preset-env", { modules: false } ] ] })
|
我们在终端执行node main.js
,打印内容如下:
1 2 3 4 5 6 7 8 9 10
| import { foo } from "./foo.js"; var index = function index() { foo(); console.log('我是index'); for (var _i = 0, _arr = [1, 2]; _i < _arr.length; _i++) { var item = _arr[_i]; console.log(item); } }; index();
|
readFile方法完整代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| const readFile = (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) const dir = './' + path.join(dirName, node.source.value).replace('\\', '/') dependencies[node.source.value] = dir } })
const { code } = babel.transformFromAstSync(ast, null, { presets: [ [ "@babel/preset-env", { modules: false } ] ] })
return { fileName, dependencies, code }
}
|
它已经成功的对我们的代码进行了降级处理,我们将我们的文件名/文件路径
、依赖关系(dependencies)
、代码(code)
进行return
返回出去,方便我们后面使用。
编写依赖关系生成器
我们需要新建一个名为createDependciesGraph
的方法,用来收集我们的文件依赖关系。添加一个形参接收我们传入的文件名。
1
| const createDependciesGraph = entry => {}
|
创建一个一个名为graphList
的数组,用来装我们的readFile
方法return
出来的返回值。
1
| const graphList = [readFile(entry)]
|
我们这里需要进行递归处理graphList
,防止里面有多重依赖。
1
| for(let i = 0;i < graphList.length; i++){}
|
我们需要在循环中暂存每一项,所以我们声明一个item
来装。
1
| const item = graphList[i]
|
我们这里还需要暂存每一项的依赖关系。
1
| const { dependencies } = item
|
这里我们需添加一个判断,如果存在依赖关系的,我们继续再次循环它的依赖关系层,并插入graphList
中,以此往复的进行递归嵌套循环,并且进行文件内容读取,直至循环结束。
1 2 3 4 5
| if(dependencies){ for(let j in dependencies){ graphList.push( readFile( dependencies[j] ) ) } }
|
此部分完整代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| const createDependciesGraph = entry => { const graphList = [readFile(entry)]
for(let i = 0;i < graphList.length; i++){ const item = graphList[i] const { dependencies } = item
if(dependencies){ for(let j in dependencies){ graphList.push( readFile(dependencies[j]) ) } } } console.log(graphList); }
|
我们打印一下已经处理好的graphList
,终端输入node main.js
,结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| [ { fileName: './src/index.js', dependencies: { './foo.js': './src/foo.js' }, code: import { foo } from "./foo.js"; var index = function index() { foo(); console.log('我是index'); for (var _i = 0, _arr = [1, 2]; _i < _arr.length; _i++) { var item = _arr[_i]; console.log(item); } }; index(); }, { fileName: './src/foo.js', dependencies: {}, code: var foo = function foo() { console.log('我是foo');};export { foo }; } ]
|
关系层梳理
我们看到刚刚的已经完整的打印出来了我们的关系层了,我们现在需要对其进行梳理。
我们新建一个对象来装我们的梳理好的关系层。
这里我们循环graphList
数组,并向graph
中写入我们的详细依赖关系图层。
1 2 3 4 5 6 7 8
| for(let item of graphList){ const {dependencies, code} = item graph[item.fileName] = { dependencies, code } }
|
我们打印一下刚刚的梳理好的内容,依旧是在终端输入node main.js
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| { './src/index.js': { dependencies: { './foo.js': './src/foo.js' }, code: import { foo } from "./foo.js"; var index = function index() { foo();\n' + console.log('我是index'); for (var _i = 0, _arr = [1, 2]; _i < _arr.length; _i++) { var item = _arr[_i]; console.log(item); } }; index(); }, './src/foo.js': { dependencies: {}, code: var foo = function foo() { console.log('我是foo');};export { foo }; } }
|
我们需要将梳理好的关系层,return
返回出去,方便我们后面使用。
createDependciesGraph方法完整代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| const createDependciesGraph = entry => { const graphList = [readFile(entry)]
for(let i = 0;i < graphList.length; i++){ const item = graphList[i] const { dependencies } = item
if(dependencies){ for(let j in dependencies){ graphList.push( readFile(dependencies[j]) ) } } }
const graph = {} for(let item of graphList){ const {dependencies, code} = item graph[item.fileName] = { dependencies, code } }
return graph }
|
我们先创建一个文件管理的方法
这一步我们先创建一个文件夹管理的方法,用于我们每次打包的时候去清空目录,重新创建。
我们声明一个名为rmdir
的方法,管理我们的打包目录文件夹
1
| const rmdir = async () => {}
|
我们给它内部return
一个new Promise
的实例,至于原因后面用到了懂了,方便我们后面使用。
1 2 3 4 5
| const rmdir = async () => { return new Promise(async (resolve, reject) => { }) }
|
我们声明一个err
用来获取我们操作文件夹、文件时的错误,
我们读取我们当前打包文件夹的状态,如果存在则清空并删除掉。recursive
表示是否删除文件夹,true
为删除。
1 2 3 4 5 6 7 8 9
| const isDir = fs.existsSync('dist')
if(isDir){ fs.rmdir('dist', {recursive: true}, error => { if(error){ err = error } }) }
|
这里我们进行错误判断,当err
为真我们则抛错并return
出去。
1 2 3 4
| if(err){ reject(err) return }
|
这里我们使用setTimeout
来延时通知成功,避免删除文件夹和创建文件夹同时进行,导致创建不成功。
1 2 3
| setTimeout(()=>{ resolve() }, 1000)
|
rmdir完整代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| const rmdir = async () => { return new Promise(async (resolve, reject) => {
let err = null
const isDir = fs.existsSync('dist')
if(isDir){ fs.rmdir('dist', {recursive: true}, error => { if(error){ err = error } }) }
if(err){ reject(err) return } setTimeout(()=>{ resolve() }, 1000) }) }
|
代码生成器方法
这里我采用的是esbuild
的一种打包输出模式,也就是打包后的文件是根据项目创建时的目录规则进行同步生成的。
这里我们创建一个名为generateCode
的方法,进行我们的代码生成入口调用,并对生成文件进行编写处理。
1
| const generateCode = entry => {}
|
在它的内部调用createDependciesGraph
方法,并将entry(打包的入口文件)
传递进去。并声明codeInfo
去接收。
1
| const codeInfo = createDependciesGraph(entry)
|
我们可以先打印看一下codeInfo
长啥样。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| { './src/index.js': { dependencies: { './foo.js': './src/foo.js' }, code: import { foo } from "./foo.js"; var index = function index() { foo(); console.log('我是index'); for (var _i = 0, _arr = [1, 2]; _i < _arr.length; _i++) { var item = _arr[_i]; console.log(item); } }; index(); }, './src/foo.js': { dependencies: {}, code: var foo = function foo() { console.log('我是foo');};export { foo }; } }
|
现在我们根据依赖关系创建文件夹并写入文件。
此时此刻我们就排上了刚才的rmdir
方法了,我们调用rmdir
方法,并在.then
中编写我们的创建文件流程。这就是刚刚为啥创建rmdir
时返回一个Promise
的原因,在等删除清空打包目录后再创建打包文件夹及文件,这样我们就避免了同时进行文件夹、文件的创建与删除的问题。
现在我们来创建打包目录文件夹。
1
| fs.mkdir('dist', () => {})
|
我们在创建打包文件夹的回调中循环我们的依赖关系,因codeInfo
是对象,我们不能使用for..of...
,使用的是es6
中新增的for..in..
。
1
| for(let key in codeInfo){}
|
这里我们创建同名文件夹,并将指定代码写入同名文件中。这里获取我们通过split
的方式获取当前的文件名称,并取最后一项,因最后一项就是我们的文件名。
1 2
| let value = key.split('/') value = value[value.length - 1]
|
我们根据上面获取到的文件名创建问价,并将对应的代码写入文件中。
1 2 3 4
| let value = key.split('/') value = value[value.length - 1]
fs.writeFile(`./dist/${value}`, codeInfo[key]['code'], [], () => {})
|
generateCode方法完整代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| const generateCode = entry => { const codeInfo = createDependciesGraph(entry)
console.log(codeInfo);
rmdir().then(()=>{ fs.mkdir('dist', () => { for(let key in codeInfo){ let value = key.split('/') value = value[value.length - 1]
fs.writeFile(`./dist/${value}`, codeInfo[key]['code'], [], () => {}) } }) })
}
|
我们需要在main.js
中调用我们的generateCode
代码生成器的方法。我们在调用的同时需要传入打包文件的入口文件。
1
| generateCode('./src/index.js')
|
我们就写完了,现在我们来运行一下,在终端输入node main.js
并允许。
我们就会发现我们的项目目录生成了dist
目录,里面是我们的src
下的js
文件。
我们看一下,foo.js
和index.js
文件中是否是src
目录下的那些内容。、
foo.js
1 2 3 4
| var foo = function foo() { console.log('我是foo'); }; export { foo };
|
index.js
1 2 3 4 5 6 7 8 9 10
| import { foo } from "./foo.js"; var index = function index() { foo(); console.log('我是index'); for (var _i = 0, _arr = [1, 2]; _i < _arr.length; _i++) { var item = _arr[_i]; console.log(item); } }; index();
|
验证打包的文件是否可以允许
我们新建一个index.html
,,并引入dist
目录下的index.js
文件。
1
| <script src="./dist/index.js" type="module"></script>
|
效果如下:
我们打包后的文件是可以正常运许的。
完整代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119
|
const fs = require('fs') const path = require('path') const parser = require('@babel/parser') const traverse = require('@babel/traverse').default const babel = require('@babel/core')
const readFile = (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) const dir = './' + path.join(dirName, node.source.value).replace('\\', '/') dependencies[node.source.value] = dir } })
const { code } = babel.transformFromAstSync(ast, null, { presets: [ [ "@babel/preset-env", { modules: false } ] ] })
return { fileName, dependencies, code }
}
const createDependciesGraph = entry => { const graphList = [readFile(entry)]
for(let i = 0;i < graphList.length; i++){ const item = graphList[i] const { dependencies } = item
if(dependencies){ for(let j in dependencies){ graphList.push( readFile(dependencies[j]) ) } } }
const graph = {} for(let item of graphList){ const {dependencies, code} = item graph[item.fileName] = { dependencies, code } }
return graph }
const generateCode = entry => { const codeInfo = createDependciesGraph(entry)
rmdir().then(()=>{ fs.mkdir('dist', () => { for(let key in codeInfo){ let value = key.split('/') value = value[value.length - 1]
fs.writeFile(`./dist/${value}`, codeInfo[key]['code'], [], () => {}) } }) })
}
const rmdir = async () => { return new Promise(async (resolve, reject) => {
let err = null
const isDir = fs.existsSync('dist')
if(isDir){ fs.rmdir('dist', {recursive: true}, error => { if(error){ err = error } }) }
if(err){ reject(err) return } setTimeout(()=>{ resolve() }, 1000) }) }
generateCode('./src/index.js')
|
总结
到此我们的一个简单的JavaScript打包器
实现完了,实现这个简单的打包器只是用于了解和理解先在主流打包器的原理。
我们现在这个打包器还有些缺陷: