从node.js的文件监听到webpack的热编译实现

7 篇文章 0 订阅
7 篇文章 0 订阅

我们知道,webpack可以通过配置实现热更新,热编译,让我们在编写代码时不需要手动去执行一些编译命令,而是在保存的时候就可以自动更新视图,那么这些功能是怎么做到的呢

首先,我们分解一下要做的事情,我们需要去监听文件的更新,当文件更新时,去修改对应的视图部分,这里做的就是webpack对文件的打包,那么,我们先看看如何去监听文件的更新

(最终代码我放在最后一步,也可以直接跳过看代码,代码里有具体注释)

node.js实现监听文件更新

使用node.js实现监听文件更新,既然是和文件有关,那么盲猜fs中可能有相关的API。

对于文件的监听,fs提供了两个API,watch和watchFile,这两个API最简单的区别,在于一个是监听文件夹下的所有文件的变化,一个是监听单个文件的变化

我们尝试去使用这两个API,首先npm init初始化,然后新建目录如下
在这里插入图片描述
src文件夹用来存放我们编写的文件

fs.watchFile

我们先看看watchFile的用法

fs.watchFile(filename[, options], listener)

该方法接收3个参数

  • filename:要监听的文件路径,可以为字符串,二进制或者url
  • options:监听的一些配置,值为一个对象,是可选参数
    • bigint:布尔值,当为true时,后面传入的回调函数参数对象里面的数字值为bigInt类型,默认为false
    • presistent:布尔值,当为true时,只要监视文件,进程就继续运行,默认为true
    • interval:整数,表示轮询的时间,这与这个API原理有关,watchFile是通过轮询来确定文件是否被修改,轮询时间越短,响应越快,但是越消耗CPU,默认为5007
  • listener:回调函数,在监听后做出的操作,该函数可传入两个参数current和previous
    • current:fs.Stats类的实例对象,包含当前文件的各种信息
    • previous:fs.Stats类的实例对象,包含之前文件的各种信息

(有关fs.Stats的相关内容,可以看node文档的fs.Stats,这里不展开说)
这里我们要关注的,是fs.Stats类里面的mtime属性,官方文档里这么写

The timestamp indicating the last time this file was modified expressed in milliseconds since the POSIX Epoch

简单来说,这个属性表示了文件上一次修改的时间,所以我们可以通过这个时间比较,来判断文件是否被修改了

因此,我们在serve.js里面写入如下代码

const fs = require('fs')
const filePath = './src/'
console.log(`正在监听 ${filePath} 下的文件`);
// 传入{ recursive: true } 表示监听目录下的所有子目录
fs.watch(filePath, { recursive: true }, (event, filename) => {
    console.log(`event类型${event}`)
        // 判断filename是否可用且eventType是否为改变
    if (filename && event == 'change') {
        console.log(`${filename}文件发生更新`)
    }
})

因为mtime属性打印如下格式,所以使用toLocalString转换为我们常见的格式
在这里插入图片描述
当我们每次保存文件时,都会打印出对应的时间
在这里插入图片描述
但是这种方式存在的缺点在于,mtime只是记录了我们上一次按下保存的时间,所以即使我们什么都不修改,只是ctrl+s,那么也会告诉我们发生更新了,然而实际上并没有

此外,这种监听方式,因为是采用轮询的,所以效率会受轮询时间的影响,如果我们要减少对CPU的损耗,文件发生的改变就无法及时地被我们监听到,同时,这种方式只能监听单个文件,对于一整个项目来说,我们要读取这个项目下的文件再进行轮询监听,无疑会带来很大的性能损耗

fs.watch

fs.watch(filename[, options][, listener])

watch传入的参数和watchFile的参数差不多,都有三个,不同的地方在于,options的配置属性不同,listener是可选参数,且传给listener的方法的参数不同。

  • filename:和watchFile一样,但是这里不是对某一个文件,而是一个文件夹
  • options:配置对象
    • persistent:与watchFile的persistent一样
    • recursive:当值为true时,除了监听的目录的文件,监听目录下的子目录里的文件也会被监听到,默认值为false
    • encoding:指定用于传递给侦听器的文件名的字符编码。默认值:‘utf8’
  • listener:回调函数,监听文件后触发的操作,有两个参数eventType和filename,返回fs.FSWatcher的实例对象
    • eventType:触发的事件类型,有’rename’和’change’,顾名思义对应重命名和修改两个事件
    • filename:监听到的文件,要注意的是,filename仅在Linux,macOS,Windows和AIX上支持在回调中提供参数

上面提到了filename会受平台影响,这是因为这个API的监听功能实现依赖于底层操作系统的对于文件更改的通知,所以针对不同平台是不一样的,但这种方式,却可以解决上面watchFile轮询带来的问题

看了watch的相应参数,我们可以利用eventType来实现文件监听

在serve中写入下面代码

const fs = require('fs')
const filePath = './src/'
console.log(`正在监听 ${filePath} 下的文件`);
fs.watch(filePath, (event, filename) => {
    console.log(`event类型${event}`)
        // 判断filename是否可用且eventType是否为改变
    if (filename && event == 'change') {
        console.log(`${filename}文件发生更新`)
    }
})

保存index.js和t1.js文件,发现触发了更新打印
在这里插入图片描述
这里之所以一次保存会打印两次,和windows本身有关,在github上也有人讨论过这个问题:传送门

但是这里同样会出现上面watchFile的问题,当我们按下保存的时候,即使我们的文件前后并没有任何改变,也会触发监听回调函数,那么这实际上就不是真正的更新了

即使我们使用最后修改时间来比较,也会和上面的watchFile一样,因为按下保存最后修改时间就会发生变化了,所以这也不是可以解决问题的方案

我们需要有一个可以用来标识文件的一个值,就好像我们在做缓存时每个资源对应的ETag一样,所以这里引入了md5

md5可以理解为为这部分内容生成一个对应的密码串,相同的内容生成的内容是相同的,可以打印出来看看

const fs = require('fs')
const md5 = require('md5')
const filePath = './src/'

console.log(`正在监听 ${filePath} 下的文件`);
// 传入{ recursive: true } 表示监听目录下的所有子目录
fs.watch(filePath, { recursive: true }, (event, filename) => {
    console.log(`event类型${event}`)

    // 判断filename是否可用且eventType是否为改变
    if (filename && event == 'change') {
        // 生成一个md5值
        let currentMd5 = md5(fs.readFileSync(filePath + filename))
        console.log('currentMd5: ', currentMd5);
        console.log(`${filename}文件发生更新`)
    }
})

保存index.js
在这里插入图片描述
将index.js的内容复制到t1.js,保存,可以看到下图
在这里插入图片描述
仔细看会发现,两者生成的md5值是相同的,也即是说,md5只和传入的内容有关,那么我们就可以利用这一点,当md5的值和之前不同的时候,我们就触发更新操作

代码如下

const fs = require('fs')
const md5 = require('md5')

const filePath = './src/'
let previousMd5 = null
console.log(`正在监听 ${filePath} 下的文件`);
// 传入{ recursive: true } 表示监听目录下的所有子目录
fs.watch(filePath, { recursive: true }, (event, filename) => {
    console.log(`event类型${event}`)

    // 判断filename是否可用且eventType是否为改变
    if (filename && event == 'change') {
        // 生成一个md5值
        let currentMd5 = md5(fs.readFileSync(filePath + filename))
            // 如果md5的值和之前一样,就return出去
        if (currentMd5 == previousMd5) {
            return
        }

        previousMd5 = currentMd5
        console.log(`${filename}文件发生更新`)
    }
})

接下来我们不修改index.js,直接保存,会发现,event类型是change,但是后面的操作没有被执行,只在第一次执行了一次(相当于初始化操作)
在这里插入图片描述
而当我们做出修改时,就会触发操作
在这里插入图片描述
这样做解决了上面不能真正监听变化的问题,但是这种方式,只适合一个文件,因为我们只用一个变量存储了md5值,所以为了解决这个问题,我们需要做出一些调整,改用一个对象来存储,采用数据字典的方式,为每一个用到的文件提供一个存储md5值的空间

const fs = require('fs')
const md5 = require('md5')
const filePath = './src/'
const previousMd5s = {}

console.log(`正在监听 ${filePath} 下的文件`);
// 传入{ recursive: true } 表示监听目录下的所有子目录
fs.watch(filePath, { recursive: true }, (event, filename) => {
    console.log(`event类型${event}`)

    // 判断filename是否可用且eventType是否为改变
    if (filename && event == 'change') {
        // 生成一个md5值
        let currentMd5 = md5(fs.readFileSync(filePath + filename))
            // 如果md5的值和之前一样,就return出去
        if (currentMd5 == previousMd5s[filename]) {
            return
        }

        previousMd5s[filename] = currentMd5
        console.log(`${filename}文件发生更新`)
    }
})

接着,我们通过防抖,来解决上面的连续打印两次的问题

const fs = require('fs')
const md5 = require('md5')
const filePath = './src/'
const previousMd5s = {}
let timmer = null

console.log(`正在监听 ${filePath} 下的文件`);
// 传入{ recursive: true } 表示监听目录下的所有子目录
fs.watch(filePath, { recursive: true }, (event, filename) => {
    // 防抖处理
    clearTimeout(timmer)
    timmer = setTimeout(() => {
        // 判断filename是否可用且eventType是否为改变
        if (filename && event == 'change') {
            // 生成一个md5值
            let currentMd5 = md5(fs.readFileSync(filePath + filename))
                // 如果md5的值和之前一样,就return出去
            if (currentMd5 == previousMd5s[filename]) {
                return
            }
            previousMd5s[filename] = currentMd5
            console.log(`${filename}文件发生更新`)
        }
    }, 500)
})

实现热编译

最后一步的实现,其实就是把之前做的东西整合起来而已,关于实现webpack的编译,可以看我的另一篇博客手写webpack部分功能实现ES module语法的编译

将两篇博客的内容整合起来,最终代码如下

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 md5 = require('md5')

let ID = 0

// 读取文件的函数
function readSrcFile(fileName) {
    // 文件内容
    const fileContent = fs.readFileSync(fileName, 'utf-8');
    const ast = parser.parse(fileContent, {
        sourceType: 'module'
    })

    // 找出依赖文件 
    // 声明存储依赖文件的数组
    const dependences = [];

    // 传入参数,第一个为要解析的ast树,第二个为一个对象,每个属性对应一个钩子,可以让我们对ast里面对应的节点进行操作
    traverse(ast, {
        ImportDeclaration: ({ node }) => {
            dependences.push(node.source.value);
        }
    })

    // 从ast转换为ES5代码
    const { code } = babel.transformFromAstSync(ast, null, {
        presets: ['@babel/preset-env']
    })

    let id = ID++;

    return {
        id,
        fileName,
        dependences,
        code
    }
}

// 从文件入口对依赖文件处理
function handleFiles(fileName) {
    // 获取入口文件处理后的对象
    const fileObj = readSrcFile(fileName);
    const list = [fileObj];
    for (const asset of list) {
        // 使用path获取asset.fileName文件所在的文件夹
        const dirname = path.dirname(asset.fileName);
        // 为了在后面能通过id找到模块,设置一个map
        asset.mapping = {};
        // 遍历队列
        asset.dependences.forEach(relativePath => {
            // 将上面得到的文件夹路径和相对路径拼接起来得到绝对路径
            const absolutePath = path.join(dirname, relativePath);
            // 将依赖的文件使用readSrcFile处理
            const child = readSrcFile(absolutePath);
            // 将对应模块的id传入到mapping中
            asset.mapping[relativePath] = child.id;
            // 将依赖的文件添加到队列尾部
            list.push(child);
        });
    }
    return list;
}


// 构建最终的bundle.js文件的内容
function bundle(graph) {
    let modules = ''

    graph.forEach(mod => {
        modules += `
            ${mod.id}:[
                function (require,module,exports){
                    ${mod.code}
                },
                ${JSON.stringify(mod.mapping)}
            ],
        `
    })

    // 在内部构建require来通过id获取到模块内容
    // 而在require函数内部,由于fn里面的内容是通过相对路径来进行文件/模块获取的
    // 所以还需要在里面写入另一个函数localRequire来对路径做处理
    // 因为以我们上面的形式处理,id是从0开始的,require(0)就是从入口文件执行
    const result = `
        (function(modules){
            function require(id){
                const [fn,mapping] = modules[id];

                function localRequire(relativePath){
                    return require(mapping[relativePath]);
                }

                const module = {
                    exports:{}
                }

                fn(localRequire , module , module.exports);

                return module.exports;
            }
            require(0)
        })({${modules}})
    `;

    return result;
}

const filePath = './src/'
const previousMd5s = {}
let timmer = null

console.log(`正在监听 ${filePath} 下的文件`);
// 传入{ recursive: true } 表示监听目录下的所有子目录
fs.watch(filePath, { recursive: true }, (event, filename) => {
    // 防抖处理
    clearTimeout(timmer)
    timmer = setTimeout(() => {
        // 判断filename是否可用且eventType是否为改变
        if (filename && event == 'change') {
            // 生成一个md5值
            let currentMd5 = md5(fs.readFileSync(filePath + filename))
                // 如果md5的值和之前一样,就return出去
            if (currentMd5 == previousMd5s[filename]) {
                return
            }
            previousMd5s[filename] = currentMd5
            console.log(`${filename}文件发生更新`)
            console.log('重新打包文件')

            const graph = handleFiles('./src/index.js')
            const result = bundle(graph)

            console.log('文件打包完成')
            fs.writeFileSync('./dist/bundle.js', result)
            console.log('文件已写入新生成的bundle.js')

        }
    }, 500)
})
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值