// +---------------------------------------------------------------------- // | CRMEB [ CRMEB赋能开发者,助力企业发展 ] // +---------------------------------------------------------------------- // | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved. // +---------------------------------------------------------------------- // | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权 // +---------------------------------------------------------------------- // | Author: CRMEB Team // +---------------------------------------------------------------------- // Vue2 -> Vue3 一次性批量语法转换(codemod) // 用法:node scripts/codemods/transform.mjs [--dry] // 仅做文本级保守转换,转换不了的记录到 scripts/codemods/report.json 供人工处理。 import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath, URL } from 'node:url'; const ROOT = fileURLToPath(new URL('../..', import.meta.url)); const SRC = path.join(ROOT, 'src'); const DRY = process.argv.includes('--dry'); const FILTER_NAMES = [ 'liveReviewStatusFilter', 'liveStatusFilter', 'formatDate', 'broadcastType', 'filterClose', 'broadcastDisplayType', 'filterEmpty', 'userType', 'sourceType', ]; const ASSET_EXT = /\.(png|jpe?g|gif|svg|webp|ico|bmp|mp3|mp4|ttf|woff2?|eot|json)$/i; const EP_IMPORT_MAP = { Message: 'ElMessage', MessageBox: 'ElMessageBox', Loading: 'ElLoading', Notification: 'ElNotification', }; const report = { files: 0, changed: 0, counts: {}, issues: [] }; const issue = (type, file, detail) => { report.counts[type] = (report.counts[type] || 0) + 1; report.issues.push({ type, file, detail: String(detail).slice(0, 300) }); }; // ---------------- 通用工具 ---------------- // 提取平衡括号的实参(处理嵌套 () [] {} 与字符串) function parseCallArgs(src, openIdx) { let depth = 0; let quote = null; const args = []; let cur = ''; for (let i = openIdx; i < src.length; i++) { const c = src[i]; if (quote) { cur += c; if (c === quote && src[i - 1] !== '\\') quote = null; continue; } if (c === '"' || c === "'" || c === '`') { quote = c; cur += c; continue; } if (i === openIdx) { // 调用自身的起始括号不计入参数 depth = 1; continue; } if (c === '(' || c === '[' || c === '{') { depth++; cur += c; continue; } if (c === ')' || c === ']' || c === '}') { depth--; if (depth === 0 && c === ')') { if (cur.trim()) args.push(cur.trim()); return { closeIdx: i, args }; } cur += c; continue; } if (c === ',' && depth === 1) { args.push(cur.trim()); cur = ''; continue; } cur += c; } return null; } // ---------------- 模板转换 ---------------- function transformSlotSyntax(tpl, file) { let out = tpl; let guard = 0; let changed = true; while (changed && guard++ < 20) { changed = false; const tagRe = /<([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>/g; let m; while ((m = tagRe.exec(out))) { const [full, tagName, attrsRaw, selfClose] = m; const attrStr = attrsRaw + ''; const slotM = attrStr.match(/\sslot="([^"]+)"/); const scopeM = attrStr.match(/\sslot-scope="([^"]+)"/); if (!slotM && !scopeM) continue; let newAttrs = attrStr.replace(/\sslot="[^"]+"/, '').replace(/\sslot-scope="[^"]+"/, ''); let replacement; if (tagName === 'template') { const slotName = slotM ? slotM[1] : 'default'; replacement = ``; } else if (selfClose) { const slotName = slotM ? slotM[1] : 'default'; replacement = ``; } else { // 需要找匹配的闭合标签并包裹 const closeRe = new RegExp(`<\\/?${tagName}\\b[^>]*>`, 'g'); closeRe.lastIndex = m.index + full.length; let depth = 1; let close = null; let cm; while ((cm = closeRe.exec(out))) { const t = cm[0]; if (t.startsWith('')) { depth++; } } if (!close) { issue('slot-wrap-failed', file, full); continue; } const slotName = slotM ? slotM[1] : 'default'; const innerStart = m.index + full.length; const innerEnd = close.index; const openTag = `<${tagName}${newAttrs}>`; const closeTag = close[0]; const wrapped = `'; out = out.slice(0, m.index) + wrapped + out.slice(close.index + close[0].length); changed = true; guard++; break; // 重新扫描(索引已变化) } out = out.slice(0, m.index) + replacement + out.slice(m.index + full.length); changed = true; break; // 重新扫描 } } return out; } function transformSync(tpl) { let out = tpl; // el-dialog / el-drawer 的 :visible.sync -> v-model(Element Plus 使用 modelValue) out = out.replace(/<(el-dialog|el-drawer)\b[^>]*>/gs, (tag) => tag.replace(/:visible\.sync="([^"]*)"/g, 'v-model="$1"'), ); // 其余 :xxx.sync="yyy" -> v-model:xxx="yyy" out = out.replace(/:([\w-]+)\.sync="([^"]*)"/g, 'v-model:$1="$2"'); return out; } function transformNative(tpl) { return tpl.replace(/(@[\w.]+)\.native\b/g, '$1').replace(/v-on:(\w+)\.native\b/g, 'v-on:$1'); } function transformFiltersInTpl(tpl, file) { return tpl.replace(/\{\{([\s\S]*?)\}\}/g, (full, inner) => { if (!/\|/.test(inner)) return full; // 按顶层单竖线切分(忽略 ||) const parts = []; let cur = ''; let quote = null; let depth = 0; for (let i = 0; i < inner.length; i++) { const c = inner[i]; if (quote) { cur += c; if (c === quote && inner[i - 1] !== '\\') quote = null; continue; } if (c === '"' || c === "'" || c === '`') { quote = c; cur += c; continue; } if (c === '(' || c === '[' || c === '{') depth++; if (c === ')' || c === ']' || c === '}') depth--; if (c === '|' && depth === 0 && inner[i + 1] !== '|' && inner[i - 1] !== '|') { parts.push(cur.trim()); cur = ''; continue; } cur += c; } parts.push(cur.trim()); if (parts.length < 2) return full; let expr = parts[0]; for (let i = 1; i < parts.length; i++) { const fname = parts[i]; if (!/^[a-zA-Z_$][\w$]*$/.test(fname)) { issue('tpl-filter-unknown', file, full); return full; } if (FILTER_NAMES.includes(fname)) { expr = `$filters.${fname}(${expr})`; } else { // 组件局部 filters:转为同名函数直接调用(迁移 script setup 时把 filters 改为普通函数) issue('tpl-filter-local', file, `${fname} <- ${full.replace(/\n/g, ' ').slice(0, 120)}`); expr = `${fname}(${expr})`; } } return `{{ ${expr} }}`; }); } function transformTplAttrHelpers(tpl, file) { let out = tpl; // el-submenu -> el-sub-menu out = out.replace(/<\/?el-submenu\b/g, (t) => t.replace('el-submenu', 'el-sub-menu')); // el-link :underline="false" -> underline="never" out = out.replace(/:underline="false"/g, 'underline="never"'); // 检测项:template v-for 内 key 未上移 / v-for 与 v-if 同标签 const tagRe = /<[a-zA-Z][\w-]*(?:"[^"]*"|'[^']*'|[^>"'])*>/g; let m; while ((m = tagRe.exec(out))) { const t = m[0]; if (/]*v-for/.test(t)) issue('tpl-template-vfor', file, t); if (/v-for=/.test(t) && /v-if=/.test(t)) issue('tpl-vfor-vif', file, t); if (/\benter-class|\bleave-class\b/.test(t)) issue('tpl-transition-class', file, t); } return out; } // ---------------- 样式转换 ---------------- function transformStyle(css, file) { let out = css; // ::v-deep / /deep/ 支持开头与选择器中间位置,逐个逗号分段包裹 out = out .replace(/(^|[\s,>~+])::v-deep\s*([^{,]+?)\s*(?=[,{])/g, (m, pre, sel) => `${pre}:deep(${sel.trim()})`) .replace(/(^|[\s,>~+])\/deep\/\s*([^{,]+?)\s*(?=[,{])/g, (m, pre, sel) => `${pre}:deep(${sel.trim()})`); // webpack tilde 前缀 out = out.replace(/@import\s+["']~/g, "@import '"); const leftover = out.match(/::v-deep|\/deep\//g); if (leftover) issue('style-deep-leftover', file, `${leftover.length} 处`); return out; } // ---------------- 脚本转换 ---------------- function transformSetDelete(code, file) { let out = code; let from = 0; const inLineComment = (idx) => { const lineStart = out.lastIndexOf('\n', idx) + 1; const before = out.slice(lineStart, idx); return before.includes('//'); }; // 支持 this.$set / that.$set / self.$set 等任意接收者别名 while (true) { const re = /\b(\w+)\.\$set\(/g; re.lastIndex = from; const m = re.exec(out); if (!m) break; const idx = m.index; const callLen = m[0].length; if (inLineComment(idx)) { from = idx + callLen; continue; } const parsed = parseCallArgs(out, idx + callLen - 1); if (!parsed) break; if (parsed.args.length === 3) { const rep = `${parsed.args[0]}[${parsed.args[1]}] = ${parsed.args[2]}`; out = out.slice(0, idx) + rep + out.slice(parsed.closeIdx + 1); from = idx + rep.length; } else { issue('set-args', file, out.slice(idx, parsed.closeIdx + 1)); from = parsed.closeIdx; } } from = 0; while (true) { const re = /\b(\w+)\.\$delete\(/g; re.lastIndex = from; const m = re.exec(out); if (!m) break; const idx = m.index; const callLen = m[0].length; if (inLineComment(idx)) { from = idx + callLen; continue; } const parsed = parseCallArgs(out, idx + callLen - 1); if (!parsed) break; if (parsed.args.length === 2) { const rep = `delete ${parsed.args[0]}[${parsed.args[1]}]`; out = out.slice(0, idx) + rep + out.slice(parsed.closeIdx + 1); from = idx + rep.length; } else { issue('delete-args', file, out.slice(idx, parsed.closeIdx + 1)); from = parsed.closeIdx; } } return out; } function transformLifecycle(code) { return code.replace(/\bbeforeDestroy\s*\(/g, 'beforeUnmount(').replace(/\bdestroyed\s*\(/g, 'unmounted('); } function transformEnv(code) { return code.replace(/process\.env\.VUE_APP_/g, 'import.meta.env.VITE_APP_'); } function transformElementUiImport(code, file) { let out = code; if (/import\s+'element-ui\/lib\/theme-chalk\/index\.css'/.test(out)) { out = out.replace(/import\s+'element-ui\/lib\/theme-chalk\/index\.css';?\n?/g, ''); report.counts['el-css-removed'] = (report.counts['el-css-removed'] || 0) + 1; } const m = out.match(/import\s+\{([^}]+)\}\s+from\s+'element-ui';/); if (m) { const names = m[1] .split(',') .map((s) => s.trim()) .filter(Boolean); const renamed = names.map((n) => EP_IMPORT_MAP[n] || n); out = out.replace(m[0], `import { ${renamed.join(', ')} } from 'element-plus';`); names.forEach((old, i) => { const neu = renamed[i]; if (old !== neu) { out = out.replace(new RegExp(`\\b${old}\\b`, 'g'), neu); } }); report.counts['el-import-fixed'] = (report.counts['el-import-fixed'] || 0) + 1; } out = out.replace(/from\s+'element-ui'/g, "from 'element-plus'"); return out; } function insertImports(code, importLines) { if (!importLines.length) return code; const lines = code.split('\n'); let insertAt = 0; // 跳过顶部版权注释块 for (let i = 0; i < lines.length; i++) { const t = lines[i].trim(); if (t === '' || t.startsWith('//') || t.startsWith('/*') || t.startsWith('*') || t.startsWith('*/')) { insertAt = i + 1; if (t === '' && i > 2) break; } else break; } lines.splice(insertAt, 0, ...importLines, ''); return lines.join('\n'); } function transformRequireAssets(code, file, isVue) { const imports = []; const map = new Map(); let counter = 0; let out = code.replace(/require\((['"])([^'"]+)\1\)/g, (full, q, p) => { if (!ASSET_EXT.test(p) || p.includes('${')) return full; if (!map.has(p)) { const base = path .basename(p) .replace(/\.[^.]+$/, '') .replace(/[^\w]/g, '_'); const id = `__asset_${base}_${counter++}`; map.set(p, id); imports.push(`import ${id} from '${p}';`); } report.counts['require->import'] = (report.counts['require->import'] || 0) + 1; return map.get(p); }); if (!imports.length) return out; return insertImports(out, imports); } // ---------------- SFC 处理 ---------------- function transformVueFile(code, file) { let out = code; // template 块(贪婪匹配第一个 ) const tplStart = out.search(/]*>/); if (tplStart !== -1) { const tplEnd = out.lastIndexOf(''); if (tplEnd > tplStart) { const inner = out.slice(tplStart, tplEnd + ''.length); let newTpl = inner; newTpl = transformSlotSyntax(newTpl, file); newTpl = transformSync(newTpl); newTpl = transformNative(newTpl); newTpl = transformFiltersInTpl(newTpl, file); newTpl = transformTplAttrHelpers(newTpl, file); const tplRequire = newTpl.match(/require\((['"])[^'"]+\1\)/g); if (tplRequire) issue('tpl-require', file, `${tplRequire.length} 处(转 script setup 时改为顶部 import)`); out = out.slice(0, tplStart) + newTpl + out.slice(tplEnd + ''.length); } } // script 块 const scriptRe = /]*)>([\s\S]*?)<\/script>/g; out = out.replace(scriptRe, (full, attrs, body) => { let nb = body; nb = transformSetDelete(nb, file); nb = transformLifecycle(nb); nb = transformEnv(nb); nb = transformElementUiImport(nb, file); nb = transformRequireAssets(nb, file, true); if (/import\s+Vue\s+from\s+'vue'/.test(nb)) issue('import-vue-default', file, ''); return `${nb}`; }); // style 块 const styleRe = /]*)>([\s\S]*?)<\/style>/g; out = out.replace(styleRe, (full, attrs, body) => `${transformStyle(body, file)}`); return out; } function transformJsFile(code, file) { let out = code; out = transformSetDelete(out, file); out = transformLifecycle(out); out = transformEnv(out); out = transformElementUiImport(out, file); out = transformRequireAssets(out, file, false); if (/import\s+Vue\s+from\s+'vue'/.test(out)) issue('import-vue-default', file, ''); return out; } // ---------------- 遍历执行 ---------------- function walk(dir, cb) { for (const name of fs.readdirSync(dir)) { const p = path.join(dir, name); const st = fs.statSync(p); if (st.isDirectory()) walk(p, cb); else cb(p); } } walk(SRC, (p) => { if (!/\.(vue|js)$/.test(p)) return; const rel = path.relative(ROOT, p); const code = fs.readFileSync(p, 'utf8'); report.files++; const out = p.endsWith('.vue') ? transformVueFile(code, rel) : transformJsFile(code, rel); if (out !== code) { report.changed++; if (!DRY) fs.writeFileSync(p, out); } }); fs.writeFileSync(path.join(ROOT, 'scripts/codemods/report.json'), JSON.stringify(report, null, 2)); const summary = Object.entries(report.counts) .map(([k, v]) => `${k}: ${v}`) .join(', '); console.log(`扫描 ${report.files} 个文件,改动 ${report.changed} 个${DRY ? '(dry 模式未写入)' : ''}`); console.log(`转换统计: ${summary || '无'}`); console.log(`待人工处理 issue: ${report.issues.length} 条,明细见 scripts/codemods/report.json`); const byType = {}; report.issues.forEach((i) => (byType[i.type] = (byType[i.type] || 0) + 1)); console.log('issue 分布:', JSON.stringify(byType));