Skip to content

Commit 349e1b4

Browse files
authored
perf(unplugin-dts): optimize watch rebuilds (#487)
* perf(unplugin-dts): optimize watch rebuilds * chore(ci): diagnose Windows worker exits * test(unplugin-dts): canonicalize Windows watch paths
1 parent 8841cd8 commit 349e1b4

16 files changed

Lines changed: 2759 additions & 62 deletions

docs/en/usage.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,75 @@ await build({
192192
})
193193
```
194194

195+
## Watch Mode
196+
197+
Keep source directories separate from bundler and declaration output directories. This is the
198+
recommended layout for watch mode because a bundler can observe source-tree additions, deletions,
199+
and renames without also watching generated files:
200+
201+
```text
202+
project/
203+
├── src/
204+
│ └── index.ts
205+
├── dist/
206+
└── types/
207+
```
208+
209+
Vite, Webpack, and Rspack can discover newly added files that match your TypeScript configuration
210+
when the recursive source watch does not resolve into generated output. If an output directory is
211+
the source directory, or a realpath/symlink makes it overlap existing sources, the plugin fails
212+
closed: exact existing source files remain watched, but an unreferenced file addition may require a
213+
watcher restart. This prevents generated files from creating a rebuild loop.
214+
215+
When Webpack or Rspack starts from a clean project and creates nested output directories, Watchpack
216+
may perform one initial compensating rebuild. Generated output is ignored after that bootstrap, so
217+
steady-state source additions, deletions, and renames do not form an output feedback loop.
218+
219+
After a source is deleted or renamed, the plugin removes declaration paths that it wrote during the
220+
previous build but that are absent from the current complete output snapshot. Other files in the
221+
output directory are left unchanged.
222+
223+
Pure Rollup watch cannot safely register a watched source directory that also contains its outputs
224+
without help from the calling configuration. In that layout, an unreferenced new file may not
225+
trigger a rebuild. Prefer moving sources into `src/`. If changing the layout is not possible,
226+
configure all generated output directories as ignored before calling `rollup.watch` and explicitly
227+
watch the type source directory:
228+
229+
```js
230+
import { resolve, sep } from 'node:path'
231+
import { fileURLToPath } from 'node:url'
232+
233+
import { defineConfig } from 'rollup'
234+
import typescript from '@rollup/plugin-typescript'
235+
import dts from 'unplugin-dts/rollup'
236+
237+
const projectRoot = resolve(fileURLToPath(import.meta.url), '..')
238+
const outputDir = resolve(projectRoot, 'dist')
239+
240+
const watchTypeRoot = {
241+
name: 'watch-type-root',
242+
buildStart() {
243+
if (this.meta.watchMode) this.addWatchFile(projectRoot)
244+
},
245+
}
246+
247+
export default defineConfig({
248+
input: resolve(projectRoot, 'index.ts'),
249+
output: { dir: outputDir, format: 'es' },
250+
watch: {
251+
chokidar: {
252+
ignored: path => path === outputDir || path.startsWith(`${outputDir}${sep}`),
253+
},
254+
},
255+
plugins: [watchTypeRoot, typescript(), dts({ root: projectRoot })],
256+
})
257+
```
258+
259+
If declarations use a different directory, or you have multiple outputs, include every generated
260+
directory in the ignored predicate. Rolldown currently does not discover unreferenced files created
261+
after watch starts through a directory `addWatchFile`. Import or reference the new file from an
262+
existing watched module, or restart the watcher after adding, deleting, or renaming such files.
263+
195264
## Bundling Types
196265
197266
By default, the generated declaration files follow the source structure.

docs/zh/usage.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,69 @@ await build({
192192
})
193193
```
194194

195+
## Watch 模式
196+
197+
推荐将源码目录与 bundler、声明文件的输出目录分开。这样构建工具可以监听源码树中的新增、
198+
删除和重命名,又不会同时监听生成文件:
199+
200+
```text
201+
project/
202+
├── src/
203+
│ └── index.ts
204+
├── dist/
205+
└── types/
206+
```
207+
208+
当递归源码监听不会解析到生成输出时,Vite、Webpack 和 Rspack 可以发现符合 TypeScript
209+
配置的新文件。如果输出目录就是源码目录,或者 realpath/symlink 使它与已有源码重叠,插件会
210+
采用 fail-close:继续精确监听已有源码,但新增未引用文件后可能需要重启 watcher。这可以避免
211+
生成文件形成重新构建循环。
212+
213+
当 Webpack 或 Rspack 从干净项目启动并创建嵌套输出目录时,Watchpack 可能执行一次初始补偿
214+
构建。bootstrap 完成后生成输出会被忽略,因此稳态下的源码新增、删除和重命名不会形成输出
215+
反馈循环。
216+
217+
源码删除或重命名后,插件会清理上一轮由自身写出、但已不在当前完整输出快照中的声明路径;
218+
输出目录中的其他文件不会被清理。
219+
220+
纯 Rollup watch 无法在没有调用方配合的情况下安全监听同时包含输出的源码目录。这种布局下,
221+
新增但未被引用的文件可能不会触发重新构建。优先将源码移入 `src/`。如果暂时不能调整布局,
222+
请在调用 `rollup.watch` 前忽略所有生成目录,并显式监听类型源码目录:
223+
224+
```js
225+
import { resolve, sep } from 'node:path'
226+
import { fileURLToPath } from 'node:url'
227+
228+
import { defineConfig } from 'rollup'
229+
import typescript from '@rollup/plugin-typescript'
230+
import dts from 'unplugin-dts/rollup'
231+
232+
const projectRoot = resolve(fileURLToPath(import.meta.url), '..')
233+
const outputDir = resolve(projectRoot, 'dist')
234+
235+
const watchTypeRoot = {
236+
name: 'watch-type-root',
237+
buildStart() {
238+
if (this.meta.watchMode) this.addWatchFile(projectRoot)
239+
},
240+
}
241+
242+
export default defineConfig({
243+
input: resolve(projectRoot, 'index.ts'),
244+
output: { dir: outputDir, format: 'es' },
245+
watch: {
246+
chokidar: {
247+
ignored: path => path === outputDir || path.startsWith(`${outputDir}${sep}`),
248+
},
249+
},
250+
plugins: [watchTypeRoot, typescript(), dts({ root: projectRoot })],
251+
})
252+
```
253+
254+
如果声明文件使用不同目录,或配置了多个输出目录,需要在 ignored 判断中覆盖每一个生成目录。
255+
Rolldown 当前无法通过目录 `addWatchFile` 发现 watch 启动后创建的未引用文件。请从已有的受监听
256+
模块中导入或引用新文件,或者在新增、删除、重命名这类文件后重启 watcher。
257+
195258
## 打包类型
196259
197260
默认情况下,生成的类型文件会跟随源文件的结构。

packages/unplugin-dts/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@
123123
"@volar/typescript": "^2.4.26",
124124
"compare-versions": "^6.1.1",
125125
"debug": "^4.4.0",
126+
"glob-to-regexp": "0.4.1",
126127
"kolorist": "^1.8.0",
127128
"local-pkg": "^1.1.1",
128129
"magic-string": "^0.30.17",
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { performance } from 'node:perf_hooks'
2+
3+
export type BuildHook = 'buildStart' | 'watchChange' | 'transform' | 'writeBundle'
4+
5+
interface BuildInterval {
6+
hook: BuildHook,
7+
start: number,
8+
end: number,
9+
}
10+
11+
interface BuildIntervalToken {
12+
hook: BuildHook,
13+
start: number,
14+
generation: number,
15+
}
16+
17+
export interface BuildTimingSummary {
18+
wallMs: number,
19+
attributedMs: number,
20+
unattributedMs: number,
21+
buildStartMs: number,
22+
watchChangeMs: number,
23+
transformSumMs: number,
24+
transformUnionMs: number,
25+
transformMaxConcurrency: number,
26+
transformOverlapMs: number,
27+
writeBundleMs: number,
28+
}
29+
30+
function measureIntervals(intervals: readonly BuildInterval[]) {
31+
if (intervals.length === 0) {
32+
return { sum: 0, union: 0, maxConcurrency: 0 }
33+
}
34+
35+
const sum = intervals.reduce((total, interval) => total + interval.end - interval.start, 0)
36+
const points = intervals.flatMap(interval => [
37+
{ time: interval.start, delta: 1 },
38+
{ time: interval.end, delta: -1 },
39+
])
40+
41+
points.sort((left, right) => {
42+
if (left.time === right.time) return left.delta - right.delta
43+
return left.time - right.time
44+
})
45+
46+
let active = 0
47+
let maxConcurrency = 0
48+
let previous = points[0].time
49+
let union = 0
50+
51+
for (const point of points) {
52+
if (active > 0) union += point.time - previous
53+
active += point.delta
54+
maxConcurrency = Math.max(maxConcurrency, active)
55+
previous = point.time
56+
}
57+
58+
return { sum, union, maxConcurrency }
59+
}
60+
61+
/**
62+
* 跟踪一次声明构建周期内的插件 hook 区间。
63+
*
64+
* 逐次耗时之和只用于解释工作量;用户可感知耗时使用单调时钟墙钟,
65+
* 并发 transform 的归因使用区间并集,避免重复计算重叠时间。
66+
*/
67+
export class BuildTimeTracker {
68+
private readonly now: () => number
69+
private generation = 0
70+
private cycleStart = 0
71+
private intervals: BuildInterval[] = []
72+
73+
constructor(now: () => number = () => performance.now()) {
74+
this.now = now
75+
this.reset()
76+
}
77+
78+
reset(start = this.now()) {
79+
this.generation++
80+
this.cycleStart = start
81+
this.intervals = []
82+
}
83+
84+
begin(hook: BuildHook): BuildIntervalToken {
85+
return { hook, start: this.now(), generation: this.generation }
86+
}
87+
88+
end(token: BuildIntervalToken) {
89+
if (token.generation !== this.generation) return
90+
91+
const end = this.now()
92+
this.intervals.push({ hook: token.hook, start: token.start, end })
93+
}
94+
95+
async track<T>(hook: BuildHook, action: () => Promise<T>): Promise<T> {
96+
const interval = this.begin(hook)
97+
98+
try {
99+
return await action()
100+
} finally {
101+
this.end(interval)
102+
}
103+
}
104+
105+
getIntervals(): readonly BuildInterval[] {
106+
return [...this.intervals].sort((left, right) => left.start - right.start)
107+
}
108+
109+
summarize(end = this.now()): BuildTimingSummary {
110+
const intervals = this.getIntervals()
111+
const all = measureIntervals(intervals)
112+
const phase = (hook: BuildHook) =>
113+
measureIntervals(intervals.filter(interval => interval.hook === hook))
114+
const transforms = phase('transform')
115+
const wallMs = Math.max(0, end - this.cycleStart)
116+
117+
return {
118+
wallMs,
119+
attributedMs: all.union,
120+
unattributedMs: Math.max(0, wallMs - all.union),
121+
buildStartMs: phase('buildStart').union,
122+
watchChangeMs: phase('watchChange').union,
123+
transformSumMs: transforms.sum,
124+
transformUnionMs: transforms.union,
125+
transformMaxConcurrency: transforms.maxConcurrency,
126+
transformOverlapMs: transforms.sum - transforms.union,
127+
writeBundleMs: phase('writeBundle').union,
128+
}
129+
}
130+
}

packages/unplugin-dts/src/core/processor/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,25 @@
11
import type ts from 'typescript'
22

3+
export interface SourceFileCacheStats {
4+
entries: number,
5+
hits: number,
6+
misses: number,
7+
invalidations: number,
8+
}
9+
10+
export interface VersionedCompilerHost extends ts.CompilerHost {
11+
invalidateSourceFile: (fileName: string) => void,
12+
getSourceFileCacheStats: () => SourceFileCacheStats,
13+
}
14+
315
export interface ProgramProcessor {
416
createParsedCommandLine: (
517
_ts: typeof ts,
618
host: ts.ParseConfigHost,
719
configPath: string
820
) => ts.ParsedCommandLine,
921
createProgram: typeof ts.createProgram,
22+
createCompilerHost?: (options: ts.CompilerOptions) => VersionedCompilerHost,
1023
}
1124

1225
export async function loadProgramProcessor(type: 'vue' | 'ts' = 'ts'): Promise<ProgramProcessor> {

0 commit comments

Comments
 (0)