NextJs学习-以及项目实战使用NextJs开发个人博客

8/19/2024 8388 阅读需要42分钟
0
0
AI总结
Next.js 是一个基于 React 的开源框架,用于构建生产级别的 React 应用程序。它提供了服务器端渲染(SSR)、静态站点生成(SSG)、API 路由、文件系统路由、图像优化、TypeScript 支持等特性,帮助开发者快速构建高性能、可扩展的 Web 应用。Next.js 的路由系统基于文件系统,简化了路由配置,支持动态路由和参数传递。数据请求可以通过 `fetch` 实现,支持缓存和按需重新验证。Next.js 还支持服务端组件和客户端组件,服务端组件可以通过静态渲染、动态渲染和流式渲染来优化性能。样式方面,Next.js 支持 Tailwind CSS 和自定义字体加载。此外,Next.js 提供了丰富的 SEO 优化工具,如 Metadata、Sitemap、OpenGraph 和 Twitter 卡片等,帮助提升搜索引擎排名。通过 Docker 和 Nginx 部署,结合 CI/CD 流程,可以轻松实现自动化构建和部署。

Nextjs 是什么

NextJs 是一个基于 React 的开源框架,用于构建生产级别的 React 应用程序。它为开发人员提供了一套丰富的工具和约定,使得创建高性能、可扩展的 Web 应用程序变得更加容易。

Next.js 的主要特性:

服务器端渲染 (SSR):

  • 提高首屏加载速度,改善用户体验。
  • 有利于 SEO,搜索引擎可以更好地抓取页面内容。

静态站点生成 (SSG):

  • 将整个应用程序或部分页面预渲染成静态 HTML 文件。
  • 适用于数据变化不频繁的网站,提供极快的加载速度。

API 路由:

  • 内置 API 路由功能,可以轻松创建 RESTful API 或 GraphQL API。

文件系统路由:

  • 根据文件系统结构自动生成路由,简化路由配置。

图像优化:

  • 内置图像优化功能,自动优化图片大小和格式,提升页面加载速度。

TypeScript 支持:

  • 原生支持 TypeScript,提供静态类型检查,提高代码质量。

自定义服务器:

  • 可以自定义服务器,满足各种复杂场景的需求。

插件系统:

  • 丰富的插件生态系统,可以扩展 Next.js 的功能。

安装

使用官网推荐的方式安装,执行 npx create-next-app@latest,输入项目名称,选择相关options选项就可以完成项目的初始化。如下 img 可以看到,我们可以选择是否使用 ts, eslint, tailwindcss等等。

锁定引擎

package.json中添加 "engines": { "node": ">=18.0.0", "pnpm": ">=9.0.0" } 这样可以限制启动的 node版本和 pnpm版本,防止出现兼容性问题。

启动

pnpm dev,看到这个界面就是启动成功了SUCCESS

pnpm/yarn/npm的区别

路由

创建路由

nextjs中的路由采用的是 约定式路由,根据文件的配置自动生成,我们可以看一下默认生成的文件结构。目录结构

.next目录是运行的文件,app是路由的目录,public可以用于存放一些静态资源。

所有的路由文件都放在 app中,page就是内容页,layout就是布局页面的模板,error是错误页面,loading是加载页面,not-found是404页面。比如我们需要创建一个 dashborad页面,只需要在 app下新增一个 dashboard/page.tsx即可。

带params的路由格式是 [id].tsx,如下。

路由结构

app/layout: 等于react中的main.ts,app.ts,以及Vue中的App.vue,全局的布局,可以用来加载全局的样式,字体,metadata等等。

import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <header className="h-12 border-b">头部</header>
        <main>{children}</main>
      </body>
    </html>
  );
}

[folder]/layout: 单个菜单下的布局,类似vue/react中的二级路由

page:页面内容

路由跳转

这里有四种方式来实现路由的跳转。

  • 使用 next/link内置组件

    import Link from 'next/link'
    
    <Link href='/dashborad'></Link>
    
  • 使用 useRouter钩子函数,适用于 client

import { useRouter } from 'next/navigation'
export default function Page() {
    const router = useRouter()
    return (
        <button type="button" onClick={() => router.push('/dashboard')}>
            Dashboard
        </button>
    )
}
  • 对于 server component

    
        import { redirect } from 'next/navigation'
    
        async function fetchTeam(id: string) {
        const res = await fetch('https://...')
        if (!res.ok) return undefined
            return res.json()
        }
    
        export default async function Profile({ params }: { params: { id: string } }) {
            const team = await fetchTeam(params.id)
            if (!team) {
                redirect('/login')
            }
        // ...
        }
    
  • 使用原生 history

        'use client'
    
        import { useSearchParams } from 'next/navigation'
    
        export default function SortProducts() {
        const searchParams = useSearchParams()
    
        function updateSorting(sortOrder: string) {
            const params = new URLSearchParams(searchParams.toString())
            params.set('sort', sortOrder)
            window.history.pushState(null, '', `?${params.toString()}`)
        }
    
        return (
            <>
                <button onClick={() => updateSorting('asc')}>Sort Ascending</button>
                <button onClick={() => updateSorting('desc')}>Sort Descending</button>
                </>
            )
        }
    

路由参数传递和获取

对于服务端组件来说,主函数的参数中自动注入,paramssearchParams,例如我们访问这个链接的时候 http://localhost:3001/dashboard/1?a=1

此时在主函数的 datas, 他的内容就是 { params: { id: '1' }, searchParams: { a: '1' } }

// { params: { id: '1' }, searchParams: { a: '1' } }
export default async function DashboardPage(datas: any) {
  const data = await getData();
  return (
    <div className="h-[100vh]">
      <div>
        这是仪表盘页面 {datas.params.id} {JSON.stringify(data)}{" "}
      </div>
    </div>
  );
}

对于客户端组件来说,也可以用上面的方式来获取,初次除此以外还可以通过 useParamsuseSearchParams来获取。

import { useParams, useSearchParams } from "next/navigation";

// { params: { id: '1' }, searchParams: { a: '1' } }
export default function DashboardPage() {
  const params = useParams();
  const searchParams = useSearchParams();
  return (
    <div className="h-[100vh]">
      <div>这是仪表盘页面 {params.id}</div>
    </div>
  );
}

API路由

由于nextjs是运行在服务端的,所以他也能实现后端的服务,实现的方式是在 app下创建一个 api目录。在 api目录下插入一个 dashboard/metrics目录,然后创建一个 route.ts,这样子就实现了一个api路由了。请求路径是 /dashboard/metrics,方法为 get的请求。

import { responseHandler } from "@/lib/fetch";

export async function GET(request: Request) {
    return responseHandler({
        rate: '100%',
        rate2: '100%',
        rate3: '100%'
    });
}

文件约定

error.js:当页面UI加载错误的时候会显示此页面;

not-found.js: 当页面不存在的时候显示此页面; 例如当我们访问这个链接 http://localhost:3001/xx,此时就会渲染此页面。

loading.js: 当组件加载的时候就会触发这个动画,使用Suspense实现

middleware.js: 中间件,常用于来做鉴权,拦截, 重写和重定向,自定义头部,缓存等等。

  
  // middleware.js 
  export async function middleware(req) { 
      const token = req.cookies.token;
      if (!token) { 
          return NextResponse.redirect(new URL('/login', req.url)); 
      } // 验证 token
      return NextResponse.next();
   }
   
   // 配置匹配的路由,符合才走中间件
   export const config = { matcher: ['/protected/:path*'] };

middleware外,其他都支持全局和局部。

数据请求

  • 在服务端组件中,使用 fetch请求,而且自带缓存,Post请求不缓存,通过在 fetch中的配置,默认是 { cache: 'force-cache' }

    如果需要重新验证数据的一致性,可以设置 { next: { revalidate: 3600 } },这样每小时会重新验证一次,或者在 page/layout.ts设置 export const revalidate = 3600

        export const revalidate = 3600

        async function getData() {
            const res = await fetch('http://localhost:3000/api/dashboard/metric')
     
            if (!res.ok) {
                throw new Error('Failed to fetch data')
            }
          
            return res.json()
        }
      
        export default async function Page() {
        const data = await getData()
      
        return <main>
                指标是: {{ JSON.stringify(data.data) }}
            </main>
        }

也可以不缓存,通过设置 { cache: 'no-store' },也可以按需重新验证,参考此文档on-demand-revalidation,也可以在文件头部 export const dynamic = 'force-dynamic';这样每次都会新的。

  • Server Actions and Mutations

渲染

服务端组件

在app下的

服务端组件渲染有三种方式。

  1. Static Rendering(静态渲染)

    这是默认的渲染方式,在build的时候就会完成数据的请求和页面组装,比如一个项目的首页

  2. Dynamic Rendering(动态渲染)

    在用户请求的时候渲染完成,比如一个订单的详情页,根据不同的ID来渲染不同的数据

  3. Streaming(流式渲染)

    简单来说就是将一整个 HTML 脚本文件通过切成一小段一小段的方式返回给客户端,客户端收到每一段内容时进行分批渲染。这样的方式相较于传统的服务端一次性渲染完成整个 HTML 内容进行返回,在视觉上大大减少了 TTFB 以及 FP 的时间,在用户体验上更好。主要原理是基于 Suspense/Lazy 进行异步渲染组件,这样我们可以把一些不变的静态数据和动态数据拆分成多个组件,父组件中用 Suspense包裹,然静态数据先渲染,从而提高 TTFB(time to first byte)和FP (first paint)指标。

// Metric1.tsx
function getDate(): Promise<string> {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve("This is Great.");
    }, 3000)
  );
}

export default async function Metric1() {
  const data: string = await getDate();

  return (
    <>
      <div>{data}</div>
    </>
  );
}

// Metric2.tsx
function getDate(): Promise<string> {
  return new Promise((resolve) =>
    setTimeout(() => {
      resolve("This is Great.");
    }, 3000)
  );
}

export default async function Metric1() {
  const data: string = await getDate();

  return (
    <>
      <div>{data}</div>
    </>
  );
}

import Metric1 from "@/components/metric1";
import Metric2 from "@/components/metric2";
import { Suspense } from "react";

async function getData() {
  const res = await fetch("http://localhost:3001/api/dashboard/metric/");
  // The return value is *not* serialized
  // You can return Date, Map, Set, etc.

  if (!res.ok) {
    // This will activate the closest `error.js` Error Boundary
    throw new Error("Failed to fetch data");
  }

  return res.json();
}

export default async function DashboardPage(datas: any) {
  const data = await getData();
  return (
    <div className="h-[100vh]">
      <div>
        这是仪表盘页面 {datas.params.id} {JSON.stringify(data)}{" "}
      </div>

      <Suspense fallback={<div>Loading metric1...</div>}>
        <Metric1 />
      </Suspense>

      <Suspense fallback={<div>Loading metric2...</div>}>
        <Metric2 />
      </Suspense>
    </div>
  );
}

这时候我们就可以看到主体内容先渲染了,metric1,和metric2在loading,等待数据获得后再渲染。 streaming

优势

在服务器上执行渲染工作有几个好处,包括:

数据提取:服务器组件允许您将数据提取移动到更接近数据源的服务器。这可以通过减少获取渲染所需数据所需的时间以及客户端需要发出的请求数量来提高性能。

安全:服务器组件允许您将敏感数据和逻辑保留在服务器上,例如令牌和API密钥,而不会面临将它们暴露给客户端的风险。

缓存:通过在服务器上呈现,结果可以被缓存并在后续请求和跨用户中重复使用。这可以通过减少每次请求上完成的渲染和数据获取量来提高性能并降低成本。

性能:服务器组件为您提供了额外的工具来从基线优化性能。例如,如果您从完全由客户端组件组成的应用程序开始,则将UI的非交互式部分移动到服务器组件可以减少所需的客户端JavaScript量。这对于互联网速度较慢或设备功能较弱的用户来说是有利的,因为浏览器需要下载、解析和执行的客户端JavaScript较少。

初始页面加载和第一个内容绘制(FCP):在服务器上,我们可以生成HTML以允许用户立即查看页面,而无需等待客户端下载、解析和执行渲染页面所需的JavaScript。

搜索引擎优化和社交网络共享性:搜索引擎机器人可以使用渲染的HTML来索引页面,社交网络机器人可以使用渲染的HTML为您的页面生成社交卡预览。

流媒体:服务器组件允许您将渲染工作拆分为块,并在准备好后将其流媒体传输给客户端。这允许用户更早地查看页面的部分内容,而不必等待整个页面在服务器上呈现。

客户端组件(Client Components)

使用客户端组件的方式也很简单,首行写 use client即开启

'use client'
 
import { useState } from 'react'
 
export default function Counter() {
  const [count, setCount] = useState(0)
 
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  )
}

客户端组件并非仅仅在客户端渲染,也可以在服务端渲染,这取决于是否是全页面加载(Full page load),还是页面中导航过去的(Subsequent Navigations)。

如果是全页加载的,即首次加载或刷新,这时候也是通过服务端渲染的而导航过去的,这时候是客户端渲染的。

优势

  • 可以使用相关的API和交互 useEffectstateevent listener
  • 可以使用浏览器API

样式

样式在初始化项目的时候安装了tailwindcss,可以看文档。

优化

字体

nextjs中使用next/font来加载谷歌字体,而不是在css到声明字体,因为它帮我们优化了字体的加载,很方便使用各种各样的字体。官方推荐使用可变字体,这里是字体库

如下,我们可以看到引入了两种字体,字体中可以设置 子集,样式等options.

import Link from "next/link";
import { Inter, Roboto_Mono } from "next/font/google";

// If loading a variable font, you don't need to specify the font weight
const inter = Inter({
  subsets: ["latin"],
  display: "swap",
});

export const roboto_mono = Roboto_Mono({
  subsets: ["latin"],
  weight: ["700"],
  display: "swap",
  variable: "--Roboto_Mono",
});
export default function Home() {
  return (
    <main
      className={`flex min-h-screen flex-col items-center justify-between p-24 ${inter.className}`}
    >
      <header>这是头部</header>
      <div>这是一种字体</div>
      <div className={roboto_mono.className}>这是另一种字体</div>
      <div>
        <Link href={"/dashboard"}>dashboard</Link>
      </div>

      <div>这是footer</div>
    </main>
  );
}

同时也可以加载本地字体。

import localFont from 'next/font/local'
const myFont = localFont({ src: './my-font.woff2', display: 'swap',})

display就是“font-display”专用于 @font-face 指令的描述符,它可以取如下几个值:

  • auto 。这个是 font-display 的默认值,字体的加载过程由浏览器自行决定,不过基本上和取值为 block 时的处理方式一致。
  • block 。在字体加载前,会使用备用字体渲染,但是显示为空白,使得它一直处于阻塞期,当字体加载完成之后,进入交换期,用下载下来的字体进行文本渲染。不过有些浏览器并不会无限的处于阻塞期,会有超时限制,一般在 3 秒后,如果阻塞期仍然没有加载完字体,那么直接就进入交换期,显示后备字体(而非空白),等字体下载完成之后直接替换。
  • swap 。基本上没有阻塞期,直接进入交换期,使用后备字体渲染文本,等用到的字体加载完成之后替换掉后备字体。
  • fallback 。阻塞期很短(大约100毫秒),也就是说会有大约 100 毫秒的显示空白的后备字体,然后交换期也有时限(大约 3 秒),在这段时间内如果字体加载成功了就会替换成该字体,如果没有加载成功那么后续会一直使用后备字体渲染文本。
  • optional 。与 fallback 的阻塞期一致,但是没有交换期,如果在阻塞期的 100 毫秒内字体加载完成,那么会使用该字体,否则直接使用后备字体。这个就是说指定的网络字体是可有可无的,如果加载很快那么可以显示,加载稍微慢一点就不会显示了,适合网络情况不好的时候,例如移动网络。

图片

优点

Image组件,Image是在img得基础上的封装,主要增强了以下功能。

大小优化:使用 WebP 和 AVIF 等现代图像格式,自动为每台设备提供正确大小的图像。

视觉稳定:防止偏移,有效优化Cumulative Layout Shift (CLS)累计布局偏移量。

更快的加载速度:进入视口后在进行加载,更快的页面加载速度。

自适应:可以设置优先级,压缩质量,包括宽高,即使图片是在服务器上。


<Image
    src={detail.coverImg}
    width={0}
    height={0}
    sizes="100%"
    style={{ width: "100%", height: "auto" }}
    priority={true}
    alt="cover_img"
  />

安全

可以设置指定域名的远程图片,在 next.config.mjs

 images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "avatars.githubusercontent.com",
      },
      {
        protocol: "https",
        hostname: "**.myqcloud.com",
      },
      {
        protocol: "https",
        hostname: "**.xitu.io",
      },
      {
        protocol: "https",
        hostname: "**.byteimg.com",
      },
    ],
  },

主题的实现

通用的主题的实现有以下几种方式。

  1. 使用CSS变量来实现
  2. 使用CSS-in-JS实现主题切换
  3. 引入不同的CSS文件来实现主题的切换
  4. 使用CSS预处理器来实现主题

具体实现可以参考这篇文章如何实现前端页面主题切换:多种方法详解

我们这里使用css变量的方式结合next-themes来实现,暗黑明亮跟随系统三种主题。

安装

pnpm add next-themes

增加主题变量

然后修改 global.css

@tailwind base;
@tailwind components;
@tailwind utilities;

:root {
  /* 背景色 */
  --background-color: #111827; 
  /* 文字颜色 */
  --text-color: white;
}

body {
  background-color: var(--background-color);
  color: var(--text-color);
  line-height: 1.5;
  font-size: 14px;
}

// 明亮主题
.light {
  --background-color: #fff;
  --text-color: #111827;
}
// 暗黑主题
.dark {
  --background-color: #111827;
  --text-color: #fff;
}

// 系统自适应暗黑
@media (prefers-color-scheme: dark) {
  :root:not(.dark):not(.light) {
    --background-color: #111827;
    --text-color: #fff;
  }
 
}

// 系统自适应明亮
@media (prefers-color-scheme: light) {
  :root:not(.dark):not(.light) {
    --background-color: #fff;
    --text-color: #111827;
  }
 
}

实现ThemeProvider

然后在 provider目录创建 ThemeProvider.

"use client";

import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ThemeProviderProps } from "next-themes/dist/types";

export default function ThemeProvider({
  children,
  ...props
}: ThemeProviderProps) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

最后在 app/layout.txs中使用,设置 attribute用class,默认主题用暗黑模式,支持跟随系统,mac支持,windows不支持。

 <ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
  <header className="h-12 border-b">头部</header>
  <main>{children}</main>
</ThemeProvider>

主题切换组件

然后就可以创建一个切换主题的组件,通过 setTheme来实现主题的设置。

"use client";
import { useTheme } from "next-themes";

const ThemeChanger = () => {
  const { theme, setTheme } = useTheme();

  return (
    <div>
      <div>
        现在主题是: <span>{theme}</span>
      </div>
      <button onClick={() => setTheme("light")}>明亮</button>
      {"  "}
      <button onClick={() => setTheme("dark")}>暗黑模式</button>
      {"  "}

      <button onClick={() => setTheme("os")}>跟随系统</button>
    </div>
  );
};

export default ThemeChanger;

到此,我们即完成了切换主题。

滚动优化

此处我们我使用了 lenis来实现滚动的优化,代替默认的滚动。

安装

pnpm add lenis

新建Provider

创建 provider/leniProvider.tsx

"use client";
import { ReactLenis } from "lenis/react";
import { ReactNode } from "react";

interface Props {
  children: ReactNode;
}

export default function LenisProvider({ children }: Props) {
  return <ReactLenis root>{children}</ReactLenis>;
}

创建 provider/scrollProvider.tsx

"use client";

import { useLenis } from "lenis/react";
import { createContext, ReactNode, useState } from "react";

interface ScrollValue {
  scrollY: number;
}

export const ScrollContext = createContext<ScrollValue>({
  scrollY: 0,
});

interface ScrollProviderProps {
  children: ReactNode;
}

export const ScrollProvider = ({ children }: ScrollProviderProps) => {
  const [scrollY, setScrollY] = useState(0);

  const [mounted, setMounted] = useState(false);

  useLenis(({ scroll }: any) => {
    setMounted(true);
    setScrollY(scroll);
  });
  if (!mounted) return null;

  return (
    <ScrollContext.Provider value={{ scrollY }}>
      {children}
    </ScrollContext.Provider>
  );
};

使用

app/layout.tsx中使用。

import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import ThemeProvider from "@/provider/themeprovider";
import dynamic from "next/dynamic";
import LenisProvider from "@/provider/LenisProvider";
import { ScrollProvider } from "@/provider/scrollProvider";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

const ThemeChanger = dynamic(() => import("@/components/theme"), {
  ssr: false,
});

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body className={inter.className}>
        <ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
          <LenisProvider>
            <ScrollProvider>
              <header className="h-12 border-b">头部</header>
              <ThemeChanger />

              <main>{children}</main>
            </ScrollProvider>
            <div id="modal"></div>
          </LenisProvider>
        </ThemeProvider>
      </body>
    </html>
  );
}


此时我们可以通过 Scrollprivider来访问 scrollY,以及progress等等

dashboard/page.tsx中我们使用context来inject进来scrollY。

"use client";

import { ScrollContext } from "@/provider/scrollProvider";
import Link from "next/link";
import { useContext } from "react";

export default function DashboardPage({
  children,
}: {
  children: React.ReactNode;
}) {
  const { scrollY } = useContext(ScrollContext);
  return (
    <div className="h-[100vh]">
      <div>
        <Link href={"/dashboard/1"}>我们去仪表盘1{scrollY}</Link>

        <div>scrollY: {scrollY}</div>
      </div>
      <div>{children}</div>
    </div>
  );
}

数据库/Docker安装

我所使用的是腾讯云的服务器,系统为 OpenCloudOS Docker版本,所以不需要安装,只需要安装 mongodb即可。

Docker命令

这里说明一下docker的常用命令。

  • systemctl start docker 启动
  • systemctl stop docker 启动
  • systemctl enable docker 开机启动
  • docker search mongodb 查询镜像
  • docker pull mongodb 拉取镜像
  • docker run -d --name=xxx xxx mongodb:latest 基于某镜像创建一个容器
  • docker ps 查看运行中的容器
  • docker stop container_id/name 停止运行中的容器
  • docker start container_id/name 启动运行中的容器 这些是常用的命令。

MongoDb

所以此处我们需要安装mongodb的话,就需要执行命令。

docker pull mongo

在启动之前,我们需要把数据挂载到本地服务器,这样容器重启数据也不会丢失,还有数据的备份,日志,配置等等。

// 数据保存到/data/mdb文件夹中
mkdir -p /data/mdb 

// 数据备份
mkdir -p  /data/backup/mongodb

// 日志
mkdir -p /data/mdblog

// 配置
mkdir -p /data/mongo_conf

其它的东西不用动,我这边加了一个配置,需要设置一个bindIP和auth来设置需要密码和指定IP可访问,否则会被黑客勒索。

# Where and how to store data.
storage:
  dbPath: /data/mdb
  journal:
    enabled: true
systemLog:
  destination: file
  logAppend: true
  path:  /data/mdblog/mongod.log

# network interfaces
net:
  port: 3009
  bindIp: 127.0.0.1,150.109.25.213

#auth 

auth:true

现在我们就可以启动moogodb了。

docker run --name mongo --restart=always -p 3009:27017 -v /data/mdb:/data/db -v /data/backup/mongodb:/data/backup -v /data/mdblog:/data/log -v /data/mongo_conf:/data/conf -d mongo

// -v映射目录

// --name名称

// 3009:27017端口映射服务器的3009端口映射到容器的的27017端口

SUCCESS

这时候我们看到了已经启动成功了。

创建管理员账户

进入容器。

docker exec -it mongo mongosh

LOGIN

显示这样基本上就表示登录成功。

查看数据库。

show dbs;

这是可以看到现有的db

show_db

然后进入admin use admin.

创建管理员账户。

admin> db.createUser({ user: 'admin', pwd: 'admin', roles: [ { role: "userAdminAnyDatabase", db: "admin" } ] });
{ ok: 1 }
admin> db.auth('admin', 'admin')
{ ok: 1 }

这样就表示创建成功且auth通过,这时候就可以测试连接了。mongodb://admin:admin@host:port/admin?authMechanism=DEFAULT

创建DB

这里我们创建一个 blog的db.

    use blog // 没有会自动创建

    db.createCollection('articles') // 创建一个集合

    db.articles.insertOne({title: 'xxx'}) // 插入数据

查看数据

可以看到数据已经插入了。

mongoose

Mongoose 提供了一种直接的、基于架构的解决方案来为您的应用程序数据建模。它包括内置的类型转换、验证、查询构建、业务逻辑挂钩等,开箱即用。

安装

pnpm add mongoose --save

创建一个工具函数 lib/mongoose.ts

import mongoose from "mongoose";

const connectMongo = async () =>
  await mongoose.connect(process.env.MONGO_URI as string, {
    autoCreate: true,
  });
// MONGO_URI 写在环境变量中
export default connectMongo;

数据模型

我们先增加一个 USER,相关规则可以查看此处schematypes

首先定义一个user的 DTOVOtypes/user.ts

export interface USER_DTO {
    name: string;
    image: string;
    email: string;
    password: string;
    created_at?: Date;
    updated_at?: Date;
}

export interface USER_VO {
    name: string;
    image: string;
    email: string;
    created_at: Date;
    updated_at: Date;
}

import { Schema, model, models } from "mongoose";
import { USER_DTO } from '@/types/user

const userSchema = new Schema<USER_DTO>({
  name: {
    type: String,
    required: true,
    trim: true,
    max_length: 50,
  },
  image: {
    type: String,
    required: true,
    default: "https://blog-1302483222.cos.ap-shanghai.myqcloud.com/images.jpg",
  },
  email: {
    type: String,
    required: true,
    unique: true,
    lowercase: true,
    match: /^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*\.(\w{2,})+$/,
  },
  password: {
    type: String,
    required: true,
    minlength: 8,
  },
  created_at: {
    type: Date,
    default: Date.now,
  },
  updated_at: {
    type: Date,
    default: Date.now,
  },
});

const User = models?.User || model<USER_DTO>("User", userSchema);
export default User;

常用API

可以熟悉以下常用的语法。


**增**:Modal.save()

**删**:Modal.deleteOne({name: 'xxx'})

**改**:Modal.findByIdAndUpdate(id, { name: 'jason bourne' }, options)

**查**::Modal.deleteOne({ name: 'xxx })

每种操作都有很多种方法,可以按需使用,具体文档查找, model操作

到这里我们就基本完成了数据库的部分,可以进入到下一阶段登录。

登录

登录使用next-auth,这里可以很方便的让我实现第三方的登录。

NEXT-AUTH安装

pnpm add next-auth

初始化配置

新建配置,在 lib目录中创建 auth_option.ts,这边我打算使用自定义登录以及github和google登录,微信登录看了一下需要注册企业,个人的话也要交钱,所以就暂时没有接入,短信登录需要接入短信服务,也要收费。

申请Github登录

进入 GitHub 之后,打开 Settings 中的 Developer Settings,点击左侧的 OAuth Apps 后,再点击右边的按钮 New OAuth App,创建一个新的配置。

New OAuth App,然后一步步往下走新建成功后,即可以拿到 CLIENT_IDSecret,可以把这些内容维护到 env文件中

然后完成以下配置。

import GitHubProvider from "next-auth/providers/github";
import GoogleProvider from "next-auth/providers/google";
import CredentialsProvider from "next-auth/providers/credentials";
import User from "models/user";
import connectMongo from "@/lib/mongoose";
import { decrypt, encrypt } from "@/lib/crypto";
import { AuthOptions } from "next-auth";

export const authOptions: AuthOptions = {
    secret: process.env.SECRET_KEY, // 密钥用来加密token
    // adapter: PrismaAdapter(MongoPrisma as PrismaClient) as Adapter,
    debug: true, // 可以查看登录时候的日志
    providers: [
      GitHubProvider({
        clientId: process.env.GIT_CLIENT_ID as string,
        clientSecret: process.env.GIT_CLIENT_SECRET as string,
        httpOptions: {
          timeout: 100000,
        },
        // 获取到用户profile后可以存储到数据库
        async profile(profile) {
          try {
            // 连接数据库
            await connectMongo();
          
            // 查询是否存在
            const existingUser = await User.findOne({ email: profile.email });
          

            // 如果存在就更新以下名字和图像
            if (existingUser) {
              // Update existing user
              const res = await User.findByIdAndUpdate(existingUser._id, {
                name: profile.name || profile.login,
                image: profile.avatar_url,
              });
              return existingUser;
            }
  
            /// 如果不存在就新增一个
            const newUser = new User({
              name: profile.name || profile.login,
              email: profile.email,
              image: profile.avatar_url,
              password: encrypt(profile.id.toString()), // Use GitHub ID as password
            });
  
            await newUser.save();
  
            return newUser;
          } catch (e: any ) {
            console.log(e.message);
          }
        },
      }),
      GoogleProvider({
        clientId: process.env.GOOGLE_ID as string,
        clientSecret: process.env.GOOGLE_SECRET_KEY as string,
        httpOptions: {
          timeout: 100000,
        },
      }),
      CredentialsProvider({
        name: "Credentials",
        credentials: {
          email: {
            label: "用户名",
            type: "text",
            placeholder: "请输入用户名",
          },
          password: {
            label: "密码",
            type: "password",
            placeholder: "请输入密码",
          },
        },

        // 自定义登录的鉴权
        async authorize(credentials, req) {
          // 出入进来账号和密码
          if (!credentials?.email || !credentials?.password) {
            return null
          }
          await connectMongo();
          // 查询用户
          const user = await User.findOne({ email: credentials.email });

          if (!user) {
            throw Error('用户不存在,请检查邮箱是否正确')
          } else {
            if (credentials.password ===  decrypt(user?.password as string)) {
              return user
            } else {
              throw Error('密码不正确,请重新输入')
            }
          }
        },
      }),
    ],
    // session 有效期 2天
    session: {
      strategy: "jwt",
      maxAge: 2 * 24 * 60 * 60,
    },

    // session的callback可以修改session传递的数据
    callbacks: {
      session: async (data: { session: any; }) => {
        return data.session;
      },
    },
  
    pages: {
      signIn: "/",
    },
  }

创建路由

api下创建,[...nextauth]/route.ts

import { authOptions } from "@/lib/auth_options";
import NextAuth from "next-auth";

const handler = NextAuth(authOptions);

export { handler as GET, handler as POST };

编写登录组件

通过调用 signIn的方法传入不同的参数即可实现对应的登录。

"use client";
import { ChangeEvent, FocusEvent, FormEvent, useEffect, useState } from "react";
import loginStyle from "./login.module.scss";
import classnames from "classnames";
import { motion } from "framer-motion";
import LoginBox from "./component/LoginBox";
import { Modal, Divider, Button, Input } from "antd";
import { post } from "lib/fetch";
import { signIn } from "next-auth/react";
import { SwapOutlined } from "@ant-design/icons";

interface FormState {
  password: string;
  email: string;
}

export default function LoginModal(data: {
  open: boolean;
  className?: string;
  onClose?: () => void;
}) {
  const [formState, setFormState] = useState<FormState>({
    email: "",
    password: "",
  });
  //

  // 登录
  const login = () => {
    signIn("credentials", { ...formState });
  };

  const confirm = () => {
    if (isLogin) {
      login();
    }
  };
  const [isLogin, setIsLogin] = useState(true);
  return (
    <>
      <Modal
        open={data.open}
        destroyOnClose
        width={500}
        footer={null}
        onClose={data.onClose}
        onCancel={data.onClose}
      >
        <section>
          <div className="flex flex-col items-center justify-center gap-6 pt-8">
            <div className="flex items-center  w-full">
              <h2 className="text-2xl font-bold text-black">
                {isLogin ? "登录" : "注册"}
                <Button
                  type={"text"}
                  onClick={() => setIsLogin(!isLogin)}
                  icon={<SwapOutlined />}
                >
                  <span className="sr-only">
                    {isLogin ? "Switch to Register" : "Switch to Login"}
                  </span>
                </Button>
              </h2>
            </div>
            <div className="w-full space-y-4">
              <div className="space-y-2">
                <label htmlFor="email">邮箱</label>
                <Input
                  id="email"
                  placeholder="请输入邮箱"
                  onChange={(event: ChangeEvent<HTMLInputElement>) => {
                    setFormState({
                      email: event.target.value,
                      password: formState.password,
                    });
                  }}
                />
              </div>
              <div className="space-y-2">
                <label htmlFor="password">密码</label>
                <Input
                  id="password"
                  type="password"
                  placeholder="请输入密码"
                  onChange={(event: ChangeEvent<HTMLInputElement>) => {
                    setFormState({
                      password: event.target.value,
                      email: formState.email,
                    });
                  }}
                />
              </div>
              {!isLogin && (
                <div className="space-y-2">
                  <label htmlFor="password">验证码</label>
                  <div>
                    <Input
                      id="password"
                      type="password"
                      placeholder="请输入验证码"
                      style={{ width: "250px" }}
                    />
                    <Button>发送验证码</Button>
                  </div>
                </div>
              )}
            </div>
            <Button className="w-full" type="primary" onClick={() => confirm()}>
              <div className=" tracking-[] ">{isLogin ? "登 录" : "注 册"}</div>
            </Button>
          </div>
          <Divider></Divider>
          <LoginBox />
        </section>
      </Modal>
    </>
  );
}

// loginBox

import { signIn } from "next-auth/react";
import { Button } from "antd";
import { GithubOutlined, GoogleOutlined } from "@ant-design/icons";

export default function LoginBox() {
  const sign = async (type: "github" | "google") => {
    await signIn(type, {
      callbackUrl: location.origin,
    });
  };
  return (
    <>
      <div className="text-xl">
        <Button
          type="primary"
          className="w-full !bg-[#24292F] mb-4"
          onClick={() => sign("github")}
          icon={<GithubOutlined></GithubOutlined>}
        >
          Login With Github
        </Button>

        <Button
          color="red"
          className="w-full"
          onClick={() => sign("google")}
          icon={<GoogleOutlined></GoogleOutlined>}
        >
          Login With Google
        </Button>
      </div>
    </>
  );
}

在页面上点击登录,弹出登录窗口。 LOGIN

登录成功后可以看到,返回了信息以及在 cookie中写入了token.

session

token

这样我们就完成了登录,注册还没做可以用一个 nodemailer来实现发送验证码,数据存储在 redis,缓存有效期1分钟,注册的时候验证以下验证码即可,密码加密存入数据库即可。

到此我们的准备工作基本上都做完了,现在就是业务开发了,这边直接不说了,没什么说的。


打包

直接使用 pnpm build即可打包,记住,打包的时候dev模式需要停止。

CI

这里使用 GITHUB_ACTION完成,在 .github下新增一个 nodejs.yml,添加如下内容。

具体操作就是使用 ssh登录到服务端。

然后执行拉取代码和打包的操作

cd /www/blog-offical && git pull && pnpm install && pnpm build
name: deploy
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: executing remote ssh commands

        uses: appleboy/ssh-action@master
        with:
          host: ${{secrets.DEPLOY_HOST}}
          username: ${{secrets.DEPLOY_USER}}
          password: ${{secrets.DEPLOY_PASS_WORD}}
          script: cd /www/blog-offical && git pull && pnpm install && pnpm build && pm2 stop all &&  pm2 delete blog && pm2 start --name blog npm -- run start

CD

构建完成了,我们需要部署,部署的话我们采用 nginx来作为web服务器,使用 pm2来管理进程,确保稳定性。

Nginx

一般linux系统安装,是通过 yum install nginx来完成,安装目录在 /etc/nginx

这是Nginx的配置,开启了转发,http2,以及跨域问题,以及 https https的配置需要申请证书,一般都有免费的,只是时间比较短,需要经常更换。


user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log;
pid /run/nginx.pid;

# Load dynamic modules. See /usr/share/doc/nginx/README.dynamic.
include /usr/share/nginx/modules/*.conf;

events {
    worker_connections 1024;
}

http {
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile            on;
    tcp_nopush          on;
    tcp_nodelay         on;
    keepalive_timeout   65;
    types_hash_max_size 2048;

    include             /etc/nginx/mime.types;
    default_type        application/octet-stream;

    # Load modular configuration files from the /etc/nginx/conf.d directory.
    # See http://nginx.org/en/docs/ngx_core_module.html#include
    # for more information.
    include /etc/nginx/conf.d/*.conf;
  
    server {
        listen       80 default_server;
        listen       [::]:80 default_server;
        server_name  _;
        root         /usr/share/nginx/html;
        # Load configuration files for the default server block.
        include /etc/nginx/default.d/*.conf;

        location /  {
             # 添加以下配置以启用 CORS
            if ($request_method = 'OPTIONS') {
                add_header 'Access-Control-Allow-Origin' '*';
                add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, DELETE, PUT';
                add_header 'Access-Control-Allow-Headers' 'Origin, Authorization, Accept, Content-Type, X-Requested-With';
                add_header 'Access-Control-Max-Age' 1728000;
                add_header 'Content-Type' 'text/plain charset=UTF-8';
                add_header 'Content-Length' 0;
                return 204;
            }

            add_header 'Access-Control-Allow-Origin' '*';
            add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, DELETE, PUT';
            add_header 'Access-Control-Allow-Headers' 'Origin, Authorization, Accept, Content-Type, X-Requested-With';
      
            proxy_pass http://localhost:3000;  # 将请求转发到本地主机的 3000 端口
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        error_page 404 /404.html;
            location = /40x.html {
        }

        error_page 500 502 503 504 /50x.html;
            location = /50x.html {
        }
    }

# Settings for a TLS enabled server.

    server {
        listen       443 ssl http2 default_server;
        listen       [::]:443 ssl http2 default_server;
        server_name  _;
        root         /usr/share/nginx/html;

        ssl_certificate "/www/cer/www.super-super.cn_bundle.pem";
        ssl_certificate_key "/www/cer/www.super-super.cn.key";
        ssl_session_cache shared:SSL:1m;
        ssl_session_timeout  10m;
        ssl_ciphers PROFILE=SYSTEM;
        ssl_prefer_server_ciphers on;

        # Load configuration files for the default server block.
        include /etc/nginx/default.d/*.conf;

        location / {
            if ($request_method = 'OPTIONS') {
                add_header 'Access-Control-Allow-Origin' '*';
                add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, DELETE, PUT';
                add_header 'Access-Control-Allow-Headers' 'Origin, Authorization, Accept, Content-Type, X-Requested-With';
                add_header 'Access-Control-Max-Age' 1728000;
                add_header 'Content-Type' 'text/plain charset=UTF-8';
                add_header 'Content-Length' 0;
                return 204;
            }

            add_header 'Access-Control-Allow-Origin' '*';
            add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, DELETE, PUT';
            add_header 'Access-Control-Allow-Headers' 'Origin, Authorization, Accept, Content-Type, X-Requested-With';
      
            proxy_pass http://localhost:3000;  # 将请求转发到本地主机的 3000 端口
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        error_page 404 /404.html;
            location = /40x.html {
        }

        error_page 500 502 503 504 /50x.html;
            location = /50x.html {
        }
    }

}

常用命令

启动服务

nginx

停止服务

nginx -s stop

重新加载,因为一般重新配置之后,不希望重启服务,这时可以使用重新加载。

nginx -s realod

域名解析

主要说一下域名解析的过程以及记录值的区别。

SEO相关

Metadata

在NextJs中,提供了设置metadata的方式,设置在page.tsx或者layout.tsx.

分为静态和动态两种。

静态metadata

静态的metadata直接export一个metada对象即可。

import type { Metadata, Viewport } from "next";

const APP_NAME = "Blog";
const APP_DEFAULT_TITLE = "汪浩的博客";
const APP_TITLE_TEMPLATE = "博客";
const APP_DESCRIPTION = "汪浩(Isaac Wang)的博客,一些关于技术和生活的的记录";

export const metadata: Metadata = {
  keywords:
    "博客,汪浩,Isaac Wang, Javascript, Vue, Css, Nextjs, React, TypeScript, NextJs, NestJs, Nodejs, Docker, web3,区块链",
  applicationName: APP_NAME,
  title: {
    default: APP_DEFAULT_TITLE,
    template: APP_TITLE_TEMPLATE,
  },
  description: APP_DESCRIPTION,
  manifest: "./manifest.json",
  appleWebApp: {
    capable: true,
    statusBarStyle: "default",
    title: APP_DEFAULT_TITLE,
    // startUpImage: [],
  },
  formatDetection: {
    telephone: false,
  },
  openGraph: {
    type: "website",
    siteName: APP_NAME,
    title: {
      default: APP_DEFAULT_TITLE,
      template: APP_TITLE_TEMPLATE,
    },
    description: APP_DESCRIPTION,
  },
  twitter: {
    card: "summary",
    title: {
      default: APP_DEFAULT_TITLE,
      template: APP_TITLE_TEMPLATE,
    },
    description: APP_DESCRIPTION,
  },
};

然后就会在页面上得到解析,会被加载到head中。

metadata

动态Metadata

比如我们一些详情页,希望我们的标题是详情的title,content是详情的内容。这时候我们就可以用,例如博客的详情页面,这时候我们可以先获取到数据,然后使用generateMetadata函数来生成动态的metadata,如下:



export async function generateMetadata({
  params,
}: {
  params: { id: string };
}): Promise<Metadata> {
  const detail = await getBlogDetail(params.id);

  if (!detail) {
    return {};
  }

  return {
    title: detail.title,
    description: detail.content,
    keywords: detail.tags.join(","),
    category: detail.categories.join(", "),
    abstract: detail.abstract,
    creator: "汪浩(isaac wang)",
    authors: [
      { url: "https://github.com/wanghao1993", name: "汪浩(isaac wang)" },
    ],
    publisher: "汪浩(isaac wang)",
  };
}

此时我们就可以在这里看到,刚刚动态配置的metadata。

动态的metadata

Sitemap

sitemap叫站点地图,可帮助搜索引擎更有效地发现您的网页并为其建立索引,也分为动态和静态两种。

静态的,可以建立一个文件叫app/sitemap.xml,如下。

<?xml version="1.0" encoding="UTF-8"?>
<urlset
      xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
            http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">

<url>
  <loc>https://www.super-super.cn/</loc>
  <lastmod>2024-07-22T07:08:29+00:00</lastmod>
  <priority>1.00</priority>
</url>
<url>
  <loc>https://www.super-super.cn/blog</loc>
  <lastmod>2024-07-22T07:08:29+00:00</lastmod>
  <priority>0.80</priority>
</url>
<url>
  <loc>https://www.super-super.cn/about</loc>
  <lastmod>2024-07-22T07:08:29+00:00</lastmod>
  <priority>0.80</priority>
</url>
</urlset>

动态的可以建立一个文件叫app/sitemap.ts,如下

import { MetadataRoute } from 'next'
 
export default function sitemap(): MetadataRoute.Sitemap {
  return [
    {
      url: 'https://acme.com',
      lastModified: new Date(),
      changeFrequency: 'yearly',
      priority: 1,
    },
    {
      url: 'https://acme.com/about',
      lastModified: new Date(),
      changeFrequency: 'monthly',
      priority: 0.8,
    },
    {
      url: 'https://acme.com/blog',
      lastModified: new Date(),
      changeFrequency: 'weekly',
      priority: 0.5,
    },
  ]
}

实施开放图谱和 Twitter 卡:

OpenGraph,又叫OG协议,可以简单的看一下介绍。

Twitter 卡片标签看上去与开放图谱标签相似,基于与开放图谱协议相同的约定。当使用开放图谱协议描述页面上的数据时,很容易生成 Twitter 卡片,而无需复制标签和数据。当 Twitter 卡片处理器在页面上寻找标签时,它会首先检查 Twitter 特定的属性;如果不存在,则会返回受支持的开放图谱属性。它允许在页面上独立定义这两种属性,并最大程度减少描述内容和体验所需的标记复制量。

如何定义呢,也是在metadata中定义,openGraph和twitter。

 openGraph: {
    type: "website",
    siteName: APP_NAME,
    title: {
      default: APP_DEFAULT_TITLE,
      template: APP_TITLE_TEMPLATE,
    },
    description: APP_DESCRIPTION,
  },
  twitter: {
    card: "summary",
    title: {
      default: APP_DEFAULT_TITLE,
      template: APP_TITLE_TEMPLATE,
    },
    description: APP_DESCRIPTION,
  },
};

此时我们可以看到在metadata中多了几个。

og-tw

语义化标签

语义化的标签在SEO中也起到了很关键的作用,平时开发的时候也是需要注意的,这些都会被搜索引擎发现,比如p,article,img.alt等等标签都是会被作为seo的考虑的因素。

robots.txt

当我们的网站发布时,搜索引擎将会尝试去抓取我们的内容,这个时候robots.txt可以规定能够被抓取的范围。

User-Agent: * // 意思是任何搜索引擎都可以
Allow: / // 允许抓取任何内容
Disallow: /admin // /admin下的内容不允许

Sitemap: https://super-super.cn/sitemap.xml // sitemap的地址

这是google的seo文档,介绍的很详细Google_SEO