Skip to content

Commit 40bc0fd

Browse files
authored
feat(plugin-hooks): add watchChange hook (#79)
* feat(plugin-hooks): add watchChange hook * add changeset
1 parent ac5e8f3 commit 40bc0fd

File tree

7 files changed

+228
-0
lines changed

7 files changed

+228
-0
lines changed

.changeset/selfish-worms-search.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@umijs/tnf': patch
3+
---
4+
5+
Add watchChange hook in Plugin

src/build.ts

+19
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { buildHtml } from './html';
88
import { PluginHookType } from './plugin/plugin_manager';
99
import { sync } from './sync/sync';
1010
import { type Context } from './types';
11+
import { Watcher } from './watch/watcher';
1112

1213
export async function build({
1314
context,
@@ -28,6 +29,24 @@ export async function build({
2829

2930
// sync with watch
3031
if (watch) {
32+
const watcher = new Watcher({
33+
chokidar: {
34+
ignoreInitial: true,
35+
},
36+
});
37+
38+
watcher.watch(['./src']);
39+
40+
watcher.on('change', async (id, { event }) => {
41+
await context.pluginManager.apply({
42+
hook: 'watchChange',
43+
args: [id, { event }],
44+
memo: [],
45+
type: PluginHookType.Parallel,
46+
pluginContext: context.pluginContext,
47+
});
48+
});
49+
3150
const pagesDir = path.join(cwd, 'src/pages');
3251
chokidar
3352
.watch(pagesDir, {

src/plugin/types.ts

+9
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@ export const PluginSchema = z.object({
3333
z.union([z.string(), z.promise(z.string()), z.null()]),
3434
)
3535
.optional(),
36+
watchChange: z
37+
.function(
38+
z.tuple([
39+
z.string(),
40+
z.object({ event: z.enum(['create', 'update', 'delete']) }),
41+
]),
42+
z.void(),
43+
)
44+
.optional(),
3645
});
3746

3847
export type Plugin = z.infer<typeof PluginSchema>;

src/watch/emitter.ts

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { EventListener } from './types';
2+
3+
export class WatchEmitter<T extends Record<string, (...args: any[]) => any>> {
4+
private handlers: {
5+
[K in keyof T]?: EventListener<T, K>[];
6+
} = Object.create(null);
7+
8+
async close(): Promise<void> {}
9+
10+
emit<K extends keyof T>(
11+
event: K,
12+
...args: Parameters<T[K]>
13+
): Promise<unknown> {
14+
const listeners = this.handlers[event] || [];
15+
return Promise.all(
16+
listeners.map(async (handler) => await handler(...args)),
17+
);
18+
}
19+
20+
on<K extends keyof T>(event: K, listener: EventListener<T, K>): this {
21+
if (!this.handlers[event]) {
22+
this.handlers[event] = [];
23+
}
24+
this.handlers[event]!.push(listener);
25+
return this;
26+
}
27+
28+
off<K extends keyof T>(event: K, listener: EventListener<T, K>): this {
29+
const listeners = this.handlers[event];
30+
if (listeners) {
31+
const index = listeners.indexOf(listener);
32+
if (index !== -1) {
33+
listeners.splice(index, 1);
34+
}
35+
}
36+
return this;
37+
}
38+
}

src/watch/fileWatcher.ts

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import chokidar, { FSWatcher } from 'chokidar';
2+
import type { ChangeEvent, ChokidarOptions } from './types';
3+
4+
export class FileWatcher {
5+
private watcher: FSWatcher;
6+
private transformWatchers = new Map<string, FSWatcher>();
7+
8+
constructor(
9+
private onChange: (id: string, event: ChangeEvent) => void,
10+
private options: ChokidarOptions = {},
11+
) {
12+
this.watcher = this.createWatcher();
13+
}
14+
15+
watch(id: string): void {
16+
this.watcher.add(id);
17+
}
18+
19+
unwatch(id: string): void {
20+
this.watcher.unwatch(id);
21+
const transformWatcher = this.transformWatchers.get(id);
22+
if (transformWatcher) {
23+
transformWatcher.close();
24+
this.transformWatchers.delete(id);
25+
}
26+
}
27+
28+
close(): void {
29+
this.watcher.close();
30+
for (const watcher of this.transformWatchers.values()) {
31+
watcher.close();
32+
}
33+
}
34+
35+
private createWatcher(): FSWatcher {
36+
return chokidar
37+
.watch([], this.options)
38+
.on('add', (id) => this.onChange(id, 'create'))
39+
.on('change', (id) => this.onChange(id, 'update'))
40+
.on('unlink', (id) => this.onChange(id, 'delete'));
41+
}
42+
}

src/watch/types.ts

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
export type ChangeEvent = 'create' | 'update' | 'delete';
2+
3+
export type ChokidarOptions = {
4+
ignored?: string | RegExp | Array<string | RegExp>;
5+
persistent?: boolean;
6+
ignoreInitial?: boolean;
7+
followSymlinks?: boolean;
8+
cwd?: string;
9+
disableGlobbing?: boolean;
10+
usePolling?: boolean;
11+
interval?: number;
12+
binaryInterval?: number;
13+
alwaysStat?: boolean;
14+
depth?: number;
15+
awaitWriteFinish?:
16+
| boolean
17+
| { stabilityThreshold?: number; pollInterval?: number };
18+
};
19+
20+
export type WatchEvent =
21+
| { code: 'START' }
22+
| { code: 'CHANGE'; id: string; event: ChangeEvent }
23+
| { code: 'ERROR'; error: Error }
24+
| { code: 'END' };
25+
26+
export type EventListener<T, K extends keyof T> = T[K] extends (
27+
...args: any[]
28+
) => any
29+
? (...args: Parameters<T[K]>) => void | Promise<void>
30+
: never;

src/watch/watcher.ts

+85
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { WatchEmitter } from './emitter';
2+
import { FileWatcher } from './fileWatcher';
3+
import type { ChangeEvent, ChokidarOptions, WatchEvent } from './types';
4+
5+
export interface WatchOptions {
6+
include?: string | RegExp | Array<string | RegExp>;
7+
exclude?: string | RegExp | Array<string | RegExp>;
8+
chokidar?: ChokidarOptions;
9+
}
10+
11+
export class Watcher {
12+
private fileWatcher: FileWatcher;
13+
private emitter: WatchEmitter<{
14+
event: (event: WatchEvent) => Promise<void>;
15+
change: (path: string, details: { event: ChangeEvent }) => Promise<void>;
16+
close: () => Promise<void>;
17+
}>;
18+
private closed = false;
19+
20+
constructor(options: WatchOptions = {}) {
21+
this.emitter = new WatchEmitter();
22+
this.fileWatcher = new FileWatcher(
23+
(id, event) => this.handleChange(id, event),
24+
options.chokidar,
25+
);
26+
}
27+
28+
watch(paths: string | string[]): void {
29+
const pathArray = Array.isArray(paths) ? paths : [paths];
30+
for (const path of pathArray) {
31+
this.fileWatcher.watch(path);
32+
}
33+
}
34+
35+
on<E extends 'event' | 'change' | 'close'>(
36+
event: E,
37+
callback: E extends 'event'
38+
? (event: WatchEvent) => Promise<void>
39+
: E extends 'change'
40+
? (id: string, details: { event: ChangeEvent }) => Promise<void>
41+
: () => Promise<void>,
42+
): void {
43+
this.emitter.on(event, callback as any);
44+
}
45+
46+
async close(): Promise<void> {
47+
if (this.closed) return;
48+
this.closed = true;
49+
this.fileWatcher.close();
50+
await this.emitter.emit('close');
51+
}
52+
53+
/**
54+
* 提供两种层次的监听
55+
*
56+
* 1. event:统一处理所有类型的事件
57+
* example:
58+
* watcher.on('event', (event) => {
59+
* switch (event.code) {
60+
* case 'START':
61+
* console.log('Watch started');
62+
* break;
63+
* case 'CHANGE':
64+
* console.log(`File ${event.id} ${event.event}`);
65+
* break;
66+
* case 'ERROR':
67+
* console.error('Watch error:', event.error);
68+
* break;
69+
* case 'END':
70+
* console.log('Watch ended');
71+
* break;
72+
* }
73+
* });
74+
*
75+
* 2. change:只关心文件变化
76+
* example:
77+
* watcher.on('change', (id, {event}) => {
78+
* console.log(`File ${id} ${event}`);
79+
* });
80+
*/
81+
private async handleChange(id: string, event: ChangeEvent): Promise<void> {
82+
await this.emitter.emit('event', { code: 'CHANGE', id, event });
83+
await this.emitter.emit('change', id, { event });
84+
}
85+
}

0 commit comments

Comments
 (0)