// P4:`data.js` 瘦身 —— 分步降低运行时对源码全文的依赖 // // 背景(实测): // data.json 991KB,其中 components[].sources 占 888KB(89.6%) // 各端源码量:html 245KB / vue2 184KB / vue3 181KB / jsx 155KB / css 119KB // // 哪些 sources 被【渲染期同步】消费(改造难点): // extractComponentAPI → vue3 / vue2 / jsx(3 端) // extractScenarios → html(1 端) // css → 不参与任何解析(可安全分离) // // 策略: // 阶段 A —— 预计算 scenarios,验证「构建期预计算 + 运行时回退」链路可行 // 阶段 B —— 分离 css 到独立文件(零风险,不参与解析) // 阶段 C —— 预计算 API 表(props/emits/slots),再分离 html/jsx/vue2/vue3 // // 设计原则:运行时保留回退路径,预计算缺失时功能不受影响。 import { fileURLToPath } from 'url'; import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, unlinkSync, statSync, } from 'fs'; import path from 'path'; /* defineEmits 数组字面量的安全解析(与 site/app.js 内联实现同源,见模块头注释)。 构建期绝不对 frameworks/ 里的内容做动态求值 —— 旧实现把数组字面量交给函数构造器求值, 投毒的 .vue 因此可在构建机上执行任意 JS。 */ import { parseStringLiteralArray } from './lib/parse-string-literal-array.mjs'; const ROOT = path.resolve(path.join(path.dirname(fileURLToPath(import.meta.url)), '..')); const DATA_JSON = path.join(ROOT, 'site', 'data.json'); const DATA_JS = path.join(ROOT, 'site', 'data.js'); const SRC_DIR = path.join(ROOT, 'site', 'sources'); const COMPONENT_COUNT = 79; const SOURCE_FILE_COUNT = 395; if (!existsSync(DATA_JSON)) { console.error('[FATAL] 找不到 site/data.json,请先运行 build-site.ps1'); process.exit(1); } const data = JSON.parse(readFileSync(DATA_JSON, 'utf8')); const beforeKb = Math.round(statSync(DATA_JSON).size / 1024); const SOURCE_KINDS = ['html', 'css', 'jsx', 'vue2', 'vue3']; const expectedSourcePaths = []; if (!data || !Array.isArray(data.components) || data.components.length !== COMPONENT_COUNT) { const actualCount = data && Array.isArray(data.components) ? data.components.length : 0; console.error(`[FATAL] 组件完整性校验失败:期望 ${COMPONENT_COUNT} 个组件,实际 ${actualCount} 个`); process.exit(1); } for (const c of data.components) { if (!c || typeof c.slug !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(c.slug)) { console.error(`[FATAL] 组件完整性校验失败:非法 slug ${String(c && c.slug)}`); process.exit(1); } for (const kind of SOURCE_KINDS) { if (!c.sources || typeof c.sources[kind] !== 'string') { console.error(`[FATAL] 源码完整性校验失败:${c.slug} 缺少 ${kind} 源码`); process.exit(1); } expectedSourcePaths.push(path.join(SRC_DIR, c.slug, `${kind}.txt`)); } } if (expectedSourcePaths.length !== SOURCE_FILE_COUNT) { console.error(`[FATAL] 源文件完整性校验失败:期望 ${SOURCE_FILE_COUNT} 个,实际 ${expectedSourcePaths.length} 个`); process.exit(1); } /* 与 app.js 的 extractScenarios 完全一致(保证结果可复现) */ function extractScenarios(c) { const html = (c.sources && c.sources.html) || ''; const bodyM = html.match(/]*>([\s\S]*)<\/body>/i); const body = bodyM ? bodyM[1] : html; const out = []; const re = /]*>([\s\S]*?)<\/h2>/gi; let m; while ((m = re.exec(body)) !== null) { let text = m[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); text = text.replace(/^\d+[.、]\s*/, ''); if (text) out.push(text); } return out; } /* ---------- 阶段 A:预计算 scenarios ---------- */ let withScenarios = 0; let totalScenarios = 0; for (const c of data.components) { const s = extractScenarios(c); c.scenarios = s; totalScenarios += s.length; if (s.length >= 2) withScenarios++; // app.js 的 buildScenarioCard 要求 >= 2 } console.log(`[precompute] 阶段A · scenarios:${totalScenarios} 条 / ${withScenarios} 个组件有 ≥2 条`); /* ---------- 与 app.js 的 extractComponentAPI 逐行一致 ---------- 唯一差别:desc 存中文源串(不调 T),运行时由 app.js 包 T() 做中英映射。 这样中文模式输出与今天逐字节一致,英文模式仍能查字典翻译。 slots 额外存 custom 标记:自定义插槽的 desc 需按 name + 后缀动态拼接, 不能存拼接后的整串(否则英文模式查不到字典)。 */ function scanObjectAt(src, openIdx) { let depth = 0, inStr = null, escCh = false, end = -1; for (let i = openIdx; i < src.length; i++) { const ch = src.charAt(i); if (inStr) { if (escCh) { escCh = false; continue; } if (ch === '\\') { escCh = true; continue; } if (ch === inStr) inStr = null; continue; } if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; continue; } if (ch === '{') depth++; else if (ch === '}') { depth--; if (depth === 0) { end = i; break; } } } return end < 0 ? null : src.slice(openIdx, end + 1); } function stripOuterBraces(s) { return s.replace(/^\s*\{/, '').replace(/\}\s*$/, ''); } function splitTopLevel(body) { const segs = []; let cur = '', depth = 0, inStr = null, escCh = false; for (let i = 0; i < body.length; i++) { const ch = body.charAt(i); if (inStr) { cur += ch; if (escCh) { escCh = false; continue; } if (ch === '\\') { escCh = true; continue; } if (ch === inStr) inStr = null; continue; } if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; cur += ch; continue; } if (ch === '{' || ch === '[' || ch === '(') { depth++; cur += ch; continue; } if (ch === '}' || ch === ']' || ch === ')') { depth--; cur += ch; continue; } if (ch === ',' && depth === 0) { segs.push(cur); cur = ''; continue; } cur += ch; } if (cur.trim()) segs.push(cur); return segs; } function scanTopLevelValue(str) { let depth = 0, inStr = null, escCh = false; for (let i = 0; i < str.length; i++) { const ch = str.charAt(i); if (inStr) { if (escCh) { escCh = false; continue; } if (ch === '\\') { escCh = true; continue; } if (ch === inStr) inStr = null; continue; } if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; continue; } if (ch === '{' || ch === '[' || ch === '(') depth++; else if (ch === '}' || ch === ']' || ch === ')') { if (depth === 0) return str.slice(0, i); depth--; } else if (ch === ',' && depth === 0) return str.slice(0, i); } return str; } function finalizeProp(p) { let type = 'any'; let def = '—'; let req = false; const typeM = p.raw.match(/type\s*:\s*(\[[^\]]+\]|[a-zA-Z0-9_$]+)/); if (typeM) type = typeM[1].replace(/\[|\]/g, '').replace(/,\s*/g, ' | '); const defM = /default\s*:/.exec(p.raw); if (defM) { let val = scanTopLevelValue(p.raw.slice(defM.index + defM[0].length)).trim(); def = val.replace(/\(\)\s*=>\s*/, '') || '—'; if (def === '()') def = '—'; def = def.replace(/\s*\n\s*/g, ' ').replace(/\s{2,}/g, ' '); if (def.length > 48) def = def.slice(0, 45) + '…'; } if (/required\s*:\s*true/.test(p.raw)) req = true; return { name: p.name, type, def, req, desc: p.comment || '—' }; } function extractComponentAPI(c) { const v3 = (c.sources && c.sources.vue3) || ''; const v2 = (c.sources && c.sources.vue2) || ''; const jsx = (c.sources && c.sources.jsx) || ''; const code = v3 || v2; const props = []; const emits = []; const slots = []; let propsBody = null; const dpM = /defineProps\s*\(/.exec(code); if (dpM) { const openIdx = code.indexOf('{', dpM.index); if (openIdx >= 0) propsBody = stripOuterBraces(scanObjectAt(code, openIdx) || ''); } if (!propsBody) { const prM = /props\s*:\s*\{/.exec(code); if (prM) { const openIdx2 = prM.index + prM[0].length - 1; propsBody = stripOuterBraces(scanObjectAt(code, openIdx2) || ''); } } if (propsBody && propsBody.trim()) { splitTopLevel(propsBody).forEach((seg) => { const m = seg.match(/^\s*([a-zA-Z0-9_$]+)\s*:\s*([\s\S]*)$/); if (!m) return; const cM = m[2].match(/\/\/\s*([^\n]+)\s*$/); props.push(finalizeProp({ name: m[1], comment: cM ? cM[1].trim() : '', raw: m[2] })); }); } else if (jsx) { const fnM = jsx.match(/export default function\s+[A-Za-z0-9_$]+\s*\(\s*\{([^}]+)\}/); if (fnM) { fnM[1].split(',').forEach((param) => { const p = param.trim(); if (!p || p.indexOf('...') === 0) return; const parts = p.split('='); const name = parts[0].trim(); const def = parts[1] ? parts[1].trim() : '—'; if (name.indexOf('on') === 0 && /[A-Z]/.test(name.charAt(2))) { emits.push({ name, descZh: '回调触发事件 (Event callback)', params: '(event: any) => void' }); } else if (name === 'children') { slots.push({ name: 'default', descZh: '子节点内容 (children)', custom: false }); } else { props.push({ name, type: 'any', def, req: false, desc: '—' }); } }); } } /* 只用 parseStringLiteralArray 解析字符串字面量数组,绝不求值。 旧实现把数组字面量交给函数构造器求值,投毒的 .vue 会在构建期执行任意 JS(已实测)。 */ const mEmits = code.match(/defineEmits\s*\(\s*(\[[^\]]+\])\s*\)/); const emitArr = mEmits ? parseStringLiteralArray(mEmits[1]) : null; if (emitArr) { emitArr.forEach((e) => { const descZh = e.indexOf('update:') === 0 ? '双向绑定更新事件 (v-model)' : '组件交互触发事件'; if (!emits.some((x) => x.name === e)) { emits.push({ name: e, descZh, params: '(val: any) => void' }); } }); } const emitRegex = /\$emit\(\s*['"]([a-zA-Z0-9_:-]+)['"]/g; let eMatch; while ((eMatch = emitRegex.exec(code)) !== null) { const eName = eMatch[1]; if (!emits.some((x) => x.name === eName)) { emits.push({ name: eName, descZh: '交互回调触发事件', params: '—' }); } } const slotRegex = / x.name === sName)) { slots.push(sName === 'default' ? { name: sName, descZh: '默认插槽(组件文本或主体内容)', custom: false } : { name: sName, descZh: '', custom: true }); } } return { props, emits, slots }; } /* ---------- 阶段 C:预计算 API 表 ---------- */ let apiProps = 0, apiEmits = 0, apiSlots = 0, apiComps = 0; for (const c of data.components) { const api = extractComponentAPI(c); c.api = api; apiProps += api.props.length; apiEmits += api.emits.length; apiSlots += api.slots.length; if (api.props.length || api.emits.length || api.slots.length) apiComps++; } console.log(`[precompute] 阶段C · api:${apiComps} 个组件有 API 表(props ${apiProps} / emits ${apiEmits} / slots ${apiSlots})`); /* ---------- 阶段 B:分离 css 源码到独立文件 ---------- css 不参与任何解析(extractComponentAPI 只读 vue3/vue2/jsx,extractScenarios 只读 html), 因此可安全移出 data.js,改为按需 fetch。 data.json 保持完整(For Agents 承诺不变)。 */ if (!existsSync(SRC_DIR)) mkdirSync(SRC_DIR, { recursive: true }); /* ---------- 阶段 C-2:分离 html/jsx/vue2/vue3 ---------- 渲染期同步消费已全部被预计算替代: - extractComponentAPI → c.api(本文件阶段 C) - extractScenarios → c.scenarios(阶段 A) 剩余消费全是异步安全(代码展示/srcOf、Playground),由 app.js 按需 fetch。 */ const SPLIT_KINDS = ['html', 'jsx', 'vue2', 'vue3']; let splitFiles = 0; for (const c of data.components) { if (!c.sources) continue; for (const kind of SPLIT_KINDS) { if (!c.sources[kind]) continue; const dir = path.join(SRC_DIR, c.slug); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); writeFileSync(path.join(dir, `${kind}.txt`), c.sources[kind]); splitFiles++; } } console.log(`[precompute] 阶段C-2 · 源码已分离:${splitFiles} 个文件 → site/sources//{html,jsx,vue2,vue3}.txt`); /* css 分离(阶段 B 保留) */ let cssSplit = 0; for (const c of data.components) { if (!c.sources || !c.sources.css) continue; const dir = path.join(SRC_DIR, c.slug); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); writeFileSync(path.join(dir, 'css.txt'), c.sources.css); cssSplit++; } console.log(`[precompute] 阶段B · css 已分离:${cssSplit} 个文件 → site/sources//css.txt`); const missingOutputSources = expectedSourcePaths.filter((filePath) => !existsSync(filePath)); if (splitFiles + cssSplit !== SOURCE_FILE_COUNT || missingOutputSources.length > 0) { console.error(`[FATAL] 源文件输出完整性校验失败:期望 ${SOURCE_FILE_COUNT} 个,生成 ${splitFiles + cssSplit} 个,缺失 ${missingOutputSources.length} 个`); process.exit(1); } console.log(`[precompute] 完整性校验:${data.components.length}/${COMPONENT_COUNT} 个组件,${splitFiles + cssSplit}/${SOURCE_FILE_COUNT} 个源文件`); /* 在 data.js 里把全部 5 端换成引用(data.json 保持完整) */ const dataForJs = JSON.parse(JSON.stringify(data)); for (const c of dataForJs.components) { if (!c.sources) continue; for (const kind of ['html', 'css', 'jsx', 'vue2', 'vue3']) { if (c.sources[kind] != null) { c.sourcesRef = c.sourcesRef || {}; c.sourcesRef[kind] = `sources/${c.slug}/${kind}.txt`; delete c.sources[kind]; } } } /* ---------- 阶段 D:分离 contract/specLines/changelog ---------- data.js 体积 ord:contract 38KB + changelog 11.6KB + specLines 11KB ≈ 60KB。 外壳只留 hasContract 布尔;详情写 site/details/.json,日志写 site/changelog.json。 data.json 保持全量完整(对外承诺不变)。 */ const DETAILS_DIR = path.join(ROOT, 'site', 'details'); if (!existsSync(DETAILS_DIR)) mkdirSync(DETAILS_DIR, { recursive: true }); let detailFiles = 0; for (const c of dataForJs.components) { const detail = { slug: c.slug, contract: c.contract ?? null, specLines: c.specLines ?? [], /* 族层:该组件属于哪个族、以什么角色参与、被哪些参数值钉成这一成员。 不属任何族时为 null(79 个组件里 35 个是独立组件)。 */ family: c.family ?? null, familyRole: c.familyRole ?? null, familyParams: c.familyParams ?? null, }; writeFileSync(path.join(DETAILS_DIR, `${c.slug}.json`), JSON.stringify(detail) + '\n'); detailFiles++; c.hasContract = c.contract != null; delete c.contract; delete c.specLines; } console.log(`[precompute] 阶段D · details:${detailFiles} 个文件 → site/details/.json`); const changelogData = { changelog: dataForJs.changelog || [] }; writeFileSync(path.join(ROOT, 'site', 'changelog.json'), JSON.stringify(changelogData) + '\n'); delete dataForJs.changelog; console.log(`[precompute] 阶段D · changelog:${changelogData.changelog.length} 条 → site/changelog.json`); /* 清理 details 下的孤儿文件(slug 已不存在时删除) */ const validSlugs = new Set(dataForJs.components.map((c) => c.slug)); let orphans = 0; for (const f of readdirSync(DETAILS_DIR)) { if (!f.endsWith('.json')) continue; if (!validSlugs.has(f.slice(0, -5))) { unlinkSync(`${DETAILS_DIR}/${f}`); orphans++; } } if (orphans) console.log(`[precompute] 阶段D · 清理孤儿 details:${orphans} 个`); /* ---------- 写回 ---------- */ writeFileSync(DATA_JSON, JSON.stringify(data, null, 2) + '\n'); const js = '/* AUTO-GENERATED by build-site.ps1 + tools/precompute.mjs - do not edit by hand */\r\nwindow.AA_DATA = ' + JSON.stringify(dataForJs) + ';\r\n'; writeFileSync(DATA_JS, js); const afterKb = (Buffer.byteLength(js) / 1024).toFixed(0); console.log(`[precompute] data.js: ${beforeKb} KB → ${afterKb} KB(−${beforeKb - afterKb} KB)`);