mm_scanner 1.0.0 → 1.0.1

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.
Files changed (2) hide show
  1. package/lib/scanner.js +684 -33
  2. package/package.json +1 -1
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
- #walk(dir) {
66
- const entries = fs.readdirSync(dir, { withFileTypes: true });
67
-
68
- for (const entry of entries) {
69
- const fullPath = path.join(dir, entry.name);
70
-
71
- if (entry.isDirectory()) {
72
- if (this.config.ignore.includes(entry.name)) continue;
73
- this.#walk(fullPath);
74
- continue;
75
- }
76
-
77
- if (!entry.isFile()) continue;
78
-
79
- const ext = path.extname(entry.name).toLowerCase();
80
- if (!this.config.extensions.includes(ext)) continue;
81
-
82
- const relPath = path.relative(this.#baseDir, fullPath).replace(/\\/g, '/');
83
- const key = normalizeKey(relPath); // 保留扩展名,如 'config/app.json'
84
- const dirPart = path.posix.dirname(relPath);
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
- this.#files.set(key, {
87
- key,
88
- absPath: fullPath,
89
- relPath,
90
- dir: dirPart === '.' ? '' : dirPart,
91
- name: path.basename(relPath, ext),
92
- ext,
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.#walk(resolved);
155
- this.#scanned = true;
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.#files.clear();
255
- this.#walk(this.#baseDir);
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,6 +1,6 @@
1
1
  {
2
2
  "name": "mm_scanner",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "目录扫描器,按扩展名递归扫描目录并缓存文件元信息,支持按 key、扩展名、目录检索文件路径。",
5
5
  "repository": {
6
6
  "type": "git",