把屏幕当宣纸,把链接当题款,把笔触换成 CSS 变量、把印章换成 Path。valaxy-theme-shuimo 写的不是一套样式,而是一份对水墨的回信。
它是什么
@jobinjia/valaxy-theme-shuimo 是 Valaxy 的一款博客主题。Valaxy 用 Vue 3 + Vite 把一份 Markdown 站点变成可编排的应用,主题层给它添上墨色:宣纸纹理、毛笔线、印章、山水开屏、四季花卉、昼夜星空、二十四节气——你想到的,差不多都有一个对应的组件。
整个主题被切成几个清晰的目录:
| 目录 | 职责 |
|---|---|
layouts/ | default / home / post / 404 四种页面骨架 |
components/ | 三十多个 Shuimo* 组件——从印章到月相,每一种墨意各管一处 |
composables/ | seed、缓存、调度、字体 Worker、昼夜计算…… |
styles/ | SCSS 变量、宣纸纹理、文章样式 |
workers/ | Hero 山水、移动端花卉的离线渲染 |
node/ | 服务端:默认配置、Vite 插件、UnoCSS safelist |
@jobinjia/shuimo-core 是它的可选搭档——Canvas / SVG 的水墨绘制底座;不装也跑得动,装上则启用全部装饰。
宣纸:屏幕即纸
xuanPaper 提供三种纸张变体:
- processed:熟宣,平实克制,最适合长篇阅读
- aged:陈纸,泛黄带霉斑,旧书的味道
- gold:洒金笺,按
goldDensity撒金粉,适合首页开屏
纹理由 shuimo-core 在 Canvas 上绘制,再走 useXuanPaperWorker 在 Web Worker 里跑——主线程拿到的只是一张 PNG。useXuanPaperPool 还把这些纹理按主题颜色 / 尺寸 LRU 缓存起来:路由切换不重绘,刷新页面才换。
HTML 解析时就铺好的纸
「先看到内容,再看到纸」的体验很糟。早先的版本里宣纸要等 Vue mount + Worker 全部就绪——4K 屏首屏要 ~2s 才显出纸纹,期间一片空白。v1.1.2 把这一步前移到了 HTML 解析阶段:
- Worker 跑完后,把纹理的 dataURL 写到
localStorage(key 仅 ~80B,不是 2.5MB 的拷贝) - 主题向
<head>注入一段同步脚本,下次刷新时它会立刻读出指针、把background-image写到一段<style>上 - body 顶部预留了一个
#shuimo-paper-boot容器——HTML 还在解析时就被铺满
// node/index.ts —— 注入到 <head> 的同步引导脚本
const ptr = localStorage.getItem('shuimo-paper-v2')
if (ptr) {
const style = document.createElement('style')
style.textContent = `#shuimo-paper-boot { background-image: url(${ptr}) }`
document.head.appendChild(style)
}实测 4K 视网膜屏第一帧真实纹理从 1987ms → ~100ms。Vue mount 后再用真正的 Worker 产物交叉淡入覆盖这层引导图——你看到的是「纸」,不是「等纸」。
顺便:CSS 自定义属性不能存 ~3MB 的
url()(setProperty会静默失败),所以才落到了<style>注入。
不喜欢算的,可以一行禁掉:
xuanPaper: { enable: true, variant: 'aged' }毛笔线:和直线说再见
CSS 的 border: 1px solid 总是太利。主题给所有该出线的地方都换上了毛笔笔触——ShuimoBrushLine 用 SVG 路径画一段带噪声的折线,再由 useBrushStyles 把笔触的浓淡、长短、偏移随机化为 CSS 变量,整站统一管理。
border-image: var(--shuimo-brush-line) 1 stretch;边框、分割线、卡片轮廓、文章下方的署名条——全都是同一支笔在写。
印章:方寸有戏
主题里有四方印:
- 侧栏作者印(
ShuimoStamp)——文章页与归档侧栏 - 导航印章(
stamp.nav)——把菜单项变成一枚枚小印 - 开屏幕布印章(
stamp.curtain)——独立配置,落在幕布正中 - 二十四节气印(
ShuimoSolarSeal)——根据当前节气换字
每一方都是 @jobinjia/shuimo-core 生成的——关于它怎么用 Perlin 噪声啃边、用 fontkit 解析字形、用 WASM 做加速,我在另一篇里写过。主题这一层不再二次修改任何渲染结果,只是把参数透传:
stamp: {
author: '受命,于天,既寿,永昌',
type: 'yang',
shape: 'rectangle',
fontSize: 70,
borderWidthPx: 4,
cornerRadiusPx: 10,
noiseAmountPx: 10,
regularShape: true,
seed: 69706,
}调外框就调 regularShape / cornerRadiusPx,调边缘就调 noiseAmountPx / borderPointsPx,调字距就调 columnSpacingPx / characterSpacingPx,要固定复现就钉死 seed。一份配置就是一方印。
shuimo-core@2.0后多了两个旋钮:carvingIntensity控制刀刻力度(替原先固定档位的 normal / strong / stone-cut 加了连续微调),fontWeight现在也会在 glyph-path 模式下生效——底层用 SVG 的feMorphology给字形做了一次形态学加粗,篆字"想再粗一点"的时候不再只能靠浏览器伪粗体。
首页山水:第一笔,要慢慢落
首页中央有一幅水墨长卷——参考自 shan-shui-inf 的山水生成算法,由 useHeroSceneWorker 在 OffscreenCanvas 里跑:
const result = await buildHeroSceneInWorker(W, H, seed)
const imgUrl = result.png
? URL.createObjectURL(result.png) // 大部分浏览器走这条
: `data:image/svg+xml;...` // 不支持 OffscreenCanvas 时回退每次刷新换一幅,但同一次 page load 内的路由切换走内存缓存——SPA 跳页时山水不会闪烁重绘。blankSide(画面里哪一边空白)还会告诉布局把作者印章贴到反方向的一侧,让画面留白处恰好托得住朱红那一点。
shuimo-core@2.0 之后山的笔法又有一层更新:ink-mount 改用水彩剪影 + 山体渐变 + 前景雾气的三段画法——主峰顶部更重、山脚渐淡,远山被一层雾托起来;这一版还顺手修了之前剪影边缘那圈"暗晕"(ctx.filter blur 替代原先的多次描边)。山看起来终于像被水墨晕过,不再像被描线笔勾出来。
开屏幕布:等三件礼物到齐
刷新页面时,首屏有一道幕布徐徐拉开。之前这套动画固定等 4–6 秒,体感很慢;现在主题改成「等内容就绪」——只要这三样准备好,幕布立刻起:
- 全站宣纸纹理 (
globalPaperReady) - 幕布上的印章 (
curtainStampReady) - 幕布自己的那张洒金纸 (
curtainPaperReady,金屑密度是日常版的 3 倍)
watch(
[globalPaperReady, curtainStampReady, curtainPaperReady, mobileFlowerReady],
tryOpenInitialCurtain,
{ immediate: true },
)冷加载时这段流程被压到了 ~200ms。任一 Worker 失败时还有兜底——fix(theme): unblock curtain gate when dev workers fail to boot 那次修的就是它,确保不会因为一颗螺丝卡住整扇门。
而对于「重复打开」的访客,再加上前面那段 HTML 解析时的宣纸预铺,幕布升起前后基本不再露出空白底色——你打开页面看到的第一帧,就已经是纸。
时令:日、月、节气
themeConfig.astronomy 接入 SunCalc 算出日出日落、月相、太阳高度;useSolarTerm 给出二十四节气当天的名称;useMoonPhase 算出当前是新月还是望月。展示在三个地方:
- ShuimoSun / ShuimoMoon:日间一颗朱红太阳、夜间一弯月——按访客本地经纬度(若开启
allowVisitorOverride)切换 - ShuimoDaySky / ShuimoNightSky:背景层,星空浓淡随时间渐变
- ShuimoSolarSeal:当下节气作为一方小印,字也跟着换
这是一个会随时间呼吸的主题——白天与夜晚不是色板的两个枚举值,而是 SunCalc 算出来的两段角度。
四季花卉:边角的小心思
ShuimoDecoration 在桌面端的四角飘落不同的花;ShuimoMobileFlower 则把这套绘制改写成单一画面,避免移动端被多 canvas 累垮——它跑在 mobile-flower.worker.ts 里,主线程只接收一张图片。
shuimo-core@2.0 之后花卉走了一次彻底重构:
- 旧的 SVG 路径实现整段移除(
feat!: remove SVG flower implementation, keep canvas-only)——所有花都改走 Canvas,路径单一、动画一致 - 莲花迁到了
species/lotus命名空间下,花瓣从早期的对称式重画成「宽肩水滴形 + 内卷曲线」,更接近水墨莲的厚薄过渡
if (themeConfig.value?.decorations?.seasonAware === false)
return // 关掉就完全静默春桃、夏荷、秋菊、冬梅——按月份切换。不喜欢可以关掉,但留着挺有意思。
字体子集化:篆书也能很轻
主题内置了一份峄山碑篆体(yishanbeizhuanti.woff2),不裁剪是 ~280KB 的 top-1000 汉字子集。装上 @jobinjia/vite-plugin-shuimo-font-subset 后无需任何配置——它在构建时扫描 pages/**/*.{md,mdx,vue} 与 valaxy.config.{ts,js} 收集真正用到的字,再用 fonttools 二次裁剪——产物常常能压到几十 KB。
字越少,盖章越快,主线程越闲。
一份能跑起来的配置
import type { ThemeConfig } from 'valaxy-theme-shuimo'
import { defineConfig } from 'valaxy'
export default defineConfig<ThemeConfig>({
theme: 'shuimo',
themeConfig: {
header: { title: '墨韵书斋', subtitle: '以墨会友 · 以文载道' },
sidebar: {
author: { name: '墨客', motto: '以码为墨,以屏为纸', avatar: '/avatar.jpg' },
},
nav: [
{ text: '归档', link: '/archives' },
{ text: '关于', link: '/about' },
],
xuanPaper: { enable: true, variant: 'processed' },
brushStrokes: { enable: true },
decorations: { enable: true, seasonAware: true, heroLandscape: true },
stamp: {
author: '受命,于天,既寿,永昌',
type: 'yang', shape: 'rectangle',
fontSize: 70, borderWidthPx: 4, cornerRadiusPx: 10,
noiseAmountPx: 10, regularShape: true, seed: 69706,
},
},
})pnpm add @jobinjia/valaxy-theme-shuimo @jobinjia/shuimo-core 之后,就可以开始写字了。
尾声
以码为墨,以屏为纸。
主题做到最后,会发现自己并不在做样式——而是在替一种古老的审美找一个合适的载体。CSS 是新墙,Vue 是新笔,WebAssembly 是新刀。中国画从未只属于宣纸,正如代码也从来不只属于终端。
它们终于在一块屏幕上相遇了。
