hw-weapp-compiler 1.0.26 → 1.0.27

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/README.md CHANGED
@@ -78,3 +78,31 @@ module.exports = {
78
78
  ]
79
79
  }
80
80
  ```
81
+
82
+ ## 独立分包(受限支持)
83
+
84
+ 在 `src/app.json` 中配置 `independent: true`,`subpackages` 和 `subPackages` 两种写法均可:
85
+
86
+ ```json
87
+ {
88
+ "pages": ["pages/index/index"],
89
+ "subpackages": [{
90
+ "root": "activity",
91
+ "pages": ["pages/index/index"],
92
+ "independent": true
93
+ }]
94
+ }
95
+ ```
96
+
97
+ 主包和普通分包沿用原有编译流程;每个独立分包单独编译,生成包内的 runtime、公共 JS/WXSS 和资源。
98
+ 页面及组件入口会自动加载所需的包内公共文件。没有独立分包时仍使用单份 webpack 配置。
99
+
100
+ - 独立分包的原生组件、JSON、WXML、WXS、样式和本地资源必须位于自己的 `root` 内;跨包原生引用会在构建时报错。
101
+ - `usingComponents` 需显式写在独立包的页面或组件 JSON 中,使用包内相对路径或 `/activity/...` 路径。不自动搬迁主包组件、npm 原生组件或插件组件。
102
+ - 可以静态引用包外的普通 JS 模块和纯 JS npm 依赖,编译器会将它们编入该独立包。依赖若继续引用包外样式、JSON 等原生文件,也会报错。同一包内共享模块实例,不同包之间不共享模块缓存。
103
+ - 本地资源自动输出到 `<root>/assets/`,引用使用小程序根路径;配置 CDN `publicPath` 时沿用 CDN。动态拼接的资源路径不能自动收集,需通过 `copyFiles` 将包内资源复制到同一包内,例如 `{ from: 'activity/images', to: 'activity/images' }`。
104
+ - 业务代码不能假设主包的 `App` 已初始化,也不能依赖 `app.wxss` 的样式。本版不支持独立包内产生异步 chunk 的动态 `import()` 或分包异步化。
105
+ - 修改已有 JS、模板、样式或资源可以增量构建;修改分包配置、增删页面或组件、改变组件依赖关系后,需要重启 `dev`。
106
+
107
+ 分包的 `root` 不能重复或互相嵌套。各包会重复编入自身使用的共享 JS/npm 依赖,因此可能增加总产物体积和编译耗时。
108
+ 多包构建只在启动时统一清理 `dist`;`-a` 分析模式会生成各包的 `bundle-report.html` 静态报告。
@@ -7,6 +7,7 @@ let _appConfig = null;
7
7
  function getAppConfig() {
8
8
  if (!_appConfig) {
9
9
  _appConfig = fse.readJSONSync(path.resolve(SRC_DIR, 'app.json'));
10
+ _appConfig.subpackages = _appConfig.subpackages || _appConfig.subPackages || [];
10
11
  }
11
12
  return _appConfig;
12
13
  }
@@ -4,8 +4,11 @@ const { addNodeModulesUsingComponent } = require('../utils/isNodeModulesUsingCom
4
4
  const { compatiblePath } = require('../utils/compatiblePath');
5
5
  const { getAppConfig } = require('./getAppConfig');
6
6
  const { PATTERNS, SRC_DIR } = require('./constants');
7
+ const { getIndependentRoots, isWithinRoot } = require('../utils/independentPackages');
7
8
 
8
9
  const appConfig = getAppConfig();
10
+ const independentRoots = getIndependentRoots(appConfig);
11
+ const ownerOf = (file) => independentRoots.find((root) => isWithinRoot(path.relative(SRC_DIR, file), root));
9
12
 
10
13
  // 惰性异步初始化:entry / usingComponents 收集改为 Promise.all 并发读 json,
11
14
  // 避免在 webpack 启动前对深组件树做同步串行 readJSONSync/existsSync/require.resolve。
@@ -22,6 +25,9 @@ function buildBaseEntries() {
22
25
 
23
26
  // pages
24
27
  (appConfig.pages || []).forEach((page) => {
28
+ if (ownerOf(path.resolve(SRC_DIR, page))) {
29
+ throw new Error(`主包 pages 不能包含独立分包页面:${page}`);
30
+ }
25
31
  addEntry(page, path.resolve(SRC_DIR, page));
26
32
  });
27
33
 
@@ -33,6 +39,9 @@ function buildBaseEntries() {
33
39
  // subpackages
34
40
  (appConfig.subpackages || []).forEach((pkg) => {
35
41
  (pkg.pages || []).forEach((page) => {
42
+ if (pkg.independent && !isWithinRoot(compatiblePath(path.join(pkg.root, page)), compatiblePath(pkg.root).replace(/\/$/, ''))) {
43
+ throw new Error(`独立分包页面越界:${pkg.root}/${page}`);
44
+ }
36
45
  addEntry(path.join(pkg.root, page), path.resolve(SRC_DIR, pkg.root, page));
37
46
  });
38
47
  });
@@ -102,10 +111,18 @@ async function collectUsingComponents(entryFiles, entries) {
102
111
 
103
112
  // 同步解析路径(existsSync/require.resolve 不宜并发但单 round 内串行,正是被批处理掉的是 readJSON)
104
113
  newComponents.forEach(({ key, value, dir }) => {
114
+ const owner = ownerOf(dir);
115
+ if (owner && PATTERNS.PLUGIN_PATH.test(value)) {
116
+ throw new Error(`独立分包 ${owner} 仅支持包内原生组件:${value}`);
117
+ }
105
118
  const resolved = resolveUsingComponentPath(key, value, dir);
106
119
  if (!resolved) {
107
120
  return;
108
121
  }
122
+ const targetOwner = ownerOf(resolved);
123
+ if (owner !== targetOwner && (owner || targetOwner)) {
124
+ throw new Error(`独立分包组件引用越界:${dir} -> ${value}。请将原生组件及其依赖放在同一独立包内。`);
125
+ }
109
126
 
110
127
  if (fse.existsSync(`${resolved}.json`)) {
111
128
  jsonQueue.push(`${resolved}.json`);
@@ -9,7 +9,8 @@ const { RawSource } = require('webpack-sources');
9
9
 
10
10
  const { addToUploadQueue } = require('../utils/upload');
11
11
  const { compatiblePath } = require('../utils/compatiblePath');
12
- const { PATTERNS, ASSET_EXT_PATTERN, ASSETS_DIR } = require('../config/constants');
12
+ const { isWithinRoot } = require('../utils/independentPackages');
13
+ const { PATTERNS, ASSET_EXT_PATTERN, ASSETS_DIR, SRC_DIR } = require('../config/constants');
13
14
 
14
15
  const pluginName = 'WeappCompilerPlugin';
15
16
 
@@ -161,17 +162,148 @@ function injectSubpackageRequires(content, assetName, subpackages) {
161
162
  return result;
162
163
  }
163
164
 
165
+ // 独立包没有 app.js/app.wxss,每个页面和组件都必须加载自己的入口依赖。
166
+ function injectIndependentEntrypoints(compilation) {
167
+ compilation.entrypoints.forEach((entrypoint, name) => {
168
+ const jsName = `${name}.js`;
169
+ const wxssName = `${name}.wxss`;
170
+ const files = [...new Set(entrypoint.getFiles())];
171
+ const js = compilation.getAsset(jsName);
172
+ if (js) {
173
+ const requires = files.filter((file) => file !== jsName && PATTERNS.JS_EXT.test(file))
174
+ .map((file) => `require(${JSON.stringify(getRelativeModulePath(path.dirname(jsName), file))});\n`);
175
+ compilation.updateAsset(jsName, new RawSource(requires.join('') + js.source.source().toString()));
176
+ }
177
+ const imports = files.filter((file) => file !== wxssName && PATTERNS.WXSS_EXT.test(file))
178
+ .map((file) => `@import ${JSON.stringify(getRelativeModulePath(path.dirname(wxssName), file))};\n`);
179
+ if (imports.length) {
180
+ const wxss = compilation.getAsset(wxssName);
181
+ const source = new RawSource(imports.join('') + (wxss ? wxss.source.source().toString() : ''));
182
+ if (wxss) compilation.updateAsset(wxssName, source);
183
+ else compilation.emitAsset(wxssName, source);
184
+ }
185
+ });
186
+ }
187
+
188
+ function validatePackageBoundary(compilation, independentRoot, independentRoots) {
189
+ const errors = new Set();
190
+ const checkFile = (file, native) => {
191
+ if (!file) return;
192
+ const relative = compatiblePath(path.relative(SRC_DIR, file.split('?')[0]));
193
+ if (independentRoot) {
194
+ if (native && !isWithinRoot(relative, independentRoot)) {
195
+ errors.add(`独立分包 ${independentRoot} 原生文件引用越界:${relative}。请将组件、模板、样式和本地资源放在包内。`);
196
+ }
197
+ } else if (independentRoots.some((root) => isWithinRoot(relative, root))) {
198
+ errors.add(`主包或普通分包不能引用独立分包源文件:${relative}`);
199
+ }
200
+ };
201
+ const checkNativeDependencies = (files) => {
202
+ for (const file of files || []) {
203
+ if (/\.(wxml|wxs|wxss|css|less)$/i.test(file) || ASSET_EXT_PATTERN.test(file)) checkFile(file, true);
204
+ }
205
+ };
206
+ for (const module of compilation.modules) {
207
+ // 普通 JS 可以从共享源码/npm 编入包内;原生文件不能自动搬迁。
208
+ checkFile(module.resource, module.resource && !/\.[cm]?js(?:\?|$)/i.test(module.resource));
209
+ }
210
+ // less 的 @import 等依赖不一定是 webpack module;不检查 package.json 等构建元数据。
211
+ checkNativeDependencies(compilation.fileDependencies);
212
+ if (independentRoot) {
213
+ for (const chunk of compilation.chunks) {
214
+ if (!chunk.canBeInitial()) errors.add(`独立分包 ${independentRoot} 暂不支持动态 import 或其他异步 chunk。请使用静态依赖。`);
215
+ }
216
+ }
217
+ compilation.getAssets().forEach(({ name, source }) => {
218
+ const owner = independentRoots.find((root) => isWithinRoot(name, root)) || '';
219
+ if (owner !== independentRoot) errors.add(`分包产物越界:${independentRoot || '主包'} -> ${name}`);
220
+ if (!independentRoot || !PATTERNS.JSON_EXT.test(name)) return;
221
+ let json;
222
+ try { json = JSON.parse(source.source().toString()); } catch (_) { return; }
223
+ Object.values(json.usingComponents || {}).forEach((request) => {
224
+ const target = path.posix.normalize(request.startsWith('/')
225
+ ? request.slice(1) : path.posix.join(path.posix.dirname(compatiblePath(name)), request));
226
+ if (!isWithinRoot(target, independentRoot) || !compilation.getAsset(`${target}.js`)) {
227
+ errors.add(`独立分包组件引用无效:${name} -> ${request}。仅支持包内组件,新增组件后请重启 dev。`);
228
+ }
229
+ });
230
+ });
231
+ errors.forEach((message) => compilation.errors.push(new Error(message)));
232
+ }
233
+
164
234
  class WeappPlugin {
165
- constructor() {}
235
+ constructor({
236
+ quiet = false,
237
+ waitForUpload = false,
238
+ independentRoot = '',
239
+ independentRoots = [],
240
+ uploadBarrier = null,
241
+ } = {}) {
242
+ this.quiet = quiet;
243
+ this.waitForUpload = waitForUpload;
244
+ this.independentRoot = independentRoot;
245
+ this.independentRoots = independentRoots;
246
+ this.uploadBarrier = uploadBarrier;
247
+ }
166
248
 
167
249
  apply(compiler) {
168
250
  let obsAssets = [];
169
251
 
170
- compiler.hooks.done.tap(pluginName, () => {
171
- addToUploadQueue([...obsAssets]);
252
+ const enqueueAssets = (stats) => {
253
+ const assets = [...obsAssets];
172
254
  obsAssets = [];
173
- });
174
255
 
256
+ // production MultiCompiler 共享同一个屏障:所有 child 都成功后才统一上传。
257
+ // fatal error 不会触发 done,remaining 也就不会归零,其他 child 不会误上传。
258
+ if (this.uploadBarrier) {
259
+ const barrier = this.uploadBarrier;
260
+ const hasErrors = !!(stats && stats.hasErrors());
261
+ barrier.remaining -= 1;
262
+ barrier.failed = barrier.failed || hasErrors;
263
+ if (!hasErrors) barrier.assets.push(...assets);
264
+
265
+ if (barrier.remaining !== 0 || barrier.failed) {
266
+ return Promise.resolve();
267
+ }
268
+
269
+ const completedAssets = [...barrier.assets];
270
+ barrier.assets = [];
271
+ return addToUploadQueue(completedAssets, {
272
+ quiet: this.quiet,
273
+ logWhenIdle: this.waitForUpload,
274
+ failOnError: this.waitForUpload,
275
+ });
276
+ }
277
+
278
+ // 编译产物不完整时不上传资源,并让 webpack 保留原始 stats 错误详情。
279
+ // 若此处再抛上传错误,done hook 会只把该错误传给最终回调,遮蔽编译错误。
280
+ if (stats && stats.hasErrors()) {
281
+ return Promise.resolve();
282
+ }
283
+
284
+ return addToUploadQueue(assets, {
285
+ quiet: this.quiet,
286
+ logWhenIdle: this.waitForUpload,
287
+ failOnError: this.waitForUpload,
288
+ });
289
+ };
290
+
291
+ if (this.waitForUpload) {
292
+ compiler.hooks.done.tapPromise(pluginName, enqueueAssets);
293
+ } else {
294
+ compiler.hooks.done.tap(pluginName, (stats) => {
295
+ enqueueAssets(stats).catch((error) => {
296
+ console.error(error);
297
+ });
298
+ });
299
+ }
300
+
301
+ if (this.independentRoots.length) {
302
+ // afterCompile 时 loader/子编译的 fileDependencies 已完整汇总,且尚未写入产物。
303
+ compiler.hooks.afterCompile.tap(pluginName, (compilation) => {
304
+ validatePackageBoundary(compilation, this.independentRoot, this.independentRoots);
305
+ });
306
+ }
175
307
  compiler.hooks.compilation.tap(pluginName, (compilation) => {
176
308
  compilation.hooks.afterProcessAssets.tap(
177
309
  {
@@ -213,14 +345,15 @@ class WeappPlugin {
213
345
  }
214
346
 
215
347
  const before = content;
216
- const after = injectGlobalModules(content, assetName, flags);
217
- const finalContent = injectSubpackageRequires(after, assetName, subpackages);
348
+ const after = injectGlobalModules(content, assetName, this.independentRoot ? {} : flags);
349
+ const finalContent = this.independentRoot ? after : injectSubpackageRequires(after, assetName, subpackages);
218
350
 
219
351
  if (finalContent !== before) {
220
352
  compilation.updateAsset(asset.name, new RawSource(finalContent));
221
353
  }
222
354
  }),
223
355
  );
356
+ if (this.independentRoot) injectIndependentEntrypoints(compilation);
224
357
  },
225
358
  );
226
359
  });
package/build/prod.js CHANGED
@@ -4,35 +4,37 @@ const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
4
4
  const { ENV } = require('./config/constants');
5
5
  const webpackConfig = require('./webpack.config');
6
6
  const { createCompilerHandler } = require('./utils/createCompilerHandler');
7
+ const { cleanDistForDev } = require('./utils/cleanDistForDev');
7
8
 
8
9
  module.exports = async (opts) => {
9
10
  // 生产环境禁用 filesystem cache,与 dev 同源:output.clean 后复用旧缓存会导致副作用产物漏生成,
10
11
  // 注入逻辑(plugin/index.js 的 PROCESS_ASSETS_STAGE_ADDITIONS)要求每次从干净的 source 重做。
11
12
  // production 单次构建本就是冷构建,命中收益低,移除以换确定性。
12
- const compiler = webpack(
13
- await webpackConfig(
14
- {
15
- mode: ENV.PROD,
16
- devtool: false,
17
- output: {
18
- clean: {
19
- keep: /^(project\.config\.json|weapp\.env\.json)$/,
20
- },
21
- },
22
- optimization: {
23
- minimizer: [
24
- new TerserPlugin({
25
- extractComments: false,
26
- }),
27
- new CssMinimizerPlugin({
28
- test: /\.(css|wxss)(\?.*)?$/i,
29
- }),
30
- ],
13
+ const config = await webpackConfig(
14
+ {
15
+ mode: ENV.PROD,
16
+ devtool: false,
17
+ output: {
18
+ clean: {
19
+ keep: /^(project\.config\.json|weapp\.env\.json)$/,
31
20
  },
32
21
  },
33
- opts || {},
34
- ),
22
+ optimization: {
23
+ minimizer: [
24
+ new TerserPlugin({
25
+ extractComments: false,
26
+ }),
27
+ new CssMinimizerPlugin({
28
+ test: /\.(css|wxss)(\?.*)?$/i,
29
+ }),
30
+ ],
31
+ },
32
+ },
33
+ opts || {},
35
34
  );
35
+ // 多份配置共用 dist,只在启动前清理,禁止子编译互相删除产物。
36
+ if (Array.isArray(config)) await cleanDistForDev();
37
+ const compiler = webpack(config);
36
38
 
37
39
  compiler.run(createCompilerHandler('build'));
38
40
  };
@@ -5,6 +5,7 @@ const chalk = require('chalk');
5
5
  * @param {string} label - 构建阶段标签,如 'dev' 或 'build'
6
6
  */
7
7
  function createCompilerHandler(label, { failOnError = label === 'build' } = {}) {
8
+ let lastBuildEnd = 0;
8
9
  const markFailed = () => {
9
10
  if (failOnError) {
10
11
  process.exitCode = 1;
@@ -44,10 +45,16 @@ function createCompilerHandler(label, { failOnError = label === 'build' } = {})
44
45
  );
45
46
  }
46
47
 
47
- console.log(chalk.green(`${label} completed in ${(stats.endTime - stats.startTime) / 1000} seconds`));
48
+ const children = stats.stats || [stats];
49
+ // MultiStats 在局部 watch 重建时还包含未重建子编译的旧 stats。
50
+ const rebuilt = children.filter((child) => child.endTime > lastBuildEnd);
51
+ const start = Math.min(...(rebuilt.length ? rebuilt : children).map((child) => child.startTime));
52
+ const end = Math.max(...children.map((child) => child.endTime));
53
+ lastBuildEnd = end;
54
+ console.log(chalk.green(`${label} completed in ${(end - start) / 1000} seconds`));
48
55
  };
49
56
  }
50
57
 
51
58
  module.exports = {
52
59
  createCompilerHandler,
53
- };
60
+ };
@@ -0,0 +1,31 @@
1
+ const path = require('path');
2
+ const { compatiblePath } = require('./compatiblePath');
3
+
4
+ function isWithinRoot(file, root) {
5
+ const name = path.posix.normalize(compatiblePath(file));
6
+ return name === root || name.startsWith(`${root}/`);
7
+ }
8
+
9
+ function getIndependentRoots(appConfig) {
10
+ const packages = appConfig.subpackages || [];
11
+ const roots = packages.filter((pkg) => pkg.independent === true).map((pkg) => {
12
+ const root = compatiblePath(pkg.root || '').replace(/\/$/, '');
13
+ if (!root || path.posix.isAbsolute(root) || /^[A-Za-z]:/.test(root)
14
+ || root === '.' || root.split('/').some((part) => !part || part === '..' || part === '.')) {
15
+ throw new Error(`独立分包 root 必须是 src 内的相对目录:${pkg.root}`);
16
+ }
17
+ return root;
18
+ });
19
+ roots.forEach((root) => {
20
+ const overlaps = packages.filter((pkg) => {
21
+ const other = compatiblePath(pkg.root || '').replace(/\/$/, '');
22
+ return other && (isWithinRoot(root, other) || isWithinRoot(other, root));
23
+ });
24
+ if (overlaps.length !== 1) {
25
+ throw new Error(`独立分包 root 重复或与其他分包目录重叠:${root}`);
26
+ }
27
+ });
28
+ return roots;
29
+ }
30
+
31
+ module.exports = { getIndependentRoots, isWithinRoot };
@@ -29,6 +29,79 @@ let ossClient;
29
29
  let progress;
30
30
  let uploadQueue = {};
31
31
  let uploadRunner;
32
+ let uploadSummary;
33
+
34
+ function getProvider() {
35
+ return ossConfig ? 'OSS' : 'OBS';
36
+ }
37
+
38
+ function createUploadSummary({ quiet, failOnError }) {
39
+ return {
40
+ provider: getProvider(),
41
+ startedAt: Date.now(),
42
+ assets: new Set(),
43
+ localCached: new Set(),
44
+ pending: new Set(),
45
+ remoteCached: 0,
46
+ uploaded: 0,
47
+ failed: 0,
48
+ failedFiles: [],
49
+ quiet,
50
+ failOnError,
51
+ };
52
+ }
53
+
54
+ function getUploadResult(summary) {
55
+ return {
56
+ provider: summary.provider,
57
+ total: summary.assets.size,
58
+ localCached: summary.localCached.size,
59
+ pending: summary.pending.size,
60
+ remoteCached: summary.remoteCached,
61
+ uploaded: summary.uploaded,
62
+ failed: summary.failed,
63
+ failedFiles: [...summary.failedFiles],
64
+ durationMs: Date.now() - summary.startedAt,
65
+ };
66
+ }
67
+
68
+ function logUploadQueue({ provider, total, localCached, pending }, quiet) {
69
+ if (quiet) {
70
+ return;
71
+ }
72
+
73
+ console.log(
74
+ `[assets] upload queue: provider=${provider}, total=${total}, ` +
75
+ `localCached=${localCached}, pending=${pending}`,
76
+ );
77
+ }
78
+
79
+ function logUploadResult(result, quiet) {
80
+ if (quiet) {
81
+ return;
82
+ }
83
+
84
+ const message =
85
+ `[assets] upload completed: provider=${result.provider}, total=${result.total}, ` +
86
+ `localCached=${result.localCached}, remoteCached=${result.remoteCached}, ` +
87
+ `uploaded=${result.uploaded}, failed=${result.failed}, ` +
88
+ `duration=${(result.durationMs / 1000).toFixed(2)}s`;
89
+
90
+ if (result.failed > 0) {
91
+ console.error(message);
92
+ } else {
93
+ console.log(message);
94
+ }
95
+ }
96
+
97
+ function getUploadError(result) {
98
+ const error = new Error(
99
+ `Asset upload failed: provider=${result.provider}, failed=${result.failed}`,
100
+ );
101
+ error.code = 'ASSET_UPLOAD_FAILED';
102
+ error.failedFiles = result.failedFiles;
103
+ return error;
104
+ }
32
105
 
33
106
  function getRemoteKey(config, file) {
34
107
  return compatiblePath(path.join(config.dir, path.relative(DIST_DIR, file)));
@@ -323,11 +396,15 @@ function doUpload(file) {
323
396
  }
324
397
 
325
398
  function updateProgress() {
399
+ if (uploadSummary && uploadSummary.quiet) {
400
+ return;
401
+ }
402
+
326
403
  const completed = Object.values(uploadQueue).filter((status) => status === 'completed').length;
327
404
  const total = Object.keys(uploadQueue).length;
328
405
 
329
406
  if (!progress) {
330
- progress = new Progress('uploading assets [:bar] :current/:total', {
407
+ progress = new Progress('syncing assets [:bar] :current/:total', {
331
408
  total,
332
409
  width: 40,
333
410
  clear: true,
@@ -348,11 +425,15 @@ async function uploadFile(file) {
348
425
  try {
349
426
  await getStat(file);
350
427
  setStorage(file, true);
428
+ uploadSummary.remoteCached += 1;
351
429
  } catch (error) {
352
430
  try {
353
431
  await doUpload(file);
354
432
  setStorage(file, true);
433
+ uploadSummary.uploaded += 1;
355
434
  } catch (uploadError) {
435
+ uploadSummary.failed += 1;
436
+ uploadSummary.failedFiles.push(getDisplayFile(file));
356
437
  const detail = `file=${getDisplayFile(file)}, ${formatObsError(uploadError)}`;
357
438
 
358
439
  if (ossConfig) {
@@ -392,13 +473,23 @@ function checkUpload() {
392
473
 
393
474
  await Promise.all(files.map(uploadFile));
394
475
  }
395
- })().finally(() => {
476
+ })().then(() => {
477
+ const result = getUploadResult(uploadSummary);
478
+ logUploadResult(result, uploadSummary.quiet);
479
+
480
+ if (uploadSummary.failOnError && result.failed > 0) {
481
+ throw getUploadError(result);
482
+ }
483
+
484
+ return result;
485
+ }).finally(() => {
396
486
  uploadRunner = null;
397
487
 
398
488
  // 文件状态已经写完后再清空,避免旧实现最后一个文件把已清空的队列重新写回。
399
489
  if (!Object.values(uploadQueue).includes('pending')) {
400
490
  progress = null;
401
491
  uploadQueue = {};
492
+ uploadSummary = null;
402
493
  return;
403
494
  }
404
495
 
@@ -409,17 +500,20 @@ function checkUpload() {
409
500
  return uploadRunner;
410
501
  }
411
502
 
412
- function addToUploadQueue(assets) {
503
+ function addToUploadQueue(
504
+ assets,
505
+ { quiet = false, logWhenIdle = false, failOnError = false } = {},
506
+ ) {
413
507
  if (!obsConfig && !ossConfig) {
414
508
  console.warn('请配置obsConfig 或 ossConfig,否则无法上传文件到obs 或 oss');
415
- return;
509
+ return Promise.resolve();
416
510
  }
417
511
 
512
+ const uniqueFiles = [...new Set(assets.map((asset) => path.resolve(DIST_DIR, asset)))];
418
513
  let cachedCount = 0;
419
514
  let pendingCount = 0;
420
515
 
421
- assets.forEach((asset) => {
422
- const file = path.resolve(DIST_DIR, asset);
516
+ uniqueFiles.forEach((file) => {
423
517
  if (getStorage(file)) {
424
518
  cachedCount += 1;
425
519
  return;
@@ -430,14 +524,64 @@ function addToUploadQueue(assets) {
430
524
  }
431
525
  });
432
526
 
433
- if (ossConfig) {
434
- console.log(
435
- `[assets] upload queue: provider=OSS, total=${assets.length}, ` +
436
- `cached=${cachedCount}, pending=${pendingCount}`,
527
+ if (pendingCount > 0) {
528
+ if (!uploadSummary) {
529
+ uploadSummary = createUploadSummary({ quiet, failOnError });
530
+ } else {
531
+ // 同一个 watch 上传批次可能由多次编译共同追加;任一调用要求严格失败或正常日志时,
532
+ // 整个批次都采用更严格、可见的配置。
533
+ uploadSummary.failOnError = uploadSummary.failOnError || failOnError;
534
+ uploadSummary.quiet = uploadSummary.quiet && quiet;
535
+ }
536
+
537
+ uniqueFiles.forEach((file) => {
538
+ uploadSummary.assets.add(file);
539
+ if (getStorage(file) && !uploadSummary.pending.has(file)) {
540
+ uploadSummary.localCached.add(file);
541
+ }
542
+ if (uploadQueue[file] !== undefined) {
543
+ uploadSummary.pending.add(file);
544
+ }
545
+ });
546
+ }
547
+
548
+ if (!quiet && (pendingCount > 0 || logWhenIdle)) {
549
+ logUploadQueue(
550
+ {
551
+ provider: getProvider(),
552
+ total: uniqueFiles.length,
553
+ localCached: cachedCount,
554
+ pending: pendingCount,
555
+ },
556
+ false,
437
557
  );
438
558
  }
439
559
 
440
- return pendingCount ? checkUpload() : Promise.resolve();
560
+ if (pendingCount > 0) {
561
+ return checkUpload();
562
+ }
563
+
564
+ if (uploadRunner) {
565
+ return uploadRunner;
566
+ }
567
+
568
+ const result = {
569
+ provider: getProvider(),
570
+ total: uniqueFiles.length,
571
+ localCached: cachedCount,
572
+ pending: 0,
573
+ remoteCached: 0,
574
+ uploaded: 0,
575
+ failed: 0,
576
+ failedFiles: [],
577
+ durationMs: 0,
578
+ };
579
+
580
+ if (logWhenIdle) {
581
+ logUploadResult(result, quiet);
582
+ }
583
+
584
+ return Promise.resolve(result);
441
585
  }
442
586
 
443
587
  module.exports = {