落梅听风雪,隔窗枕雨眠
← 归去来兮隔窗听雨

屏上宣纸:valaxy-theme-shuimo 的几件事

2026/5/12 · tech · 阅读 - 次

把屏幕当宣纸,把链接当题款,把笔触换成 CSS 变量、把印章换成 Path。valaxy-theme-shuimo 写的不是一套样式,而是一份对水墨的回信。

它是什么

@jobinjia/valaxy-theme-shuimoValaxy 的一款博客主题。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 解析阶段

  1. Worker 跑完后,把纹理的 dataURL 写到 localStorage(key 仅 ~80B,不是 2.5MB 的拷贝)
  2. 主题向 <head> 注入一段同步脚本,下次刷新时它会立刻读出指针、把 background-image 写到一段 <style>
  3. body 顶部预留了一个 #shuimo-paper-boot 容器——HTML 还在解析时就被铺满
ts
// 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> 注入。

不喜欢算的,可以一行禁掉:

ts
xuanPaper: { enable: true, variant: 'aged' }

毛笔线:和直线说再见

CSS 的 border: 1px solid 总是太利。主题给所有该出线的地方都换上了毛笔笔触——ShuimoBrushLine 用 SVG 路径画一段带噪声的折线,再由 useBrushStyles 把笔触的浓淡、长短、偏移随机化为 CSS 变量,整站统一管理。

scss
border-image: var(--shuimo-brush-line) 1 stretch;

边框、分割线、卡片轮廓、文章下方的署名条——全都是同一支笔在写。

印章:方寸有戏

主题里有四方印:

  1. 侧栏作者印ShuimoStamp)——文章页与归档侧栏
  2. 导航印章stamp.nav)——把菜单项变成一枚枚小印
  3. 开屏幕布印章stamp.curtain)——独立配置,落在幕布正中
  4. 二十四节气印ShuimoSolarSeal)——根据当前节气换字

每一方都是 @jobinjia/shuimo-core 生成的——关于它怎么用 Perlin 噪声啃边、用 fontkit 解析字形、用 WASM 做加速,我在另一篇里写过。主题这一层不再二次修改任何渲染结果,只是把参数透传:

ts
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 里跑:

ts
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 秒,体感很慢;现在主题改成「等内容就绪」——只要这三样准备好,幕布立刻起:

  1. 全站宣纸纹理 (globalPaperReady)
  2. 幕布上的印章 (curtainStampReady)
  3. 幕布自己的那张洒金纸 (curtainPaperReady,金屑密度是日常版的 3 倍)
ts
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 命名空间下,花瓣从早期的对称式重画成「宽肩水滴形 + 内卷曲线」,更接近水墨莲的厚薄过渡
ts
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。

字越少,盖章越快,主线程越闲。

一份能跑起来的配置

ts
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 是新刀。中国画从未只属于宣纸,正如代码也从来不只属于终端。

它们终于在一块屏幕上相遇了。

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.4.1
归去来兮 ←