/** * keep-alive 组件名匹配的执行级验证台。 * * 【要证的一句话】`` 匹配的是**组件名**,而 `cachedViews` 里放的是 * **路由名**(=`sys_menu.route_name`)⇒ 两者不逐字相同时,那页的 `keep_alive=true` * **静默失效**(vue 不报错,只是不缓存)。乐收赢 `collection/` 下三个页面正是这个状态。 * * 【为什么不能只看源码】「名字现在对上了」与「现在真的会缓存」是两句不同的话。 * 前者是文本读数,后者是运行时行为。本台测**后者**:编译**真**的 SFC(`@vue/compiler-sfc`)、 * 用 `frontend/node_modules` 里那份**真** `vue` 挂进**真** ``, * 数 `setup()` 被调用了几次 —— 缓存命中时再激活**不会**重跑 `setup`。 * * 【两向读数一次跑完】`include` 给两个值: * ① 该页面真实的组件名 ⇒ 期望 setups=1(命中缓存) * ② 一个必然不匹配的串 ⇒ 期望 setups=2(未缓存) * ② 是 ① 的正对照:没有它,setups=1 也可能只是「计数器根本没在工作」。 * * 【不做 DOM】用 `createRenderer` 接一套哑节点操作,不引 jsdom。 * 【模板不渲染】(`inlineTemplate: false`)⇒ 本台证明的是**缓存语义**,不是页面长什么样。 * 【不设 process.exitCode】与同目录各台一致 ⇒ 失败也退 0,**只认最后那行 `SUMMARY`**。 * * 用法:node lsy-keepalive-harness.mjs [标签] */ import fs from "node:fs"; import { createRequire } from "node:module"; const FE = "D:/project2/payment-platform/frontend/"; const require = createRequire(FE + "package.json"); const vue = require("vue"); const { parse, compileScript } = require("@vue/compiler-sfc"); const SRC = process.argv[2]; const LABEL = process.argv[3] || SRC; /** 把 SFC 编译成可 `new Function` 的普通脚本(配方抄自同目录 fe-update-harness.mjs)。 */ function compileSfc(file) { const src = fs.readFileSync(file, "utf8"); const { descriptor, errors } = parse(src, { filename: file }); if (errors && errors.length) throw new Error("SFC 解析失败: " + errors.map(String).join("; ")); let code = compileScript(descriptor, { id: "kaprobe", inlineTemplate: false }).content; const braceImport = (spec) => new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${spec}['"];?`, "g"); const asStub = (slot) => (_m, n) => `const {${n.replace(/\btype\s+[\w$.]+\s*,?/g, "").replace(/\bas\b/g, ":")}} = globalThis.__U.${slot};`; code = code // ★ 会有**两条** vue import(`defineOptions` 带出的 `defineComponent` 一条、文件自己一条) // ⇒ 正则必须带 `g`。 .replace(braceImport("vue"), asStub("vue")) // 业务接口与 UI 库都只打桩(模板不渲染,它们不会有真行为)。 // ★ 下面那句兜底断言是**故意**留的:漏了哪个 import 会当场炸,而不是悄悄跑出假绿。 .replace(braceImport("@/api/module_payment/collection"), asStub("api")) .replace(braceImport("@/api/module_payment/onboarding"), asStub("api")) .replace(braceImport("element-plus"), asStub("ep")) // 默认导入(`import QRCode from "qrcode"` / 子组件)—— 正则与上面那族**不同形**, // ★ 漏掉它时上面那句兜底断言会当场炸(实测:cashier 页就是靠它发现的)。 .replace(/import\s+(\w+)\s+from\s*['"]qrcode['"];?/, (_m, n) => `const ${n} = globalThis.__U.ep;`) .replace( /import\s+(\w+)\s+from\s*['"]@\/components\/Upload\/SingleImageUpload\.vue['"];?/, (_m, n) => `const ${n} = globalThis.__U.ep;` ); if (/^\s*import\s/m.test(code)) throw new Error("还有没换掉的 import:\n" + code.slice(0, 400)); const ts = require("typescript"); code = ts.transpileModule(code, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext }, fileName: "index.ts", }).outputText; code = code.replace(/export\s+default/, "const __sfc ="); if (/^\s*export\s/m.test(code)) throw new Error("还有没换掉的 export"); code += "\nglobalThis.__U.sfc = __sfc;"; return code; } // ---------- 桩 ---------- // 页面 `onMounted` 会打这几个接口;模板不渲染,所以只要它别抛就行。 const okPage = () => ({ data: { code: 0, data: { list: [], total: 0 } } }); const apiStub = new Proxy( {}, { get: (_t, name) => (name === "then" ? undefined : async () => okPage()) } ); // UI 库:任何名字拿到都是「可调用、可继续取属性、调用返回 Promise」的东西 // (`ElMessageBox.confirm(...)` 要能 await)。 const callable = new Proxy(async () => undefined, { get: (_t, k) => (k === "then" ? undefined : callable), apply: () => Promise.resolve(), }); const epStub = new Proxy({}, { get: () => callable }); // 无 DOM 台子的垫片:有的页面 `onMounted` 里会碰 `window`(如 `window.setInterval`)。 // ★ 这是**台子**的垫片,不是页面的问题 —— 指出来免得后人把它当成缺陷去改页面。 if (typeof globalThis.window === "undefined") { globalThis.window = globalThis; } globalThis.__U = { vue, api: apiStub, ep: epStub, sfc: null }; new Function(compileSfc(SRC))(); const Real = globalThis.__U.sfc; const REAL_NAME = Real && Real.name; if (!REAL_NAME) throw new Error("编译出来的组件没有 name —— defineOptions 没被保留?"); // ---------- 无 DOM 渲染器 ---------- const node = () => ({ children: [] }); const noop = () => {}; const { createRenderer, h, KeepAlive, ref, nextTick } = vue; const renderer = createRenderer({ createElement: node, createText: node, createComment: node, setText: noop, setElementText: noop, insert: noop, remove: noop, parentNode: () => null, nextSibling: () => null, patchProp: noop, }); /** 数 setup 跑了几次 —— 缓存命中后再激活**不会**重跑 setup。 */ async function countSetups(includeName) { let setups = 0; const Wrapped = { ...Real, render: () => null, setup(props, ctx) { setups++; return Real.setup ? Real.setup(props, ctx) : undefined; }, }; const Other = { name: "KaprobeOther", render: () => null }; // ★ 必须是 ref:普通变量不会被追踪,`__switch` 改完不会触发重渲染, // 于是「没缓存」与「根本没切页」在读数上分不开。 const cur = ref("target"); const Host = { setup() { globalThis.__switch = (v) => { cur.value = v; }; return () => h(KeepAlive, { include: [includeName] }, () => cur.value === "target" ? h(Wrapped) : h(Other) ); }, }; renderer.createApp(Host).mount(node()); await nextTick(); globalThis.__switch("other"); await nextTick(); globalThis.__switch("target"); await nextTick(); return setups; } // ★ 第 4 个参数可把「命中」那一轮喂成**别的**名字 —— 用来跑变异: // 把页面里的 name 改成旧值,而 include 仍喂活库那个名字,此时**必须**读出不缓存。 // 不给就是自比(include 取本页真名)。 const INCLUDE = process.argv[4] || REAL_NAME; const hit = await countSetups(INCLUDE); const miss = await countSetups("__kaprobe_never_matches__"); const verdict = hit === 1 && miss === 2 ? "PASS" : "FAIL"; console.log(`组件名=${REAL_NAME} 文件=${LABEL} include=${INCLUDE}`); console.log(` include=[${INCLUDE}] setups=${hit} 期望 1(命中缓存)`); console.log(` include=[必然不匹配的串] setups=${miss} 期望 2(未缓存,正对照)`); console.log(`SUMMARY ${verdict} hit=${hit} miss=${miss} name=${REAL_NAME}`);