博客

👭 通过利用明亮/暗黑模式,以1个网站的成本构建2个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 的扩展插件与 Gato Plugins 的插件使用相同的布局:

gatographql.com 的扩展插件板块
gatographql.com 的扩展插件板块
gatoplugins.com 的插件板块
gatoplugins.com 的插件板块

(顺便一提,两者的 Logo 也几乎一样!😜)

gatographql.com 的 Logo
gatographql.com 的 Logo
gatoplugins.com 的 Logo
gatoplugins.com 的 Logo

这篇博客文章也同时发布在两个站点上!😂

在 gatographql.com 上阅读:Building 2 Nextjs websites at the price of 1, by hacking the dark/light mode

不过,两个站点的文章之间恰好有 7 处不同。你能全部找出来吗?如果能,我将赠送你一张 Gato GraphQL 的折扣优惠券 🙏

为什么用明亮/暗黑模式来构建 2 个网站

原因有以下几点:

我没有时间和精力维护两套独立的代码库。我需要保持简单

花在网站上的每一个小时,都是无法用于产品开发的时间。

我希望它们外观相似,让用户能够识别出它们属于同一个家族。

我不是设计师。能做出那种外观和风格,我已经很满意了,不想从零重来

换句话说:因为既省钱又省力。这为我节省了大量时间和精力,可以将它们投入到自己的产品中。

缺点是,这 2 个网站无法支持暗黑/明亮模式切换,风格是固定的,但这在可接受的范围内。


好了!那我们就动手来看看具体是怎么实现的吧。

技术栈:应用基于 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": "2.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 = "Our 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>
  )
}

所有内容都是相同的,除了以下几点:

  • 页面标题(标题/描述)
  • 博客文章
  • 活动横幅

由于两个网站可以独立开展各自的活动,将 campaignBanner 作为 React.ReactNode 传入,不会对活动定制造成任何限制。

例如,在我发布这篇博客文章时,Gato GraphQL 正在开展一个活动,而 Gato Plugins 没有:

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

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

注入博客文章

博客文章的数据通过 blogPosts prop 注入到 BlogSection 中。

由于我使用的是 Contentlayer,每个网站的根目录都会有一个 contentlayer.config.js 文件,用于定义站点上的类型。

这个配置文件无法移入共享包 gatoapp。因此,我们创建一个导出模块来提供共享类型的配置,然后在每个站点的 contentlayer.config.js 中导入,从而保持逻辑的 DRY 原则。

gatoapp 有一个提供共享类型 BlogPost 的导出模块 contentlayer.config.js

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 类型存在于网站侧,而非共享包中,因此共享代码无法直接引用该类型。

我们用一个 hack 来解决这个问题:从编译好的 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 上下文,只需注入一次导航菜单组件即可。

我们在共享包中创建一个名为 AppComponent 的上下文:

'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>
        <li>
          <Link href='/roadmap'>Roadmap</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-700):

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-700`}>
        {children}
      </body>
    </html>
  )
}

总结

我现在拥有了 2 个网站,却只付出了 1 个的代价。我对此非常满意。

快去找出那 7 处不同,领取你的奖品吧!😅


了解接下来会推出什么

订阅我们的新闻通讯:当我们发布新版本、推出新插件或有消息要分享时,第一时间通知你。