Architecture 2026-04-13·Ievgenii Svyryd

五个 Wagtail 多站点与一场主题化危机:wagtail-visual-themes 是如何诞生的

在交付了第五个 Wagtail 多站点应用、又一次复制粘贴了一堆纠缠不清的品牌配色覆盖之后,我终于把主题化这一层抽取成了独立的软件包。这就是为什么每个 Wagtail 项目最终都会重新发明主题化的故事,以及那个让我没有第六次重蹈覆辙的、小而有主见的库。

五个 Wagtail 多站点与一场主题化危机:wagtail-visual-themes 是如何诞生的

五个站点,五种不同的主题化技巧

在过去几年里,我持续交付了一系列 Wagtail 多站点应用——一个带有联合品牌课程门户的辅导平台、一个服务于数十所大学的筹款平台、一个托管着一整片微型站点的代理机构 CMS、一个各部门有各自主题版块的内部知识库,以及几个拼接在同一棵 Wagtail 树下的营销站点。

它们每一个都带着自己定制的主题层交付。它们每一个都有点让人难堪。

  • 项目一:每个站点各有硬编码的 SCSS 分片文件,每次调整品牌就重新编译一遍。"重新构建一下 CSS 就好"成了 Slack 里的一个梗。
  • 项目二:一个带有七个 ColorFieldSiteSettings 模型,外加一个把内联 style 属性喷洒到每个组件上的模板标签。
  • 项目三:为每个租户在构建时生成 Tailwind 配置。运行得非常漂亮,直到某个内容编辑想在不重新部署的情况下预览一次配色更改。
  • 项目四:Wagtail Site 模型上的一个由上下文处理器解析的 JSON 大对象。那个 JSON 长出了触手。
  • 项目五:当我第五次开始敲下 class SiteTheme(models.Model) 时,我停住了。

我反复重复的那个模式

抛开那些外观上的差异,每一种实现其实都在解决同样的三个问题:

  1. 品牌 token 存在哪里?硬编码在 CSS 里、在某个 settings 模型里、在 Tailwind 配置里,还是在一行行按租户区分的数据库记录里?
  2. 它们如何抵达页面?重新编译 CSS、内联样式、上下文处理器,还是自定义模板标签?
  3. 如何在合适的作用域上进行覆盖?站点级很容易。栏目级更难。而按页面覆盖,正是我构建过的每一个系统悄然崩溃的地方。

而且每一种实现都与那个项目的租户模型、它的构建流水线,以及那个季度流行的任何组件库紧紧耦合在一起。把其中任何一部分挪到下一个项目里都是一场器官移植手术,而不是一句 import 语句。

当品牌团队要求做暗色模式时

临界点出现在那个筹款平台上。我们有三十来个大学租户,每个都在一个 Branding 片段中存储着自己的品牌配色,每个都生成着自己编译好的 CSS 包。

随后一个品牌团队提出要暗黑模式。不是给某一个站点——而是给他们自己的。可选地。带一个切换开关。跨页面加载持久保留。而且不重建我们的 CSS 流水线。

我为这个需求苦思了一天,勾勒出把它改造进现有系统需要付出些什么,然后悄悄承认:我一个项目又一个项目地沿用下来的架构,在结构上根本无力支撑它。颜色在构建时就被烘焙进了样式表。根本不存在「模式」的概念——每个租户只有单一的一套配色。

诚实的解法不是给系统打补丁。而是把它扔掉,去构建那个我早在四个项目之前就该构建的系统。

抽取主题 Snippet

第一个设计决策是最重要的一个:主题是 Wagtail 的 snippet,而不是站点设置,不是租户字段,也不是任何与特定数据模型耦合的东西。主题是一个自包含的对象,可以被创建、编辑、预览和分配,而无需了解它所在站点的任何信息。

正是这种解耦让这个软件包具备了可移植性。同一个 Theme snippet 既能用在单站点的 Wagtail 博客上,也能用在拥有三十个租户的 SaaS 上,还能用在微站集群里——因为它对租户模型不做任何假设。

@register_snippet
class Theme(models.Model):
    name = models.CharField(max_length=120)
    slug = models.SlugField(unique=True)

    # Brand colours with auto-computed contrast pairs
    brand_colors = StreamField(
        [("color", BrandColorBlock())], blank=True
    )

    # Light + dark surface palettes
    light_surface = ColorPaletteField()
    dark_surface = ColorPaletteField()

    typography = TypographyField()
    radii = RadiusScaleField()
    shadows = ShadowScaleField()

    def to_css_variables(self) -> str:
        """Emit a complete set of design tokens as CSS custom properties."""
        ...

解析顺序:先页面,再祖先节点,再站点,最后默认值

第二个设计决策是解析链——也就是那个我屡屡碰壁的第三个问题的答案:如何在正确的作用域上进行覆盖?

对于每一个请求,该包都会走一条确定性的链路:

  1. 当前页面是否有明确指定的主题?用它。
  2. 是否有任何祖先页面带有主题?用最近的那个。
  3. Site 是否配置了默认主题?用它。
  4. 是否存在一个站点级的「默认」主题 snippet?用它。
  5. 否则:不输出任何主题变量,让基础 CSS 来渲染。

这正是我五个项目之前就该构建的部分。它模仿了 Wagtail 本身解析菜单和搜索推广等内容的方式——依赖页面树,回退到 site,再回退到默认值。编辑们只需要一个心智模型:在需要它的最近容器上设置主题

def resolve_theme(page: Page, site: Site) -> Theme | None:
    # Page-level override
    if (theme := getattr(page.specific, "theme", None)):
        return theme

    # Walk ancestors looking for the nearest themed page
    for ancestor in page.get_ancestors().specific():
        if (theme := getattr(ancestor, "theme", None)):
            return theme

    # Site default, then global default
    return SiteThemeSettings.for_site(site).default_theme \
        or Theme.objects.filter(slug="default").first()

CSS 变量,而非组件

第三个决策是这个包做什么。它不提供按钮。它不提供卡片。它不提供组件库。它甚至不提供 Tailwind 插件。

它以纯 CSS 自定义属性的形式输出设计令牌。这就是它的全部产出。五十来个变量,涵盖表面颜色、语义状态、模块化字号比例、间距序列、圆角、阴影和 z-index 层级——外加一个模板标签,把它们注入到你的 <head> 中。

{% load wagtail_visual_themes %}
<!DOCTYPE html>
<html>
<head>
  {% theme_css %}  {# 输出 :root + [data-theme="dark"] #}
  <link rel="stylesheet" href="{% static 'css/site.css' %}">
</head>

为什么不直接提供组件呢?因为我构建过的每一个项目都自带一套组件库——这里用 Tailwind,那里用 Bootstrap,别处又是手写的 SCSS——而主题系统总是会与上层的东西纠缠不清。纯粹的设计令牌才是万能供体。Tailwind 通过 theme.extend.colors 读取它们,手写 CSS 通过 var(--color-brand-500) 读取它们,Bootstrap 代码库也可以用同样的令牌覆盖其 SCSS 变量。每个人都能得到自己想要的,谁都不必与这套方案对抗。

无闪烁暗色模式技巧

那个深色模式功能——正是它引发了这整场提取工作——最终却是整个软件包中代码量最小的一块。只需两样东西:

  1. 主题在同一份样式表中同时输出 :root 浅色变量和 [data-theme="dark"] 深色变量。
  2. 文档 head 中一段极小的内联脚本,从 localStorage 读取用户保存的偏好,并在首次绘制之前设置好 data-theme 属性。

第二步才是关键所在。如果你把 data-theme 的赋值推迟到页面加载之后,每个使用深色模式的访客都会看到浅色主题一闪而过。而如果你把它以阻塞方式内联在 head 中,绘制就会干净利落。

// 由 {% theme_css %} 内联到 <head> 中 —— 在首次绘制之前运行
(function () {
  const stored = localStorage.getItem('theme-mode');
  const prefersDark = window.matchMedia(
    '(prefers-color-scheme: dark)'
  ).matches;
  const mode = stored || (prefersDark ? 'dark' : 'light');
  if (mode === 'dark') {
    document.documentElement.dataset.theme = 'dark';
  }
})();

多站点真正需要的是什么

回过头看,多站点 Wagtail 主题化中令人痛苦的部分从来都不是颜色,而是那些假设。每个项目都把它的租户模型编码进了主题化层,而一旦需求发生漂移 —— 深色模式、按区块覆盖、内容编辑者预览、隶属于母品牌之下的白标微站点 —— 每个项目都为此付出了代价。

解决之道不是一个更聪明的 SiteSettings 模型,而是把主题完全移出租户抽象,让页面树和 snippet 系统 —— Wagtail 早已提供的工具 —— 来完成这项工作。

我为什么将它开源

老实说?为的是我再也不必写这段代码了。这个包托管在 github.com/ujeenet/wagtail-visual-themes。它小巧、有明确主张、并有意限定了范围——它负责主题,负责暗色模式,除此之外什么都不做。下次再有人交给我一份 Wagtail 多站点需求时,主题这一章就只是一句 pip install 加一个模板标签。

如果你正在做你的第二个或第三个 Wagtail 多站点项目,并且能感觉到同样的主题 hack 又在成型,那就为自己省下这个循环吧。把 snippet 模型拿去用,把解析链拿去用,或者干脆装上这个包。关键在于——别再解决这个问题了。

主题化不是你为单个应用构建的功能。它是你一次性划定的边界,位于品牌决策与组件代码之间。我交付的每一个缺失这条边界的多站点项目,最终都为此付出了代价。

联系我 →