mm_tpl 2.7.1 → 2.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,10 +19,15 @@
19
19
 
20
20
  - 🚀 基于art-template高性能模板引擎
21
21
  - 🔧 支持多种模板语法规则
22
- - 🎨 主题切换和模板文件优先级搜索
23
- - ⚡ 内置缓存机制提升性能
24
- - 🔌 强大的钩子系统支持
25
- - 📁 灵活的目录结构支持
22
+ - 🎨 部署级主题、用户级风格与语言切换,模板按功能划分放主题目录下、样式按风格分层,各自按目录优先级引用
23
+ - 🧩 组件独立目录 + `$component` 引用,公共片段与功能页面分离,支持主题回退与 diy 覆盖
24
+ - 📜 应用级 + 主题级两级脚本,不按风格分层,应用级公共脚本先执行
25
+ - 🌐 多用户支持,每个用户可独立选择风格和语言,按此归类共享缓存
26
+ - 🗂️ JSON 语言包文件,应用级与主题级按目录优先级合并,支持运行时重载
27
+ - ⚡ 内置缓存机制,支持物理文件与进程内存两种方式,模板改动后自动失效
28
+ - 🔌 强大的钩子系统支持,全局钩子与实例级钩子两级生效
29
+ - 🧩 模板函数表,`setFunc` 注册即可在模板内直接调用,派生实例自动继承
30
+ - 📁 灵活的目录结构,`static/diy/` 自定义目录可覆盖主题内的同名模板、样式与脚本
26
31
  - 🛡️ 完善的错误处理机制
27
32
 
28
33
  ## 安装
@@ -44,16 +49,59 @@ npm install mm_tpl
44
49
  ```javascript
45
50
  const {Tpl} = require('mm_tpl');
46
51
 
52
+ // 应用根目录下的目录结构(详见 doc/目录结构.md):
53
+ // app/shop/
54
+ // config.js # { defaults: { theme, style, lang } }
55
+ // static/ # 静态资源根目录,模板、样式、脚本、语言包与覆盖目录都放这里
56
+ // diy/ # 用户自定义覆盖目录,优先级最高,可放模板、组件与脚本
57
+ // component/ # 覆盖全部主题的组件
58
+ // js/ # 应用级脚本,各主题共用的公共脚本
59
+ // lang/ # 应用级语言包,文件名即语言代码
60
+ // template/{主题}/ # 模板直接放主题目录下,按功能划分,不按风格分层
61
+ // index.html # 通用页模板
62
+ // article/list.html # 功能目录内的模板
63
+ // component/ # 组件:可被功能页面复用的公共片段
64
+ // bar.html
65
+ // js/ # 主题级脚本,覆盖应用级同名脚本
66
+ // lang/ # 主题级语言包,覆盖应用级
67
+ // style/ # 只放样式 .css,不放模板
68
+ // base/ # 基础风格:各风格共享的公共样式
69
+ // {风格}/ # 各风格只放差异样式
70
+
47
71
  // 创建模板实例
48
72
  const tpl = new Tpl({
49
- root: process.cwd(), // 根目录
50
- default_dir: "./template/", // 默认模板目录
51
- default_theme: 'default', // 默认主题
52
- extname: ".html", // 模板文件扩展名
53
- cache_root: './cache', // 缓存目录
54
- cache_extname: '.cache.html', // 缓存文件扩展名
73
+ root: process.cwd(), // 应用根目录
74
+ theme_dir: './static/template/', // 主题目录,相对应用根目录
75
+ style_dir: './style/', // 风格目录,相对主题目录
76
+ component_dir: './component/', // 组件目录,相对主题目录,公共片段放此处;为空则不启用
77
+ diy_dir: './static/diy/', // 自定义覆盖目录,相对应用根目录,优先级最高
78
+ lang_dir: './static/lang/', // 应用级语言包目录,按文件名识别语言,为空则不启用
79
+ theme_lang_dir: './lang/', // 主题级语言包目录,相对主题目录,覆盖应用级同名词条
80
+ js_dir: './static/js/', // 应用级脚本目录,相对应用根目录,存放公共脚本
81
+ theme_js_dir: './js/', // 主题级脚本目录,相对主题目录;脚本不按风格分层
82
+ default_theme: 'default', // 默认主题,部署级配置,用户不参与选择
83
+ default_style: 'light', // 默认风格,为主题内的变体,用户可选择
84
+ base_style: 'base', // 基础风格目录名,各风格共享公共样式(只针对样式),为空则不启用
85
+ default_lang: 'zh_cn', // 默认语言,用户可选择
86
+ langs: {}, // 内存语言包字典,优先级高于语言包文件
87
+ cache_dims: ['style', 'lang'], // 缓存分层维度,只放用户可选的维度,用户按此归类共享缓存
88
+ cache_type: 'file', // 缓存方式,file 为物理文件,memory 为进程内存
89
+ cache_max: 1000, // 内存缓存最大条数
90
+ extname: ".html", // 模板文件扩展名,可自行设定;省略扩展名时自动补全
91
+ cache_root: './cache', // 缓存目录
92
+ cache_extname: '.cache.html', // 缓存文件扩展名
93
+ plural_rules: {
94
+ 'en_us': function (count) { return count === 1 ? 0 : 1; }
95
+ },
96
+ // 复数规则,键为语言代码;mm_expand 的 Lang 已内置 zh_cn、en 规则,其他语言代码(如 en_us)在此登记,未登记时会回退到内置 en 规则
55
97
  rules: [nativeRule, artRule, htmlRule, jsRule, pyRule, pyRule2] // 模板规则
56
98
  });
99
+
100
+ // 也可以直接读取应用配置文件,defaults 会展开为 default_theme / default_style / default_lang
101
+ const app_config = require('./app/shop/config.js');
102
+ const tpl_shop = new Tpl(Object.assign({ root: './app/shop/' }, app_config));
103
+ // config.js 内容:module.exports = { defaults: { theme: 'default', style: 'light', lang: 'zh_cn' } }
104
+ // 显式传入的 default_* 优先于 defaults,defaults 不会残留在 config 中,因此 fork() 不会重复展开
57
105
  ```
58
106
 
59
107
  ### 3. 基础模板渲染
@@ -137,8 +185,9 @@ tpl.set({
137
185
  keywords: "关键词"
138
186
  });
139
187
 
140
- // 渲染视图文件
141
- const html = tpl.view("./views/index.html", {
188
+ // 渲染视图文件,路径相对主题目录,如 static/template/{主题}/index.html,可含功能目录如 article/list.html
189
+ // 省略扩展名时按 extname 自动补全,view('./article/list') 与 view('./article/list.html') 等价
190
+ const html = tpl.view("./index.html", {
142
191
  user: { name: "张三" }
143
192
  });
144
193
  // 输出: 渲染结果 <div>Hello World</div>
@@ -150,23 +199,166 @@ const title = tpl.get("title");
150
199
  tpl.del("keywords");
151
200
  ```
152
201
 
202
+ #### 组件渲染
203
+
204
+ 可被多个页面复用的公共片段(页头、侧栏、广告位等)放主题的 `component/` 目录,页面用 `$component` 引用,只写组件名,不写目录前缀与扩展名:
205
+
206
+ ```html
207
+ <!-- 页面引用组件:省略 ./component/ 前缀与 .html 后缀 -->
208
+ <div>${@$component('bar')}</div>
209
+
210
+ <!-- 组件内可再引用其他组件,支持嵌套 -->
211
+ <!-- static/template/default/component/bar.html -->
212
+ <div class="ad">${@$component('ad')}</div>
213
+
214
+ <!-- 传数据给组件:组件内直接读该字段 -->
215
+ ${@$component('user-card', { user: item })}
216
+
217
+ <!-- 子目录内的组件 -->
218
+ ${@$component('article/card')}
219
+ ```
220
+
221
+ 服务端等价写法:
222
+
223
+ ```javascript
224
+ // 渲染组件,返回渲染结果字符串
225
+ tpl.component('bar', { message: 'hello' });
226
+
227
+ // 只取命中的组件文件全路径,不渲染
228
+ tpl.getComponentFile('bar');
229
+ ```
230
+
231
+ 组件可传 model,且与调用方数据合并:调用方的数据与 `$view` / `$css` / `$t` 等模板函数都会传给组件,传入的同名字段覆盖调用方。model 可写成对象字面量,也可传变量,两者等价:
232
+
233
+ ```html
234
+ ${@$component('user-card', { user: item })}
235
+ ${@$component('user-card', card_data)}
236
+ ```
237
+
238
+ 组件名相对组件目录解析,**不相对调用方所在文件解析**,因此同一个名字在任何位置引用结果一致;组件找不到时返回空字符串并置 `tpl.error`(`code: 10000`),不抛错。完整目录链见[高级特性](#1-主题风格和目录支持)。
239
+
153
240
  ### 6. 缓存机制
154
241
 
155
242
  ```javascript
156
- // 使用缓存渲染视图
157
- const html = tpl.view("./views/page", {
243
+ // 使用缓存渲染视图:cache 为 true 即可,缓存文件名按模板路径自动推导
244
+ const html = tpl.view("./index.html", {
158
245
  data: someData
159
246
  }, {
160
- cache: true, // 是否使用缓存
161
- cache_filename: "./cache" // 缓存文件名
247
+ cache: true
248
+ });
249
+ // 缓存文件:cache/{风格}/{语言}/index.cache.html(默认 cache_dims 为 ['style', 'lang'])
250
+ // 例:cache/light/zh_cn/index.cache.html、cache/dark/en_us/index.cache.html
251
+
252
+ // 也可以显式指定缓存文件名
253
+ tpl.view("./index.html", { data: someData }, {
254
+ cache: true,
255
+ cache_filename: "./index" // 等价于 ./index.cache.html
256
+ });
257
+
258
+ // cache 为 false 时只保存缓存、不读取,适合主动刷新
259
+ tpl.view("./index.html", { data: someData }, {
260
+ cache: false,
261
+ cache_filename: "./index"
262
+ });
263
+
264
+ // 清除缓存:不传参数时只清除当前实例的缓存目录,不影响其他主题或语言的缓存
265
+ tpl.clearCache();
266
+ // 清除指定目录,路径相对 cache_root
267
+ tpl.clearCache('./game');
268
+ // 清除全部缓存
269
+ tpl.clearCache('./');
270
+
271
+ // 可通过 getCacheFile 查看当前实例的缓存文件路径,支持直接传字符串
272
+ tpl.getCacheFile('./index');
273
+ ```
274
+
275
+ #### 缓存数量由 cache_dims 决定
276
+
277
+ `cache_dims` 只应包含**用户能选择**的维度。本模块的分工是:
278
+
279
+ | 维度 | 层级 | 是否用户可选 | 是否参与缓存分层 |
280
+ | --- | --- | --- | --- |
281
+ | 主题 `theme` | 部署级 | 否 | 否,站点内统一 |
282
+ | 风格 `style` | 用户级 | 是 | 是 |
283
+ | 语言 `lang` | 用户级 | 是 | 是 |
284
+
285
+ 因此默认是 `['style', 'lang']`:用户按「风格 + 语言」归类,同一组合的用户共享同一份缓存。
286
+
287
+ 缓存文件数的上限为:
288
+
289
+ ```
290
+ 缓存文件数 ≤ (命名风格数 + 1) × 语言数 × 被缓存的模板数
291
+ ```
292
+
293
+ `+ 1` 指**未选风格**——它是一个独立的组合,但空字符串不占目录层级,所以它的缓存直接落在语言目录下(`default_style` 也为空时才会出现该组合):
294
+
295
+ ```
296
+ cache/dark/zh_cn/test.cache.html ← 选 dark 风格,多一层风格目录
297
+ cache/zh_cn/test.cache.html ← 未选风格,lang 直接落在根下
298
+ ```
299
+
300
+ 同时这是**上限而非固定值**:只有实际渲染过的组合和模板才产生缓存文件,未被请求的组合不会有文件。以 2 种命名风格(dark、light,外加未选共 3 种组合)、2 种语言、20 个模板为例:
301
+
302
+ | `cache_dims` | 层次 | 文件数(上限) |
303
+ | --- | --- | --- |
304
+ | `['style', 'lang']`(默认) | 3 种组合 × 2 语言 × 20 模板 | 120 |
305
+ | `['lang']` | 不含风格维度,2 语言 × 20 模板 | 40 |
306
+ | `[]` | 不分层,20 模板 | 20 |
307
+
308
+ 维度只取「风格」和「语言」这类**有限枚举**,用户只是落到其中某个组合里,所以这个数量**与用户数完全无关**。以 5000 个用户、1 种命名风格(dark,外加未选共 2 种组合)、2 种语言、2 个模板为例,4 个组合全部用到时也只产生 8 个缓存文件:
309
+
310
+ ```
311
+ cache/zh_cn/test.cache.html ← 未选风格 + 中文
312
+ cache/en_us/test.cache.html ← 未选风格 + 英文
313
+ cache/dark/zh_cn/test.cache.html ← dark 风格 + 中文
314
+ cache/dark/en_us/test.cache.html ← dark 风格 + 英文
315
+ ```
316
+
317
+ 模块不提供按用户分层的维度:每份缓存只服务一个用户,只在「同一用户重复访问同一页面且期间数据不变」时才命中,而用户数据(余额、订单、消息)通常每次请求都在变,命中率趋近于 0,还可能读到过期数据。**含用户数据的页面不适合做整页缓存**,应改为前端异步加载,或按下面的内存缓存方案处理。
318
+
319
+ #### 主题不参与缓存分层
320
+
321
+ 主题是部署级配置,站点内所有用户一致,正常运行中不切换,所以默认不把 `theme` 放进 `cache_dims`。由此带来一个约束:**运行时切换主题会复用旧主题的缓存**。模块会在 `setTheme` 时告警提示;确实需要切换主题时,请清缓存或把 `theme` 纳入维度:
322
+
323
+ ```javascript
324
+ // 方式一:切主题后清理缓存
325
+ tpl.setTheme('default');
326
+ tpl.clearCache('./');
327
+
328
+ // 方式二:需要多主题长期并存时,把主题纳入维度
329
+ const tpl_multi = new Tpl({
330
+ default_theme: 'game',
331
+ cache_dims: ['theme', 'style', 'lang']
162
332
  });
333
+ ```
334
+
335
+ #### 缓存自动失效
336
+
337
+ 模板文件或语言包文件被修改后,旧缓存会在下次渲染时自动失效并重建,**不需要手动清理缓存**。判断依据是文件修改时间,不依赖文件内容比对:
163
338
 
164
- // 清除缓存
165
- tpl.clearCache('./views/mall'); // 清除指定目录缓存
166
- // 或
167
- tpl.clearCache(); // 清除所有缓存
339
+ ```javascript
340
+ // 开发时改了模板,刷新即生效,无需 clearCache
341
+ // 改了 static/lang/zh_cn.json 后,中文缓存同样会自动重建
342
+ ```
343
+
344
+ 按语言包分层(`cache_dims` 含 `lang`)时才会校验语言包改动;不含 `lang` 时只校验模板文件。
345
+
346
+ #### 内存缓存
347
+
348
+ 希望缓存不落盘、只存活于进程内时,用内存缓存:
349
+
350
+ ```javascript
351
+ const tpl = new Tpl({
352
+ default_theme: 'game',
353
+ cache_type: 'memory', // file(默认)为物理文件,memory 为进程内存
354
+ cache_max: 1000 // 内存缓存最大条数,超出后淘汰最早写入的条目,0 表示不限制
355
+ });
168
356
  ```
169
357
 
358
+ 内存缓存在 `Tpl.cache_store` 中按缓存文件全路径存放,采用最少使用淘汰:命中后条目移到队尾,超出 `cache_max` 时淘汰最早写入的。进程重启后缓存清空,这在多实例部署下意味着各进程各有一份缓存。
359
+
360
+ `clearCache` 会同时清除物理文件缓存和内存缓存,清除范围同样受 `cache_dims` 限制。
361
+
170
362
  ### 7. 钩子系统
171
363
 
172
364
  #### 添加函数钩子
@@ -223,17 +415,300 @@ $.hook.delFilter('content', 'contentFilter');
223
415
  $.hook.clear();
224
416
  ```
225
417
 
418
+ ### 8. 多用户风格与语言
419
+
420
+ 模块只依据传入的参数工作,不关心参数来源。配置分两层:
421
+
422
+ - **部署级**:主题、模板目录、缓存目录、语言包目录等,站点内所有用户一致,由主实例承载
423
+ - **用户级**:风格、语言,由每个用户自行选择
424
+
425
+ 模块提供两种使用方式:**传参渲染**(单实例,`view` 的 `options` 传 `style` / `lang`,无需派生实例)和**派生实例**(`fork`)。推荐前者,下面用 `fork` 演示便于看清各配置项的作用,两者的取舍见[多用户风格与语言的使用方式](#6-多用户风格与语言的使用方式)。
426
+
427
+ ```javascript
428
+ // 主实例:承载部署级配置
429
+ const tpl = new Tpl({
430
+ root: './app/shop/', // 应用根目录
431
+ default_theme: 'game', // 主题部署级固定,用户不参与选择
432
+ default_style: '', // 用户未选风格时的默认值
433
+ default_lang: 'zh_cn', // 用户未选语言时的默认值
434
+ lang_dir: './static/lang/', // 应用级语言包目录,按文件名识别语言
435
+ langs: {}, // 可选,内存语言包,优先级高于语言包文件
436
+ // 缓存按用户可选的维度分层:风格 + 语言
437
+ cache_dims: ['style', 'lang']
438
+ });
439
+
440
+ // 用户A:未选风格 + 中文
441
+ const tpl_a = tpl.fork();
442
+ // 用户B:dark风格 + 英文
443
+ const tpl_b = tpl.fork({ style: 'dark', lang: 'en_us' });
444
+
445
+ tpl_a.getTheme(); // "game",继承自主实例
446
+ tpl_a.getStyle(); // ""
447
+ tpl_a.getLang(); // "zh_cn"
448
+
449
+ tpl_b.getTheme(); // "game"
450
+ tpl_b.getStyle(); // "dark"
451
+ tpl_b.getLang(); // "en_us"
452
+
453
+ // 各自渲染:语言与缓存按各自配置区分,模板不受风格影响
454
+ tpl_a.view('./index.html', { title: '首页' });
455
+ tpl_b.view('./index.html', { title: 'Home' });
456
+ ```
457
+
458
+ 模板按主题分段查找,**不按风格分层**:用户A与用户B风格不同,模板命中的却是同一个文件(完整优先级见[高级特性](#1-主题风格和目录支持)):
459
+
460
+ ```
461
+ static/diy/index.html ← 自定义覆盖目录,优先级最高,各实例共享
462
+ static/template/game/index.html ← 当前主题 game,用户A与用户B均命中此文件
463
+ static/template/default/index.html ← 当前主题缺文件时回退到默认主题的同名功能路径
464
+ ```
465
+
466
+ 模板只按主题分层,按功能路径拼接(如 `article/list.html`),换风格不改变命中的模板。样式不同,仍按「主题 + 风格」分层,是含风格与基础风格的五段链,因此用户B命中 `static/template/game/style/dark/...`、用户A命中 `static/template/game/style/light/...`,**风格的作用只体现在样式上**。以用户B(game 主题 + dark 风格)为例:
467
+
468
+ ```
469
+ static/diy/common.css ← 自定义覆盖目录,优先级最高
470
+ static/template/game/style/dark/common.css ← 当前主题 + 当前风格
471
+ static/template/game/style/base/common.css ← 当前主题 + 基础风格
472
+ static/template/default/style/dark/common.css ← 默认主题 + 当前风格
473
+ static/template/default/style/base/common.css ← 默认主题 + 基础风格
474
+ ```
475
+
476
+ 样式只回退主题、不回退风格:dark 与 light 各查自己的样式目录,两者共用的样式放基础风格目录,不必各存一份。
477
+
478
+ `$t` 会自动绑定当前实例的语言,可在模板字符串和模板文件中直接使用:
479
+
480
+ ```html
481
+ <!-- 渲染字符串 -->
482
+ <p>${$t('hello', '用户A')}</p>
483
+ <!-- 用户A(zh_cn)输出:<p>你好,用户A!</p> -->
484
+ <!-- 用户B(en_us)输出:<p>Hello, 用户A!</p> -->
485
+
486
+ <!-- 命名插值:词条写 {name},传对象,可读且不怕参数顺序 -->
487
+ <p>${$t('welcome', { name: user.name })}</p>
488
+ ```
489
+
490
+ 模板内还可用 `$view` 渲染子模板、`$component` 引用组件、`$css` 引用样式、`$js` 引用脚本,它们都沿各自的目录优先级链查找;`$css` / `$js` 的名称支持 `*` 通配,含通配符时返回数组:
491
+
492
+ ```html
493
+ <div>${@$component('bar')}</div>
494
+ <link rel="stylesheet" href="${$css('common.css')}">
495
+ ${each $css('*.css') value}
496
+ <link rel="stylesheet" href="${value}">
497
+ ${/each}
498
+
499
+ <!-- 全部脚本;也可用 app*、*promo* 按名筛选,顺序即执行顺序 -->
500
+ ${each $js('*.js') value}
501
+ <script src="${value}"></script>
502
+ ${/each}
503
+ ```
504
+
505
+ 服务端代码中取词用 `t` / `tc`,与模板里的 `$t` 等价:
506
+
507
+ ```javascript
508
+ tpl.t('hello', '用户A'); // "你好,用户A!",依次替换 {0}、{1} 等占位符
509
+ tpl.t('welcome', { name: '张三' }); // "欢迎,张三!",传对象时按 {name} 命名占位符替换
510
+ tpl.t('bar.nav'); // "游戏导航",支持 a.b 形式的层级键
511
+ tpl.tc('items', 1); // 复数取词,按语言规则选择形式:中文 "共 1 项"
512
+ tpl.tc('items', 5); // 英文为 "1 item" / "5 items"
513
+ ```
514
+
515
+ 带复数形式的词条在语言包中写成数组,`{count}` 为数量占位符:
516
+
517
+ ```json
518
+ // static/lang/en_us.json
519
+ { "items": ["{count} item", "{count} items"] }
520
+ ```
521
+
522
+ 复数形式由语言的复数规则决定。语言代码统一用小写下划线(如 `zh_cn`、`en_us`),与语言包文件名一致,也便于浏览器端引用。`zh_cn`、`en` 是 mm_expand 的 Lang 内置的规则,可直接命中;其他语言代码(如 `en_us`)建议在配置项 `plural_rules` 中显式登记,`tc()` 才能按该语言取到正确的复数形式;未登记时会回退到内置的 `en` 规则。
523
+
524
+ #### 语言包文件
525
+
526
+ 文案放在 JSON 语言包中,模板文件只写 `$t`,不要把文案硬编码在模板里。文件名即语言代码:
527
+
528
+ ```
529
+ static/lang/zh_cn.json ← 应用级中文
530
+ static/lang/en_us.json ← 应用级英文
531
+ static/template/game/lang/zh_cn.json ← game 主题的中文,覆盖同名词条
532
+ static/template/game/lang/en_us.json ← game 主题的英文,覆盖同名词条
533
+ ```
534
+
535
+ ```json
536
+ // static/lang/zh_cn.json
537
+ {
538
+ "hello": "你好,{0}!",
539
+ "theme": { "game": "游戏主题" },
540
+ "bar": { "nav": "导航栏", "user_id": "用户ID" }
541
+ }
542
+ ```
543
+
544
+ ```json
545
+ // static/template/game/lang/zh_cn.json:只写需要覆盖的词条
546
+ {
547
+ "bar": { "nav": "游戏导航" }
548
+ }
549
+ ```
550
+
551
+ ```html
552
+ <!-- static/template/game/test.html:模板只负责取词 -->
553
+ <div>${$t('theme.game')}</div>
554
+ <!-- zh_cn 输出:<div>游戏主题</div> -->
555
+ <!-- en_us 输出:<div>Game Theme</div> -->
556
+ ```
557
+
558
+ 语言包按以下优先级合并,后者覆盖前者:应用级 `lang_dir/{lang}.json` → 默认主题 `{theme_dir}/{默认主题}/{theme_lang_dir}/{lang}.json` → 当前主题 `{theme_dir}/{当前主题}/{theme_lang_dir}/{lang}.json` → 配置项 `langs`。同一语言按词条合并,高优先级覆盖同名词条,因此同一份模板在不同主题下可以有不同的文案。
559
+
560
+ 词条查找顺序为:当前语言 → 默认语言。两级都未命中时会记录一条 error 日志并返回 `undefined`,渲染结果为空字符串。想快速上手时,也可以先在模板中直接写文案,后续再逐步抽到语言包。
561
+
562
+ 语言包文件在创建实例时读取,之后按文件指纹(路径 + 修改时间)缓存:文件未变化时,新建实例或 `fork` 直接复用已解析结果;文件新增、修改、删除后,下次读取会自动重新加载,无需手工干预。已创建实例的语言包不会自动刷新,需调用 `reloadLangs()`。
563
+
564
+ ```javascript
565
+ tpl.getLangs(); // ['zh_cn', 'en_us'],无词条的语言会被过滤掉
566
+ tpl.reloadLangs(); // 按文件指纹重新加载,保持当前语言,返回语言列表
567
+ tpl.reloadLangs(true); // 跳过缓存强制重新读取语言包文件
568
+ tpl.loadLang('en_us'); // 重新加载并返回指定语言的词条对象
569
+ ```
570
+
571
+ 运行时切换(只影响当前实例)。风格和语言是用户可选项;主题是部署级配置,正常运行中不应由用户切换:
572
+
573
+ ```javascript
574
+ tpl_b.setStyle('light'); // 返回设置后的风格名称
575
+ tpl_b.setLang('zh_cn'); // 返回设置后的语言代码
576
+ ```
577
+
578
+ 主题与风格会校验目录是否存在,不存在时记录告警并**保持原值**,避免用户以为切换成功、实际仍走默认值:
579
+
580
+ ```javascript
581
+ // static/template 下没有 not_exist 主题或风格目录
582
+ tpl_b.setStyle('not_exist'); // 返回原风格 "dark",并输出告警
583
+ tpl_b.setTheme('not_exist'); // 返回原主题 "game",并输出告警(主题校验同样生效)
584
+
585
+ // 目录尚未创建时可强制设置,跳过校验
586
+ tpl_b.setStyle('not_exist', true); // 返回 "not_exist"
587
+
588
+ // 空字符串表示不设置风格,模板查找与缓存分层按默认风格回退
589
+ tpl_b.setStyle('');
590
+ ```
591
+
592
+ 缓存按 `cache_dims` 的维度分层存放,用户按「风格 + 语言」归类,同一组合的用户共享缓存:
593
+
594
+ ```
595
+ cache/zh_cn/test.cache.html ← 用户A(未选风格 + 中文)
596
+ cache/en_us/test.cache.html ← 用户A切英文
597
+ cache/dark/zh_cn/test.cache.html ← 用户B(dark 风格 + 中文)
598
+ ```
599
+
600
+ ```javascript
601
+ // 获取当前实例的缓存文件全路径
602
+ tpl_a.getCacheFile({ cache_filename: './test' });
603
+ ```
604
+
605
+ 缓存按「风格 + 语言」归类,同一组合的用户共享同一份缓存文件,数量与用户数无关,详见[缓存数量由 cache_dims 决定](#缓存数量由-cache_dims-决定)。
606
+
226
607
  ## 高级特性
227
608
 
228
- ### 1. 主题和目录支持
609
+ ### 1. 主题、风格和目录支持
229
610
  ```javascript
230
- // 设置当前主题
231
- tpl.current_theme = "dark";
611
+ // 设置当前风格(主题内的变体,用户可选项;目录不存在时保持原风格并告警)
612
+ tpl.setStyle("dark");
613
+ // 清空风格
614
+ tpl.setStyle("");
615
+
616
+ // 设置当前主题(部署级配置,正常运行中不应由用户切换)
617
+ // 目录不存在时保持原主题并告警;切换主题后需清理缓存,详见「主题不参与缓存分层」
618
+ tpl.setTheme("default");
619
+ // 跳过目录校验强制设置
620
+ tpl.setTheme("default", true);
621
+
622
+ // 或直接赋值(不校验)
623
+ tpl.current_theme = "default";
624
+ tpl.current_style = "dark";
625
+
626
+ // 模板目录查找顺序(优先级从高到低,{dir} 指 this.dir 即应用根目录):
627
+ // 1. {dir}/static/diy/ 自定义覆盖目录
628
+ // 2. {dir}/static/template/{当前主题}/
629
+ // 3. {dir}/static/template/{默认主题}/
630
+ //
631
+ // 模板按功能路径在主题目录下查找,如 static/template/{当前主题}/article/list.html
632
+ // 模板不按风格分层:换风格命中同一个模板文件;风格是视觉变体,只影响样式
633
+ // 模板同名覆盖,取优先级最高的文件(getFile)
634
+ // 当前主题缺文件时回退到默认主题的同名功能路径
635
+
636
+ // 组件目录查找顺序(优先级从高到低,走独立的组件目录链,与模板链同构,每级多一个固定的 component/ 前缀):
637
+ // 1. {dir}/static/diy/component/ 自定义覆盖目录
638
+ // 2. {dir}/static/template/{当前主题}/component/
639
+ // 3. {dir}/static/template/{默认主题}/component/
640
+ //
641
+ // 组件名相对组件目录解析,不相对调用方所在文件解析,同一名字在任何位置引用结果一致
642
+ // 组件名可含子目录,如 article/card;省略扩展名时自动补全(getComponentFile)
643
+ // 当前主题缺组件时回退到默认主题的组件目录,static/diy/component/ 可全局覆盖
644
+ // 组件默认不写缓存,需要时由 component 的 options 显式指定 cache 与 cache_filename
645
+
646
+ // 样式目录查找顺序(优先级从高到低):
647
+ // 1. {dir}/static/diy/ 自定义覆盖目录
648
+ // 2. {dir}/static/template/{当前主题}/style/{当前风格}/
649
+ // 3. {dir}/static/template/{当前主题}/style/{基础风格}/
650
+ // 4. {dir}/static/template/{默认主题}/style/{当前风格}/
651
+ // 5. {dir}/static/template/{默认主题}/style/{基础风格}/
652
+ //
653
+ // 样式按风格分层,因此比模板多出风格与基础风格两级
654
+ // 只回退主题,不回退风格:其他风格目录的文件不会被继承
655
+ // 公共样式放基础风格目录(base_style,默认 'base'),各风格目录只放差异文件
656
+ // 样式收集各级目录下的全部 css,同名文件只保留优先级最高的一份(getCssFiles)
657
+ // 未设置风格时该层用默认风格,默认风格也为空时直接用主题下的风格目录本身
658
+
659
+ // 脚本目录查找顺序(优先级从高到低):只按主题分层,不含风格与基础风格
660
+ // 1. {dir}/static/diy/ 自定义覆盖目录
661
+ // 2. {dir}/static/template/{当前主题}/js/
662
+ // 3. {dir}/static/template/{默认主题}/js/
663
+ // 4. {dir}/static/js/ 应用级公共脚本
664
+ //
665
+ // 脚本不按风格分层:风格是视觉变体,脚本不因颜色明暗而变,换风格不应换脚本
666
+ // 公共脚本放应用级 static/js/,换主题时由应用级目录继续提供
667
+
668
+ // 样式按优先级升序返回:基础风格在前打底,当前风格在后覆盖(后加载的规则才生效)
669
+ tpl.getCssFiles(); // ['.../static/template/default/style/base/common.css', '.../static/template/default/style/light/light.css', ...]
670
+
671
+ // 脚本同样按升序:应用级公共脚本在前,主题脚本在后(依赖顺序才成立)
672
+ tpl.getJsFiles(); // ['.../static/js/utils.js', '.../static/template/default/js/app.js', ...]
673
+
674
+ // 获取单个样式文件的路径:沿同一条链查找,命中返回全路径,不存在返回 null
675
+ tpl.getCssFile('common.css'); // 命中基础风格的 common.css
676
+ tpl.getCssFile('dark.css', 'default', 'dark'); // 显式取其他主题、风格的样式
677
+
678
+ // 名称支持 * 通配:*后缀 结尾匹配、前缀* 开头匹配、*包含* 包含匹配
679
+ tpl.getCssFile('*.css'); // 返回数组,等价于 getCssFiles()
680
+ tpl.matchCssFiles('light*'); // 返回数组:只取 light 名下的样式
681
+ tpl.matchCssFiles('*common*'); // 返回数组:跨目录命中公共样式
682
+ // 模板内使用:
683
+ // <link rel="stylesheet" href="${$css('common.css')}"> 精确引用
684
+ // ${each $css('*.css') value}<link href="${value}">${/each} 通配批量引用
685
+ // 引几个、什么顺序由模板决定,引擎只返回路径,不注入 HTML
686
+
687
+ // 脚本与样式用法一致,只是参数少了风格;名称同样支持 * 通配
688
+ tpl.getJsFile('utils.js'); // 命中应用级公共脚本
689
+ tpl.getJsFile('app*'); // 含通配符返回数组,同上
690
+ tpl.getJsFile('promo.js', 'festival'); // 显式取某主题的脚本,不存在返回 null
691
+ tpl.matchJsFiles('*.js'); // 全部脚本,数组,顺序即执行顺序
692
+ tpl.matchJsFiles('app*'); // 前缀匹配:只取 app 开头的脚本
693
+ tpl.matchJsFiles('*promo*'); // 包含匹配:跨目录命中同名族脚本
694
+ tpl.matchJsFiles('nope*'); // 无命中:返回空数组 [],不是 null
695
+ tpl.matchJsFiles('promo*', 'festival'); // 指定主题
696
+ // 模板内使用:
697
+ // <script src="${$js('utils.js')}"></script> 精确引用
698
+ // ${each $js('*.js') value}<script src="${value}"></script>${/each} 通配批量引用
699
+ // ${each $js('app*') value}<script src="${value}"></script>${/each} 按模块前缀分组引用
700
+ // ${if $js('chart.js')}<script src="${$js('chart.js')}"></script>${/if} 不存在则跳过,避免 404
701
+ //
702
+ // 以「站点默认主题 default、当前主题 festival」为例,$js('*.js') 的命中顺序即分层:
703
+ // ['utils.js'(应用级 static/js/), 'app.js'(默认主题,festival 未提供时回退), 'promo.js'(当前主题 static/template/festival/js/), 'patch.js'(static/diy/)]
232
704
 
233
- // 模板文件查找顺序(优先级从高到低):
234
- // 1. diy目录(自定义目录)
235
- // 2. 当前主题目录
236
- // 3. 默认主题目录
705
+ // 主题、风格与脚本目录的定位结果
706
+ tpl.getThemeDir('default'); // {dir}/static/template/default/
707
+ tpl.getStyleDir('default', 'dark');// {dir}/static/template/default/style/dark/
708
+ tpl.getTemplateDirs(); // 模板的三级目录,高到低:static/diy → 当前主题 → 默认主题
709
+ tpl.getComponentDirs(); // 组件的三级目录,高到低:static/diy/component → 当前主题/component → 默认主题/component
710
+ tpl.getStyleDirs(); // 样式的五级目录,顺序即优先级(当前主题与默认主题重复时会去重)
711
+ tpl.getJsDirs(); // 脚本的各级目录,高到低:static/diy → 当前主题 → 默认主题 → 应用级
237
712
  ```
238
713
 
239
714
  ### 2. 模板规则
@@ -246,7 +721,7 @@ tpl.current_theme = "dark";
246
721
 
247
722
  ### 3. 错误处理
248
723
  ```javascript
249
- const html = tpl.view("./template.html", data);
724
+ const html = tpl.view("./index.html", data);
250
725
  if (tpl.error) {
251
726
  console.error("渲染错误:", tpl.error);
252
727
  }
@@ -272,22 +747,344 @@ if (global.$ && !$.Tpl) {
272
747
  }
273
748
  ```
274
749
 
750
+ ### 6. 多用户风格与语言的使用方式
751
+
752
+ 主题是部署级配置,风格和语言由用户选择。模块提供两种使用方式,按是否需要实例级独立状态来选:
753
+
754
+ | | 传参渲染 | 派生实例 |
755
+ | --- | --- | --- |
756
+ | 用法 | `tpl.view(file, model, { style, lang })` | `tpl.fork({ style, lang })` |
757
+ | 实例数 | 1 | 每个派生实例一个 |
758
+ | 适用 | 大多数场景,无需管理实例 | 需要 `viewBag` 等实例级独立状态 |
759
+
760
+ #### 方式一:传参渲染(推荐)
761
+
762
+ 单实例即可服务所有用户,每次渲染通过 `options` 指定风格和语言,渲染结束后实例状态自动恢复:
763
+
764
+ ```javascript
765
+ const tpl = new Tpl({
766
+ root: './app/shop/',
767
+ default_theme: 'game', // 主题部署级固定,用户不参与选择
768
+ lang_dir: './static/lang/',
769
+ cache_dims: ['style', 'lang'] // 用户按风格 + 语言归类
770
+ });
771
+
772
+ // 用户A:dark 风格 + 英文
773
+ tpl.view('./index.html', { title: 'Home' }, { style: 'dark', lang: 'en_us', cache: true });
774
+
775
+ // 用户B:未选风格 + 中文
776
+ tpl.view('./index.html', { title: '首页' }, { lang: 'zh_cn', cache: true });
777
+
778
+ tpl.getStyle(); // "",不受上面两次渲染影响
779
+ tpl.getLang(); // "zh_cn"
780
+ ```
781
+
782
+ 用户偏好可交给模块内置的映射表维护(`user_id` → `{style, lang}`),再由 `userOptions` 取出渲染参数:
783
+
784
+ ```javascript
785
+ tpl.setUser(req.user_id, { style: 'dark', lang: 'en_us' });
786
+
787
+ // 已登记的用户按映射的风格与语言渲染,未登记的用户回退实例默认
788
+ tpl.view('./index.html', { title: 'Home' }, tpl.userOptions(req.user_id, { cache: true }));
789
+ ```
790
+
791
+ 存储的用法见[用户偏好存储](#用户偏好存储)。
792
+
793
+ 模板查找、缓存路径、语言都按传参生效,缓存路径也按传入的组合分层:
794
+
795
+ ```javascript
796
+ // getCacheFile 支持同样的传参,便于查询或预生成
797
+ tpl.getCacheFile({ cache_filename: './index', style: 'dark', lang: 'en_us' });
798
+ // cache/dark/en_us/index.cache.html
799
+ ```
800
+
801
+ `view` 是同步渲染,进入时套用、退出时恢复,因此**并发请求下也是安全的**,请求之间不会串。
802
+
803
+ #### 方式二:派生实例
804
+
805
+ 需要实例级独立状态时(例如模板要读 `${viewBag.xxx}`),用 `fork` 派生。按「风格 + 语言」派生可复用实例,实例数等于组合数:
806
+
807
+ ```javascript
808
+ const pool = new Map();
809
+
810
+ /**
811
+ * 按风格和语言取实例
812
+ * @param {string} style - 风格,空字符串表示未选
813
+ * @param {string} lang - 语言
814
+ * @returns {Tpl} 对应组合的模板实例
815
+ */
816
+ function getInstance(style, lang) {
817
+ const pool_key = [style || '', lang].join('|');
818
+ if (!pool.has(pool_key)) {
819
+ pool.set(pool_key, tpl.fork({ style: style, lang: lang }));
820
+ }
821
+ return pool.get(pool_key);
822
+ }
823
+
824
+ // 用户ID → 风格+语言的映射用模块内置的映射表维护,也可由上层业务自行维护
825
+ tpl.setUser(1001, { style: 'dark', lang: 'en_us' });
826
+ const pref = tpl.getUser(1001);
827
+ const instance = getInstance(pref.style, pref.lang);
828
+ const html = instance.view('./index.html', { title: 'Home' }, { cache: true });
829
+ ```
830
+
831
+ 实例数与用户数解耦,只等于实际用到的组合数;同组合的用户共用实例,模板查找结果与缓存文件都一致。
832
+
833
+ 若需要每个用户拥有独立的 `viewBag`,则按用户派生(实例数等于用户数):
834
+
835
+ ```javascript
836
+ const user_inst = tpl.fork({
837
+ user_id: req.user_id,
838
+ style: pref.style,
839
+ lang: pref.lang
840
+ });
841
+ ```
842
+
843
+ #### 用户偏好存储
844
+
845
+ 用户偏好(`user_id` → `{style, lang}`)由存储对象维护,实例上以 `store` 暴露,模块提供内置的内存存储 `Memory` 与 SQLite 存储 `Sqlite`:
846
+
847
+ ```javascript
848
+ const {Tpl, Memory, Sqlite} = require('mm_tpl');
849
+
850
+ const tpl = new Tpl({ user_store: new Memory() }); // 内存存储(默认)
851
+ const tpl2 = new Tpl({ user_store: new Sqlite('tpl.db') }); // 持久化到 SQLite 文件
852
+
853
+ // 写入:同一用户重复写入按字段合并,数字与字符串形式的同一 user_id 视为同一人
854
+ tpl.setUser(1001, { style: 'dark', lang: 'en_us' });
855
+ tpl.setUser(1001, { lang: 'zh_cn' }); // 只更新语言,风格保持 dark
856
+
857
+ tpl.getUser(1001); // { style: 'dark', lang: 'zh_cn' },未登记返回 null
858
+ tpl.getUsers(); // ['1001'],已登记的用户标识列表
859
+ tpl.removeUser(1001); // 删除,返回是否删除成功
860
+
861
+ // 取渲染参数:已登记的用户按映射,未登记的用户回退当前实例的风格与语言
862
+ tpl.userOptions(1001); // { style: 'dark', lang: 'zh_cn' }
863
+ tpl.userOptions(9999); // { style: <当前风格>, lang: <当前语言> }
864
+ tpl.userOptions(1001, { cache: true }); // 可与其他渲染参数合并
865
+ ```
866
+
867
+ 也可以注入自定义存储,实现六个同步方法即可(内置的 `Memory` / `Sqlite` 同样按这一契约实现):
868
+
869
+ ```javascript
870
+ const tpl3 = new Tpl({
871
+ user_store: {
872
+ data: {},
873
+ getUser(user_id) {
874
+ return this.data[user_id] || null;
875
+ },
876
+ setUser(user_id, prefs) {
877
+ this.data[user_id] = Object.assign({}, this.data[user_id], prefs);
878
+ return this.data[user_id];
879
+ },
880
+ delUser(user_id) {
881
+ delete this.data[user_id];
882
+ return true;
883
+ },
884
+ hasUser(user_id) {
885
+ return this.data[user_id] !== undefined;
886
+ },
887
+ listUsers() {
888
+ return Object.keys(this.data);
889
+ },
890
+ clear() {
891
+ this.data = {};
892
+ return this;
893
+ }
894
+ }
895
+ });
896
+
897
+ tpl3.setUser(2001, { style: 'light' }); // 写入
898
+ tpl3.getStore(); // 取到该存储对象
899
+ ```
900
+
901
+ `setUser` 需自行按字段合并,`clear` 返回自身以便链式调用;`user_id` 的规范化由实现决定,内置存储统一转为字符串,因此 `1001` 与 `'1001'` 视为同一用户。
902
+
903
+ 几条约定:
904
+
905
+ - 只保存 `style` 与 `lang` 两个字段,其他字段会被忽略;存储不做权限校验,`user_id` 由调用方保证可信
906
+ - 派生实例共享同一份存储:偏好属于用户而非某个实例,`fork` 后读写的是同一份数据(可用 `tpl.getStore()` 取到存储对象)
907
+ - 存储以接口方式注入,默认使用进程内存的 `Memory`;配置项 `user_store` 传入实现 `getUser` / `setUser` / `delUser` / `hasUser` / `listUsers` / `clear` 六个同步方法的对象,即可替换为文件、数据库或缓存服务,接口不完整时会告警并回退内置内存存储
908
+ - 内存存储随进程销毁,多进程或多机部署时请注入共享存储(如 `Sqlite`),否则各进程的数据互相不可见
909
+ - `Sqlite` 依赖 Node 内置的 `node:sqlite`(Node 22.5 起提供),低版本下模块不导出 `Sqlite`,其余功能不受影响
910
+
911
+ #### viewBag 是实例级共享的
912
+
913
+ `viewBag` 挂在实例上,被使用该实例的所有请求共享,因此**不要往 `viewBag` 写用户级数据**。
914
+
915
+ **1. 用户数据只能走 `model` 传参**
916
+
917
+ 写入 `viewBag` 的数据会残留给使用同一实例的后续请求:
918
+
919
+ ```javascript
920
+ // 错误:钩子把请求数据写进 viewBag,同一实例的后续请求会读到
921
+ $.hook.addFunc('login', async function (m, req) {
922
+ m.user_id = req.user_id; // m 就是 this.viewBag
923
+ });
924
+
925
+ // 正确:每次渲染通过 model 传
926
+ tpl.view('./index.html', { user_id: req.user_id, title: 'Home' });
927
+ ```
928
+
929
+ **2. 模板中读 `${user_id}`,不要读 `${viewBag.user_id}`**
930
+
931
+ 两者取的是不同来源,`model` 是本次请求独有,`viewBag` 是实例级共享:
932
+
933
+ ```html
934
+ <div>${user_id}</div> <!-- 本次请求的 model,安全 -->
935
+ <div>${viewBag.user_id}</div> <!-- 实例级共享数据,会串号 -->
936
+ ```
937
+
938
+ `viewBag` 只适合放站点级、所有用户一致的数据(如系统名称)。该约束对传参渲染和按组合派生实例都成立;只有为每个用户各派生一个独立实例时,`viewBag` 才是该用户私有的,此时「钩子写 `viewBag`」的数据流才可用。
939
+
940
+ #### 缓存文件数为什么是有限的
941
+
942
+ 风格和语言都是有限枚举,用户只是落到其中某个组合里,所以缓存文件数与用户数无关:
943
+
944
+ ```
945
+ 缓存文件数 ≤ (命名风格数 + 1) × 语言数 × 被缓存的模板数
946
+ ```
947
+
948
+ 以 1 种命名风格(dark,外加未选共 2 种组合)、2 种语言、20 个模板为例,上限是 80 个文件(80 = 2 种组合 × 2 语言 × 20 模板)。站点有 10 个用户还是 10,000 个用户,这个数字都不变,因为用户只是落到这几种组合里;未请求的组合不产生文件。
949
+
950
+ 反过来说,缓存里**不能放会随用户变化的数据**:缓存住会变的数据,用户充完值刷新页面看到的还是旧余额 —— **这是正确性问题,不是性能问题**。
951
+
952
+ 所以判断标准很简单:**渲染结果依赖用户数据时,整页缓存本身就不该做**。此时有三个方向:
953
+
954
+ 1. 把用户相关的区域交给前端异步请求,页面主体按风格和语言做共享缓存;
955
+ 2. 确实要分用户存放时,显式指定 `cache_filename` 区分(如 `'u_' + user_id + '_index'`),或改用内存缓存(`cache_type: 'memory'`)并设置较小的 `cache_max` 和较短的生命周期;
956
+ 3. 直接不做缓存。
957
+
958
+ #### 维度顺序决定清理粒度
959
+
960
+ `cache_dims` 的顺序就是目录层级,也就决定了能按哪个维度批量清理。默认 `['style', 'lang']` 时目录是 `cache/{style}/{lang}/`:
961
+
962
+ ```javascript
963
+ // 按风格清理,一次清掉 dark 风格下所有语言的缓存
964
+ tpl.clearCache('./dark');
965
+
966
+ // 未选风格的用户缓存在根下的语言目录里
967
+ tpl.clearCache('./zh_cn');
968
+ ```
969
+
970
+ ### 7. 模板函数表与实例级钩子
971
+
972
+ #### 模板函数表
973
+
974
+ 用 `setFunc` 注册模板函数,注册后每次渲染都注入模型,模板内直接按名调用;`removeFunc` 删除,`getFuncs` 取全部:
975
+
976
+ ```javascript
977
+ tpl.setFunc('$md', function (text) {
978
+ return text.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
979
+ });
980
+
981
+ tpl.render('${$md(\'**加粗**\')}'); // <b>加粗</b>
982
+ ```
983
+
984
+ 优先级为:**本次 `model` 的同名字段 > 内置函数(`$view` / `$t` / `$css` / `$js` / `$component` / `hookAction` / `hookFilter`)> 已注册的函数**——注册的函数覆盖不了内置函数,也不会顶掉本次请求传进来的数据。`fork` 时模板函数表会复制给派生实例。
985
+
986
+ #### 实例级钩子
987
+
988
+ `$.hook` 是**全局单例**,往里加的钩子对所有实例生效;`tpl.hook` 是**实例级钩子**,只影响当前实例及其派生实例。渲染时两级都会执行,**全局先执行、实例级后执行**:动作钩子的输出全局在前,过滤器钩子的返回值由全局传给实例级继续处理。
989
+
990
+ ```javascript
991
+ tpl.hook.addAction('header', function (m) { return '<header>实例级</header>'; });
992
+ ```
993
+
275
994
  ## API 参考
276
995
 
277
996
  ### Tpl 类方法
278
997
 
279
998
  - `constructor(config)` - 构造函数,创建模板实例
280
- - `setConfig(config)` - 设置配置参数
281
- - `render(body, model, options)` - 渲染模板字符串
999
+ - `setConfig(config)` - 设置配置参数(会重建语言实例)
1000
+ - `render(body, model, options)` - 渲染模板字符串;`options.style` / `options.lang` 仅对本次渲染生效
282
1001
  - `compile(body, options)` - 编译模板为函数
283
- - `view(file, model, options)` - 渲染模板文件
1002
+ - `view(file, model, options)` - 渲染模板文件;`options.style` / `options.lang` 仅对本次渲染生效,渲染结束后自动恢复;`options.dir` 指定时只在该目录查找模板(用于邮件、打印等主题体系外的模板)
1003
+ - `getFile(file, options)` - 按目录优先级查找模板文件,返回命中的文件全路径;`options.dir` 指定时只在该目录查找
1004
+ - `getComponentFile(name, options)` - 沿组件目录链查找组件文件,返回命中的文件全路径;`name` 为组件名,可含子目录如 `article/card`,省略扩展名时按 `extname` 自动补全;`options.dir` 指定时只在该目录查找
1005
+ - `component(name, model, options)` - 渲染组件,返回渲染结果字符串;组件名相对组件目录解析,不相对调用方文件解析;找不到组件时返回空字符串并置 `error`(`code: 10000`),`component_dir` 为空时置 `code: 10001`,均不抛错。模板内的 `$component(name, model?)` 与它等价
1006
+ - `getCssFiles()` - 获取当前风格下按优先级升序排列的样式文件全路径数组,同名文件只保留优先级最高的一份;基础风格在前打底,当前风格与 `static/diy/` 在后覆盖
1007
+ - `matchCssFiles(pattern, theme, style)` - 按通配符匹配样式文件,返回全路径数组;`pattern` 支持 `*.css`、`blue*`、`*common*`,为空时等同于 `*.css`
1008
+ - `getCssFile(name, theme, style)` - 按目录优先级查找样式文件,返回全路径;`name` 含 `*` 时返回数组(无命中为空数组),否则返回单个路径,不存在为 `null`。`theme` / `style` 不传时取当前值,可用于引用其他主题或风格的样式
1009
+ - `getJsFiles()` - 获取按优先级升序排列的脚本文件全路径数组,同名文件只保留优先级最高的一份;应用级在前,主题级与 `static/diy/` 在后
1010
+ - `matchJsFiles(pattern, theme)` - 按通配符匹配脚本文件,返回全路径数组;`pattern` 支持 `*.js`、`app*`、`*promo*`,为空时等同于 `*.js`;无命中返回空数组
1011
+ - `getJsFile(name, theme)` - 按目录优先级查找脚本文件,返回全路径;`name` 含 `*` 时返回数组,否则返回单个路径,不存在为 `null`
284
1012
  - `set(obj)` - 设置视图数据
285
1013
  - `get(key)` - 获取视图数据
286
1014
  - `del(keys)` - 删除视图数据
287
- - `clearCache(path, file)` - 清除缓存
288
- - `runFunc(name, req, ...args)` - 执行函数钩子
289
- - `runHook(name, req, ...args)` - 执行动作钩子(runFunc别名)
1015
+ - `clearCache(path, file)` - 清除缓存;`path` 相对 `cache_root`,不传时只清除当前实例的维度目录,传 `'./'` 清除全部
1016
+ - `getCacheFile(options)` - 获取按维度分层后的缓存文件全路径,参数可为配置对象或缓存文件名字符串;传入 `options.style` / `options.lang` 时按该组合计算
1017
+ - `runFunc(name, req, ...args)` - 执行函数钩子,返回 Promise;全局钩子与实例级钩子都会执行,实例级返回值优先
1018
+ - `runHook(name, req, ...args)` - 执行函数钩子(runFunc 别名)
290
1019
  - `escape(body, restore)` - 转义/还原字符
1020
+ - `setFunc(name, func)` - 注册模板函数,每次渲染都注入模型供模板内调用;内置函数与本次 `model` 的同名字段优先
1021
+ - `removeFunc(name)` - 删除已注册的模板函数,返回是否存在并删除成功
1022
+ - `getFuncs()` - 获取模板函数表
1023
+
1024
+ ### Tpl 多用户相关方法
1025
+
1026
+ - `fork(config)` - 派生实例,用户级参数为 `{style, lang}`,其余部署级配置(主题、目录、语言包等)自动继承主实例;若只需按风格和语言渲染,用 `view` 的 `options` 传参即可,无需派生实例
1027
+ - `getTheme()` - 获取当前主题
1028
+ - `setTheme(theme, force)` - 设置当前主题(部署级配置,正常运行中不应由用户切换),目录不存在时保持原值并告警,`force` 为 `true` 时跳过校验;缓存维度不含 `theme` 时切换会输出告警
1029
+ - `getStyle()` - 获取当前风格,未设置时返回空字符串
1030
+ - `setStyle(style, force)` - 设置当前风格(用户可选项),空字符串表示不设置风格(查找与缓存按默认风格回退),目录不存在时保持原值并告警,`force` 为 `true` 时跳过校验
1031
+ - `getThemeDir(theme)` - 获取指定主题的目录全路径
1032
+ - `getStyleDir(theme, style)` - 获取指定主题下指定风格的目录全路径,`style` 为空时返回该主题的风格目录本身
1033
+ - `getTemplateDirs(theme)` - 获取模板的目录列表(全路径),顺序即优先级:`static/diy/`、当前主题、默认主题;`theme` 不传时取当前主题
1034
+ - `getComponentDirs(theme)` - 获取组件的目录列表(全路径),顺序即优先级:`static/diy/component/`、当前主题 `component/`、默认主题 `component/`,与模板链同构、每级多一个固定的 `component/` 前缀;`theme` 不传时取当前主题,`component_dir` 为空时返回空数组
1035
+ - `getStyleDirs(theme, style)` - 获取**样式**的目录列表(全路径,只用于样式),顺序即优先级:`static/diy/`、当前主题+当前风格、当前主题+基础风格、默认主题+当前风格、默认主题+基础风格;`theme` / `style` 不传时取当前值
1036
+ - `getJsDirs(theme)` - 获取脚本的目录列表(全路径),顺序为优先级从高到低:`static/diy/`、当前主题、默认主题、应用级
1037
+ - `getLang()` - 获取当前语言代码
1038
+ - `setLang(lang)` - 设置当前语言,返回设置后的语言代码
1039
+ - `t(key, ...args)` - 获取翻译文本,与模板中的 `$t` 等价,支持 `a.b` 层级键;`args` 为单个对象时按 `{name}` 命名占位符替换,否则按 `{0}`、`{1}` 位置占位符替换
1040
+ - `tc(key, count, ...args)` - 获取带复数形式的翻译文本,`{count}` 为数量占位符
1041
+ - `getLangs()` - 获取当前支持的语言列表
1042
+ - `reloadLangs(force)` - 重新加载语言包,保持当前语言,返回语言列表;默认按文件指纹判断,`force` 为 `true` 时强制重新读取文件
1043
+ - `loadLang(lang)` - 重新加载并返回指定语言的词条对象,不存在时返回 `null`
1044
+ - `setUser(user_id, prefs)` - 写入用户偏好,`prefs` 形如 `{style, lang}`,只保存这两个字段;同一用户重复写入按字段合并,返回保存后的偏好
1045
+ - `getUser(user_id)` - 获取用户偏好,未登记时返回 `null`
1046
+ - `removeUser(user_id)` - 删除用户偏好,返回是否删除成功
1047
+ - `getUsers()` - 获取已登记的用户标识列表
1048
+ - `userOptions(user_id, options)` - 获取用户的渲染参数:已登记的用户返回其映射的 `style` / `lang`,未登记的用户回退当前实例的风格与语言;结果可直接传给 `view` / `render`,`options` 可传其他渲染参数
1049
+ - `setStore(store)` - 设置用户偏好存储,留空或接口不完整时回退为内置内存存储(`Memory`),返回当前实例
1050
+ - `getStore()` - 获取用户偏好存储(`Memory` / `Sqlite` 或自定义存储对象)
1051
+
1052
+ ### Tpl 实例属性
1053
+
1054
+ - `config` - 配置对象
1055
+ - `viewBag` - 视图数据
1056
+ - `current_theme` - 当前主题
1057
+ - `current_style` - 当前风格
1058
+ - `lang` - 当前语言实例(`Lang`),语言包字典在实例间共享,当前语言各自独立
1059
+ - `store` - 用户偏好存储(`Memory` 或 `Sqlite`),通过 `user_id` 映射用户的风格与语言,派生实例共享同一份存储
1060
+ - `funcs` - 实例级模板函数表,由 `setFunc` 写入
1061
+ - `hook` - 实例级钩子(`Hook`),只影响当前实例及其派生实例;与全局 `$.hook` 一起执行,全局先执行
1062
+ - `dir` - 应用根目录,即 `config.root`
1063
+ - `error` - 错误提示
1064
+
1065
+ ### 配置项说明
1066
+
1067
+ - `root` - 应用根目录,默认 `$.run_path`,主题、语言包、自定义覆盖目录均相对它定位
1068
+ - `theme_dir` - 主题目录,相对应用根目录,默认 `'./static/template/'`
1069
+ - `style_dir` - 风格目录,相对主题目录,默认 `'./style/'`
1070
+ - `component_dir` - 组件目录,相对主题目录(`static/diy/` 下取同名子目录),默认 `'./component/'`,存放可被功能页面复用的公共片段;为空则不启用组件,模板内的 `$component` 取不到文件
1071
+ - `diy_dir` - 自定义覆盖目录,相对应用根目录,默认 `'./static/diy/'`,优先级最高
1072
+ - `lang_dir` - 应用级语言包目录,相对应用根目录,按文件名识别语言,默认 `'./static/lang/'`,为空则不启用文件语言包
1073
+ - `theme_lang_dir` - 主题级语言包目录,相对主题目录,默认 `'./lang/'`,覆盖应用级语言包的相同词条
1074
+ - `js_dir` - 应用级脚本目录,相对应用根目录,默认 `'./static/js/'`,存放各主题共用的公共脚本
1075
+ - `theme_js_dir` - 主题级脚本目录,相对主题目录,默认 `'./js/'`,覆盖应用级脚本目录的同名脚本。脚本不按风格分层(风格是视觉变体,脚本不因颜色明暗而变),故没有对应风格的两级
1076
+ - `default_theme` - 默认主题,默认 `'default'`
1077
+ - `default_style` - 默认风格(主题内的变体),默认 `'light'`,为空则不以风格分层
1078
+ - `base_style` - 基础风格目录名,默认 `'base'`,作为各风格共享的公共样式层(只针对样式,模板不按风格分层):优先级低于当前风格、高于默认主题,公共样式放此处即可被各风格复用;与当前风格同名时该层不重复,为空则不启用
1079
+ - `default_lang` - 默认语言代码,默认 `'zh_cn'`
1080
+ - `defaults` - 应用配置文件 `config.js` 的简写,形如 `{theme, style, lang}`,创建实例时展开为 `default_theme` / `default_style` / `default_lang`;显式传入的 `default_*` 优先,`defaults` 不会残留在 `config` 中,因此 `fork()` 不会重复展开
1081
+ - `extname` - 模板与组件文件的扩展名,默认 `'.html'`,可自行设定为 `'.art'` 等其他后缀;`getFile`、`getComponentFile`、`view` 与 `component` 在入参省略扩展名时会自动补全,因此 `view('./article/list')` 与 `view('./article/list.html')` 等价
1082
+ - `plural_rules` - 复数规则,键为语言代码,默认登记 `'en_us'`;mm_expand 的 Lang 已内置 `zh_cn`、`en` 规则,其他语言代码需在此登记,`tc()` 才能按该语言取到正确的复数形式
1083
+ - `langs` - 内存语言包字典,形如 `{'zh_cn': {...}, 'en_us': {...}}`,优先级高于语言包文件;它与 `lang_dir` 都为空时复用全局 `$.lang.config`
1084
+ - `user_store` - 用户偏好存储,默认 `null`(用进程内存的 `Memory`);传入实现 `getUser` / `setUser` / `delUser` / `hasUser` / `listUsers` / `clear` 六个同步方法的对象即可替换为文件、数据库或缓存服务,也可直接用内置的 `Memory` / `Sqlite`,派生实例复用同一存储
1085
+ - `cache_dims` - 缓存分层维度,默认 `['style', 'lang']`,可选 `theme`、`style`、`lang`;顺序即目录层级,只应包含**用户可选**的维度
1086
+ - `cache_type` - 缓存方式,默认 `'file'`(物理文件),可选 `'memory'`(进程内存)
1087
+ - `cache_max` - 内存缓存最大条数,默认 `1000`,超出后淘汰最早写入的条目,`0` 表示不限制
291
1088
 
292
1089
  ### Hook 类方法
293
1090
 
@@ -300,20 +1097,56 @@ if (global.$ && !$.Tpl) {
300
1097
  - `delFunc(name, alias)` - 删除函数钩子
301
1098
  - `delAction(name, alias)` - 删除动作钩子
302
1099
  - `delFilter(name, alias)` - 删除过滤器钩子
303
- - `clear(name)` - 清空钩子
1100
+ - `clear(name)` - 清空钩子
1101
+
1102
+ 以上方法在全局单例 `$.hook` 与实例级 `tpl.hook` 上用法一致。区别在于作用范围:`$.hook` 影响所有实例,`tpl.hook` 只影响当前实例及其派生实例;渲染时两级都会执行,全局先执行、实例级后执行。
304
1103
 
305
1104
  ## 注意事项
306
1105
 
307
1106
  1. **依赖要求**: 需要安装 `art-template` 和 `mm_expand` 依赖包
308
1107
  2. **目录权限**: 确保模板目录具有读写权限
309
- 3. **缓存管理**: 及时清理过期缓存,避免内存泄漏
1108
+ 3. **缓存管理**: 缓存按 `cache_dims` 分层存放,清除时注意路径层级;`clearCache` 不传路径时只清除当前实例的缓存目录,清除全部需显式传 `'./'`。模板或语言包改动后缓存会自动失效,通常不需要手动清理
310
1109
  4. **钩子优先级**: 数值越小优先级越高,执行顺序越靠前
311
1110
  5. **异步处理**: 钩子函数支持异步操作,注意错误处理
312
- 6. **文件查找**: 模板文件按优先级顺序查找,确保文件存在
1111
+ 6. **文件查找与输出顺序**: 模板按**主题**三段查找:`static/diy/` → 当前主题 → 默认主题,按功能路径在主题目录下拼接(如 `static/template/{主题}/article/list.html`),**不按风格分层**,同名覆盖取优先级最高的文件。组件走独立的**组件目录链**,与模板链同构、每级多一个固定的 `component/` 前缀:`static/diy/component/` → 当前主题 `component/` → 默认主题 `component/`,组件名不相对调用方文件解析,当前主题缺组件时回退默认主题。样式按「主题 + 风格」五段查找:`static/diy/` → 当前主题+当前风格 → 当前主题+基础风格 → 默认主题+当前风格 → 默认主题+基础风格;只回退主题,不回退风格,其他风格目录的文件不会被继承,公共样式放基础风格目录(`base_style`,默认 `base`)。脚本只按主题分层:`static/diy/` → 当前主题 → 默认主题 → 应用级 `static/js/`。样式与脚本同名只保留优先级最高的一份,属「引用多个」类资源,`getCssFiles` / `getJsFiles` 等按优先级**升序**返回(低优先级在前、高优先级在后),因为 CSS 后加载者生效、JS 顺序决定执行;语言包相反,按优先级降序合并
313
1112
  7. **字符转义**: 自动处理模板语法字符,避免冲突
1113
+ 8. **多用户隔离**: 主题是部署级配置,站点内所有用户一致,不参与用户选择;风格和语言是用户级配置。渲染可用**传参方式**(单实例,`view` 的 `options` 传 `style` / `lang`,渲染后自动恢复)或**派生实例**(`fork`),见[多用户风格与语言的使用方式](#6-多用户风格与语言的使用方式)。**`viewBag` 是实例级共享的**:用户级数据必须通过 `model` 传递,模板写 `${user_id}`,不可写入 `viewBag`(模板写 `${viewBag.user_id}`);只有为每个用户各派生一个独立实例时 `viewBag` 才是该用户私有的。不要把用户级数据 `set` 进共用实例的 `viewBag`,也不要让多个用户共用同一实例再切换 `current_style` / `lang`,否则会互相污染
1114
+ 9. **语言包共享**: 配置项 `langs` 中已存在的语言对象在实例间共享,设计上只用于"选择";若在实例上通过 `lang.set()` 写入自定义词条,可能污染其他用户,确需写入请在 `fork` 后自行深拷贝。语言包文件每次读取都会生成新对象,不受此影响;`lang_dir` 与 `langs` 都为空时兜底复用的全局 `$.lang.config` 也按深拷贝副本使用,实例上写入不会污染全局
1115
+ 10. **缓存数量**: 缓存文件数 ≤ (命名风格数 + 1) × 语言数 × 被缓存的模板数,**与用户数无关**;未选风格单独算一种组合但不占目录层级(`default_style` 也为空时才会出现),未被请求的组合不产生文件。`cache_dims` 只应包含**用户可选**的维度(默认风格和语言),多一个维度就是成倍增长;可选值为 `theme`、`style`、`lang`,`user_id` 已不再支持,传入会告警并忽略该维度
1116
+ 11. **按用户缓存的正确性风险**: 缓存内容依赖用户数据时,用户数据在缓存有效期内变化会读到过期内容(如充值后仍显示旧余额)。这种页面本就不适合整页缓存,应改为前端异步加载;确实要分用户存放时,显式指定 `cache_filename` 区分,或用内存缓存 + 较小 `cache_max`
1117
+ 12. **主题不参与缓存分层**: 主题为部署级配置,默认不在 `cache_dims` 中。运行时切换主题会复用旧主题的缓存,`setTheme` 会输出告警;切换后需 `clearCache()`,或把 `theme` 纳入维度
1118
+ 13. **主题与风格校验**: `setTheme` / `setStyle` 默认校验目录存在性,不存在时保持原值并告警;目录尚未创建时可用 `force` 参数跳过校验。风格为空字符串表示不设置风格,查找与缓存按默认风格回退
1119
+ 14. **风格与主题回退(仅样式)**: `setStyle` 只校验该风格目录是否存在于当前主题或默认主题下,目录缺失时保持原值并告警;直接给 `current_style` 赋值则不校验。样式查找时**只回退主题**(当前主题+当前风格 → 当前主题+基础风格 → 默认主题+当前风格 → 默认主题+基础风格),**不回退风格**,其他风格目录的文件不会被继承。公共样式放入基础风格目录(`base_style`,默认 `base`),各风格目录只放差异文件;若某风格需要不同于其他风格的公共样式,可在自己的风格目录内放同名文件覆盖基础风格。模板不按风格分层,换风格不改变命中的模板
1120
+ 15. **模板取词**: 模板文件需通过 `$t('key')` 才能输出多语言内容,硬编码文案不会随语言切换;文案应放在语言包目录下的 JSON 语言包中(文件名即语言代码,如 `zh_cn.json`、`en_us.json`),模板文件只负责取词
1121
+ 16. **钩子作用域**: `$.hook` 是全局单例,钩子对所有实例生效;`tpl.hook` 只作用于当前实例及其派生实例,渲染时两级都执行(全局先执行、实例级后执行)。模板函数优先级为「本次 `model` 同名字段 > 内置函数 > 已注册的函数」
1122
+ 16. **语言缓存开销**: 未从 `cache_dims` 去掉 `lang` 时,同一份模板会按语言各存一份缓存。若模板本身不做多语言,移除 `lang` 维度可减少缓存文件数量
1123
+ 17. **语言文件变更**: 语言包文件仅在创建实例时读取,已创建的实例不会自动刷新;新建实例或 `fork` 会按文件指纹自动判断是否需要重新读取。需让已创建的实例生效时调用 `reloadLangs()` 或 `loadLang(lang)`。`setTheme` 会自动重新加载,使主题级语言包生效
1124
+ 18. **语言包缓存**: 语言包按「目录组 + 文件指纹」缓存在静态属性 `Tpl.lang_cache` 中,文件未变化时 `fork` 不会重复解析;缓存仅在内存中,进程重启后重新读取。指纹依赖文件修改时间,同一时间粒度内的改动可能识别不到,此时可用 `reloadLangs(true)` 强制刷新
1125
+ 19. **缓存文件名**: `cache_filename` 统一规范化为以 `cache_extname` 结尾,`./index` 与 `./index.html` 都转为 `index.cache.html`,便于 `clearCache` 按扩展名匹配;`view` 传 `cache` 且未指定 `cache_filename` 时按模板路径自动推导,无需手动指定
1126
+ 20. **缓存自动失效**: 以模板文件或语言包文件的修改时间与缓存文件对比,文件更新则缓存重建。依赖时间戳意味着同一时间粒度内的改动可能识别不到;`cache_dims` 不含 `lang` 时不校验语言包改动
1127
+ 21. **内存缓存**: `cache_type` 为 `memory` 时不产生物理文件,缓存在 `Tpl.cache_store` 中按最少使用淘汰。进程重启后缓存清空,多实例部署下各进程各有一份缓存,且不共享失效状态
1128
+ 22. **内存缓存与物理缓存不互通**: 两种模式读写的是不同位置,切换 `cache_type` 后旧缓存不会自动清理,需要按需 `clearCache()`
1129
+ 23. **传参渲染的临时选项**: `view` / `render` / `getCacheFile` 的 `options.style` 与 `options.lang` 只对本次调用生效,调用结束后自动恢复,不改变实例状态;`view` 为同步渲染,因此并发请求下安全。需与 `setStyle` / `setLang`(持久修改实例状态)区分使用
1130
+ 24. **用户偏好存储**: `user_store` 默认用进程内存的 `Memory`,随进程销毁;多进程或多机部署时请注入共享存储(内置的 `Sqlite`,或实现 `getUser` / `setUser` / `delUser` / `hasUser` / `listUsers` / `clear` 六个同步方法的对象),否则各进程的数据互相不可见。存储不做权限校验,`user_id` 由调用方保证可信
1131
+ 25. **表达式内可写对象字面量**: `${ }` 内的对象字面量会被完整解析,`${@$component('bar', { id: 1 })}`、`${$t('welcome', { name: 'Tom' })}` 均可直接写,不必先赋给变量。仅支持**非嵌套**字面量,`{a:{b:1}}` 这类嵌套写法不会被识别为表达式,会按纯文本原样输出(不报错);此时改用 `<!--{@ }-->` 注释式定界符或先赋给变量即可
1132
+ 26. **组件传参与继承**: `$component` 的 model 与调用方数据合并(`Object.assign`),调用方的数据与 `$view` / `$css` / `$t` 等模板函数都会传给组件,传入的同名字段覆盖调用方
314
1133
 
315
1134
  ## 开发
316
1135
 
1136
+ 模块文件布局:
1137
+
1138
+ - `index.js` - 模块入口,定义对外主类 `Tpl`,并导出存储类 `Memory` / `Sqlite`
1139
+ - `lib/engine.js` - `Engine`,单个引擎实例:渲染、缓存、主题/风格/语言编排、派生实例(`fork`),以及 `getThemeDir` / `getStyleDir` / `getTemplateDirs` / `getComponentDirs` / `getComponentFile` / `getStyleDirs` / `getJsDirs` / `getCssFiles` / `getJsFiles`
1140
+ - `lib/template.js` - `Template`,模板文件查找:按功能路径在主题目录下查找(`static/diy/` → 当前主题 → 默认主题),同时负责组件的目录链查找(`getComponent`)
1141
+ - `lib/asset.js` - `Asset`,资源加载基类:按目录优先级收集、同名去重、按优先级升序输出
1142
+ - `lib/css.js` - `Css`,样式文件收集(继承 `Asset`)
1143
+ - `lib/js.js` - `Js`,脚本文件收集(继承 `Asset`)
1144
+ - `lib/lang.js` - `Lang`,语言包合并与缓存
1145
+ - `lib/store.js` - `Memory`,内存存储:`user_id` → `{style, lang}`
1146
+ - `lib/sqlite.js` - `Sqlite`,SQLite 存储:与 `Memory` 同契约,数据持久化到文件(依赖 `node:sqlite`)
1147
+ - `lib/util.js` - `TplUtil`,纯辅助静态方法
1148
+ - `hook.js`、`tpl_rule.js` - 钩子与模板语法规则
1149
+
317
1150
  ```bash
318
1151
  # 运行测试
319
1152
  npm test