From 9167aca15c9d474753afa1e73ae7eddc60a74726 Mon Sep 17 00:00:00 2001 From: From-wh Date: Mon, 28 Sep 2026 18:09:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=A7=BB=E5=8A=A8=E7=AB=AF):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20pages-modules=20=E5=85=8D=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E5=88=86=E5=8C=85=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 丢目录即自动注册页面路由:scripts/uni-auto-subpackages.mjs 对读取 src/pages.json 做路径级拦截,把 src/pages-modules/ 扫描出的分包条目在内存中 append-only 合并(磁盘 pages.json 字节不变,含 _template 复制模板)。vite.config.js 接入安装函数与 dev watch 插件;显式声明 jsonc-parser / chokidar 依赖。已验证 mp-weixin / h5 / mp-alipay 构建注入、dev watch 丢文件 15s 内自动注册、空目录回归与系统行为一致。 --- frontend/uni-app/package-lock.json | 6 + frontend/uni-app/package.json | 2 + .../uni-app/scripts/uni-auto-subpackages.mjs | 293 ++++++++++++++++++ .../src/pages-modules/_template/index.vue | 56 ++++ .../src/pages-modules/_template/routes.js | 14 + frontend/uni-app/vite.config.js | 7 +- 6 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 frontend/uni-app/scripts/uni-auto-subpackages.mjs create mode 100644 frontend/uni-app/src/pages-modules/_template/index.vue create mode 100644 frontend/uni-app/src/pages-modules/_template/routes.js diff --git a/frontend/uni-app/package-lock.json b/frontend/uni-app/package-lock.json index 9f736c613..8d534f9cd 100644 --- a/frontend/uni-app/package-lock.json +++ b/frontend/uni-app/package-lock.json @@ -33,6 +33,8 @@ "@dcloudio/uni-stacktracey": "3.0.0-5020420260813003", "@dcloudio/vite-plugin-uni": "3.0.0-5020420260813003", "@vue/runtime-core": "^3.4.21", + "chokidar": "3.6.0", + "jsonc-parser": "3.3.1", "sass": "~1.78.0", "stylus": "^0.64.0", "vite": "5.2.8" @@ -6140,6 +6142,8 @@ }, "node_modules/chokidar": { "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -9531,6 +9535,8 @@ }, "node_modules/jsonc-parser": { "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "license": "MIT" }, "node_modules/jsonfile": { diff --git a/frontend/uni-app/package.json b/frontend/uni-app/package.json index d5543bb58..226bcc18e 100644 --- a/frontend/uni-app/package.json +++ b/frontend/uni-app/package.json @@ -43,6 +43,8 @@ "@dcloudio/uni-stacktracey": "3.0.0-5020420260813003", "@dcloudio/vite-plugin-uni": "3.0.0-5020420260813003", "@vue/runtime-core": "^3.4.21", + "chokidar": "3.6.0", + "jsonc-parser": "3.3.1", "sass": "~1.78.0", "stylus": "^0.64.0", "vite": "5.2.8" diff --git a/frontend/uni-app/scripts/uni-auto-subpackages.mjs b/frontend/uni-app/scripts/uni-auto-subpackages.mjs new file mode 100644 index 000000000..e296ef2cf --- /dev/null +++ b/frontend/uni-app/scripts/uni-auto-subpackages.mjs @@ -0,0 +1,293 @@ +// CLI 调试:node scripts/uni-auto-subpackages.mjs [--print] +// 注意:本文件被 vite.config.js import,由 esbuild 内联打包,不能带 shebang 行 +/** + * pages-modules 自动分包注册(免改 src/pages.json) + * + * 机制:本版本 uni 编译器(@dcloudio 3.0.0-5020420260813003)没有官方"目录即路由"机制, + * src/pages.json 文本是路由唯一输入;但 H5 / 各小程序 / APP 三端的全部消费点(路由生成、 + * easycom、分包优化等)都通过 fs.readFileSync 读取该文件。这里在 vite 配置加载期对 + * "读取 src/pages.json"做路径级拦截,把 src/pages-modules/ 扫描出的分包条目在内存中 + * append-only 合并进返回文本——磁盘上的 pages.json 保持字节不变,系统配置零改动。 + * uni 官方在 @dcloudio/vite-plugin-uni 内部用同样手法拦截 scss 读取实现条件编译 + * (rewriteScssReadFileSync);@dcloudio/* 整组锁版升级后需回归验证(见 AGENTS.md)。 + * + * 约定(详见 frontend/uni-app/AGENTS.md「pages-modules 自动分包注册」): + * - src/pages-modules// = 一个独立分包,root = pages-modules/<模块名> + * - 模块内 .vue 自动注册为页面,路径 = /pages-modules/<模块名>/<相对路径去扩展名>; + * 排除 components/、component/、static/ 目录及 `_` 前缀的文件/目录 + * - 模块根可放自己的 pages.json 片段({ "pages": [{ "path", "style" }] }), + * 提供后该模块不再自动扫描,完全按片段注册 + * - 只追加 subPackages,绝不触碰 pages/tabBar/globalStyle/easycom 等系统字段 + * + * CLI 调试:node scripts/uni-auto-subpackages.mjs [--print] + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse, parseTree, findNodeAtLocation, getNodeValue } from 'jsonc-parser'; + +const ROOT = path.resolve(fileURLToPath(new URL('..', import.meta.url))); +const PAGES_JSON_PATH = path.join(ROOT, 'src', 'pages.json'); +const MODULES_DIR = path.join(ROOT, 'src', 'pages-modules'); +const MODULES_ROOT_PREFIX = 'pages-modules'; +// uni jsonJs 插件把虚拟模块 pages-json-js 解析为 /pages-json-js,dev 下用于失效路由模块 +const PAGES_JSON_JS_IDS = [path.join(ROOT, 'src', 'pages-json-js')]; +// 模块内不参与页面注册的目录,与 scripts/check-naming.mjs 的 ROLE_DIRS 对齐 +const ROLE_DIRS = new Set(['components', 'component', 'static']); + +const toPosix = (p) => p.split(path.sep).join('/'); + +/** 递归收集模块内可注册为页面的 .vue 相对路径(排除 `_` 前缀与角色目录) */ +function walkVueFiles(dir, base, out) { + for (const name of fs.readdirSync(dir)) { + if (name.startsWith('_') || name.startsWith('.')) continue; + const full = path.join(dir, name); + if (fs.statSync(full).isDirectory()) { + if (ROLE_DIRS.has(name)) continue; + walkVueFiles(full, base, out); + } else if (name.endsWith('.vue')) { + out.push(toPosix(path.relative(base, full)).replace(/\.vue$/, '')); + } + } +} + +/** 读取模块自带的 pages.json 片段(JSONC),不存在返回 null */ +function readModuleFragment(moduleDir) { + const fragPath = path.join(moduleDir, 'pages.json'); + if (!fs.existsSync(fragPath)) return null; + const errors = []; + const value = parse(fs.readFileSync(fragPath, 'utf8'), errors, { allowTrailingComma: true }); + if (errors.length) { + throw new Error(`${toPosix(path.relative(ROOT, fragPath))} 解析失败(第 ${errors[0].offset} 字符附近)`); + } + return value; +} + +/** 扫描单个模块,生成分包条目;无页面时返回 null */ +function scanModule(name, moduleDir) { + const root = `${MODULES_ROOT_PREFIX}/${name}`; + const frag = readModuleFragment(moduleDir); + let pages; + if (frag) { + if (!Array.isArray(frag.pages) || frag.pages.length === 0) { + throw new Error(`${root}/pages.json 必须提供非空 pages 数组`); + } + pages = frag.pages.map((page, i) => { + if (!page || typeof page.path !== 'string' || !page.path.trim()) { + throw new Error(`${root}/pages.json 第 ${i + 1} 项缺少 path`); + } + return { + path: page.path.trim().replace(/^\.?\//, '').replace(/\.vue$/, ''), + style: page.style || {} + }; + }); + } else { + const found = []; + walkVueFiles(moduleDir, moduleDir, found); + pages = found.sort().map((p) => ({ path: p, style: {} })); + if (pages.length === 0) return null; + } + return { root, pages }; +} + +/** 扫描 src/pages-modules/ 一级目录,产出按模块名排序的分包条目列表 */ +export function collectSubpackageEntries() { + if (!fs.existsSync(MODULES_DIR)) return []; + return fs + .readdirSync(MODULES_DIR) + .filter((name) => { + if (name.startsWith('_') || name.startsWith('.')) return false; + return fs.statSync(path.join(MODULES_DIR, name)).isDirectory(); + }) + .sort() + .map((name) => scanModule(name, path.join(MODULES_DIR, name))) + .filter(Boolean); +} + +const stripComments = (s) => s.replace(/\/\/[^\n\r]*/g, '').replace(/\/\*[\s\S]*?\*\//g, ''); + +/** 把分包条目以文本方式注入 pages.json 原文(增量编辑,其余内容逐字节保留) */ +function injectSubPackages(text, entries) { + const tree = parseTree(text); + if (!tree || tree.type !== 'object') return text; + const snippet = entries.map((e) => JSON.stringify(e, null, 2)).join(',\n'); + const prop = findNodeAtLocation(tree, ['subPackages']); + if (!prop) { + // 系统配置没有 subPackages 时在根对象末尾追加属性(当前工程不适用,仅兜底) + const closeIdx = tree.offset + tree.length - 1; + const children = tree.children || []; + const lastEnd = children.length ? children[children.length - 1].offset + children[children.length - 1].length : tree.offset + 1; + const needComma = children.length > 0 && !stripComments(text.slice(lastEnd, closeIdx)).includes(','); + return ( + text.slice(0, closeIdx) + + `${needComma ? ',' : ''}\n\t"subPackages": [\n\t\t${snippet}\n\t]\n` + + text.slice(closeIdx) + ); + } + const arr = prop; + if (arr.type !== 'array') return text; + // 插入点选在闭括号 `]` 前:天然位于所有元素级 `// #ifdef ... // #endif` 块之后, + // 不会被条件编译裁掉;逗号按“最后一个元素与 ] 之间(去注释后)是否已有逗号”判断 + const closeIdx = arr.offset + arr.length - 1; + const children = arr.children || []; + const lastEnd = children.length ? children[children.length - 1].offset + children[children.length - 1].length : arr.offset + 1; + const needComma = children.length > 0 && !stripComments(text.slice(lastEnd, closeIdx)).includes(','); + return text.slice(0, closeIdx) + `${needComma ? ',' : ''}\n\t\t${snippet}\n\t` + text.slice(closeIdx); +} + +/** 校验合并结果:仍是合法 JSONC 对象,且每个模块分包都在 subPackages 中完整存在 */ +function verifyMerged(mergedText, entries) { + const errors = []; + const value = parse(mergedText, errors, { allowTrailingComma: true, disallowComments: false }); + if (errors.length || !value || !Array.isArray(value.subPackages)) return false; + return entries.every((e) => { + const found = value.subPackages.find((v) => v && v.root === e.root); + return !!found && Array.isArray(found.pages) && found.pages.length === e.pages.length; + }); +} + +/** 扫描模块并把分包条目合并进 pages.json 原文(内存操作,不写盘);异常时回退原文 */ +export function mergePagesJsonText(originalText) { + const entries = collectSubpackageEntries(); + if (entries.length === 0) return originalText; + const merged = injectSubPackages(originalText, entries); + if (!verifyMerged(merged, entries)) { + console.warn('[uni-auto-subpackages] 合并结果自检失败,本次读取回退原始 pages.json'); + return originalText; + } + return merged; +} + +let installed = false; + +/** + * 安装 fs.readFileSync 拦截:精确匹配 src/pages.json 的字符串读取, + * 返回合并了 pages-modules 分包的文本;其余读取零开销透传。 + * 必须在 uni() 插件首次读取 pages.json 之前调用(vite.config.js 配置体内、return 之前)。 + */ +export function installAutoSubpackages() { + if (installed) return; + installed = true; + if (!fs.existsSync(PAGES_JSON_PATH)) return; + const origReadFileSync = fs.readFileSync; + const isPagesJson = (file) => { + try { + return typeof file === 'string' && path.resolve(file) === PAGES_JSON_PATH; + } catch { + return false; + } + }; + fs.readFileSync = function readFileSyncWithPagesModules(file, options) { + const content = origReadFileSync.call(fs, file, options); + if (typeof content === 'string' && isPagesJson(file)) { + try { + return mergePagesJsonText(content); + } catch (e) { + console.warn(`[uni-auto-subpackages] 合并失败,回退原始 pages.json:${e && e.message}`); + return content; + } + } + return content; + }; + const entries = collectSubpackageEntries(); + if (entries.length) { + const summary = entries.map((e) => `${e.root}(${e.pages.length}页)`).join('、'); + console.log(`[uni-auto-subpackages] 已注入模块分包:${summary}`); + } +} + +/** + * dev 增量生效插件:监听 src/pages-modules/,文件增删改时 touch 一次 pages.json 的 + * mtime(内容不变),借 uni 自己的 addWatchFile(pagesJsonPath) 链路触发重建 + * (H5 整页刷新 / MP 重新产出 app.json)。 + * 两种 dev 形态分别接管: + * - H5 dev(vite dev server):configureServer 挂 server.watcher,并失效 pages-json-js 虚拟模块 + * - MP dev(vite build --watch,无 dev server):buildStart 且 watchMode 时自建 chokidar 监听 + * 一次性 build(watchMode 为 false)不建监听,零开销。 + */ +export function uniAutoSubpackagesPlugin() { + let timer = null; + let chokidarWatcher = null; + let serverWatcherInstalled = false; + + const handleModuleChange = (server) => { + clearTimeout(timer); + timer = setTimeout(() => { + try { + const now = new Date(); + fs.utimesSync(PAGES_JSON_PATH, now, now); + } catch { + /* pages.json 必然存在,忽略 */ + } + if (server) { + for (const id of PAGES_JSON_JS_IDS) { + const mod = server.moduleGraph.getModuleById(id); + if (mod) server.moduleGraph.invalidateModule(mod); + } + server.ws.send({ type: 'full-reload' }); + } + }, 200); + }; + + const isUnderModulesDir = (fp) => { + const p = path.resolve(String(fp)); + return p === MODULES_DIR || p.startsWith(MODULES_DIR + path.sep); + }; + + return { + name: 'uni-auto-subpackages', + configureServer(server) { + if (!fs.existsSync(MODULES_DIR)) return; + serverWatcherInstalled = true; + server.watcher.add(MODULES_DIR); + const trigger = (fp) => { + if (isUnderModulesDir(fp)) handleModuleChange(server); + }; + server.watcher.on('add', trigger); + server.watcher.on('unlink', trigger); + server.watcher.on('change', trigger); + }, + async buildStart() { + if (!this.meta.watchMode || serverWatcherInstalled || chokidarWatcher) return; + if (!fs.existsSync(MODULES_DIR)) return; + let chokidar; + try { + chokidar = (await import('chokidar')).default; + } catch { + console.warn('[uni-auto-subpackages] 缺少 chokidar,dev watch 下新增模块页面需手动重启(build 不受影响);可 npm i -D chokidar'); + return; + } + chokidarWatcher = chokidar.watch(MODULES_DIR, { ignoreInitial: true }); + const trigger = (fp) => { + if (isUnderModulesDir(fp)) handleModuleChange(null); + }; + chokidarWatcher.on('add', trigger); + chokidarWatcher.on('unlink', trigger); + chokidarWatcher.on('change', trigger); + }, + closeWatcher() { + if (chokidarWatcher) { + const w = chokidarWatcher; + chokidarWatcher = null; + w.close().catch(() => {}); + } + } + }; +} + +// CLI 调试:node scripts/uni-auto-subpackages.mjs [--print] +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const entries = collectSubpackageEntries(); + if (entries.length === 0) { + console.log('未发现可注册模块(src/pages-modules/ 为空或仅含排除目录)'); + } else { + for (const e of entries) { + console.log(`▸ ${e.root}(${e.pages.length} 页)`); + for (const p of e.pages) console.log(` /${e.root}/${p.path}`); + } + } + const merged = mergePagesJsonText(fs.readFileSync(PAGES_JSON_PATH, 'utf8')); + const subCount = getNodeValue(findNodeAtLocation(parseTree(merged), ['subPackages']) || {}).length; + console.log(`合并自检:${verifyMerged(merged, entries) ? '通过' : '失败'}(subPackages 共 ${subCount} 个分包)`); + if (process.argv.includes('--print')) console.log('\n' + merged); +} diff --git a/frontend/uni-app/src/pages-modules/_template/index.vue b/frontend/uni-app/src/pages-modules/_template/index.vue new file mode 100644 index 000000000..bfded0844 --- /dev/null +++ b/frontend/uni-app/src/pages-modules/_template/index.vue @@ -0,0 +1,56 @@ + + + + + + diff --git a/frontend/uni-app/src/pages-modules/_template/routes.js b/frontend/uni-app/src/pages-modules/_template/routes.js new file mode 100644 index 000000000..6a585ea59 --- /dev/null +++ b/frontend/uni-app/src/pages-modules/_template/routes.js @@ -0,0 +1,14 @@ +// 模块路由常量:模块内外跳转统一引用,禁止硬编码路径(命名细则见 docs/命名规范.md §2.1)。 +// 复制本目录后,把 MODULE_NAME 换成实际插件标识(与目录名一致,如 seckill),按页面追加条目。 +// 跳转用法:uni.navigateTo({ url: routes.goodsDetails + '?id=' + id }); +const MODULE_NAME = '_template'; + +export default { + /** 模块首页 */ + index: `/pages-modules/${MODULE_NAME}/index`, + // 按实际页面追加(示例): + // /** 商品详情页 */ + // goodsDetails: `/pages-modules/${MODULE_NAME}/goods_details/index`, + // /** 参与记录页 */ + // record: `/pages-modules/${MODULE_NAME}/record/index`, +}; diff --git a/frontend/uni-app/vite.config.js b/frontend/uni-app/vite.config.js index 570b29316..4e50be23d 100644 --- a/frontend/uni-app/vite.config.js +++ b/frontend/uni-app/vite.config.js @@ -1,13 +1,18 @@ import { defineConfig, loadEnv } from 'vite'; import uni from '@dcloudio/vite-plugin-uni'; +import { installAutoSubpackages, uniAutoSubpackagesPlugin } from './scripts/uni-auto-subpackages.mjs'; export default defineConfig(({ mode }) => { // 读取根目录 .env / .env.[mode] 中自定义的 UNI_APP_* 变量 // (vite 的 loadEnv 默认只取 VITE_ 前缀,第三个参数传 '' 表示取全部) const env = loadEnv(mode, process.cwd(), ''); + // 把 src/pages-modules/ 模块分包在内存中合并进 pages.json 的读取(磁盘文件不变); + // 必须在 uni() 首次读取 pages.json 之前执行 + installAutoSubpackages(); + return { - plugins: [uni()], + plugins: [uniAutoSubpackagesPlugin(), uni()], define: { // 注入 src/config/app.js 中使用的接口地址变量;.env 未配置时置空串,代码内回退到各端默认值 'process.env.UNI_APP_API_URL': JSON.stringify(env.UNI_APP_API_URL || '')