mm_scanner 0.0.0-stage → 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.
- package/example/data/app/app.json +5 -0
- package/example/data/config/db.json +4 -0
- package/example/data/config/logger.js +3 -0
- package/example/data/dist/build.json +3 -0
- package/example/data/readme.txt +1 -0
- package/example/demo.js +62 -0
- package/index.js +16 -0
- package/lib/scanner.js +915 -0
- package/package.json +41 -4
- package/README.md +0 -3
|
@@ -0,0 +1 @@
|
|
|
1
|
+
演示文件,不会被默认配置扫描
|
package/example/demo.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { Scanner, scannerAdmin } = require('../index');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 使用示例:扫描 example/data 目录并按多种方式检索文件
|
|
8
|
+
*/
|
|
9
|
+
function demo() {
|
|
10
|
+
const dir = path.join(__dirname, 'data');
|
|
11
|
+
|
|
12
|
+
// 1. 创建扫描器,默认只扫描 .json,并忽略 node_modules 与 .git
|
|
13
|
+
const scanner = new Scanner();
|
|
14
|
+
console.log('=== 默认配置 ===');
|
|
15
|
+
console.log(' 扩展名: ' + scanner.extensions.join(', '));
|
|
16
|
+
console.log(' 忽略目录: ' + scanner.config.ignore.join(', '));
|
|
17
|
+
|
|
18
|
+
// 2. 扫描目录,可重复调用(仅首次生效)
|
|
19
|
+
scanner.scan(dir);
|
|
20
|
+
console.log('=== 扫描结果 ===');
|
|
21
|
+
console.log(' 基目录: ' + scanner.baseDir);
|
|
22
|
+
console.log(' 文件数: ' + scanner.size);
|
|
23
|
+
for (const key of scanner.keys()) {
|
|
24
|
+
console.log(' ' + key);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 3. 按 key 查询,key 可省略扩展名
|
|
28
|
+
console.log('=== 按 key 查询 ===');
|
|
29
|
+
console.log(' 元信息: ' + JSON.stringify(scanner.get('app/app')));
|
|
30
|
+
console.log(' 绝对路径: ' + scanner.getPath('config/db'));
|
|
31
|
+
console.log(' 是否存在 config/logger: ' + scanner.has('config/logger'));
|
|
32
|
+
|
|
33
|
+
// 4. 按扩展名、目录、层级筛选
|
|
34
|
+
console.log('=== 分类检索 ===');
|
|
35
|
+
console.log(' .json 文件: ' + scanner.getByExt('.json').length + ' 个');
|
|
36
|
+
console.log(' config 目录: ' + scanner.getByDir('config').map((f) => f.key).join(', '));
|
|
37
|
+
console.log(' 一级子目录: ' + scanner.dirTypes().join(', '));
|
|
38
|
+
console.log(' 扩展名类型: ' + scanner.extTypes().join(', '));
|
|
39
|
+
|
|
40
|
+
// 5. 自定义扩展名与忽略目录
|
|
41
|
+
const custom = new Scanner({ extensions: ['json', 'js'], ignore: ['dist'] });
|
|
42
|
+
custom.scan(dir);
|
|
43
|
+
console.log('=== 自定义配置 ===');
|
|
44
|
+
console.log(' 文件数: ' + custom.size);
|
|
45
|
+
console.log(' 文件列表: ' + custom.keys().join(', '));
|
|
46
|
+
console.log(' dist 目录已被忽略: ' + (custom.getByDir('dist').length === 0));
|
|
47
|
+
|
|
48
|
+
// 6. 文件变化后重新扫描
|
|
49
|
+
custom.rescan();
|
|
50
|
+
console.log('=== 重新扫描 ===');
|
|
51
|
+
console.log(' 文件数: ' + custom.size);
|
|
52
|
+
|
|
53
|
+
// 7. 通过 admin 按作用域复用扫描器实例
|
|
54
|
+
const os_scanner = scannerAdmin('os', { extensions: ['json'] });
|
|
55
|
+
const demo_scanner = scannerAdmin('demo');
|
|
56
|
+
console.log('=== 作用域实例 ===');
|
|
57
|
+
console.log(' os 实例类型正确: ' + (os_scanner instanceof Scanner));
|
|
58
|
+
console.log(' 重复获取 os 实例复用缓存: ' + (scannerAdmin('os') === os_scanner));
|
|
59
|
+
console.log(' demo 实例与 os 实例相互独立: ' + (demo_scanner !== os_scanner));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
demo();
|
package/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const Scanner = require('./lib/scanner');
|
|
2
|
+
const Admin = require('mm_corejs').Admin;
|
|
3
|
+
|
|
4
|
+
const admin = new Admin();
|
|
5
|
+
admin.setClass(Scanner);
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
Scanner,
|
|
9
|
+
admin,
|
|
10
|
+
scannerAdmin(scope, config) {
|
|
11
|
+
if (!scope) {
|
|
12
|
+
scope = 'os';
|
|
13
|
+
}
|
|
14
|
+
return admin.get(scope, config);
|
|
15
|
+
}
|
|
16
|
+
};
|
package/lib/scanner.js
ADDED
|
@@ -0,0 +1,915 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
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
|
+
|
|
23
|
+
/* key 规范化:只统一分隔符、去首尾 /,不再剥扩展名 */
|
|
24
|
+
function normalizeKey(p) {
|
|
25
|
+
return String(p)
|
|
26
|
+
.replace(/\\/g, '/')
|
|
27
|
+
.replace(/^\.\//, '')
|
|
28
|
+
.replace(/^\/+|\/+$/g, '');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* 扩展名规范化:补点、转小写 */
|
|
32
|
+
function normalizeExt(ext) {
|
|
33
|
+
const e = String(ext).trim().toLowerCase();
|
|
34
|
+
return e.startsWith('.') ? e : '.' + e;
|
|
35
|
+
}
|
|
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
|
+
|
|
582
|
+
/**
|
|
583
|
+
* 合并默认配置与用户配置
|
|
584
|
+
* @param {{ extensions: string[], ignore: string[] }} default_config - 默认配置
|
|
585
|
+
* @param {{ extensions?: string[], ignore?: string[] }} config - 用户配置
|
|
586
|
+
* @returns {{ extensions: string[], ignore: string[] }} 规范化后的配置
|
|
587
|
+
*/
|
|
588
|
+
function createConfig(default_config, config) {
|
|
589
|
+
const extensions =
|
|
590
|
+
Array.isArray(config.extensions) && config.extensions.length
|
|
591
|
+
? config.extensions
|
|
592
|
+
: default_config.extensions;
|
|
593
|
+
const ignore = Array.isArray(config.ignore) ? config.ignore : default_config.ignore;
|
|
594
|
+
|
|
595
|
+
return {
|
|
596
|
+
extensions: extensions.map(normalizeExt),
|
|
597
|
+
ignore: [...ignore],
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
class Scanner {
|
|
602
|
+
/**
|
|
603
|
+
* 扫描器默认配置
|
|
604
|
+
*/
|
|
605
|
+
static config = {
|
|
606
|
+
/** 支持的扩展名 */
|
|
607
|
+
extensions: ['.json'],
|
|
608
|
+
/** 忽略的目录 */
|
|
609
|
+
ignore: ['node_modules', '.git'],
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
/** key(含扩展名) -> { key, absPath, relPath, dir, name, ext } */
|
|
613
|
+
#files = new Map();
|
|
614
|
+
#baseDir = null;
|
|
615
|
+
#scanned = false;
|
|
616
|
+
/** 本次遍历过的目录,用于缓存有效性校验 */
|
|
617
|
+
#dirs = [];
|
|
618
|
+
/** 本次扫描对应的缓存键 */
|
|
619
|
+
#cacheKey = null;
|
|
620
|
+
/** 外部变更回调 */
|
|
621
|
+
#callback = null;
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* 创建扫描器实例
|
|
625
|
+
* @param {{ extensions?: string[], ignore?: string[] }} [config] - 扫描器配置
|
|
626
|
+
*/
|
|
627
|
+
constructor(config = {}) {
|
|
628
|
+
this.config = createConfig(Scanner.config, config);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/* -------- 私有 -------- */
|
|
632
|
+
|
|
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
|
+
}
|
|
658
|
+
|
|
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
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
#ensureReady() {
|
|
674
|
+
if (!this.#scanned) {
|
|
675
|
+
throw new Error('[scanner] 尚未初始化,请先在入口调用 scanner.scan(dir)');
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** 宽松解析 key:精确命中 → 按扩展名顺序尝试补全 */
|
|
680
|
+
#resolveKey(input) {
|
|
681
|
+
const norm = normalizeKey(input);
|
|
682
|
+
if (this.#files.has(norm)) return norm;
|
|
683
|
+
|
|
684
|
+
for (const ext of this.config.extensions) {
|
|
685
|
+
const tryKey = norm + ext;
|
|
686
|
+
if (this.#files.has(tryKey)) return tryKey;
|
|
687
|
+
}
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* 判断文件的相对目录是否命中目标目录
|
|
693
|
+
* @param {string} dir_path - 文件所在相对目录
|
|
694
|
+
* @param {string} dir_key - 规范化后的目标目录
|
|
695
|
+
* @returns {boolean} 命中返回 true,目标目录为空时视为全部命中
|
|
696
|
+
*/
|
|
697
|
+
#matchDir(dir_path, dir_key) {
|
|
698
|
+
if (!dir_key) return true;
|
|
699
|
+
if (dir_key.includes('/')) {
|
|
700
|
+
return dir_path === dir_key || dir_path.endsWith('/' + dir_key);
|
|
701
|
+
}
|
|
702
|
+
return dir_path.split('/').includes(dir_key);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/* -------- 公开 API -------- */
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* 扫描目录(重复调用只生效一次)
|
|
709
|
+
* @description 优先复用其他实例的扫描缓存,命中时不再遍历目录
|
|
710
|
+
* @param {string} dir
|
|
711
|
+
* @param {{ extensions?: string[], ignore?: string[] }} [options]
|
|
712
|
+
*/
|
|
713
|
+
scan = (dir, options = {}) => {
|
|
714
|
+
const resolved = path.resolve(dir);
|
|
715
|
+
|
|
716
|
+
if (this.#scanned) {
|
|
717
|
+
if (this.#baseDir !== resolved) {
|
|
718
|
+
console.warn(`[scanner] 已使用 ${this.#baseDir} 扫描过,忽略本次 scan(${resolved})`);
|
|
719
|
+
}
|
|
720
|
+
return this;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
if (!fs.existsSync(resolved)) throw new Error(`[scanner] 目录不存在: ${resolved}`);
|
|
724
|
+
if (!fs.statSync(resolved).isDirectory()) throw new Error(`[scanner] 不是目录: ${resolved}`);
|
|
725
|
+
|
|
726
|
+
if (Array.isArray(options.extensions) || Array.isArray(options.ignore)) {
|
|
727
|
+
this.config = createConfig(this.config, options);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
this.#baseDir = resolved;
|
|
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());
|
|
741
|
+
return this;
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
/** 拿条目元信息(含 absPath),key 可带可不带扩展名 */
|
|
745
|
+
get = (key) => {
|
|
746
|
+
this.#ensureReady();
|
|
747
|
+
const k = this.#resolveKey(key);
|
|
748
|
+
return k ? this.#files.get(k) : undefined;
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
/** 拿绝对路径(给 Loader 用的最常见接口) */
|
|
752
|
+
getPath = (key) => {
|
|
753
|
+
const info = this.get(key);
|
|
754
|
+
return info ? info.absPath : undefined;
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
has = (key) => {
|
|
758
|
+
this.#ensureReady();
|
|
759
|
+
return this.#resolveKey(key) !== null;
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
keys = () => {
|
|
763
|
+
this.#ensureReady();
|
|
764
|
+
return [...this.#files.keys()];
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
getAll = () => {
|
|
768
|
+
this.#ensureReady();
|
|
769
|
+
return [...this.#files.values()];
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
/** 按"扩展名类型"筛:getByExt('.json') / getByExt('json') 都行 */
|
|
773
|
+
getByExt = (ext) => {
|
|
774
|
+
this.#ensureReady();
|
|
775
|
+
const e = normalizeExt(ext);
|
|
776
|
+
return this.getAll().filter((f) => f.ext === e);
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
/** 所有出现过的扩展名类型 */
|
|
780
|
+
extTypes = () => {
|
|
781
|
+
this.#ensureReady();
|
|
782
|
+
const set = new Set();
|
|
783
|
+
for (const f of this.#files.values()) set.add(f.ext);
|
|
784
|
+
return [...set];
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
/** 按目录筛:getByDir('config') -> data/config/**.{json,yaml,...} */
|
|
788
|
+
getByDir = (dir) => {
|
|
789
|
+
this.#ensureReady();
|
|
790
|
+
const norm = normalizeKey(dir);
|
|
791
|
+
const prefix = norm ? norm + '/' : '';
|
|
792
|
+
return this.getAll().filter((f) => f.key.startsWith(prefix));
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* 判断文件是否命中指定类型
|
|
797
|
+
* 只写扩展名('.json' / 'json')按扩展名匹配,写文件名('event.json')按文件名结尾匹配
|
|
798
|
+
* @param {{ key: string, ext: string }} file - 文件条目
|
|
799
|
+
* @param {string} ext - 扩展名或文件名
|
|
800
|
+
* @returns {boolean} 命中返回 true
|
|
801
|
+
*/
|
|
802
|
+
#matchExt(file, ext) {
|
|
803
|
+
const norm = String(ext).trim().toLowerCase();
|
|
804
|
+
const bare = norm.replace(/^\./, '');
|
|
805
|
+
if (!bare.includes('.')) return file.ext === '.' + bare;
|
|
806
|
+
return file.key === bare || file.key.endsWith('/' + bare);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* 按目录名与文件类型筛选文件
|
|
811
|
+
* 目录参数可为目录名(匹配任意层级)或相对路径(匹配路径结尾)
|
|
812
|
+
* @param {string} dir - 目录名或相对目录,如 'event_api'、'ai/event_api'
|
|
813
|
+
* @param {string} ext - 扩展名或文件名,如 '.json'、'event.json'
|
|
814
|
+
* @returns {Array<{ key: string, absPath: string, relPath: string, dir: string, name: string, ext: string }>} 命中的文件条目
|
|
815
|
+
*/
|
|
816
|
+
getFileByDir = (dir, ext) => {
|
|
817
|
+
this.#ensureReady();
|
|
818
|
+
const dir_key = normalizeKey(dir);
|
|
819
|
+
return this.getAll().filter((f) => this.#matchDir(f.dir, dir_key) && this.#matchExt(f, ext));
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
/** 所有一级子目录名(可当作业务分类) */
|
|
823
|
+
dirTypes = () => {
|
|
824
|
+
this.#ensureReady();
|
|
825
|
+
const set = new Set();
|
|
826
|
+
for (const f of this.#files.values()) {
|
|
827
|
+
const first = f.dir.split('/')[0];
|
|
828
|
+
if (first) set.add(first);
|
|
829
|
+
}
|
|
830
|
+
return [...set];
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
/** 当前支持的扩展名列表 */
|
|
834
|
+
get extensions() { return [...this.config.extensions]; }
|
|
835
|
+
|
|
836
|
+
/** 重新扫描(开发环境热更新用) */
|
|
837
|
+
rescan = () => {
|
|
838
|
+
if (!this.#baseDir) throw new Error('[scanner] 尚未初始化,无法 rescan');
|
|
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);
|
|
873
|
+
return this;
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* 清空全部扫描缓存
|
|
878
|
+
* @returns {void}
|
|
879
|
+
*/
|
|
880
|
+
static clearCache = () => {
|
|
881
|
+
_clearAll();
|
|
882
|
+
};
|
|
883
|
+
|
|
884
|
+
get size() { return this.#files.size; }
|
|
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
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/* 导出单例 + 类 */
|
|
915
|
+
module.exports = Scanner;
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mm_scanner",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
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
|
+
}
|
package/README.md
DELETED