mm_scanner 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/scanner.js +684 -33
- package/package.json +41 -41
package/lib/scanner.js
CHANGED
|
@@ -3,6 +3,23 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
|
|
6
|
+
/* 扫描结果缓存:跨实例复用同一目录的遍历结果 */
|
|
7
|
+
const _scan_cache = new Map();
|
|
8
|
+
/* 目录监听器:缓存键 -> { watcher, timer, pending } */
|
|
9
|
+
const _watchers = new Map();
|
|
10
|
+
/* 处于监听状态的实例:缓存键 -> Set<Scanner> */
|
|
11
|
+
const _instances = new Map();
|
|
12
|
+
/* 缓存条目上限,超出后整体清空 */
|
|
13
|
+
const _CACHE_MAX = 200;
|
|
14
|
+
/* 短时间窗口内直接复用,避免每次重复校验目录与文件的时间戳 */
|
|
15
|
+
const _CACHE_TTL = 2000;
|
|
16
|
+
/* 文件事件防抖窗口,合并一次写入产生的多条事件 */
|
|
17
|
+
const _DEBOUNCE = 100;
|
|
18
|
+
/* 内部同步方法名,仅供监听器调用 */
|
|
19
|
+
const _SYNC = Symbol('mm_scanner.sync');
|
|
20
|
+
/* 内部回调方法名,仅供监听器调用 */
|
|
21
|
+
const _NOTIFY = Symbol('mm_scanner.notify');
|
|
22
|
+
|
|
6
23
|
/* key 规范化:只统一分隔符、去首尾 /,不再剥扩展名 */
|
|
7
24
|
function normalizeKey(p) {
|
|
8
25
|
return String(p)
|
|
@@ -17,6 +34,551 @@ function normalizeExt(ext) {
|
|
|
17
34
|
return e.startsWith('.') ? e : '.' + e;
|
|
18
35
|
}
|
|
19
36
|
|
|
37
|
+
/**
|
|
38
|
+
* 生成扫描缓存键
|
|
39
|
+
* @param {string} dir 目录绝对路径
|
|
40
|
+
* @param {Array<string>} extensions 扩展名数组
|
|
41
|
+
* @param {Array<string>} ignore 忽略目录数组
|
|
42
|
+
* @returns {string} 缓存键
|
|
43
|
+
* @private
|
|
44
|
+
*/
|
|
45
|
+
function _cacheKey(dir, extensions, ignore) {
|
|
46
|
+
return [dir, extensions.join(','), ignore.join(',')].join('\u0001');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 构造单个文件的条目元信息
|
|
51
|
+
* @param {string} base_dir 扫描根目录绝对路径
|
|
52
|
+
* @param {string} abs_path 文件绝对路径
|
|
53
|
+
* @returns {{ key: string, absPath: string, relPath: string, dir: string, name: string, ext: string }} 条目
|
|
54
|
+
* @private
|
|
55
|
+
*/
|
|
56
|
+
function _createItem(base_dir, abs_path) {
|
|
57
|
+
const rel_path = path.relative(base_dir, abs_path).replace(/\\/g, '/');
|
|
58
|
+
const ext = path.extname(rel_path).toLowerCase();
|
|
59
|
+
const dir_part = path.posix.dirname(rel_path);
|
|
60
|
+
return {
|
|
61
|
+
key: normalizeKey(rel_path),
|
|
62
|
+
absPath: abs_path,
|
|
63
|
+
relPath: rel_path,
|
|
64
|
+
dir: dir_part === '.' ? '' : dir_part,
|
|
65
|
+
name: path.basename(rel_path, ext),
|
|
66
|
+
ext,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 判断相对路径是否位于忽略目录内
|
|
72
|
+
* @param {string} rel_path 相对路径
|
|
73
|
+
* @param {Array<string>} ignore 忽略目录数组
|
|
74
|
+
* @returns {boolean} 命中忽略目录返回 true
|
|
75
|
+
* @private
|
|
76
|
+
*/
|
|
77
|
+
function _isIgnored(rel_path, ignore) {
|
|
78
|
+
const parts = rel_path.split('/');
|
|
79
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
80
|
+
if (ignore.includes(parts[i])) return true;
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 递归遍历目录并写入条目
|
|
87
|
+
* @param {string} dir 当前目录绝对路径
|
|
88
|
+
* @param {string} base_dir 扫描根目录绝对路径
|
|
89
|
+
* @param {{ extensions: string[], ignore: string[] }} config 扫描配置
|
|
90
|
+
* @param {Map<string, object>} files 条目容器
|
|
91
|
+
* @param {Array<string>} dirs 遍历过的目录,用于记录与缓存校验
|
|
92
|
+
* @returns {void}
|
|
93
|
+
* @private
|
|
94
|
+
*/
|
|
95
|
+
function _walkDir(dir, base_dir, config, files, dirs) {
|
|
96
|
+
dirs.push(dir);
|
|
97
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
98
|
+
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
const full_path = path.join(dir, entry.name);
|
|
101
|
+
|
|
102
|
+
if (entry.isDirectory()) {
|
|
103
|
+
if (config.ignore.includes(entry.name)) continue;
|
|
104
|
+
_walkDir(full_path, base_dir, config, files, dirs);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!entry.isFile()) continue;
|
|
109
|
+
|
|
110
|
+
const item = _createItem(base_dir, full_path);
|
|
111
|
+
if (!config.extensions.includes(item.ext)) continue;
|
|
112
|
+
files.set(item.key, item);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 获取路径的修改时间
|
|
118
|
+
* @param {string} target 文件或目录路径
|
|
119
|
+
* @returns {number} 毫秒时间戳,读取失败返回 -1
|
|
120
|
+
* @private
|
|
121
|
+
*/
|
|
122
|
+
function _getMtime(target) {
|
|
123
|
+
try {
|
|
124
|
+
return fs.statSync(target).mtimeMs;
|
|
125
|
+
} catch {
|
|
126
|
+
return -1;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 校验缓存是否有效
|
|
132
|
+
* @description 目录时间戳变化表示增删条目,文件时间戳变化表示内容修改
|
|
133
|
+
* @param {object} cache 缓存项
|
|
134
|
+
* @returns {boolean} 全部未变化返回 true
|
|
135
|
+
* @private
|
|
136
|
+
*/
|
|
137
|
+
function _isCacheValid(cache) {
|
|
138
|
+
for (let i = 0; i < cache.dirs.length; i++) {
|
|
139
|
+
if (_getMtime(cache.dirs[i]) !== cache.dir_mtimes[i]) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
for (let i = 0; i < cache.items.length; i++) {
|
|
144
|
+
if (_getMtime(cache.items[i].absPath) !== cache.file_mtimes[i]) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 读取仍然有效的缓存
|
|
153
|
+
* @param {string} key 缓存键
|
|
154
|
+
* @returns {object|null} 缓存项,不存在或已失效返回 null
|
|
155
|
+
* @private
|
|
156
|
+
*/
|
|
157
|
+
function _getCache(key) {
|
|
158
|
+
let cache = _scan_cache.get(key);
|
|
159
|
+
if (!cache) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
if (cache.watched) {
|
|
163
|
+
return cache;
|
|
164
|
+
}
|
|
165
|
+
let now = Date.now();
|
|
166
|
+
if (now - cache.time <= _CACHE_TTL) {
|
|
167
|
+
return cache;
|
|
168
|
+
}
|
|
169
|
+
if (_isCacheValid(cache)) {
|
|
170
|
+
cache.time = now;
|
|
171
|
+
return cache;
|
|
172
|
+
}
|
|
173
|
+
_scan_cache.delete(key);
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 读取缓存项,不做有效性校验
|
|
179
|
+
* @param {string} key 缓存键
|
|
180
|
+
* @returns {object|null} 缓存项,不存在返回 null
|
|
181
|
+
* @private
|
|
182
|
+
*/
|
|
183
|
+
function _readCache(key) {
|
|
184
|
+
return _scan_cache.get(key) || null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 记录缓存对应的目录与文件时间戳
|
|
189
|
+
* @param {object} cache 缓存项
|
|
190
|
+
* @returns {void}
|
|
191
|
+
* @private
|
|
192
|
+
*/
|
|
193
|
+
function _captureMtimes(cache) {
|
|
194
|
+
cache.dir_mtimes = [];
|
|
195
|
+
for (let i = 0; i < cache.dirs.length; i++) {
|
|
196
|
+
cache.dir_mtimes.push(_getMtime(cache.dirs[i]));
|
|
197
|
+
}
|
|
198
|
+
cache.file_mtimes = [];
|
|
199
|
+
for (let i = 0; i < cache.items.length; i++) {
|
|
200
|
+
cache.file_mtimes.push(_getMtime(cache.items[i].absPath));
|
|
201
|
+
}
|
|
202
|
+
cache.time = Date.now();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 创建缓存项
|
|
207
|
+
* @param {{ dir: string, extensions: Array<string>, ignore: Array<string>, items: Array<object>, dirs: Array<string> }} options 缓存内容
|
|
208
|
+
* @param {boolean} watched 是否已开启监听
|
|
209
|
+
* @returns {object} 缓存项
|
|
210
|
+
* @private
|
|
211
|
+
*/
|
|
212
|
+
function _createCache(options, watched) {
|
|
213
|
+
const items = [];
|
|
214
|
+
for (let i = 0; i < options.items.length; i++) {
|
|
215
|
+
items.push({ ...options.items[i] });
|
|
216
|
+
}
|
|
217
|
+
const cache = {
|
|
218
|
+
dir: options.dir,
|
|
219
|
+
extensions: [...options.extensions],
|
|
220
|
+
ignore: [...options.ignore],
|
|
221
|
+
items,
|
|
222
|
+
dirs: [...options.dirs],
|
|
223
|
+
watched: !!watched,
|
|
224
|
+
dir_mtimes: [],
|
|
225
|
+
file_mtimes: [],
|
|
226
|
+
time: Date.now(),
|
|
227
|
+
};
|
|
228
|
+
if (!cache.watched) {
|
|
229
|
+
_captureMtimes(cache);
|
|
230
|
+
}
|
|
231
|
+
return cache;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 写入扫描缓存
|
|
236
|
+
* @description 已有监听器时保留监听标记,避免监听期间退化回时间戳校验
|
|
237
|
+
* @param {string} key 缓存键
|
|
238
|
+
* @param {{ dir: string, extensions: Array<string>, ignore: Array<string>, items: Array<object>, dirs: Array<string> }} options 缓存内容
|
|
239
|
+
* @returns {void}
|
|
240
|
+
* @private
|
|
241
|
+
*/
|
|
242
|
+
function _setCache(key, options) {
|
|
243
|
+
const old = _scan_cache.get(key);
|
|
244
|
+
const watched = !!(old && old.watched);
|
|
245
|
+
if (_scan_cache.size >= _CACHE_MAX && !_scan_cache.has(key)) {
|
|
246
|
+
_clearAll();
|
|
247
|
+
}
|
|
248
|
+
_scan_cache.set(key, _createCache(options, watched));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 向缓存中新增或更新一个文件条目
|
|
253
|
+
* @param {object} cache 缓存项
|
|
254
|
+
* @param {{ key: string, absPath: string, relPath: string, dir: string, name: string, ext: string }} item 文件条目
|
|
255
|
+
* @returns {boolean} 新增返回 true,覆盖已有条目返回 false
|
|
256
|
+
* @private
|
|
257
|
+
*/
|
|
258
|
+
function _addItem(cache, item) {
|
|
259
|
+
const index = cache.items.findIndex((one) => one.key === item.key);
|
|
260
|
+
if (index >= 0) {
|
|
261
|
+
cache.items[index] = item;
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
cache.items.push(item);
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 从缓存中移除指定条目并记录变更
|
|
270
|
+
* @param {object} cache 缓存项
|
|
271
|
+
* @param {string} key 条目键
|
|
272
|
+
* @param {Array<object>} changes 变更收集器
|
|
273
|
+
* @returns {boolean} 是否移除成功
|
|
274
|
+
* @private
|
|
275
|
+
*/
|
|
276
|
+
function _delItem(cache, key, changes) {
|
|
277
|
+
const index = cache.items.findIndex((one) => one.key === key);
|
|
278
|
+
if (index < 0) return false;
|
|
279
|
+
const [item] = cache.items.splice(index, 1);
|
|
280
|
+
changes.push({ type: 'unlink', key, item: { ...item } });
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 移除同目录同文件名但扩展名不同的条目
|
|
286
|
+
* @description 文件改名后扩展名不再受支持时,需要清掉旧条目
|
|
287
|
+
* @param {object} cache 缓存项
|
|
288
|
+
* @param {{ key: string, dir: string, name: string }} item 当前文件条目
|
|
289
|
+
* @param {Array<object>} changes 变更收集器
|
|
290
|
+
* @returns {void}
|
|
291
|
+
* @private
|
|
292
|
+
*/
|
|
293
|
+
function _delSameName(cache, item, changes) {
|
|
294
|
+
for (let i = cache.items.length - 1; i >= 0; i--) {
|
|
295
|
+
const one = cache.items[i];
|
|
296
|
+
if (one.key === item.key || one.dir !== item.dir || one.name !== item.name) continue;
|
|
297
|
+
_delItem(cache, one.key, changes);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* 按文件系统事件更新单个路径
|
|
303
|
+
* @param {object} cache 缓存项
|
|
304
|
+
* @param {string} rel_path 相对路径
|
|
305
|
+
* @param {Array<object>} changes 变更收集器
|
|
306
|
+
* @returns {boolean} 无法精确定位时返回 false,需要全量刷新
|
|
307
|
+
* @private
|
|
308
|
+
*/
|
|
309
|
+
function _applyPath(cache, rel_path, changes) {
|
|
310
|
+
if (!rel_path) return false;
|
|
311
|
+
if (_isIgnored(rel_path, cache.ignore)) return true;
|
|
312
|
+
|
|
313
|
+
const abs_path = path.join(cache.dir, rel_path);
|
|
314
|
+
let stat = null;
|
|
315
|
+
try {
|
|
316
|
+
stat = fs.statSync(abs_path);
|
|
317
|
+
} catch {
|
|
318
|
+
stat = null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (stat && stat.isDirectory()) return false;
|
|
322
|
+
|
|
323
|
+
const item = _createItem(cache.dir, abs_path);
|
|
324
|
+
|
|
325
|
+
if (!stat) {
|
|
326
|
+
return _delItem(cache, item.key, changes);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (!stat.isFile()) return true;
|
|
330
|
+
|
|
331
|
+
if (!cache.extensions.includes(item.ext)) {
|
|
332
|
+
_delSameName(cache, item, changes);
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const added = _addItem(cache, item);
|
|
337
|
+
changes.push({ type: added ? 'add' : 'change', key: item.key, item: { ...item } });
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* 对比两份条目集合,生成增量变更
|
|
343
|
+
* @param {Array<object>} before 变更前的条目
|
|
344
|
+
* @param {Array<object>} after 变更后的条目
|
|
345
|
+
* @returns {Array<{ type: string, key: string, item: object }>} 变更列表
|
|
346
|
+
* @private
|
|
347
|
+
*/
|
|
348
|
+
function _diffItems(before, after) {
|
|
349
|
+
const before_map = new Map();
|
|
350
|
+
for (let i = 0; i < before.length; i++) before_map.set(before[i].key, before[i]);
|
|
351
|
+
|
|
352
|
+
const after_map = new Map();
|
|
353
|
+
for (let i = 0; i < after.length; i++) after_map.set(after[i].key, after[i]);
|
|
354
|
+
|
|
355
|
+
const changes = [];
|
|
356
|
+
for (const [key, item] of after_map) {
|
|
357
|
+
const old = before_map.get(key);
|
|
358
|
+
if (!old) {
|
|
359
|
+
changes.push({ type: 'add', key, item: { ...item } });
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (old.absPath !== item.absPath || old.relPath !== item.relPath) {
|
|
363
|
+
changes.push({ type: 'change', key, item: { ...item } });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
for (const [key, item] of before_map) {
|
|
367
|
+
if (!after_map.has(key)) changes.push({ type: 'unlink', key, item: { ...item } });
|
|
368
|
+
}
|
|
369
|
+
return changes;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* 全量重扫缓存对应的目录
|
|
374
|
+
* @param {object} cache 缓存项
|
|
375
|
+
* @param {Array<object>} changes 变更收集器
|
|
376
|
+
* @returns {void}
|
|
377
|
+
* @private
|
|
378
|
+
*/
|
|
379
|
+
function _refreshCache(cache, changes) {
|
|
380
|
+
const before = cache.items;
|
|
381
|
+
const files = new Map();
|
|
382
|
+
const dirs = [];
|
|
383
|
+
_walkDir(cache.dir, cache.dir, cache, files, dirs);
|
|
384
|
+
const after = [...files.values()];
|
|
385
|
+
cache.items = after;
|
|
386
|
+
cache.dirs = dirs;
|
|
387
|
+
cache.time = Date.now();
|
|
388
|
+
|
|
389
|
+
const diff = _diffItems(before, after);
|
|
390
|
+
for (let i = 0; i < diff.length; i++) changes.push(diff[i]);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* 把缓存的最新条目与变更同步到监听中的实例
|
|
395
|
+
* @param {string} key 缓存键
|
|
396
|
+
* @param {Array<object>} changes 变更列表
|
|
397
|
+
* @returns {void}
|
|
398
|
+
* @private
|
|
399
|
+
*/
|
|
400
|
+
function _syncInstances(key, changes) {
|
|
401
|
+
const cache = _readCache(key);
|
|
402
|
+
const set = _instances.get(key);
|
|
403
|
+
if (!cache || !set) return;
|
|
404
|
+
for (const instance of set) {
|
|
405
|
+
instance[_SYNC](cache.items, cache.dirs);
|
|
406
|
+
instance[_NOTIFY](changes);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* 处理防抖窗口内累积的文件事件
|
|
412
|
+
* @param {string} key 缓存键
|
|
413
|
+
* @returns {void}
|
|
414
|
+
* @private
|
|
415
|
+
*/
|
|
416
|
+
function _flushChanges(key) {
|
|
417
|
+
const entry = _watchers.get(key);
|
|
418
|
+
if (!entry || !entry.pending.size) return;
|
|
419
|
+
|
|
420
|
+
const list = [...entry.pending];
|
|
421
|
+
entry.pending.clear();
|
|
422
|
+
|
|
423
|
+
const cache = _readCache(key);
|
|
424
|
+
const changes = [];
|
|
425
|
+
let need_refresh = false;
|
|
426
|
+
for (let i = 0; i < list.length; i++) {
|
|
427
|
+
if (!_applyPath(cache, list[i], changes)) {
|
|
428
|
+
need_refresh = true;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (need_refresh) {
|
|
432
|
+
_refreshCache(cache, changes);
|
|
433
|
+
}
|
|
434
|
+
_syncInstances(key, changes);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* 记录文件系统事件,按防抖窗口合并后处理
|
|
439
|
+
* @param {string} key 缓存键
|
|
440
|
+
* @param {string|null} filename 事件对应的相对路径,为空表示无法定位
|
|
441
|
+
* @returns {void}
|
|
442
|
+
* @private
|
|
443
|
+
*/
|
|
444
|
+
function _onWatchEvent(key, filename) {
|
|
445
|
+
const entry = _watchers.get(key);
|
|
446
|
+
if (!entry) return;
|
|
447
|
+
|
|
448
|
+
entry.pending.add(filename ? normalizeKey(filename) : '');
|
|
449
|
+
if (entry.timer) return;
|
|
450
|
+
|
|
451
|
+
entry.timer = setTimeout(() => {
|
|
452
|
+
entry.timer = null;
|
|
453
|
+
_flushChanges(key);
|
|
454
|
+
}, _DEBOUNCE);
|
|
455
|
+
|
|
456
|
+
if (typeof entry.timer.unref === 'function') {
|
|
457
|
+
entry.timer.unref();
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* 为缓存绑定目录监听器
|
|
463
|
+
* @param {string} key 缓存键
|
|
464
|
+
* @returns {boolean} 监听是否可用
|
|
465
|
+
* @private
|
|
466
|
+
*/
|
|
467
|
+
function _bindWatcher(key) {
|
|
468
|
+
const cache = _readCache(key);
|
|
469
|
+
if (!cache) return false;
|
|
470
|
+
|
|
471
|
+
if (_watchers.has(key)) {
|
|
472
|
+
cache.watched = true;
|
|
473
|
+
cache.dir_mtimes = [];
|
|
474
|
+
cache.file_mtimes = [];
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
let watcher = null;
|
|
479
|
+
try {
|
|
480
|
+
watcher = fs.watch(cache.dir, { recursive: true }, (event_type, filename) => {
|
|
481
|
+
_onWatchEvent(key, filename);
|
|
482
|
+
});
|
|
483
|
+
} catch {
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
cache.watched = true;
|
|
488
|
+
cache.dir_mtimes = [];
|
|
489
|
+
cache.file_mtimes = [];
|
|
490
|
+
|
|
491
|
+
if (typeof watcher.unref === 'function') watcher.unref();
|
|
492
|
+
watcher.on('error', () => _unbindWatcher(key));
|
|
493
|
+
_watchers.set(key, { watcher, timer: null, pending: new Set() });
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* 解除目录监听并恢复时间戳校验
|
|
499
|
+
* @param {string} key 缓存键
|
|
500
|
+
* @returns {void}
|
|
501
|
+
* @private
|
|
502
|
+
*/
|
|
503
|
+
function _unbindWatcher(key) {
|
|
504
|
+
const entry = _watchers.get(key);
|
|
505
|
+
if (entry) {
|
|
506
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
507
|
+
try {
|
|
508
|
+
entry.watcher.close();
|
|
509
|
+
} catch {
|
|
510
|
+
// 监听器已关闭时忽略
|
|
511
|
+
}
|
|
512
|
+
_watchers.delete(key);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
_instances.delete(key);
|
|
516
|
+
|
|
517
|
+
const cache = _readCache(key);
|
|
518
|
+
if (cache) {
|
|
519
|
+
cache.watched = false;
|
|
520
|
+
_captureMtimes(cache);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* 判断实例是否处于监听状态
|
|
526
|
+
* @param {string} key 缓存键
|
|
527
|
+
* @param {object} instance 扫描器实例
|
|
528
|
+
* @returns {boolean} 已监听返回 true
|
|
529
|
+
* @private
|
|
530
|
+
*/
|
|
531
|
+
function _hasInstance(key, instance) {
|
|
532
|
+
const set = _instances.get(key);
|
|
533
|
+
return !!(set && set.has(instance));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* 登记监听中的实例
|
|
538
|
+
* @param {string} key 缓存键
|
|
539
|
+
* @param {object} instance 扫描器实例
|
|
540
|
+
* @returns {void}
|
|
541
|
+
* @private
|
|
542
|
+
*/
|
|
543
|
+
function _addInstance(key, instance) {
|
|
544
|
+
let set = _instances.get(key);
|
|
545
|
+
if (!set) {
|
|
546
|
+
set = new Set();
|
|
547
|
+
_instances.set(key, set);
|
|
548
|
+
}
|
|
549
|
+
set.add(instance);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* 注销监听中的实例,最后一个实例注销时关闭监听器
|
|
554
|
+
* @param {string} key 缓存键
|
|
555
|
+
* @param {object} instance 扫描器实例
|
|
556
|
+
* @returns {void}
|
|
557
|
+
* @private
|
|
558
|
+
*/
|
|
559
|
+
function _delInstance(key, instance) {
|
|
560
|
+
const set = _instances.get(key);
|
|
561
|
+
if (!set) return;
|
|
562
|
+
set.delete(instance);
|
|
563
|
+
if (set.size === 0) {
|
|
564
|
+
_unbindWatcher(key);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* 清空全部缓存、监听器与实例登记
|
|
570
|
+
* @returns {void}
|
|
571
|
+
* @private
|
|
572
|
+
*/
|
|
573
|
+
function _clearAll() {
|
|
574
|
+
const keys = [..._watchers.keys()];
|
|
575
|
+
for (let i = 0; i < keys.length; i++) {
|
|
576
|
+
_unbindWatcher(keys[i]);
|
|
577
|
+
}
|
|
578
|
+
_instances.clear();
|
|
579
|
+
_scan_cache.clear();
|
|
580
|
+
}
|
|
581
|
+
|
|
20
582
|
/**
|
|
21
583
|
* 合并默认配置与用户配置
|
|
22
584
|
* @param {{ extensions: string[], ignore: string[] }} default_config - 默认配置
|
|
@@ -51,6 +613,12 @@ class Scanner {
|
|
|
51
613
|
#files = new Map();
|
|
52
614
|
#baseDir = null;
|
|
53
615
|
#scanned = false;
|
|
616
|
+
/** 本次遍历过的目录,用于缓存有效性校验 */
|
|
617
|
+
#dirs = [];
|
|
618
|
+
/** 本次扫描对应的缓存键 */
|
|
619
|
+
#cacheKey = null;
|
|
620
|
+
/** 外部变更回调 */
|
|
621
|
+
#callback = null;
|
|
54
622
|
|
|
55
623
|
/**
|
|
56
624
|
* 创建扫描器实例
|
|
@@ -62,36 +630,44 @@ class Scanner {
|
|
|
62
630
|
|
|
63
631
|
/* -------- 私有 -------- */
|
|
64
632
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
633
|
+
/**
|
|
634
|
+
* 用缓存条目填充扫描结果
|
|
635
|
+
* @param {Array<object>} items 缓存中的条目
|
|
636
|
+
* @param {Array<string>} dirs 缓存中的目录列表
|
|
637
|
+
* @returns {void}
|
|
638
|
+
*/
|
|
639
|
+
#fill(items, dirs) {
|
|
640
|
+
this.#files.clear();
|
|
641
|
+
for (const item of items) {
|
|
642
|
+
this.#files.set(item.key, { ...item });
|
|
643
|
+
}
|
|
644
|
+
this.#dirs = [...dirs];
|
|
645
|
+
this.#scanned = true;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* 遍历根目录并填充扫描结果
|
|
650
|
+
* @returns {void}
|
|
651
|
+
*/
|
|
652
|
+
#walkAndFill() {
|
|
653
|
+
this.#files.clear();
|
|
654
|
+
this.#dirs = [];
|
|
655
|
+
_walkDir(this.#baseDir, this.#baseDir, this.config, this.#files, this.#dirs);
|
|
656
|
+
this.#scanned = true;
|
|
657
|
+
}
|
|
85
658
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
659
|
+
/**
|
|
660
|
+
* 构造写入缓存的内容
|
|
661
|
+
* @returns {{ dir: string, extensions: Array<string>, ignore: Array<string>, items: Array<object>, dirs: Array<string> }} 缓存内容
|
|
662
|
+
*/
|
|
663
|
+
#snapshot() {
|
|
664
|
+
return {
|
|
665
|
+
dir: this.#baseDir,
|
|
666
|
+
extensions: this.config.extensions,
|
|
667
|
+
ignore: this.config.ignore,
|
|
668
|
+
items: [...this.#files.values()],
|
|
669
|
+
dirs: this.#dirs,
|
|
670
|
+
};
|
|
95
671
|
}
|
|
96
672
|
|
|
97
673
|
#ensureReady() {
|
|
@@ -130,6 +706,7 @@ class Scanner {
|
|
|
130
706
|
|
|
131
707
|
/**
|
|
132
708
|
* 扫描目录(重复调用只生效一次)
|
|
709
|
+
* @description 优先复用其他实例的扫描缓存,命中时不再遍历目录
|
|
133
710
|
* @param {string} dir
|
|
134
711
|
* @param {{ extensions?: string[], ignore?: string[] }} [options]
|
|
135
712
|
*/
|
|
@@ -151,8 +728,16 @@ class Scanner {
|
|
|
151
728
|
}
|
|
152
729
|
|
|
153
730
|
this.#baseDir = resolved;
|
|
154
|
-
this.#
|
|
155
|
-
|
|
731
|
+
this.#cacheKey = _cacheKey(resolved, this.config.extensions, this.config.ignore);
|
|
732
|
+
|
|
733
|
+
const cache = _getCache(this.#cacheKey);
|
|
734
|
+
if (cache) {
|
|
735
|
+
this.#fill(cache.items, cache.dirs);
|
|
736
|
+
return this;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
this.#walkAndFill();
|
|
740
|
+
_setCache(this.#cacheKey, this.#snapshot());
|
|
156
741
|
return this;
|
|
157
742
|
};
|
|
158
743
|
|
|
@@ -251,13 +836,79 @@ class Scanner {
|
|
|
251
836
|
/** 重新扫描(开发环境热更新用) */
|
|
252
837
|
rescan = () => {
|
|
253
838
|
if (!this.#baseDir) throw new Error('[scanner] 尚未初始化,无法 rescan');
|
|
254
|
-
this.#
|
|
255
|
-
|
|
839
|
+
const cache = _readCache(this.#cacheKey);
|
|
840
|
+
const before = cache ? cache.items : [];
|
|
841
|
+
this.#walkAndFill();
|
|
842
|
+
_setCache(this.#cacheKey, this.#snapshot());
|
|
843
|
+
_syncInstances(this.#cacheKey, _diffItems(before, this.getAll()));
|
|
844
|
+
return this;
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* 开启目录监听,文件增删改时增量维护缓存并同步到本实例
|
|
849
|
+
* @description 监听期间不再按时间戳校验缓存;不支持递归监听的环境会静默降级
|
|
850
|
+
* @param {(changes: Array<{ type: string, key: string, item: object }>) => void} [callback] 变更回调,type 为 add/change/unlink
|
|
851
|
+
* @returns {Scanner} 当前实例
|
|
852
|
+
*/
|
|
853
|
+
watch = (callback) => {
|
|
854
|
+
this.#ensureReady();
|
|
855
|
+
if (typeof callback === 'function') this.#callback = callback;
|
|
856
|
+
if (_hasInstance(this.#cacheKey, this)) return this;
|
|
857
|
+
_addInstance(this.#cacheKey, this);
|
|
858
|
+
if (!_bindWatcher(this.#cacheKey)) {
|
|
859
|
+
_delInstance(this.#cacheKey, this);
|
|
860
|
+
this.#callback = null;
|
|
861
|
+
}
|
|
862
|
+
return this;
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* 关闭本实例的目录监听并注销变更回调,最后一个实例关闭后停止监听
|
|
867
|
+
* @returns {Scanner} 当前实例
|
|
868
|
+
*/
|
|
869
|
+
unwatch = () => {
|
|
870
|
+
this.#callback = null;
|
|
871
|
+
if (!this.#cacheKey) return this;
|
|
872
|
+
_delInstance(this.#cacheKey, this);
|
|
256
873
|
return this;
|
|
257
874
|
};
|
|
258
875
|
|
|
876
|
+
/**
|
|
877
|
+
* 清空全部扫描缓存
|
|
878
|
+
* @returns {void}
|
|
879
|
+
*/
|
|
880
|
+
static clearCache = () => {
|
|
881
|
+
_clearAll();
|
|
882
|
+
};
|
|
883
|
+
|
|
259
884
|
get size() { return this.#files.size; }
|
|
260
885
|
get baseDir() { return this.#baseDir; }
|
|
886
|
+
/** 当前实例是否处于监听状态 */
|
|
887
|
+
get watching() { return this.#cacheKey ? _hasInstance(this.#cacheKey, this) : false; }
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* 接收监听器同步的最新条目
|
|
891
|
+
* @param {Array<object>} items 缓存中的条目
|
|
892
|
+
* @param {Array<string>} dirs 缓存中的目录列表
|
|
893
|
+
* @returns {void}
|
|
894
|
+
*/
|
|
895
|
+
[_SYNC](items, dirs) {
|
|
896
|
+
this.#fill(items, dirs);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* 把变更列表交给外部回调
|
|
901
|
+
* @param {Array<{ type: string, key: string, item: object }>} changes 变更列表
|
|
902
|
+
* @returns {void}
|
|
903
|
+
*/
|
|
904
|
+
[_NOTIFY](changes) {
|
|
905
|
+
if (!this.#callback || !changes.length) return;
|
|
906
|
+
try {
|
|
907
|
+
this.#callback(changes.map((one) => ({ ...one, item: { ...one.item } })));
|
|
908
|
+
} catch (error) {
|
|
909
|
+
console.error('[scanner] watch 回调执行失败:', error);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
261
912
|
}
|
|
262
913
|
|
|
263
914
|
/* 导出单例 + 类 */
|
package/package.json
CHANGED
|
@@ -1,43 +1,43 @@
|
|
|
1
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
|
-
|
|
2
|
+
"name": "mm_scanner",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "目录扫描器,按扩展名递归扫描目录并缓存文件元信息,支持按 key、扩展名、目录检索文件路径。",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://gitee.com/qiuwenwu91/mm_scanner.git"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"mm_mod",
|
|
11
|
+
"mm_infra",
|
|
12
|
+
"mm_common",
|
|
13
|
+
"超级美眉",
|
|
14
|
+
"目录扫描",
|
|
15
|
+
"文件检索",
|
|
16
|
+
"scanner"
|
|
17
|
+
],
|
|
18
|
+
"layer": "infra",
|
|
19
|
+
"group": "common",
|
|
20
|
+
"author": "邱文武",
|
|
21
|
+
"license": "ISC",
|
|
22
|
+
"main": "index.js",
|
|
23
|
+
"files": [
|
|
24
|
+
"index.js",
|
|
25
|
+
"lib",
|
|
26
|
+
"example"
|
|
27
|
+
],
|
|
28
|
+
"directories": {
|
|
29
|
+
"example": "example",
|
|
30
|
+
"lib": "lib"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "jest --coverage",
|
|
34
|
+
"demo": "node example/demo.js",
|
|
35
|
+
"prepublishOnly": "npm test"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"mm_corejs": "^1.1.5"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"jest": "^30.5.2"
|
|
42
|
+
}
|
|
43
43
|
}
|