/** * Build-time: 按需注入 diy componentPlaceholder 占位 * * 设计原则: 局部文本替换, 不破坏 pages.json 原有结构 * ============================================================ * - 不调用 JSON.parse / JSON.stringify (它们会丢失注释和预处理指令) * - 不剥离 // 注释 * - 不剥离 // #ifdef / // #ifndef / // #endif 等 uni-app 预处理指令 * - 只对每个 page 的 "style": { ... } 块做局部修改 * - 块外的所有内容 (注释, 预处理, 缩进, 换行) 保持原样 * * 工作流 (按需注入, 避免 vendor.js 膨胀): * 1. 扫描 components-diy/ 目录, 收集所有 diy-*.vue 组件名 (单一数据源: 文件系统) * 2. 逐行扫描 pages.json, 跟踪每个 page 的完整路径: * - 顶层 pages[] 里的 page: 路径 = path (如 "pages/index/index") * - subPackages[] 里的 page: 路径 = root + "/" + path (如 "pages_goods/category") * 3. 对每个 "style": { ... } 块: * a) 读取对应 .vue 文件, 检查是否包含 的 24 个 page 注入, vendor.js 维持 ~570KB, 主包减少 ~265KB. * * 调用时机: vue.config.js 顶部 (module.exports 之前) require 此文件自动执行. */ const path = require('path'); const fs = require('fs'); const PROJECT_ROOT = path.join(__dirname, '..'); const PAGES_JSON = path.join(PROJECT_ROOT, 'pages.json'); const DIY_DIR = path.join(PROJECT_ROOT, 'components-diy'); /** * 扫描 components-diy/ 目录, 收集所有 diy-* 组件名. * - 只匹配顶层 diy-*.vue 文件 (uni-app easycom 单文件组件) * - 排除子目录 (css/ js/ placeholder/) 和其它文件 (minx.js 等) * - 单一数据源: 文件系统, 无需手维护清单 */ function collectDiyComponents() { if (!fs.existsSync(DIY_DIR)) { console.warn(`[InjectPlaceholder] ⚠️ ${DIY_DIR} 不存在, diy 组件清单为空`); return []; } const entries = fs.readdirSync(DIY_DIR, { withFileTypes: true }); const names = []; for (const e of entries) { if (e.isFile() && e.name.startsWith('diy-') && e.name.endsWith('.vue')) { names.push(e.name.slice(0, -'.vue'.length)); } } names.sort(); return names; } const DIY_LIST = collectDiyComponents(); /** * 字符串感知的花括号计数. * 跳过 // 行注释和 "" 字符串内的花括号. * 返回该行净增的花括号深度. */ function netBraces(line) { let d = 0; let inStr = false; let esc = false; let i = 0; while (i < line.length) { const c = line[i]; if (inStr) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === '"') inStr = false; } else { if (c === '/' && line[i + 1] === '/') break; if (c === '"') inStr = true; else if (c === '{') d++; else if (c === '}') d--; } i++; } return d; } /** * 在 body 中找最后一个 "有意义的" 行 (非空, 非纯注释). */ function lastMeaningfulIdx(bodyLines) { for (let j = bodyLines.length - 1; j >= 0; j--) { if (!/^\s*(\/\/.*)?$/.test(bodyLines[j])) return j; } return -1; } /** * 把 import specifier 解析为相对项目根的 .vue 路径(不带扩展名). * 仅处理 ./ ../ @/ 开头的本地路径; node_modules 等外部模块直接返回 null. */ function resolveVueImport(fromRel, spec) { let abs; if (spec.startsWith('@/')) { abs = path.join(PROJECT_ROOT, spec.slice(2)); } else if (spec.startsWith('./') || spec.startsWith('../')) { abs = path.resolve(path.dirname(path.join(PROJECT_ROOT, fromRel + '.vue')), spec); } else { return null; } let rel = path.relative(PROJECT_ROOT, abs).replace(/\\/g, '/'); if (rel.startsWith('..')) return null; if (!rel.endsWith('.vue')) return null; return rel.slice(0, -'.vue'.length); } /** * 缓存: 相对路径 → 是否(直接或传递)使用了 . */ const _usesDiyCache = new Map(); const _importRe = /import\s+[^'"`;\n]*['"]([^'"`]+\.vue)['"]/g; /** * 检查一个 .vue 文件是否(传递)使用了 组件. * 不仅看模板里是否含 */ function fileUsesDiy(relVuePath) { if (!relVuePath) return false; if (_usesDiyCache.has(relVuePath)) return _usesDiyCache.get(relVuePath); // 暂存 false 以打破循环引用 _usesDiyCache.set(relVuePath, false); const abs = path.join(PROJECT_ROOT, relVuePath + '.vue'); let content; try { content = fs.readFileSync(abs, 'utf-8'); } catch (e) { return false; } if (/ 组件. * fullPagePath 是 pages.json 中拼接的页面路径, 如 "pages_goods/detail". */ function pageUsesDiy(fullPagePath) { return fileUsesDiy(fullPagePath); } /** * 从 body 中移除 componentPlaceholder 块和 _diy_placeholder 标记. * 用于: page 之前注入了 placeholder, 但重构后不再使用 diy 组件. * 自动修正尾逗号, 保证移除后 JSON 仍合法. */ function stripPlaceholderFromBody(bodyLines) { let startIdx = -1, endIdx = -1, braceDepth = 0; for (let i = 0; i < bodyLines.length; i++) { const line = bodyLines[i]; if (startIdx === -1) { const m = line.match(/^\s*"componentPlaceholder"\s*:\s*\{/); if (m) { startIdx = i; const idx = line.indexOf('{', m.index); for (let j = idx; j < line.length; j++) { const c = line[j]; if (c === '{') braceDepth++; else if (c === '}') braceDepth--; } if (braceDepth === 0) endIdx = i; } } else { for (const c of line) { if (c === '{') braceDepth++; else if (c === '}') braceDepth--; } if (braceDepth === 0) { endIdx = i; break; } } } const toRemove = new Set(); if (startIdx >= 0 && endIdx >= 0) { for (let i = startIdx; i <= endIdx; i++) toRemove.add(i); } for (let i = 0; i < bodyLines.length; i++) { if (/^\s*"_diy_placeholder"\s*:\s*true/.test(bodyLines[i])) toRemove.add(i); } if (toRemove.size === 0) return bodyLines; const newBody = []; for (let i = 0; i < bodyLines.length; i++) { if (toRemove.has(i)) continue; newBody.push(bodyLines[i]); } // 修正: 移除后, 新的最后有意义行不应有尾逗号 const lastIdx = lastMeaningfulIdx(newBody); if (lastIdx >= 0) { const line = newBody[lastIdx]; const commentMatch = line.match(/(\/\/.*)$/); const comment = commentMatch ? commentMatch[1] : ''; const valuePart = line.substring(0, line.length - comment.length); if (valuePart.trimEnd().endsWith(',')) { newBody[lastIdx] = valuePart.trimEnd().replace(/,\s*$/, '') + comment; } } return newBody; } /** * 处理单个 style 块. * needsPlaceholder=true: 注入/展开/保留 componentPlaceholder * needsPlaceholder=false: 若已有 placeholder 则移除 * 返回 { lines, action } action: 'expand' | 'inject-marker' | 'remove' | 'skip' | 'no-change' */ function processStyleBlock(openLine, bodyLines, closeLine, needsPlaceholder) { const styleIndent = (openLine.match(/^(\s*)/) || ['', ''])[1]; const innerIndent = styleIndent + '\t'; // 不需要 placeholder: 移除已有 if (!needsPlaceholder) { const newBody = stripPlaceholderFromBody(bodyLines); if (newBody.length === bodyLines.length) { return { lines: [openLine, ...bodyLines, closeLine], action: 'no-change' }; } return { lines: [openLine, ...newBody, closeLine], action: 'remove' }; } // 需要 placeholder: 已存在 → 跳过 if (bodyLines.some(l => /^\s*"componentPlaceholder"\s*:/.test(l))) { return { lines: [openLine, ...bodyLines, closeLine], action: 'skip' }; } // 找 _diy_placeholder 标记, 展开 const markerIdx = bodyLines.findIndex(l => /^\s*"_diy_placeholder"\s*:\s*true/.test(l)); if (markerIdx >= 0) { const tailCommaMatch = bodyLines[markerIdx].match(/^\s*"_diy_placeholder"\s*:\s*true\s*(,?)/); const hasTailComma = tailCommaMatch && tailCommaMatch[1] === ','; const newBody = []; for (let j = 0; j < bodyLines.length; j++) { if (j === markerIdx) { newBody.push(`${innerIndent}"componentPlaceholder": {`); // JSON 不允许尾逗号, 最后一个 diy 条目不能加 "," for (let i = 0; i < DIY_LIST.length; i++) { const name = DIY_LIST[i]; const isLast = i === DIY_LIST.length - 1; newBody.push(`${innerIndent}\t"${name}": "view"${isLast ? '' : ','}`); } newBody.push(`${innerIndent}}` + (hasTailComma ? ',' : '')); } else { newBody.push(bodyLines[j]); } } return { lines: [openLine, ...newBody, closeLine], action: 'expand' }; } // 注入标记 const newBody = bodyLines.slice(); const lastIdx = lastMeaningfulIdx(newBody); if (lastIdx === -1) { newBody.push(`${innerIndent}"_diy_placeholder": true`); } else { const lastLine = newBody[lastIdx]; const commentMatch = lastLine.match(/(\/\/.*)$/); const comment = commentMatch ? commentMatch[1] : ''; const valuePart = lastLine.substring(0, lastLine.length - comment.length); if (!valuePart.trimEnd().endsWith(',')) { newBody[lastIdx] = valuePart.trimEnd() + ',' + (comment ? ' ' + comment.trimStart() : ''); } newBody.push(`${innerIndent}"_diy_placeholder": true`); } return { lines: [openLine, ...newBody, closeLine], action: 'inject-marker' }; } function run() { let raw; try { raw = fs.readFileSync(PAGES_JSON, 'utf-8'); } catch (e) { console.error('[InjectPlaceholder] ❌ 读取 pages.json 失败:', e.message); return false; } const EOL = raw.includes('\r\n') ? '\r\n' : '\n'; const lines = raw.split(/\r?\n/); const out = []; let i = 0; let currentSubRoot = null; // 当前 subPackage 的 root, 顶层 page 时为 null let currentPath = null; // 当前 page 的 path let expanded = 0, added = 0, removed = 0, skipped = 0, noChange = 0; while (i < lines.length) { const line = lines[i]; // 跟踪 subPackage root const rootMatch = line.match(/^\s*"root"\s*:\s*"([^"]+)"/); if (rootMatch) { currentSubRoot = rootMatch[1]; out.push(line); i++; continue; } // 跟踪 page path const pathMatch = line.match(/^\s*"path"\s*:\s*"([^"]+)"/); if (pathMatch) { currentPath = pathMatch[1]; out.push(line); i++; continue; } // 处理 style 块 const styleMatch = line.match(/^(\s*)"style"\s*:\s*\{\s*,?\s*$/); if (!styleMatch) { out.push(line); i++; continue; } const openLine = line; const blockStart = i; let depth = 1; i++; while (i < lines.length && depth > 0) { depth += netBraces(lines[i]); i++; } const blockEnd = i; if (depth !== 0) { for (let j = blockStart; j < blockEnd; j++) out.push(lines[j]); continue; } const blockLines = lines.slice(blockStart, blockEnd); const closeLine = blockLines[blockLines.length - 1]; const bodyLines = blockLines.slice(1, -1); // 关键: 按需判断. 读取 .vue 文件看是否真的用了 . const fullPagePath = currentSubRoot ? `${currentSubRoot}/${currentPath}` : currentPath; const needsPlaceholder = pageUsesDiy(fullPagePath); const result = processStyleBlock(openLine, bodyLines, closeLine, needsPlaceholder); for (const l of result.lines) out.push(l); if (result.action === 'expand') expanded++; else if (result.action === 'inject-marker') added++; else if (result.action === 'remove') removed++; else if (result.action === 'skip') skipped++; else if (result.action === 'no-change') noChange++; } const outText = out.join(EOL); if (outText !== raw) { try { fs.writeFileSync(PAGES_JSON, outText, 'utf-8'); console.log(`[InjectPlaceholder] ✅ 展开 ${expanded}, 注入 ${added}, 移除 ${removed}, 跳过 ${skipped}, 无变化 ${noChange}, diy 组件 ${DIY_LIST.length}`); } catch (e) { console.error('[InjectPlaceholder] ❌ 写回 pages.json 失败:', e.message); return false; } } else { console.log(`[InjectPlaceholder] ✓ 无需改动, diy 组件 ${DIY_LIST.length}`); } return true; } run();