บล็อก

👭 สร้างเว็บไซต์ Next.js 2 แห่งในราคาเดียว ด้วยการแฮ็กโหมดสว่าง/มืด

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

(อนึ่ง โลโก้ก็แทบจะเหมือนกันด้วย! 😜)

โลโก้บน gatographql.com
โลโก้บน gatographql.com
โลโก้บน gatoplugins.com
โลโก้บน gatoplugins.com

และใช่ บทความบล็อกนี้ก็อยู่บนทั้งสองไซต์เช่นกัน! 😂

อ่านบน gatographql.com: Building 2 Nextjs websites at the price of 1, by hacking the dark/light mode

อย่างไรก็ตาม มีความแตกต่างกันอยู่ 7 จุดระหว่างบทความบนสองไซต์ คุณหาเจอครบทุกจุดไหม? ถ้าหาได้ ฉันจะมอบคูปองส่วนลดสำหรับ Gato GraphQL ให้คุณ 🙏

ทำไมเราจึงใช้โหมดสว่าง/มืดในการสร้างเว็บไซต์ 2 แห่ง

มีเหตุผลหลายประการ:

ฉันไม่มีเวลาหรือพลังงานในการดูแลโค้ดเบสแยกสองชุด ฉันต้องการให้ทุกอย่างเรียบง่าย.

ทุกชั่วโมงที่ใช้กับเว็บไซต์คือชั่วโมงที่ไม่ได้ใช้กับผลิตภัณฑ์ของฉัน

ฉันต้องการให้ดูคล้ายกัน เพื่อให้ผู้ใช้รับรู้ว่าเป็นส่วนหนึ่งของครอบครัวเดียวกัน

ฉันไม่ใช่นักออกแบบ การที่ทำลุคแอนด์ฟีลและสไตล์นั้นสำเร็จแล้ว ฉันพอใจ และไม่อยากเริ่มต้นใหม่จากศูนย์.

กล่าวอีกนัยหนึ่ง: เพราะ ถูกและง่าย มันช่วยประหยัดเวลาและพลังงานได้มหาศาล ซึ่งฉันสามารถนำไปใช้กับผลิตภัณฑ์ของตัวเองได้

ข้อเสียคือ ทั้งสองไซต์ไม่รองรับการสลับโหมดมืด/สว่าง ดังนั้นสไตล์จึงถูกตรึงไว้ แต่นั่นเป็นสิ่งที่ฉันอยู่กับมันได้


เอาล่ะ! มาดูกันว่ามันทำได้อย่างไร

สแตก: แอปพลิเคชันนี้สร้างบน Next.js และใช้ Tailwind CSS สำหรับการจัดสไตล์

สร้างจากการผสมผสานเทมเพลตหลายรายการของ Cruip ที่ปรับแต่งให้ตรงตามความต้องการ (เทมเพลตเหล่านั้นสวยงามมาก!)

เนื้อหาจัดการผ่าน Contentlayer

แยกโค้ดที่ใช้ร่วมกันออกเป็นแพ็คเกจที่แชร์ และโฮสต์ทุกอย่างใน monorepo

เนื่องจากโค้ดเบสของทั้งสองเว็บไซต์เหมือนกัน จึงสมเหตุสมผลที่จะโฮสต์ไว้ด้วยกันใน monorepo

ในตอนแรก repo ของฉันมีโปรเจกต์เดียว:

  • gatographql.com

จากนั้นได้ปรับโครงสร้างใหม่เป็นดังนี้:

  • apps/gatographql.com: เว็บไซต์ Gato GraphQL
  • apps/gatoplugins.com: เว็บไซต์ Gato Plugins
  • packages/shared/gatoapp: โค้ดที่ใช้ร่วมกันระหว่างทั้งสองไซต์

นี่คือ workspace ของฉันใน VSCode:

โครงสร้าง monorepo ของฉัน
โครงสร้าง monorepo ของฉัน

ฉันไม่ได้ใช้อะไรหรูหราสำหรับ monorepo workspaces แบบง่ายๆ ก็ทำงานได้ดี

package.json ที่ root ของ monorepo ตอนนี้มีหน้าตาแบบนี้:

{
  "name": "gatowebsites",
  "version": "2.0.0",
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/shared/*"
  ]
}

นอกจากนี้ ฉันได้เพิ่มสคริปต์ใน package.json เพื่อรัน/build/deploy ทั้งสองโปรเจกต์ (รวมถึงการ deploy ไปยัง 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"
  }
}

แปลง component ให้รับ props สำหรับข้อมูลที่กำหนดเอง

เราย้ายโค้ดจากแต่ละเว็บไซต์ไปยังแพ็คเกจที่แชร์ให้มากที่สุด แล้วปรับแต่งพฤติกรรมผ่าน props

ตัวอย่างเช่น แพ็คเกจที่แชร์ gatoapp มี component 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

การ inject บล็อกโพสต์ต้องการ logic ที่ซับซ้อนกว่านี้อีกเล็กน้อย

การ inject บล็อกโพสต์

ข้อมูลสำหรับบล็อกโพสต์ถูก inject ไปยัง BlogSection ผ่าน prop blogPosts

เนื่องจากฉันใช้ Contentlayer แต่ละเว็บไซต์จะมีไฟล์ contentlayer.config.js ที่ root เพื่อกำหนด type บนไซต์

ไฟล์ configuration นี้ไม่สามารถย้ายไปยัง gatoapp ที่แชร์ได้ จึงสร้าง export module เพื่อให้ configuration สำหรับ type ที่แชร์ แล้ว import เข้าใน contentlayer.config.js ของแต่ละไซต์ ทำให้ logic เป็น DRY

gatoapp มี export module contentlayer.config.js ที่ให้ type ที่แชร์อย่าง 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,
  },
}

ไฟล์ contentlayer.config.js ทั้งใน apps/gatographql.com และ apps/gatoplugins.com สามารถ import type นั้นได้:

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],
})

โดยปกติ ในการอ้างอิง type BlogPost ในโค้ด เราจะ import แบบนี้:

import { BlogPost } from '@/.contentlayer/generated'

อย่างไรก็ตาม type BlogPost อยู่ภายใต้เว็บไซต์ ไม่ใช่แพ็คเกจที่แชร์ ดังนั้นโค้ดที่แชร์จึงไม่สามารถอ้างอิง type นั้นโดยตรงได้

เราแก้ปัญหานี้ด้วยเทคนิค hack: คัดลอก definition ของ type นั้นจากไฟล์ 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,
}

จากนั้นอ้างอิง type ที่แชร์นี้ในโค้ดที่แชร์:

import { BlogPost } from 'gatoapp/types'

เนื่องจาก property ระหว่าง type BlogPost ในเว็บไซต์และแพ็คเกจที่แชร์เหมือนกัน เราจึงสามารถส่งอันแรกไปยัง component ที่คาดหวังอันหลังได้

สร้าง context เพื่อ inject global props

Navigation menu component จะแสดงในโค้ดที่แชร์ แต่ต้องถูกจัดหาจากโค้ดของเว็บไซต์ เนื่องจากแต่ละเว็บไซต์จะมีเมนูของตัวเอง

เมนูปรากฏในทุกหน้า และเราไม่ต้องการส่งผ่าน props ซ้ำแล้วซ้ำเล่า จึงใช้ React context ซึ่งช่วยให้เรา inject navigation menu component เพียงครั้งเดียว

เราสร้าง context ชื่อ 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>
  )
}

และเรา inject มันผ่านโค้ดของเว็บไซต์ ใน 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>
  )
}

สุดท้าย เว็บไซต์จะ implement HeaderMenu component ของตัวเอง:

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: นำหน้า class เพื่อใช้เมื่อเปิดโหมดมืด

ดังนั้น โค้ดในแพ็คเกจที่แชร์จึงต้องมีสไตล์สำหรับทั้งโหมดสว่างและมืด

ตัวอย่างเช่น component 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 ใช้โหมดมืด กำหนดโดยการเพิ่ม classname dark ให้กับ <body> ในไฟล์ apps/gatographql/app/layout.tsx (พร้อม classname สำหรับ styling: 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 ใช้โหมดสว่าง นี่คือโหมดเริ่มต้น ดังนั้นไม่จำเป็นต้องเพิ่ม classname พิเศษให้กับ <body> (มีเพียง classname สำหรับ styling: 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 แห่ง ที่ได้มาในราคาเดียว และฉันพอใจมากกับมัน

ตอนนี้ ไปหา 7 ความแตกต่าง แล้วรับรางวัลของคุณ! 😅


ติดตามว่าจะมีอะไรใหม่ตามมา

สมัครรับจดหมายข่าวของเรา: รับรู้เมื่อเราปล่อยเวอร์ชันใหม่ เปิดตัวปลั๊กอินใหม่ หรือมีข่าวสารมาแบ่งปันกับคุณ