在开发中,有将未下载的文件类消息拖拽到本地,完成下载复制功能的需求(同样的场景有如 FileZilla 远程文件管理器拖拽远程文件到本地文件系统)。本文记录下此次优化的过程。
当前实现方案分析
electron(chromium) 提供了下载文件到本地的能力。通过为 dragstart 事件的 event.dataTransfer 设置 DownloadURL ,既可使 chromium 实现拖拽文件到本地的功能.
let t = 'image/png';
let n = 'Dog.png';
let u = 'https://example.com/img/1.png';
e.dataTransfer.setData('DownloadURL', `${t}:${n}:${u}`);
如上,DownloadURL 值必须是 [MIME]:[name]:[resource] 格式,而且必须设置 MIME type,文件地址可以是本地/远程地址。但对于本应用来说,我们只能获取到文件id和文件名,所以要想实现同样的拖拽效果,需要先创建一个 fake 文件供 chromium 完成拖拽。然后监听操作系统文件变化,获取到真正的文件拖拽目的地,下载完成后拷贝即可。
graph TD
A[dragstart] -->| 生成 fake 文件并设置 dataTransfer | B(chromium 拖拽)
B --> C(启动 Fs.watch )
C --> D{监听文件系统变化, 变化文件名等于拖拽文件? }
D --> Yes --> E(启动下载)
D --> No --> D
E --> F(下载完成复制文件,删除缓存)
F --> G(关闭 Fs.watch) --> A
此处的问题就出在 Fs.watch 上。对于用户来说,常见的拖拽目的地一般是 home 目录下的可见文件夹。nodejs 的 Fs.watch 需要先监听到目的目录的 fd 后,才能获取到其对应的变化。如果还没监听到拖拽目的地文件夹就关闭 watcher, 那么自然就观察不到文件的变化。因此造成了上述问题。
优化方案
1、既然 Fs.watch 需要时间,那么优化的思路就是尽可能的在拖拽前 watch, 以便拖拽时能监听到变化。同时不再关闭 watcher。
2、同时为了避免在渲染线程监听造成 js 的 eventLoop 卡顿,可以将监听放到子线程。而 nodejs@10.5.0+ 实验性支持 worker_threads (需要开启 --experimental-worker flag), nodesjs@12+ 正式支持。
3、官方表示 Fs.watch 性能不咋滴
Node.js
fs.watch:
- Doesn't report filenames on MacOS. 在 MacOS 上不上报文件名
- Doesn't report events at all when using editors like Sublime on MacOS. 在 MacOS 上使用如 sublime 等编辑器时不会上报任何事件
- Often reports events twice. 通常会上报两次事件
- Emits most changes as
rename. 大部分的文件更新事件名是rename- Does not provide an easy way to recursively watch file trees. 没有提供简便的方式遍历监听文件树
- Does not support recursive watching on Linux. 不支持在 linux 上遍历监听文件树 Node.js
fs.watchFile:- Almost as bad at event handling. 在事件处理方面非常糟糕
- Also does not provide any recursive watching. 也不提供遍历监听文件树
- Results in high CPU utilization.
而 Chokidar 则解决了以上问题,并且 Microsoft's Vistual Studio Code, gulp, karma, PM2, browserify, webpack, BrowserSync 等其他 项目都在使用。所以使用 chokidar 代替 Fs.watch
ps: chokidar 项目已经开发十年了🐮
3.1 chokidar
- chokidar@3 最低 nodejs 支持版本是 8+
- 依赖的 readdirp 在某些情况下可以节省5倍内存占用。
- 在 linux 上报错:
cannot set terminal process group (-1): Inappropriate ioctl for device \n no job control in this shell Error: watch /home/ ENOSPC: 这是因为 chokidar 用尽了系统的 filehandle, 需要手动提高上限。执行以下命令可以解决:echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p
如下为优化方案
chokidar 监听文件系统变化
const Chok = require('chokidar')
function chokWatch(observer) {
const ignoreDirs = ['.Trash', 'Application Support', 'Saved Application State']
const start = Date.now()
const watchOptions = {
// 禁止刚 watch 就 push 的事件
ignoreInitial: true,
// 禁止 EACCESS 或者 EPERM 等访问文件的报错
ignorePermissionErrors: true,
// 跳过非文件夹,隐藏文件夹(此处使用函数的话,会导致被调用2次)
// ignored: (path, stats) => {
// return !path || Path.basename(path).indexOf('.') === 0 || (stats && !stats.isDirectory())
// }
ignored: /((^|[\/\\])\..)+|(\.exe)+/
}
// if (process.platform === 'win32') {
// watchOptions.ignored = (path, stats) => {
// return !path || Path.basename(path).indexOf('.') === 0 || (stats && !stats.isDirectory())
// }
// }
const watcher = Chok.watch(Os.homedir(), watchOptions)
// native event
// watcher.on('raw', (eventName, path, details) => {
// if (!path) return
// if (ignoreDirs.find((dir) => path.indexOf(dir) > -1)) return
// // linux 是 rename/change 事件
// // macOS 是 created 事件
// // Windows 是 changed 事件
// let targetEvent = 'changed'
// if (process.platform === 'darwin') {
// targetEvent = 'created'
// } else if (process.platform === 'win32') {
// targetEvent = 'changed'
// }
// // console.log(targetEvent, path)
// eventName == targetEvent && !isStopPostMessage && subject.next({ added: path })
// })
watcher.on('add', (path, stats) => {
if (!path) return
if (ignoreDirs.find((dir) => path.indexOf(dir) > -1)) return
!isStopPostMessage && observer.next({ added: path })
})
watcher.on('change', (path, stats) => {
if (!path) return
if (ignoreDirs.find((dir) => path.indexOf(dir) > -1)) return
!isStopPostMessage && observer.next({ changed: path })
})
if (process.platform === 'win32') {
const chromeDragCacheDirSegment = `\AppData\Local\Temp\chrome_drag`
watcher.on('addDir', (path, stats) => {
if (path && path.indexOf(chromeDragCacheDirSegment) > -1 && !isStopPostMessage) {
observer.next({ chromeDragCacheDir: path })
}
})
}
watcher
.on('ready', () => {
const cost = timeTrace(start)
// 获取到 ready 事件时,watch 了多少文件夹 (仅计算watch了多少文件夹,不要查看使用被 watch 的目录,【风险行为】!!!)
observer.next({ ready: { timeCost: cost, watchedDirs: Object.keys(watcher.getWatched()).length } })
})
.on('error', (error) => console.error(error))
return watcher
}
electron + worker_threads
1、worker.js
const { parentPort } = require('worker_threads')
const Os = require('os')
const Fs = require('fs')
const Chok = require('chokidar')
chokWatch()
function timeTrace(start) {
const diff = Date.now() - start
console.log(`[time] ${diff / 1000}s, ${diff}ms`)
return diff
}
// flag 标识,是否将监听到的文件变化发送到子线程
// 因为 js 线程间传递消息是结构化拷贝,为了节约资源,使用此标识来管理
let isStopPostMessage = false
function chokWatch() {
// 可以跳过的一些目录
const ignoreDirs = ['wwSharedLoading', '.Trash', 'Application Support', 'Saved Application State']
const start = Date.now()
parentPort.on('message', (msg) => {
console.log('[dropMonitorWorker] emit control message:', msg)
if (msg.pause) {
isStopPostMessage = true
} else if (msg.resume) {
isStopPostMessage = false
} else {
console.error('[dropMonitorWorker] wrong message type', msg)
}
})
const watchOptions = {
// 禁止刚 watch 就 push 的事件
ignoreInitial: true,
// 禁止 EACCESS 或者 EPERM 等访问文件的报错
ignorePermissionErrors: true,
// ignored:跳过非文件夹。 参数可以是字符串、通配符、正则表达式或者函数
// 如果是函数的话,会导致被调用两次。
// ignored: (path, stats) => {
// return !path || Path.basename(path).indexOf('.') === 0 || (stats && !stats.isDirectory())
// }
// 跳过隐藏文件或者 .exe 后缀文件
// windows watch exe 文件会报错 lstat error, 所以跳过
ignored: /(^|[\/\\])\..|(.exe)+/
}
const watcher = Chok.watch(Os.homedir(), watchOptions)
// 也可以用 raw 事件,即原生事件
// native event
// watcher.on('raw', (eventName, path, details) => {
// if (!path) return
// if (ignoreDirs.find((dir) => path.indexOf(dir) > -1)) return
// // linux 是 rename/change 事件
// // macOS 是 created 事件
// // Windows 是 changed 事件
// let targetEvent = 'changed'
// if (process.platform === 'darwin') {
// targetEvent = 'created'
// } else if (process.platform === 'win32') {
// targetEvent = 'changed'
// }
// // console.log(targetEvent, path)
// eventName == targetEvent && !isStopPostMessage && parentPort.postMessage({ added: path })
// })
// 下面使用 chokidar 提供的 add/addDir 事件处理
watcher.on('add', (path, stats) => {
if (!path) return
if (ignoreDirs.find((dir) => path.indexOf(dir) > -1)) return
!isStopPostMessage && parentPort.postMessage({ added: path })
})
// windows 在chrome 拖拽到文件管理器时,会在以下路径 \AppData\Local\Temp\chrome_drag 生成拖拽中间缓存文件。拖拽完成后需要一并删除掉
if (process.platform === 'win32') {
const chromeDragCacheDirSegment = `\AppData\Local\Temp\chrome_drag`
watcher.on('addDir', (path, stats) => {
if (path && path.indexOf(chromeDragCacheDirSegment) > -1 && !isStopPostMessage) {
parentPort.postMessage({ chromeDragCacheDir: path })
}
})
}
watcher
.on('ready', () => {
const cost = timeTrace(start)
// 获取到 ready 事件时,watch 了多少文件夹 (仅计算watch了多少文件夹,不要查看使用被 watch 的目录,【风险行为】!!!)
// 后面会分析下,此处也可以继续优化。
parentPort.postMessage({ ready: { timeCost: cost, watchedDirs: Object.keys(watcher.getWatched()).length } })
})
.on('error', (error) => console.error(error))
return watcher
}
这里使用 parentPort.postMessage 向主线程发消息
- worker_threads 创建 Worker 有三种方式调用:
- 同一个文件调用
const { Worker, isMainThread, workerData, parentPort, MessageChannel } = require('worker_threads') if (isMainThread) { const woker = new Worker('') // ...blahblahblah } else { // worker thread workspace chokWatch() }- eval 内嵌方式
const { Worker, isMainThread } = require('worker_threads') if (isMainThread) { const w = new Worker(` console.log('[worker] i am in worker thread 🐶') `) }- 文件分开调用 drop_monitor_main.js
const Path = require('path')
const { ipcMain, app } = require('electron')
const { Worker, isMainThread, workerData, parentPort, MessageChannel } = require('worker_threads')
const requestMonitorFileQueue = []
let mainWindow
const ipcChannel = {
add: 'drop_monitor:add',
quit: 'drop_monitor:quit',
checked: 'drop_monitor:checked',
checked_cache: 'drop_monitor:checked_cache'
}
/**
* 绑定主窗口
* @param {*} mainWin
*/
module.exports = (mainWin) => {
console.log('[dropMonitorMain] mainWin.id=', mainWin.id)
mainWindow = mainWin
}
const workerJs = process.env.NODE_ENV == 'development' ? Path.join(process.cwd(), __filename) : Path.join(__dirname, 'drop_monitor.js')
console.log('[dropMonitorMain] in main thread:', workerJs, ',__dirname:', __dirname, ',__filename:', __filename)
const worker = new Worker(workerJs)
// 添加新的拖拽文件监听
ipcMain.handle(ipcChannel.add, (_, filename) => {
console.log(`[dropMonitorMain]${now()} receivedAdd:`, filename)
if (!mainWindow || mainWindow.isDestroyed()) {
console.warn('[dropMonitorMain] mainWindow has destroyed')
return
}
// TODO: 需要处理同一个(大)文件连续的被拖动到不同目录下
if (requestMonitorFileQueue.findIndex((item) => item === filename) === -1) {
resumeEmit(worker)
requestMonitorFileQueue.push(filename)
}
console.log(`[dropMonitorMain]${now()} receivedAdd-queue:`, requestMonitorFileQueue)
})
// 监听关闭 worker 线程
ipcMain.handle(ipcChannel.quit, async () => {
if (worker) {
// 返回 exitCode
const exitCode = worker.terminate()
console.log('[dropMonitorMain] quit:', exitCode)
}
})
app.on('exit', () => {
if (worker) {
worker.terminate()
}
})
worker.on('message', (msg) => {
// console.log(`[dropMonitorMain] onmessage:`, msg)
if (!mainWindow || mainWindow.isDestroyed()) {
console.warn('[dropMonitorMain] mainWindow has destroyed')
return
}
if (!msg) {
console.warn('[dropMonitorMain] msg is undefined')
return
}
if (msg.ready) {
// ready 事件
console.log('[dropMonitorMain] ready:', msg.ready.timeCost, msg.ready.watchedDirs)
} else if (msg.added) {
pauseEmit(worker)
const createdDir = msg.added
console.log(`[dropMonitorMain]${now()} added(before):`, createdDir, requestMonitorFileQueue)
const idx = requestMonitorFileQueue.findIndex((item) => createdDir.indexOf(item) > -1)
if (idx === -1) return
console.log(`[dropMonitorMain]${now()} added(after):`, createdDir, requestMonitorFileQueue)
mainWindow.webContents.send(ipcChannel.checked, createdDir)
requestMonitorFileQueue.splice(idx, 1)
} else if (msg.chromeDragCacheDir) {
console.log(`[fsMinotorMain]${now()} addDir:`, msg.chromeDragCacheDir)
mainWindow.webContents.send(ipcChannel.checked_cache, msg.chromeDragCacheDir)
}
})
worker.on('error', (err) => {
console.log(`[dropMonitorMain] error: ${err ? err.message : ''}`)
})
worker.on('exit', (exitCode) => {
console.log(`[dropMonitorMain] worker exited: ${exitCode}`)
})
const resumeEmit = (worker) => worker && worker.postMessage({ resume: true })
const pauseEmit = (worker) => worker && !requestMonitorFileQueue.length && worker.postMessage({ pause: true })
三种方式有以下区别:
1、逻辑简单,可以使用前两者
2、后两者的 worker scriptPath 不同。特别是使用 webpack/rollup 等打包工具时,需要将 worker 文件单独打包,而不是作为主线程的依赖由 webpack 处理成内嵌代码。
相同之处:
1、后两者在 electron 打包📦时,需要将 worker.js 放入 app.asar.unpacked 文件夹下, 否则会报 cann't found module: xxx 的错误
PS: worker 线程和主线程交互的方式
1、worker 初始化阶段: workerData
const { Worker, workerData, isMainThread } = require('worker_threads')
if (isMainThread) {
// 创建时通过 options 里的 workerData 属性传递数据
const w = new Worker(__filename, {
workerData: obj
})
} else {
// 获取 workerData **模块** 来自主线程创建 Worker 时传递的数据
const objFromMain = workerData
}
2、worker 运行时阶段:parentPort
const { Worker, workerData, isMainThread, parentPort } = require('worker_threads')
if (isMainThread) {
const w = new Worker(__filename)
w.postMessage({ control: 'output', payload: '🚀🚀🚀' })
w.on('message', (msg) => {
})
} else {
parentPort.on('message', (msg) => {
if (msg.control === 'output') {
console.log('[worker]', msg.payload)
}
parentPort.postMessage('[from worker] 老铁,感谢你的三连支持!!!')
})
}
3、messageChannel 方式
import { MessageChannel, Worker, isMainThread, parentPort } from 'worker_threads'
import Path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = Path.dirname(__filename)
// console.log(`__dirname:${__dirname},\n__filename:${__filename}`)
const { port1, port2 } = new MessageChannel()
// Object that needs transfer was found in message but not listed in transferList
if (isMainThread) {
const w = new Worker(__filename)
port1.on('message', (msg) => {
console.log('[main]', msg)
w.postMessage('不啦不啦,钱包瘪了!!!')
})
w.postMessage({ port: port2 }, [port2])
} else {
parentPort.on('message', (msg) => {
if (msg.port) {
msg.port.postMessage('来搓麻🦷')
} else {
console.log('[worker]', msg)
}
})
}
1)、从名称上也能推断出,parentPort 和 messageChannel 返回的 messagePort 是一样的
2)、messageChannel 不仅可以在 worker 线程使用,主线程也可以使用。
const { MessageChannel } = require('worker_threads')
const { port1, port2 } = new MessageChannel()
port1.postMessage('nihou')
port2.on('message', (msg) => console.log(msg))
// 如果不关闭 port, 则此线程(主线程)就会被 hold 住
// port1.close()
// port2.close() // 如果关闭 port2, 那么 port2 的 message 就无法输出
3)、注意,使用 postMessage 传递 ArrayBuffer、MessagePort、FileHandle 等对象时,需要使用 postMessage 的 transferList 参数,表示不要将其拷贝过去。
4、broadcastChannel 方式
const { isMainThread,BroadcastChannel,Worker} = require('worker_threads');
const bc = new BroadcastChannel('hello');
const abc = new BroadcastChannel('hello')
if (isMainThread) {
abc.onmessage = (event) => {
console.log('[abc] i am a fake news.', event.data)
abc.close()
}
bc.onmessage = (event) => {
console.log('[bc] ', event.data)
}
const w = new Worker(__filename)
} else {
bc.postMessage('fake news.');
bc.close();
}
可以看到 brocastChannel 使用的是 name 作为通道唯一标识的。
如上就可以实现获取到拖拽文件的真实目的地
electron + threads
从上面介绍可以看到,在 nodejs 使用 worker_threads 时有版本要求。因为适配问题,linux 本机打包时使用的是 nodejs@10.19.0 版本。而 electron 运行时环境包括了 electron@6.1.12(Node@v12.4.0) 和 electron@8.5.5(Node@v12.13.0)。所以在 webpack 编译时和运行时都要使用 flag 开启 worker_threads 这一特性。
而社区提供了 threads 这一兼容简化的 worker thread 库。使用如下:
worker.js
const Os = require('os')
const Fs = require('fs')
const Path = require('path')
const { expose } = require('threads')
const { Observable } = require('threads/observable')
const Chok = require('chokidar')
let isStopPostMessage = false
expose({
start() {
console.log('[dropMonitorWorker] in worker thread')
return new Observable((observer) => {
chokWatch(observer)
})
},
stop() {
// console.log('[dropMonitorWork] stop')
},
pause() {
// console.log('[dropMonitor-worker] paused')
// 暂停⏸️ worker postMessage 的原因是 worker -> main 传消息是拷贝,减少不必要的内存消耗
isStopPostMessage = true
},
resume() {
// console.log('[dropMonitor-worker] resumed')
isStopPostMessage = false
}
})
- 在 worker 线程里不是用 postMessage 传递消息,而是使用了 threads 提供的 Observable/observer 来传递消息
- 使用 expose 将 worker 线程中的函数暴露给主线程
main.js
const { ipcMain, app } = require('electron')
const Path = require('path')
const { Thread, Worker, spawn } = require('threads')
const requestMonitorFileQueue = []
const ipcDropChannel = {
add: 'drop_monitor:add',
quit: 'drop_monitor:quit',
checked: 'drop_monitor:checked',
checked_cache: 'drop_monitor:checked_cache'
}
/// ---- drop monitor
async function initDropMonitor(mainWindow, nativeLog) {
nativeLog('[dropMonitorMain] in main thread')
// nativeLog('[DropMonitorJsPath]', DropMonitorJsPath)
const DropMonitorJsPath =
process.env.NODE_ENV == 'development'
? Path.join(process.cwd(), 'src/main/lib/drop_monitor/worker.js')
: Path.join(process.resourcesPath, 'app.asar.unpacked/dist/electron/drop_monitor_worker.js')
nativeLog('[DropMonitorJsPath]' + DropMonitorJsPath)
const worker = await spawn(new Worker(DropMonitorJsPath))
// 添加新的拖拽文件监听
ipcMain.handle(ipcDropChannel.add, (_, filename) => {
nativeLog(`[dropMonitorMain]${now()} receivedAdd: ${filename}`)
if (!mainWindow || mainWindow.isDestroyed()) {
console.warn('[dropMonitorMain] mainWindow has destroyed')
return
}
// TODO: 需要处理同一个(大)文件连续的被拖动到不同目录下
if (requestMonitorFileQueue.findIndex((item) => item === filename) === -1) {
nativeLog(`[dropMonitorMain]resumed`)
worker.resume()
requestMonitorFileQueue.push(filename)
}
nativeLog(`[dropMonitorMain]${now()} receivedAdd-queue: ${JSON.stringify(requestMonitorFileQueue)}`)
})
// 监听关闭 worker 线程
ipcMain.handle(ipcDropChannel.quit, async () => worker && Thread.terminate(worker))
app.on('will-quit', async () => worker && Thread.terminate(worker))
worker.start().subscribe((msg) => {
// nativeLog(`[dropMonitorMain] onmessage:`, msg)
if (!mainWindow || mainWindow.isDestroyed()) {
nativeLog('[dropMonitorMain] mainWindow has destroyed')
return
}
if (!msg) {
nativeLog('[dropMonitorMain] msg is undefined')
return
}
if (msg.ready) {
// ready 事件
nativeLog(`[dropMonitorMain] ready:${msg.ready.timeCost}ms, watched:${msg.ready.watchedDirs} items.`)
} else if (msg.added || msg.changed) {
if (!requestMonitorFileQueue.length) {
worker.pause()
nativeLog(`[dropMonitorMain]paused`)
}
const createdDir = msg.added || msg.changed
nativeLog(`[dropMonitorMain]${now()} added(before): ${createdDir}, ${JSON.stringify(requestMonitorFileQueue)}`)
const idx = requestMonitorFileQueue.findIndex((item) => createdDir.indexOf(item) > -1)
if (idx === -1) return
nativeLog(`[dropMonitorMain]${now()} added(after): ${createdDir}, ${requestMonitorFileQueue}`)
mainWindow.webContents.send(ipcDropChannel.checked, createdDir)
requestMonitorFileQueue.splice(idx, 1)
} else if (msg.chromeDragCacheDir) {
nativeLog(`[fsMinotorMain]${now()} chromeDragCacheDir: ${msg.chromeDragCacheDir}`)
mainWindow.webContents.send(ipcDropChannel.checked_cache, msg.chromeDragCacheDir)
}
})
}
module.exports = {
initDropMonitor
}
- 需要注意:主线程里使用 threads 提供的 Worker 来创建 worker 线程(它使用 tiny-worker 自动兼容了低版本), 使用 Thread.terminate() 而不是 worker.close() 来终结子线程。
- threads 提供了 spawn 函数,可以方便直接调用子线程暴露出来的函数。本例中使用的子线程中返回的 Observable,所以只要 Observable 没有调用
Observable.complete(),就可以一直监听到变化。
electron 打包与子线程
1、worker_threads 模块下的 Worker scriptPath 与 electron 版本问题
1.1 electron@8.5.5
webpack 单独输出 worker.js, 同时 electron-builder 将 worker.js 打包到 asar.
electron@8.5.5 使用 Path.join(__dirname, 'worker.js') 加载失败
[dropMonitorMain(nativeThreads)] error: Cannot find module '/Users/humphrey/Desktop/tada/build/mac/tada.app/Contents/Resources/app.asar/dist/worker.js'
解决方案:
- workerScript 改为
Path.join(process.resourcesPath, 'app.asar.unpacked/dist/worker.js') - electron-builder.yaml 中添加
asarUnpack: "dist/worker.js"将 worker.js 剔除 asar
1.2 electron@13.6.9 同上
1.3 electron@18.0.4
不需要将 worker.js 移出 asar 了。可以直接在 asar 中加载。如路径: new Worker(Path.join(__dirname, 'worker.js'))
2、threads + electron
首先 threads 最新版 1.7.0 在 electron 使用时有个 bug.就是在正式版加载的时候,会将 worker scriptPath 拼接错误,导致找不到 worker 脚本。具体原因已经再提 issue 了。修复方案可以使用 issue 里的方案自己先修复远程发布或者本地 npm link
2.1 electron@8.5.5 和 2.2 electron@13.6.9 2.3 electron@18.0.4 均不接受 asar 中的 worker.js, 需要将其放到 app.asar.unpacked 中。
3、npm/pnpm + electron-builder
npm: 7.20.3 pnpm: 6.32.7
npm 安装的依赖库在 node_modules 下都是扁平的,而 pnpm 是使用软链接的方式将 dependencies 中的依赖放在 node_modules 下的,其余的依赖都在 .pnpm 目录下。
这在 electron-builder 打包时就会出现问题。比如缺少A 依赖库的子依赖库
glob
那么解决的方向就是:缺哪个补哪个
npx npm-remote-ls --flatten chokidar -d false -o false 查看依赖库的第一子依赖库,然后将其放到 app.asar.unpacked 中.
emmm, pnpm 的依赖层级时 .pnpm 目录下还带了版本号。有点麻烦,🧄了,还是用 npm 吧
疑问:根据 electron-builder 文档里的 asar 配置项表明,electron-builder 会自动将 node_modules 里的依赖项添加到 app.asar.unpacked 中,但实际并没有 :(
asar=trueAsarOptions | Boolean | “undefined” - Whether to package the application’s source code into an archive, using Electron’s archive format. Node modules, that must be unpacked, will be detected automatically, you don’t need to explicitly set asarUnpack - please file an issue if this doesn’t work.asarUnpackArray | String | “undefined” - A glob patterns relative to the app directory, which specifies which files to unpack when creating the asar archive.
4、其他注意事项
4.1 electron 库不能在 worker 线程中使用
4.2 找不到 app.asar.unpacked 里的 worker 线程依赖库
* app.asar 和 app.asar.unpacked 里的依赖库可以彼此相互依赖,也就是说 asar 中的a的依赖库b可以在 app.asar.unpacked 中,反过来也是一样*

可以使用 `npx npm-remote-ls --flatten chokidar -d false -o false` 查看依赖库的第一子依赖库,然后将其放到 app.asar.unpacked 中.
进一步优化
1、减少被 watched 文件数
为了获取被拖拽文件目的地的真正地址,我们 watch 了 home 目录的所有非隐藏文件和非 .exe 后缀文件,通过 watcher.getWatched() 能查看总共 watch 了多少文件。对于文件系统比较大的用户来说,可能会超出文件系统所限制的 fileHandle 数(overlimit/EBUSY 等错误)。所以当 ready 后,通过 watcher.unwatch 取消对文件的 watch, 而留下对 dir 的 watch,毕竟拖拽文件只能是文件夹发生变化。 ready 后的 addDir 新建目录自动会添加 watch 里。这样就可以减少很多资源消耗。
graph TD
A[watcher.ready] --> B(遍历 watcher.getWatched)
B --> C(watcher.unwatch 文件观察)
A --> D(监听 add 事件)
D --> | 有新文件生成 | E(如果不是目标文件,且在 watcher.getWatched 之列,则 watcher.unwatch 它)
2、worker 线程异常退出重启
只需要在 worker.on('exit', (code ) => code != 0 )时重新加载 worker 脚本即可
3、提升 watch 的效率
chokidar 的依赖库 readirp 里有提到issue可以借鉴 nodejs 里的 fs.opendir() 来处理 watch 超限的问题。不过这个需要上游库(chokidar)去跟进啦