实现一款简单的JavaScript打包器

前言

在现在的前端大环境下,由从前的htmlcssjs,逐渐衍生出来了前端的工程化,由简到繁,越来越复杂,最复杂的要属我们的webpack了,已经出现了webpack工程师,用来专门配置webpack

前端工程化打包工具千千万,谁又是你的NO.One

本篇文章实现的是一款简单的javaScript打包工具,不涉及非javaScript的打包,如:csshtml静态文件等。

环境

我们的电脑上需要配备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文件。然乎安装上面的模块。


//npm
npm i @babel/core @babel/parser @babel/preset-env @babel/traverse

//pnpm
pnpm i @babel/core @babel/parser @babel/preset-env @babel/traverse

新建main.js文件

我们新建一个main.js文件,用来编写我们的代码,当然你也可以使用其他的文件名。

新建src目录

这里我们需要新建一个src目录,用来装我们写的代码。

src目录里面我们新建两个js文件,如下:


// foo.js
const foo = () => {
    
    
    console.log('我是foo');
}

export {
    
    
    foo
}

我们再新建一个index.js文件,并引入foo.js,并在index.js的方法里面执行foo方法。然后我们执行index防范。

// index.js

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的形式进行引用,


const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
// 这里需要添加.default来引入@babel/traverse模块,因为require不支持default的导出,故添加此内容
const traverse = require('@babel/traverse').default
const babel = require('@babel/core')

读取文件内容

我们添加readFile方法读取我们编写的js文件内容,这里我们使用fs模块中的readFileSync方法,并设置内容格式为utf-8

然后我们传入index.js文件的路径并执行该方法。

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换行符。

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,并设置sourceTypemodule。并依旧在终端执行node main.js

    const ast = parser.parse(content, {
    
    
        sourceType: 'module'
    })

    console.log(ast);

打印结果如下,是一个node格式的节点,我们的代码内容在program -> body 中。

    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等于一个函数,添加一个形参接收每个文件的路径。

    const dependencies = {
    
    }

    traverse(ast, {
    
    
        ImportDeclaration: ({
     
      node }) => {
    
    
            
        }
    })

我们通过path模块来处理我们文件的路径。

    const dirName = path.dirname(fileName)

我们需要对我们的文件名和路径进行进一步的处理。并对其进行正则替换反斜杠。

    const dir = './' + path.join(dirName, node.source.value).replace('\\', '/')

上面代码中的node.source.value就是我们根据ast中获取到的所有的文件名和路径。

我们将我们拿到的文件路径存入dependencies对象中。

    dependencies[node.source.value] = dir

最终我们在终端执行node main.js并打印我们的dependencies对象。打印内容和我们需要编译的文件路径一致。

    {
    
     './foo.js': './src/foo.js' }

使用@babel/core转编我们的代码

这里我们需要使用@babel/core工具中的transform方案转编我们的代码,让我们的代码在第版本的浏览器中也可以正常运行。

这里我们使用最新的api transformFromAstSync来转编我们的代码。

transformFromAstSync的作用:将我们刚刚修改的ast转编回我们的代码。

我们只需要它转换后的代码,其他的我们不需要,所以我们对其结果进行解构,只获取其代码。

    const {
    
     code } = babel.transformFromAstSync(ast, null, {
    
    })

我们这里需要使用到@babel/preset-env,对我们的代码进行降级处理,也是就是说我们使用的新版规范编写,我们要转它转回老版本规范。如果这里我们不处理,我们的代码也将不会处理,原模原样的输出。

所以我们需要给其添加一个presets属性并放入我们的@babel/preset-env工具。这里我们将modules属性设为false,让它输出为esm格式的代码。

其他属性扩展:commonjsamdumdsystemjsauto

    const {
    
     code } = babel.transformFromAstSync(ast, null, {
    
    
        presets: [
            [
                "@babel/preset-env",
                {
    
    
                    modules: false
                }
            ]
        ]
    })

我们在终端执行node main.js,打印内容如下:

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方法完整代码:

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的方法,用来收集我们的文件依赖关系。添加一个形参接收我们传入的文件名。

const createDependciesGraph = entry => {
    
    }

创建一个一个名为graphList的数组,用来装我们的readFile方法return出来的返回值。

const graphList = [readFile(entry)]

我们这里需要进行递归处理graphList,防止里面有多重依赖。

for(let i = 0;i < graphList.length; i++){
    
    }

我们需要在循环中暂存每一项,所以我们声明一个item来装。

const item = graphList[i]

我们这里还需要暂存每一项的依赖关系。

const {
    
     dependencies } = item

这里我们需添加一个判断,如果存在依赖关系的,我们继续再次循环它的依赖关系层,并插入graphList中,以此往复的进行递归嵌套循环,并且进行文件内容读取,直至循环结束。

    if(dependencies){
    
    
        for(let j in dependencies){
    
    
            graphList.push( readFile( dependencies[j] ) )
        }
    }

此部分完整代码:

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,结果如下:

[
  {
    
    
    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 };
  }
]

关系层梳理

我们看到刚刚的已经完整的打印出来了我们的关系层了,我们现在需要对其进行梳理。

我们新建一个对象来装我们的梳理好的关系层。

const graph = {
    
    }

这里我们循环graphList数组,并向graph中写入我们的详细依赖关系图层。

    for(let item of graphList){
    
    
        const {
    
    dependencies, code} = item
        graph[item.fileName] = {
    
    
            dependencies,
            code
        }
    }

我们打印一下刚刚的梳理好的内容,依旧是在终端输入node main.js:

{
    
    
  './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返回出去,方便我们后面使用。

return graph

createDependciesGraph方法完整代码:

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的方法,管理我们的打包目录文件夹

const rmdir = async () =>  {
    
    }

我们给它内部return一个new Promise的实例,至于原因后面用到了懂了,方便我们后面使用。

const rmdir = async () =>  {
    
    
    return new Promise(async (resolve, reject) => {
    
    
    
    })
}

我们声明一个err用来获取我们操作文件夹、文件时的错误,

let err = null

我们读取我们当前打包文件夹的状态,如果存在则清空并删除掉。recursive表示是否删除文件夹,true为删除。

const isDir = fs.existsSync('dist')

if(isDir){
    
    
    fs.rmdir('dist', {
    
    recursive: true},  error => {
    
    
        if(error){
    
    
            err = error
        }
    })
}

这里我们进行错误判断,当err为真我们则抛错并return出去。

if(err){
    
    
    reject(err)
    return
}

这里我们使用setTimeout来延时通知成功,避免删除文件夹和创建文件夹同时进行,导致创建不成功。

setTimeout(()=>{
    
    
    resolve()
}, 1000)

rmdir完整代码:

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的方法,进行我们的代码生成入口调用,并对生成文件进行编写处理。

const generateCode = entry => {
    
    }

在它的内部调用createDependciesGraph方法,并将entry(打包的入口文件)传递进去。并声明codeInfo去接收。

const codeInfo = createDependciesGraph(entry)

我们可以先打印看一下codeInfo长啥样。

{
    
    
  './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的原因,在等删除清空打包目录后再创建打包文件夹及文件,这样我们就避免了同时进行文件夹、文件的创建与删除的问题。

rmdir().then(()=>{
    
    })

现在我们来创建打包目录文件夹。

fs.mkdir('dist', () => {
    
    })

我们在创建打包文件夹的回调中循环我们的依赖关系,因codeInfo是对象,我们不能使用for..of...,使用的是es6中新增的for..in..

for(let key in codeInfo){
    
    }

这里我们创建同名文件夹,并将指定代码写入同名文件中。这里获取我们通过split的方式获取当前的文件名称,并取最后一项,因最后一项就是我们的文件名。

let value = key.split('/')
value = value[value.length - 1]

我们根据上面获取到的文件名创建问价,并将对应的代码写入文件中。

let value = key.split('/')
value = value[value.length - 1]

fs.writeFile(`./dist/${
      
      value}`, codeInfo[key]['code'], [], () => {
    
    })

generateCode方法完整代码

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代码生成器的方法。我们在调用的同时需要传入打包文件的入口文件。

generateCode('./src/index.js')

我们就写完了,现在我们来运行一下,在终端输入node main.js并允许。

我们就会发现我们的项目目录生成了dist目录,里面是我们的src下的js文件。

image.png

我们看一下,foo.jsindex.js文件中是否是src目录下的那些内容。、

foo.js

var foo = function foo() {
    
    
  console.log('我是foo');
};
export {
    
     foo };

index.js

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文件。

<script src="./dist/index.js" type="module"></script>

效果如下:

image.png

我们打包后的文件是可以正常运许的。

完整代码



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打包器实现完了,实现这个简单的打包器只是用于了解和理解先在主流打包器的原理。

我们现在这个打包器还有些缺陷:

  • 不可以打包嵌套的目录文件。
  • 只能打包js文件

猜你喜欢

转载自blog.csdn.net/qq_44500360/article/details/128251927