# VALAXY
> Valaxy is a next-generation static blog framework powered by Vue, Vite, and TypeScript.
## Math 渲染引擎评估与加载策略
- **Date**: 2026-02-23
- **Tags**: performance, katex, mathjax, dev-notes
## 背景
Valaxy 需要支持 Markdown 中的数学公式渲染。此前仅支持 KaTeX,VitePress 则选择了 MathJax3。本文评估两种引擎的优劣,以及 KaTeX 的加载策略。
## KaTeX vs MathJax3 对比
### 渲染性能
| 维度 | KaTeX | MathJax3 |
|------|-------|----------|
| 渲染速度 | 极快(专为速度优化) | 较慢(功能更全面) |
| 渲染位置 | Node 端构建时渲染为 HTML | Node 端构建时渲染为 SVG |
| 客户端 JS | 零(构建时已渲染完成) | 零(构建时已渲染完成) |
两者在 Valaxy 中都是**构建时渲染**,不依赖客户端 JS,因此运行时性能差异不大。
### 输出格式与依赖
| 维度 | KaTeX | MathJax3 |
|------|-------|----------|
| **输出格式** | HTML + CSS spans | **SVG**(自包含矢量图) |
| **外部 CSS** | 需要 `katex.min.css`(~1.2KB gzip) | **无** |
| **字体文件** | ~20 个 woff2(浏览器按需加载) | **无**(SVG 内嵌字形) |
| **FOUC 风险** | CSS 未加载前有闪烁风险 | **无**(SVG 自包含) |
| **按需特性** | 需要全局加载 CSS | **天然按需**——无公式页面零开销 |
### 功能完整性
| 维度 | KaTeX | MathJax3 |
|------|-------|----------|
| LaTeX 覆盖度 | 大部分常用命令 | 更全面,支持更多扩展 |
| 交换图/XyJax | 不支持 | 支持(通过 XyJax-v3) |
| `\ce{}` 化学 | 需要额外扩展 | 内置支持 |
| `\cancel`/`\xcancel` | 支持 | 支持 |
| 自定义宏 | 支持 | 支持(更灵活) |
| 可访问性 | MathML 输出 | MathML + SVG |
### 依赖体积(npm 包大小)
| | KaTeX | MathJax3 (`markdown-it-mathjax3`) |
|---|---|---|
| 安装大小 | `katex`: ~3.5MB(含字体) | `markdown-it-mathjax3@4`: ~40MB(`mathjax-full`) |
| 客户端影响 | ~1.2KB CSS(gzip) | 零 |
> MathJax 的 npm 安装体积更大,但这仅影响 `node_modules`,不影响客户端产物。
### 为什么 VitePress 选择 MathJax3?
1. **零运行时依赖**:SVG 内联在 HTML 中,无需 CSS/字体/JS
2. **天然按需**:无公式页面完全零开销
3. **通用文档工具**:大多数文档站不使用数学公式,MathJax 的 SVG 方案确保零影响
4. **`math: false` 默认关闭**:仅需要时才安装和启用
### Valaxy 的选择:KaTeX + MathJax 分离配置
Valaxy 通过两个独立配置分别控制两种引擎,语义清晰,对齐 VitePress:
```ts
// valaxy.config.ts
// KaTeX(默认开启)
export default defineValaxyConfig({
features: { katex: true },
})
// MathJax3(对齐 VitePress,零 CSS 依赖)
// 需先安装:pnpm add markdown-it-mathjax3
export default defineValaxyConfig({
math: true,
})
// 禁用所有数学渲染
export default defineValaxyConfig({
features: { katex: false },
})
```
- `features.katex` — 控制 KaTeX(Valaxy 原有配置,保持不变)
- `math` — 控制 MathJax(对齐 VitePress `markdown.math`)
- 两者互斥:启用 `math` 时 KaTeX 自动禁用
---
## KaTeX 加载策略评估
### 前提
当选择 KaTeX 引擎时,首页首屏不需要渲染数学公式,但 `katex.min.css`(~25KB)及字体文件会在所有页面全局加载。评估是否应改为按需加载。
### 方案对比
#### 方案 A:全局条件加载(当前方案)
在 `virtual/styles.ts` 中,当 math engine 为 `katex` 时全局引入 `katex.min.css` + `katex.scss`。
#### 方案 B:按需加载
从全局样式移除 KaTeX CSS,通过 node 端正则检测文章内容中的数学公式语法、客户端 DOM 检测 `.katex` 元素,按需动态 `import()` CSS。
#### 对比
| 维度 | 全局加载 | 按需加载 |
|------|---------|---------|
| 首屏性能 | ~1.2KB gzip CSS | 无数学页面零开销 |
| 实现复杂度 | 一行 import | node 检测 + composable + DOM 检测,分布 5+ 文件 |
| 可靠性 | 不可能遗漏 | 依赖正则和 DOM 检测的完备性 |
| FOUC 风险 | 无 | 列表页 DOM 检测路径存在短暂 FOUC |
| SSG 友好度 | 完美 | 文章详情页 OK;列表页 `onMounted` 路径不参与 SSR |
| 维护成本 | 低 | 中——多处代码联动,正则需持续维护 |
### 按需加载方案的具体问题
#### 1. 正则检测误判与遗漏(严重)
```ts
const hasInlineMath = /(?
## KaTeX vs MathJax3 对比 {#katex-vs-mathjax3-对比}
### 渲染性能 {#渲染性能}
| 维度 | KaTeX | MathJax3 |
|------|-------|----------|
| 渲染速度 | 极快(专为速度优化) | 较慢(功能更全面) |
| 渲染位置 | Node 端构建时渲染为 HTML | Node 端构建时渲染为 SVG |
| 客户端 JS | 零(构建时已渲染完成) | 零(构建时已渲染完成) |
两者在 Valaxy 中都是**构建时渲染**,不依赖客户端 JS,因此运行时性能差异不大。
### 输出格式与依赖 {#输出格式与依赖}
| 维度 | KaTeX | MathJax3 |
|------|-------|----------|
| **输出格式** | HTML + CSS spans | **SVG**(自包含矢量图) |
| **外部 CSS** | 需要 `katex.min.css`(~1.2KB gzip) | **无** |
| **字体文件** | ~20 个 woff2(浏览器按需加载) | **无**(SVG 内嵌字形) |
| **FOUC 风险** | CSS 未加载前有闪烁风险 | **无**(SVG 自包含) |
| **按需特性** | 需要全局加载 CSS | **天然按需**——无公式页面零开销 |
### 功能完整性 {#功能完整性}
| 维度 | KaTeX | MathJax3 |
|------|-------|----------|
| LaTeX 覆盖度 | 大部分常用命令 | 更全面,支持更多扩展 |
| 交换图/XyJax | 不支持 | 支持(通过 XyJax-v3) |
| `\ce{}` 化学 | 需要额外扩展 | 内置支持 |
| `\cancel`/`\xcancel` | 支持 | 支持 |
| 自定义宏 | 支持 | 支持(更灵活) |
| 可访问性 | MathML 输出 | MathML + SVG |
### 依赖体积(npm 包大小) {#依赖体积npm-包大小}
| | KaTeX | MathJax3 (`markdown-it-mathjax3`) |
|---|---|---|
| 安装大小 | `katex`: ~3.5MB(含字体) | `markdown-it-mathjax3@4`: ~40MB(`mathjax-full`) |
| 客户端影响 | ~1.2KB CSS(gzip) | 零 |
> MathJax 的 npm 安装体积更大,但这仅影响 `node_modules`,不影响客户端产物。
### 为什么 VitePress 选择 MathJax3? {#为什么-vitepress-选择-mathjax3}
1. **零运行时依赖**:SVG 内联在 HTML 中,无需 CSS/字体/JS
2. **天然按需**:无公式页面完全零开销
3. **通用文档工具**:大多数文档站不使用数学公式,MathJax 的 SVG 方案确保零影响
4. **`math: false` 默认关闭**:仅需要时才安装和启用
### Valaxy 的选择:KaTeX + MathJax 分离配置 {#valaxy-的选择katex-mathjax-分离配置}
Valaxy 通过两个独立配置分别控制两种引擎,语义清晰,对齐 VitePress:
```ts
// valaxy.config.ts
// KaTeX(默认开启)
export default defineValaxyConfig({
features: { katex: true },
})
// MathJax3(对齐 VitePress,零 CSS 依赖)
// 需先安装:pnpm add markdown-it-mathjax3
export default defineValaxyConfig({
math: true,
})
// 禁用所有数学渲染
export default defineValaxyConfig({
features: { katex: false },
})
```
- `features.katex` — 控制 KaTeX(Valaxy 原有配置,保持不变)
- `math` — 控制 MathJax(对齐 VitePress `markdown.math`)
- 两者互斥:启用 `math` 时 KaTeX 自动禁用
---
## KaTeX 加载策略评估 {#katex-加载策略评估}
### 前提 {#前提}
当选择 KaTeX 引擎时,首页首屏不需要渲染数学公式,但 `katex.min.css`(~25KB)及字体文件会在所有页面全局加载。评估是否应改为按需加载。
### 方案对比 {#方案对比}
#### 方案 A:全局条件加载(当前方案) {#方案-a全局条件加载当前方案}
在 `virtual/styles.ts` 中,当 math engine 为 `katex` 时全局引入 `katex.min.css` + `katex.scss`。
#### 方案 B:按需加载 {#方案-b按需加载}
从全局样式移除 KaTeX CSS,通过 node 端正则检测文章内容中的数学公式语法、客户端 DOM 检测 `.katex` 元素,按需动态 `import()` CSS。
#### 对比 {#对比}
| 维度 | 全局加载 | 按需加载 |
|------|---------|---------|
| 首屏性能 | ~1.2KB gzip CSS | 无数学页面零开销 |
| 实现复杂度 | 一行 import | node 检测 + composable + DOM 检测,分布 5+ 文件 |
| 可靠性 | 不可能遗漏 | 依赖正则和 DOM 检测的完备性 |
| FOUC 风险 | 无 | 列表页 DOM 检测路径存在短暂 FOUC |
| SSG 友好度 | 完美 | 文章详情页 OK;列表页 `onMounted` 路径不参与 SSR |
| 维护成本 | 低 | 中——多处代码联动,正则需持续维护 |
### 按需加载方案的具体问题 {#按需加载方案的具体问题}
#### 1. 正则检测误判与遗漏(严重) {#1-正则检测误判与遗漏严重}
```ts
const hasInlineMath = /(?
{{ t("sidebar.toc") }}
```
### Messages when SSG
`vue-i18n` supports importing multiple languages by using the virtual module `@intlify/unplugin-vue-i18n/messages`.
Unfortunately, it doesn't support SSR perfectly.[#78 | intlify/bundle-tools](https://github.com/intlify/bundle-tools/issues/78)
And Vite's `import.meta.globEager` import must use a static string.
```ts {3}
const messages = Object.fromEntries(
Object.entries(
import.meta.globEager('../../locales/*.y(a)?ml')
)
.map(([key, value]) => {
const yaml = key.endsWith('.yaml')
return [key.slice(14, yaml ? -5 : -4), value.default]
}),
)
```
It works when there is a defined directory, but Valaxy also needs to merge Valaxy's own `locales` with the theme's `locales` and user-defined `locales`.
This means that we cannot use variables to splice strings for import, and it is difficult to determine the relative location of where these `locales` are for different package managers with different directory structures.
So I implemented it in the form of a plugin virtual module (`@valaxyjs/locales`):
> The principle of the Vite virtual module is actually a spliced string.
```ts
import type { Plugin } from 'vite'
// import the locales data in each directory in turn and merge them
function generateLocales(roots: string[]) {
const imports: string[] = [
'const messages = { "zh-CN": {}, en: {} }',
]
const languages = ['zh-CN', 'en']
roots.forEach((root, i) => {
languages.forEach((lang) => {
const langYml = `${root}/locales/${lang}.yml`
if (fs.existsSync(langYml) && fs.readFileSync(langYml, 'utf-8')) {
const varName = lang.replace('-', '') + i
// in windows, you need to change slash
// more info you can refer 'packages/valaxy/src/node/plugins/index.ts'
imports.push(`import ${varName} from "${langYml}"`)
imports.push(`Object.assign(messages['${lang}'], ${varName})`)
}
})
})
imports.push('export default messages')
return imports.join('\n')
}
export function createValaxyPlugin(options: ResolvedValaxyOptions): Plugin {
// ...
const roots = [options.clientRoot, options.themeRoot, options.userRoot]
return {
name: 'Valaxy',
load(id) {
// ...
if (id === '/@valaxyjs/locales')
return generateLocales(roots)
},
async handleHotUpdate(ctx) {
// ...
},
}
}
```
Finally load in the i18n initialization file:
```ts
// i18n.ts
import messages from '/@valaxyjs/locales'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages,
})
app.use(i18n)
```
## CSS i18n - Another solution
> CSS i18n - Another complementary solution
While the article section has large sections of text, the scenario of `vue-i18n` lies in some separate field translations.
And the traditional way of managing them independently in separate files is not really convenient for blogs.
In most cases, you don't want to create a dedicated folder to manage it.
So I tried to solve the problem using pure CSS.
::: tip IDEA
That is, with the help of CSS rules, the content of the corresponding block is displayed according to the corresponding language.
The general solution: set fence to pre-compile Markdown via [markdown-it-container](https://github.com/markdown-it/markdown-it-container).
Wrap new `
` inside `
`, `
Screen width: {{ screenWidth }}
``` ### `import.meta.env.SSR` Use the `import.meta.env.SSR` flag (provided by Vite) to conditionally execute code: ```ts if (!import.meta.env.SSR) { // This code only runs in the browser document.addEventListener('scroll', handleScroll) } ``` > This is useful in composables or setup functions where you need to guard browser-only side effects. ### CSS-Based Responsive Rendering Avoid using `v-if` with reactive viewport values for responsive layouts — this causes hydration mismatches because the server cannot know the viewport size. Use CSS instead: ```vue` 嵌套在 `
` 中,`
屏幕宽度:{{ screenWidth }}
``` ### `import.meta.env.SSR` 使用 `import.meta.env.SSR` 标志(由 Vite 提供)来有条件地执行代码: ```ts if (!import.meta.env.SSR) { // 这段代码只在浏览器中运行 document.addEventListener('scroll', handleScroll) } ``` > 这在 composables 或 setup 函数中保护仅浏览器的副作用时很有用。 ### 基于 CSS 的响应式渲染 避免在响应式布局中使用 `v-if` 配合响应式视口值——这会导致水合不匹配,因为服务器无法知道视口大小。改用 CSS: ```vue