博客

👭 利用深色/浅色模式,以一份成本构建两个 Next.js 网站

Leonardo Losoviz
作者:Leonardo Losoviz ·

最近,Gato GraphQL 团队推出了 Gato Plugins,它是 Gato GraphQL 的姊妹站点。

你会发现两个网站其实是同一个网站!两者唯一的区别在于配色方案:Gato GraphQL 采用深色主题,而 Gato Plugins 采用浅色主题。

两个网站的博客部分完全相同:

gatographql.com 的博客部分
gatographql.com 的博客部分
gatoplugins.com 的博客部分
gatoplugins.com 的博客部分

文档部分也一样:

gatographql.com 的文档部分
gatographql.com 的文档部分
gatoplugins.com 的文档部分
gatoplugins.com 的文档部分

有些部分虽然不同,但底层基础是一样的。

例如,Gato GraphQL 的扩展(extensions)和 Gato Plugins 的插件使用相同的布局:

gatographql.com 的扩展部分
gatographql.com 的扩展部分
gatoplugins.com 的插件部分
gatoplugins.com 的插件部分

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

gatographql.com 的 logo
gatographql.com 的 logo
gatoplugins.com 的 logo
gatoplugins.com 的 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 结构
我的 monorepo 结构

我没有使用任何复杂工具来管理 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 上没有:

gatographql.com 上的活动横幅
gatographql.com 上的活动横幅

注入博客文章需要稍多一些逻辑。

注入博客文章

博客文章的数据通过 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.comapps/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 处不同,领取你的奖励吧! 😅


订阅我们的新闻通讯

及时了解 Gato GraphQL 的所有更新。