👭 利用深色/浅色模式,以一份成本构建两个 Next.js 网站
最近,Gato GraphQL 团队推出了 Gato Plugins,它是 Gato GraphQL 的姊妹站点。
你会发现两个网站其实是同一个网站!两者唯一的区别在于配色方案:Gato GraphQL 采用深色主题,而 Gato Plugins 采用浅色主题。
两个网站的博客部分完全相同:


文档部分也一样:


有些部分虽然不同,但底层基础是一样的。
例如,Gato GraphQL 的扩展(extensions)和 Gato Plugins 的插件使用相同的布局:


(顺便一提,连 logo 也几乎一样! 😜)


是的,这篇博文也同时发布在两个网站上! 😂
在 gatoplugins.com 上阅读:Building 2 Nextjs websites at the price of 1, by hacking the light/dark mode。
不过,两个网站的文章之间恰好有 7 处不同。你能全部找出来吗?如果能,我将为你提供一张 Gato GraphQL 的折扣券 🙏
为什么使用浅色/深色模式来生产两个网站
原因有几点:
我没有时间和精力维护两套独立的代码库。我需要保持简单。
花在网站上的每一个小时,都是无法用于任何一款产品的时间。
我希望它们看起来相似,让用户能够认出它们同属一个系列。
我不是设计师。在实现了那种外观和风格之后,我已经很满意,不想从头再来。
换句话说:因为既省钱又省力。它为我节省了大量时间和精力,让我得以投入到自己的产品中。
缺点是,两个网站无法支持深色/浅色模式切换,样式是固定的,但这是我可以接受的。
好了!那就让我们动手,看看具体是怎么实现的。
技术栈:应用程序基于 Next.js,样式使用 Tailwind CSS。
它是由 Cruip 的多个模板组合而成,并按照我们的需求进行了定制。(那些模板真的很漂亮!)
内容通过 Contentlayer 进行管理。
将公共代码提取到共享包,并将所有内容托管在 monorepo 中
由于两个网站的代码库相同,将它们统一托管在一个 monorepo 中是自然之选。
我的仓库最初只有一个项目:
- gatographql.com
后来重构为以下结构:
- apps/gatographql.com:Gato GraphQL 网站
- apps/gatoplugins.com:Gato Plugins 网站
- packages/shared/gatoapp:两个网站的共享代码
这是我在 VSCode 中的工作区:

我没有使用任何复杂工具来管理 monorepo,简单的 workspaces 已经够用。
monorepo 根目录下的 package.json 现在如下所示:
{
"name": "gatowebsites",
"version": "3.0.0",
"private": true,
"workspaces": [
"apps/*",
"packages/shared/*"
]
}此外,我在 package.json 中添加了运行/构建/部署两个项目的脚本(包括部署到 Netlify,两个网站都托管在那里):
{
"scripts": {
"dev-gatographql": "npm run dev --workspace=apps/gatographql",
"build-gatographql": "npm run build --workspace=apps/gatographql",
"deploy-gatographql": "npm run deploy-staging-gatographql",
"deploy-dev-gatographql": "netlify dev --filter gatographql",
"deploy-staging-gatographql": "netlify deploy --build --context deploy-preview --filter gatographql",
"deploy-prod-gatographql": "netlify deploy --build --prod --context production --filter gatographql",
"dev-gatoplugins": "npm run dev --workspace=apps/gatoplugins",
"build-gatoplugins": "npm run build --workspace=apps/gatoplugins",
"deploy-gatoplugins": "npm run deploy-staging-gatoplugins",
"deploy-dev-gatoplugins": "netlify dev --filter gatoplugins",
"deploy-staging-gatoplugins": "netlify deploy --build --context deploy-preview --filter gatoplugins",
"deploy-prod-gatoplugins": "netlify deploy --build --prod --context production --filter gatoplugins"
}
}将组件改造为通过 props 接收自定义数据
尽可能将各网站的代码迁移到共享包中,然后通过 props 定制行为。
例如,共享包 gatoapp 包含一个 BlogSection 组件(用于在两个网站上渲染 /blog 页面):
import PopularPosts from 'gatoapp/components/blog/popular-posts'
import PageHeader from 'gatoapp/components/page-header'
import { BlogPostProps } from 'gatoapp/types/list-types'
import BlogSectionPostList from './blog-section-post-list'
import { useEffect, useState, Suspense } from "react";
export default function BlogSection({
blogPosts,
title = "Blog",
description,
campaignBanner,
}: {
blogPosts: BlogPostProps[],
title?: string,
description: string,
campaignBanner?: React.ReactNode
}) {
const sidebar = (
<aside className="hidden sm:block relative mt-12 md:mt-0 md:w-64 md:ml-12 lg:ml-20 md:shrink-0">
<PopularPosts
blogPosts={blogPosts}
/>
</aside>
)
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6">
<div className="pt-32 pb-12 md:pt-40 md:pb-20">
{campaignBanner}
{/* Page header */}
<PageHeader
title={title}
description={description}
/>
{/* Main content */}
<BlogSectionPostList
blogPosts={blogPosts}
sidebar={sidebar}
/>
</div>
</div>
)
}所有内容都相同,除了以下几点:
- 页面标题(title/description)
- 博客文章
- 活动横幅(campaign banner)
由于两个网站可以独立运行各自的活动,将 campaignBanner 作为 React.ReactNode 传入不会限制活动的定制。
例如,在我发布这篇博文时,我在 Gato GraphQL 上运行了一个活动,而在 Gato Plugins 上没有:

注入博客文章需要稍多一些逻辑。
注入博客文章
博客文章的数据通过 blogPosts prop 注入到 BlogSection。
由于我使用的是 Contentlayer,每个网站在根目录都有一个 contentlayer.config.js 文件,用于定义网站上的类型。
这个配置文件无法迁移到共享包 gatoapp 中。因此,我们创建一个导出模块来提供共享类型的配置,然后在各网站的 contentlayer.config.js 中导入,使逻辑保持 DRY。
gatoapp 提供了一个导出模块 contentlayer.config.js,包含共享类型 BlogPost:
import { defineDocumentType } from 'contentlayer2/source-files'
const BlogPost = defineDocumentType(() => ({
name: 'BlogPost',
filePathPattern: `blog/**/*.mdx`,
contentType: 'mdx',
fields: {
title: {
type: 'string',
required: true
},
publishedAt: {
type: 'date',
required: true
},
description: {
type: 'string',
required: true,
},
image: {
type: 'string',
},
},
computedFields: {
slug: {
type: 'string',
resolve: (doc) => doc._raw.flattenedPath.replace(new RegExp('^blog/?'), ''),
},
urlPath: {
type: 'string',
resolve: (doc) => `/blog/${doc._raw.flattenedPath.replace(new RegExp('^blog/?'), '')}`,
},
},
}))
module.exports = {
types: {
BlogPost: BlogPost,
},
}apps/gatographql.com 和 apps/gatoplugins.com 中的 contentlayer.config.js 文件都可以导入该类型:
import { makeSource } from 'contentlayer2/source-files'
import ContentLayerConfig from '../../packages/shared/gatoapp/contentlayer.config.js'
const BlogPost = ContentLayerConfig.types.BlogPost
export default makeSource({
documentTypes: [BlogPost],
})通常,要在代码中引用类型 BlogPost,我们会像这样导入:
import { BlogPost } from '@/.contentlayer/generated'然而,类型 BlogPost 位于网站下,而非共享包下,因此共享代码无法直接引用该类型。
我们通过一个技巧来解决这个问题:从已编译的 Contentlayer 文件(apps/gatographql/.contentlayer/generated/types.d.ts 下)复制该类型的定义,并粘贴到共享包中的新 types.tsx 文件中:
import type { MDX, IsoDateTimeString } from 'contentlayer2/core'
export type BlogPost = {
// _id: string // not needed
// _raw: Local.RawDocumentData // not needed
type: 'BlogPost'
title: string
publishedAt: IsoDateTimeString
description: string
image?: string | undefined
body: MDX
slug: string,
urlPath: string,
}然后在共享代码中引用这个共享类型:
import { BlogPost } from 'gatoapp/types'由于网站和共享包中 BlogPost 类型的属性相同,我们可以将前者传递给期望后者的组件。
创建上下文以注入全局 props
导航菜单组件将在共享代码中渲染,但由于每个网站有自己的菜单,需要通过网站代码来提供。
菜单出现在所有页面上,我们不想每次都通过 props 传递。因此我们使用 React context,使导航菜单组件只需注入一次。
我们在共享包中创建一个名为 AppComponent 的 context:
'use client'
import React from 'react'
import { createContext, useContext } from 'react'
import { StaticImageData } from 'next/image'
type ContextProps = {
header: {
menu: React.ReactNode,
mobileMenu: React.ReactNode,
},
}
const AppComponentContext = createContext<ContextProps>({
header: {
menu: <div></div>,
mobileMenu: <div></div>,
},
})
export interface AppComponentProviderInterface extends ContextProps {
children: React.ReactNode,
}
export default function AppComponentProvider({
children,
header,
}: AppComponentProviderInterface) {
return (
<AppComponentContext.Provider value={{ header }}>
{children}
</AppComponentContext.Provider>
)
}
export const useAppComponentProvider = () => useContext(AppComponentContext)在共享包中引用它:
'use client'
import Logo from './logo'
import HeaderMobile from './header-mobile'
import { useAppComponentProvider } from 'gatoapp/app/appcomponent-provider'
export default function Header() {
const AppComponent = useAppComponentProvider()
return (
<header className="fixed w-full z-50">
<div className={`absolute inset-0 bg-opacity-70 backdrop-blur -z-10 bg-white border-slate-200 border-b dark:border-b-0 dark:bg-transparent dark:border-slate-800`} aria-hidden="true"/>
<div className="max-w-6xl mx-auto px-4 sm:px-6">
<div className="flex items-center justify-between h-16">
{/* Site branding */}
<div className="flex-1">
<Logo />
</div>
<nav className="hidden md:flex md:grow">
{/* Desktop menu links */}
{AppComponent.header.menu}
</nav>
<HeaderMobile />
</div>
</div>
</header>
)
}然后通过网站代码注入,在 apps/gatographql/app/(default)/layout.tsx 中:
import AppComponentProvider from 'gatoapp/app/appcomponent-provider'
import HeaderMenu from '@/components/menu/header-menu'
import HeaderMobileMenu from '@/components/menu/header-mobile-menu'
import DefaultLayout from 'gatoapp/app/(default)/layout'
export default function AppDefaultLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<AppComponentProvider
header={{
menu: <HeaderMenu />,
mobileMenu: <HeaderMobileMenu />,
}}
>
<DefaultLayout>
{children}
</DefaultLayout>
</AppComponentProvider>
)
}最后,网站实现自己的 HeaderMenu 组件:
import Link from 'next/link'
import Dropdown from 'gatoapp/components/utils/dropdown'
export default function HeaderMenu() {
return (
<ul className="flex grow justify-center flex-wrap items-center">
<li>
<Link href="/pricing">Pricing</Link>
</li>
<li>
<Link href='/extensions'>Extensions</Link>
</li>
<Dropdown title="Product">
<li>
<Link href='/features'>Features</Link>
</li>
<li>
<Link href='/highlights'>Highlights</Link>
</li>
<li>
<Link href='/demos'>Demos</Link>
</li>
<li>
<Link href='/comparisons'>Comparisons</Link>
</li>
</Dropdown>
</ul>
)
}浅色和深色模式的样式
在 Tailwind 中,我们通过在类名前加上 dark: 来在深色模式启用时使用该样式。
因此,我们的共享包代码必须同时包含浅色和深色变体的样式。
例如,组件 PageHeader 在浅色模式(text-gray-600)和深色模式(dark:text-slate-400)下使用不同颜色显示描述:
export default function PageHeader({
title,
description,
children,
}: {
title: string,
description?: string,
children?: React.ReactNode,
}) {
return (
<div className="max-w-3xl mx-auto text-center">
<h1 className="h1 pb-4">{title}</h1>
{description && (
<div className="max-w-3xl mx-auto">
<p className="text-gray-600 dark:text-slate-400">{description}</p>
</div>
)}
{children}
</div>
)
}为网站设置浅色或深色模式
gatographql.com 使用深色模式。通过在文件 apps/gatographql/app/layout.tsx 的 <body> 中添加类名 dark 来定义(同时还有样式类名 bg-slate-900 text-slate-100):
import { Inter } from 'next/font/google'
import RootLayoutHeader from 'gatoapp/app/layout-header'
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap'
})
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<RootLayoutHeader />
<body className={`${inter.variable} dark bg-slate-900 text-slate-100`}>
{children}
</body>
</html>
)
}gatoplugins.com 使用浅色模式。这是默认模式,因此无需在 <body> 中添加特定类名(只有样式类名 bg-white text-slate-800):
import { Inter } from 'next/font/google'
import RootLayoutHeader from 'gatoapp/app/layout-header'
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap'
})
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<RootLayoutHeader />
<body className={`${inter.variable} bg-white text-slate-800`}>
{children}
</body>
</html>
)
}就这些了
现在我拥有了两个网站,而只付出了一份成本。对此我非常满意。
快去找出那 7 处不同,领取你的奖励吧! 😅