| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163 |
- /**
- * 打包脚本:压缩并拷贝静态资源到 dist 目录。
- *
- * 压缩策略:
- * - HTML → html-minifier(移除空白、注释、可省略引号)
- * - CSS → clean-css
- * - JS → terser
- * - 图片 → 直接拷贝
- *
- * 排除:token.js、.env*、welfare-config.example.js、隐藏目录、node_modules、docs、scripts
- */
- const fs = require('fs');
- const path = require('path');
- const { minify: minifyHtml } = require('html-minifier');
- const CleanCSS = require('clean-css');
- const { minify: minifyJs } = require('terser');
- const ROOT = path.resolve(__dirname, '..');
- const DIST = path.join(ROOT, 'dist');
- // ───────────────── 排除规则 ─────────────────
- const EXCLUDE_NAMES = new Set(['token.js', '.env', '.env.local', 'welfare-config.example.js']);
- function shouldExclude(relativePath) {
- const name = path.basename(relativePath);
- if (EXCLUDE_NAMES.has(name)) return true;
- // 隐藏目录 / 黑名单目录
- for (const part of relativePath.split(path.sep)) {
- if (part.startsWith('.') || ['node_modules', 'docs', 'dist', 'scripts'].includes(part)) return true;
- }
- return false;
- }
- // ───────────────── 压缩器实例 ─────────────────
- const htmlMinifyOpts = {
- collapseWhitespace: true, // 合并空白
- removeComments: true, // 删除注释
- removeAttributeQuotes: false, // 保留引号(避免兼容性问题)
- minifyCSS: true, // 内联 CSS 也压缩
- minifyJS: false, // 内联 JS 不压缩(避免变量名冲突)
- removeEmptyAttributes: true,
- keepClosingSlash: false,
- };
- const cleanCSS = new CleanCSS({ level: 2 });
- // ───────────────── 文件处理 ─────────────────
- const extHandlers = {
- '.html': handleHtml,
- '.css': handleCss,
- '.js': handleJs,
- };
- function handleHtml(src, dest) {
- const raw = fs.readFileSync(src, 'utf8');
- const result = minifyHtml(raw, htmlMinifyOpts);
- fs.writeFileSync(dest, result, 'utf8');
- logFile(src, dest, raw.length, result.length);
- }
- async function handleJs(src, dest) {
- const raw = fs.readFileSync(src, 'utf8');
- const result = await minifyJs(raw, {
- compress: { drop_console: false },
- mangle: false, // 不混淆变量名(i18n key 等动态引用)
- output: { comments: false },
- });
- if (result.code) {
- fs.writeFileSync(dest, result.code, 'utf8');
- logFile(src, dest, raw.length, result.code.length);
- } else {
- // terser 压缩失败时直接拷贝
- fs.copyFileSync(src, dest);
- console.warn('[build] ⚠ terser 跳过: ' + path.relative(ROOT, src));
- }
- }
- function handleCss(src, dest) {
- const raw = fs.readFileSync(src, 'utf8');
- const output = cleanCSS.minify(raw);
- if (output.styles) {
- fs.writeFileSync(dest, output.styles, 'utf8');
- logFile(src, dest, raw.length, output.styles.length);
- } else {
- fs.copyFileSync(src, dest);
- }
- }
- function logFile(src, dest, before, after) {
- const name = path.relative(ROOT, src);
- const ratio = before > 0 ? ((1 - after / before) * 100).toFixed(1) : '0.0';
- console.log(`[build] ${name} ${(before / 1024).toFixed(1)} KB → ${(after / 1024).toFixed(1)} KB (${ratio}%)`);
- }
- // ───────────────── 目录拷贝(含压缩) ─────────────────
- async function buildDir(srcDir, destDir) {
- if (!fs.existsSync(srcDir)) return;
- const entries = fs.readdirSync(srcDir, { withFileTypes: true });
- for (const entry of entries) {
- const src = path.join(srcDir, entry.name);
- const relative = path.relative(ROOT, src);
- if (shouldExclude(relative)) continue;
- const dest = path.join(DIST, relative);
- if (entry.isDirectory()) {
- fs.mkdirSync(dest, { recursive: true });
- await buildDir(src, dest);
- } else {
- fs.mkdirSync(path.dirname(dest), { recursive: true });
- const handler = extHandlers[path.extname(entry.name)];
- if (handler) {
- await handler(src, dest);
- } else {
- fs.copyFileSync(src, dest);
- }
- }
- }
- }
- // ───────────────── 统计 ─────────────────
- function countFiles(dir) {
- let c = 0;
- if (!fs.existsSync(dir)) return 0;
- for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
- c += e.isDirectory() ? countFiles(path.join(dir, e.name)) : 1;
- }
- return c;
- }
- // ───────────────── 主流程 ─────────────────
- (async () => {
- const start = Date.now();
- // 1. 清理
- console.log('[build] 清理 dist…');
- if (fs.existsSync(DIST)) fs.rmSync(DIST, { recursive: true });
- fs.mkdirSync(DIST, { recursive: true });
- // 2. html/ 子目录(含压缩)
- console.log('[build] 打包 html/…');
- await buildDir(path.join(ROOT, 'html'), path.join(DIST, 'html'));
- // 3. css / js / images
- for (const dir of ['css', 'js', 'images']) {
- console.log('[build] 打包 ' + dir + '/…');
- await buildDir(path.join(ROOT, dir), path.join(DIST, dir));
- }
- // 4. 根目录 *.html(压缩)
- console.log('[build] 打包根目录 HTML…');
- for (const entry of fs.readdirSync(ROOT, { withFileTypes: true })) {
- if (!entry.isFile() || !entry.name.endsWith('.html')) continue;
- const src = path.join(ROOT, entry.name);
- const dest = path.join(DIST, entry.name);
- await handleHtml(src, dest);
- }
- const total = countFiles(DIST);
- const elapsed = ((Date.now() - start) / 1000).toFixed(2);
- console.log('[build] 完成! ' + total + ' 个文件 → dist/ (' + elapsed + 's)');
- })();
|