五个 Wagtail 多站点与一场主题化危机:wagtail-visual-themes 是如何诞生的
在交付了第五个 Wagtail 多站点应用、又一次复制粘贴了一堆纠缠不清的品牌配色覆盖之后,我终于把主题化这一层抽取成了独立的软件包。这就是为什么每个 Wagtail 项目最终都会重新发明主题化的故事,以及那个让我没有第六次重蹈覆辙的、小而有主见的库。
五个站点,五种不同的主题化技巧
在过去几年里,我持续交付了一系列 Wagtail 多站点应用——一个带有联合品牌课程门户的辅导平台、一个服务于数十所大学的筹款平台、一个托管着一整片微型站点的代理机构 CMS、一个各部门有各自主题版块的内部知识库,以及几个拼接在同一棵 Wagtail 树下的营销站点。
它们每一个都带着自己定制的主题层交付。它们每一个都有点让人难堪。
- 项目一:每个站点各有硬编码的 SCSS 分片文件,每次调整品牌就重新编译一遍。"重新构建一下 CSS 就好"成了 Slack 里的一个梗。
- 项目二:一个带有七个
ColorField的SiteSettings模型,外加一个把内联style属性喷洒到每个组件上的模板标签。 - 项目三:为每个租户在构建时生成 Tailwind 配置。运行得非常漂亮,直到某个内容编辑想在不重新部署的情况下预览一次配色更改。
- 项目四:Wagtail Site 模型上的一个由上下文处理器解析的 JSON 大对象。那个 JSON 长出了触手。
- 项目五:当我第五次开始敲下
class SiteTheme(models.Model)时,我停住了。
我反复重复的那个模式
抛开那些外观上的差异,每一种实现其实都在解决同样的三个问题:
- 品牌 token 存在哪里?硬编码在 CSS 里、在某个 settings 模型里、在 Tailwind 配置里,还是在一行行按租户区分的数据库记录里?
- 它们如何抵达页面?重新编译 CSS、内联样式、上下文处理器,还是自定义模板标签?
- 如何在合适的作用域上进行覆盖?站点级很容易。栏目级更难。而按页面覆盖,正是我构建过的每一个系统悄然崩溃的地方。
而且每一种实现都与那个项目的租户模型、它的构建流水线,以及那个季度流行的任何组件库紧紧耦合在一起。把其中任何一部分挪到下一个项目里都是一场器官移植手术,而不是一句 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."""
...
解析顺序:先页面,再祖先节点,再站点,最后默认值
第二个设计决策是解析链——也就是那个我屡屡碰壁的第三个问题的答案:如何在正确的作用域上进行覆盖?
对于每一个请求,该包都会走一条确定性的链路:
- 当前页面是否有明确指定的主题?用它。
- 是否有任何祖先页面带有主题?用最近的那个。
- Site 是否配置了默认主题?用它。
- 是否存在一个站点级的「默认」主题 snippet?用它。
- 否则:不输出任何主题变量,让基础 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 变量。每个人都能得到自己想要的,谁都不必与这套方案对抗。
无闪烁暗色模式技巧
那个深色模式功能——正是它引发了这整场提取工作——最终却是整个软件包中代码量最小的一块。只需两样东西:
- 主题在同一份样式表中同时输出
:root浅色变量和[data-theme="dark"]深色变量。 - 文档 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 模型拿去用,把解析链拿去用,或者干脆装上这个包。关键在于——别再解决这个问题了。
主题化不是你为单个应用构建的功能。它是你一次性划定的边界,位于品牌决策与组件代码之间。我交付的每一个缺失这条边界的多站点项目,最终都为此付出了代价。