build.js 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. /**
  2. * 打包脚本:压缩并拷贝静态资源到 dist 目录。
  3. *
  4. * 压缩策略:
  5. * - HTML → html-minifier(移除空白、注释、可省略引号)
  6. * - CSS → clean-css
  7. * - JS → terser
  8. * - 图片 → 直接拷贝
  9. *
  10. * 排除:token.js、.env*、welfare-config.example.js、隐藏目录、node_modules、docs、scripts
  11. */
  12. const fs = require('fs');
  13. const path = require('path');
  14. const { minify: minifyHtml } = require('html-minifier');
  15. const CleanCSS = require('clean-css');
  16. const { minify: minifyJs } = require('terser');
  17. const ROOT = path.resolve(__dirname, '..');
  18. const DIST = path.join(ROOT, 'dist');
  19. // ───────────────── 排除规则 ─────────────────
  20. const EXCLUDE_NAMES = new Set(['token.js', '.env', '.env.local', 'welfare-config.example.js']);
  21. function shouldExclude(relativePath) {
  22. const name = path.basename(relativePath);
  23. if (EXCLUDE_NAMES.has(name)) return true;
  24. // 隐藏目录 / 黑名单目录
  25. for (const part of relativePath.split(path.sep)) {
  26. if (part.startsWith('.') || ['node_modules', 'docs', 'dist', 'scripts'].includes(part)) return true;
  27. }
  28. return false;
  29. }
  30. // ───────────────── 压缩器实例 ─────────────────
  31. const htmlMinifyOpts = {
  32. collapseWhitespace: true, // 合并空白
  33. removeComments: true, // 删除注释
  34. removeAttributeQuotes: false, // 保留引号(避免兼容性问题)
  35. minifyCSS: true, // 内联 CSS 也压缩
  36. minifyJS: false, // 内联 JS 不压缩(避免变量名冲突)
  37. removeEmptyAttributes: true,
  38. keepClosingSlash: false,
  39. };
  40. const cleanCSS = new CleanCSS({ level: 2 });
  41. // ───────────────── 文件处理 ─────────────────
  42. const extHandlers = {
  43. '.html': handleHtml,
  44. '.css': handleCss,
  45. '.js': handleJs,
  46. };
  47. function handleHtml(src, dest) {
  48. const raw = fs.readFileSync(src, 'utf8');
  49. const result = minifyHtml(raw, htmlMinifyOpts);
  50. fs.writeFileSync(dest, result, 'utf8');
  51. logFile(src, dest, raw.length, result.length);
  52. }
  53. async function handleJs(src, dest) {
  54. const raw = fs.readFileSync(src, 'utf8');
  55. const result = await minifyJs(raw, {
  56. compress: { drop_console: false },
  57. mangle: false, // 不混淆变量名(i18n key 等动态引用)
  58. output: { comments: false },
  59. });
  60. if (result.code) {
  61. fs.writeFileSync(dest, result.code, 'utf8');
  62. logFile(src, dest, raw.length, result.code.length);
  63. } else {
  64. // terser 压缩失败时直接拷贝
  65. fs.copyFileSync(src, dest);
  66. console.warn('[build] ⚠ terser 跳过: ' + path.relative(ROOT, src));
  67. }
  68. }
  69. function handleCss(src, dest) {
  70. const raw = fs.readFileSync(src, 'utf8');
  71. const output = cleanCSS.minify(raw);
  72. if (output.styles) {
  73. fs.writeFileSync(dest, output.styles, 'utf8');
  74. logFile(src, dest, raw.length, output.styles.length);
  75. } else {
  76. fs.copyFileSync(src, dest);
  77. }
  78. }
  79. function logFile(src, dest, before, after) {
  80. const name = path.relative(ROOT, src);
  81. const ratio = before > 0 ? ((1 - after / before) * 100).toFixed(1) : '0.0';
  82. console.log(`[build] ${name} ${(before / 1024).toFixed(1)} KB → ${(after / 1024).toFixed(1)} KB (${ratio}%)`);
  83. }
  84. // ───────────────── 目录拷贝(含压缩) ─────────────────
  85. async function buildDir(srcDir, destDir) {
  86. if (!fs.existsSync(srcDir)) return;
  87. const entries = fs.readdirSync(srcDir, { withFileTypes: true });
  88. for (const entry of entries) {
  89. const src = path.join(srcDir, entry.name);
  90. const relative = path.relative(ROOT, src);
  91. if (shouldExclude(relative)) continue;
  92. const dest = path.join(DIST, relative);
  93. if (entry.isDirectory()) {
  94. fs.mkdirSync(dest, { recursive: true });
  95. await buildDir(src, dest);
  96. } else {
  97. fs.mkdirSync(path.dirname(dest), { recursive: true });
  98. const handler = extHandlers[path.extname(entry.name)];
  99. if (handler) {
  100. await handler(src, dest);
  101. } else {
  102. fs.copyFileSync(src, dest);
  103. }
  104. }
  105. }
  106. }
  107. // ───────────────── 统计 ─────────────────
  108. function countFiles(dir) {
  109. let c = 0;
  110. if (!fs.existsSync(dir)) return 0;
  111. for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
  112. c += e.isDirectory() ? countFiles(path.join(dir, e.name)) : 1;
  113. }
  114. return c;
  115. }
  116. // ───────────────── 主流程 ─────────────────
  117. (async () => {
  118. const start = Date.now();
  119. // 1. 清理
  120. console.log('[build] 清理 dist…');
  121. if (fs.existsSync(DIST)) fs.rmSync(DIST, { recursive: true });
  122. fs.mkdirSync(DIST, { recursive: true });
  123. // 2. html/ 子目录(含压缩)
  124. console.log('[build] 打包 html/…');
  125. await buildDir(path.join(ROOT, 'html'), path.join(DIST, 'html'));
  126. // 3. css / js / images
  127. for (const dir of ['css', 'js', 'images']) {
  128. console.log('[build] 打包 ' + dir + '/…');
  129. await buildDir(path.join(ROOT, dir), path.join(DIST, dir));
  130. }
  131. // 4. 根目录 *.html(压缩)
  132. console.log('[build] 打包根目录 HTML…');
  133. for (const entry of fs.readdirSync(ROOT, { withFileTypes: true })) {
  134. if (!entry.isFile() || !entry.name.endsWith('.html')) continue;
  135. const src = path.join(ROOT, entry.name);
  136. const dest = path.join(DIST, entry.name);
  137. await handleHtml(src, dest);
  138. }
  139. const total = countFiles(DIST);
  140. const elapsed = ((Date.now() - start) / 1000).toFixed(2);
  141. console.log('[build] 完成! ' + total + ' 个文件 → dist/ (' + elapsed + 's)');
  142. })();