我们知道,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)
})