使用 Next.js、LeanCloud 和 Tailwind CSS 建立全棧應用

張澤豪發表於2022-04-04
瞭解如何使用 LeanCloud 建立資料庫並使用 Next.js 建立帶有服務端的應用程式。

前言

通過本教程您將瞭解到:

  • 使用 LeanCloud 作為免費資料庫
  • 使用 Next.js 開發一個包含前後端的應用
  • 將應用釋出到 Vercel
  • 使用 Tailwind 輕鬆設定樣式

我們將建立一個用於影視劇打分應用,我將它部署在 rec.zehao.me,完整原始碼我放在 2eha0/records

https://rec.zehao.me

建立 Next.js 應用

使用 Next.js 官方模板建立專案

& npx create-next-app --example with-tailwindcss my-app

該目標已經為您配置好以下內容:

  • Next.js 最新版本
  • TypeScript
  • Tailwind CSS & 自動去除未使用的類名
  • Next.js API 路由示例

建立前端元件

現在我們可以開始建立元件了,pages/index.tsx 是應用的入口檔案,我們先來修改整體佈局

// pages/index.tsx
import type { NextPage } from 'next'
import Head from 'next/head'

const Home: NextPage = () => {
  return (
    <div className='mx-[3.5rem] min-w-[15rem] max-w-full sm:mx-auto sm:w-[30rem] font-sans'>
      <Head>
        <title>我看過的</title>
        <meta name="viewport" content="width=device-width" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <h1 className='text-slate-300 flex justify-between items-center text-xl sm:text-5xl my-8 sm:my-20'>
        <span>我看過的</span>
        <span className='text-xs sm:text-xl'>電影 / 動漫 / 劇 / 書</span>
      </h1>
    </div>
  )
}

export default Home

接下來,我們需要為應用新增一個卡片元件,用於顯示影視作品的資訊,新建 components/card.tsx 檔案

// components/card.tsx
import Image from 'next/image'

export const Card: React.FC<Props> = (props) => {
  return (
    <section className='relative before:content-[""] before:border-l-2 before:absolute before:inset-y-0 before:-left-9 before:translate-x-[0.44em] pb-10 first:before:top-1 last:before:bottom-10'>
      <p className='text-slate-400 text-xs mb-2 sm:text-base sm:mb-3 relative'>
        2022/04/02
        <i className='absolute w-4 h-4 rounded-full bg-slate-200 -left-9 top-1/2 translate-y-[-50%]' />
      </p>
      <div className="flex items-start">
        <div className="flex-1 mr-2">
          <p className='text-md mb-2 sm:text-2xl sm:mb-3 leading-6 text-slate-900'>
            鬼滅之刃
            <span className='text-slate-400'>(2019)</span>
          </p>

          <p className='text-xs sm:text-base text-slate-700'>
            <span className='text-slate-400'>評分:</span>
            <big className='font-bold text-blue-500'>? 還行</big>
          </p>

          <p className='text-xs sm:text-base text-slate-700'>
            <span className='text-slate-400'>分類:</span>
            動漫
          </p>

          <div className="bg-white text-xs text-slate-500 leading-2 mt-4 sm:text-base">
            老套的升級打怪式劇情,但動畫製作的質量還不錯,適合下飯
          </div>
        </div>
        <div className='flex-none w-1/6 rounded-md sm:w-[5rem] sm:rounded-xl overflow-hidden bg-slate-100 relative aspect-[85/113]'>
          <Image
            src='https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2551222097.webp'
            layout='fill'
            objectFit="cover"
            alt='鬼滅之刃'
            className="hover:opacity-75 duration-300 ease-in-out"
          />
        </div>
      </div>
    </section>
  )
}

圖片我們使用了 next/image 元件,我們需要修改一下 next.config.js 檔案,新增圖片域名配置

// next.config.js
module.exports = {
  reactStrictMode: true,
  images: {
    domains: [
      'img1.doubanio.com',
      'img2.doubanio.com',
      'img3.doubanio.com',
      'img9.doubanio.com',
    ],
  },
}

然後我們可以新增 <Card /> 元件到 pages/index.tsx 中,看一下效果

// pages/index.tsx
import type { NextPage } from 'next'
import Head from 'next/head'
import { Card } from '../components/card'

const Home: NextPage = () => {
  return (
    <div className='mx-[3.5rem] min-w-[15rem] max-w-full sm:mx-auto sm:w-[30rem] font-sans'>
      <Head>
        <title>我看過的</title>
        <meta name="viewport" content="width=device-width" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <h1 className='text-slate-300 flex justify-between items-center text-xl sm:text-5xl my-8 sm:my-20'>
        <span>我看過的</span>
        <span className='text-xs sm:text-xl'>電影 / 動漫 / 劇 / 書</span>
      </h1>

      <div>
        <Card />
        <Card />
        <Card />
      </div>
    </div>
  )
}

export default Home

至此應用的外觀已經初見雛形了,接下來我們為 Card 新增一些 props,首先我們來定義 props 的型別,我們在根目錄下新建一個 types.ts 檔案

// types.ts
export type Record = {
  date: string
  title: string
  score: 1 | 2 | 3 | 4 | 5
  comment?: string
  year: number
  img: string
  type: 'movie' | 'tv' | 'anime' | 'book'
}

之所以放在根目錄,是因為等一下建立 API 時也會用到這個型別

接下來我們修改一下 Card 元件,將資料部分替換成 props

// components/card.tsx
import Image from 'next/image'
import { Record } from '../types'

type Props = Record

const Score: React.FC<Pick<Props, 'score'>> = ({ score }) => {
  switch (score) {
    case 1:
      return <big className='font-bold text-black-500'>? 不看也罷</big>
    case 2:
      return <big className='font-bold text-green-500'>? 無聊</big>
    case 3:
      return <big className='font-bold text-blue-500'>? 還行</big>
    case 4:
      return <big className='font-bold text-violet-500'>? 值得一看</big>
    case 5:
      return <big className='font-bold text-orange-500'>? 神作!</big>
  }
}

const renderType = (type: Props['type']) => {
  const typeMap = {
    movie: '電影',
    tv: '劇',
    book: '書',
    anime: '動漫',
  }
  return typeMap[type] ?? '未知'
}

export const Card: React.FC<Props> = (props) => {
  return (
    <section className='relative before:content-[""] before:border-l-2 before:absolute before:inset-y-0 before:-left-9 before:translate-x-[0.44em] pb-10 first:before:top-1 last:before:bottom-10'>
      <p className='text-slate-400 text-xs mb-2 sm:text-base sm:mb-3 relative'>
        { new Date(props.date).toLocaleDateString() }
        <i className='absolute w-4 h-4 rounded-full bg-slate-200 -left-9 top-1/2 translate-y-[-50%]' />
      </p>
      <div className="flex items-start">
        <div className="flex-1 mr-2">
          <p className='text-md mb-2 sm:text-2xl sm:mb-3 leading-6 text-slate-900'>
            { props.title }
            <span className='text-slate-400'>({props.year})</span>
          </p>

          <p className='text-xs sm:text-base text-slate-700'>
            <span className='text-slate-400'>評分:</span>
            <Score score={ props.score } />
          </p>

          <p className='text-xs sm:text-base text-slate-700'>
            <span className='text-slate-400'>分類:</span>
            { renderType(props.type) }
          </p>

          <div className="bg-white text-xs text-slate-500 leading-2 mt-4 sm:text-base">
            { props.comment }
          </div>
        </div>
        <div className='flex-none w-1/6 rounded-md sm:w-[5rem] sm:rounded-xl overflow-hidden bg-slate-100 relative aspect-[85/113]'>
          <Image
            src={ props.img }
            layout='fill'
            objectFit="cover"
            alt={ props.title }
            className="hover:opacity-75 duration-300 ease-in-out"
          />
        </div>
      </div>
    </section>
  )
}

設定 LeanCloud Storage

LeanCloud 是一個 BaaS(Backend as a Service)^Backend as a Service: [後端即服務] 平臺,建議註冊國際版 LeanCloud,可免實名認證

首先,我們需要在 Data Storage 中建立一個 Class

  • 將 Class 命名為 Records
  • 新增 imgtitletypecommenttype 欄位,它們的型別都是 String
  • 新增 yearscore 欄位,將他們的型別設定為 Number

Records Class

建立讀取資料 API

現在我們來建立一個 API 用於讀取 LeanCloud 中的資料

首先我們需要安裝 LeanCloud JS SDK

$ npm install leancloud-storage --save

然後我們需要將 LeanCloud 的配置資訊新增到 .env.local 中,配置資訊可以在 "Settings" -> "App keys" 中找到

LEANCLOUD_APP_ID="{replace-your-app-id}"
LEANCLOUD_APP_KEY="{replace-to-your-app-key}"
LEANCLOUD_SERVER_URL="{replace-to-your-server-url}"

新建 pages/api/records.ts

// pages/api/records.ts
import AV from 'leancloud-storage'
import { Record } from '../../types'

import type { NextApiRequest, NextApiResponse } from 'next'

export default async function handler(
  _req: NextApiRequest,
  res: NextApiResponse<Record[]>
) {
  try {
    const {
      LEANCLOUD_APP_ID: appId,
      LEANCLOUD_APP_KEY: appKey,
      LEANCLOUD_SERVER_URL: serverURL,
    } = process.env
    if (!appId || !appKey || !serverURL) {
      res.status(500).json({ error: 'Missing Leancloud config' } as any)
      return
    }

    AV.init({ appId, appKey, serverURL })

    const query = new AV.Query('Record')

    const data = await query.find()
    const records: Record[] = data.reverse().map(x => {
      const json = x.toJSON()
      return {
        date: json.createdAt,
        title: json.title,
        score: json.score,
        comment: json.comment,
        year: json.year,
        img: json.img,
        type: json.type,
      }
    })
    res.status(200).json(records)
  } catch (e: any) {
    res.status(500).json(e)
  }
}

接著我們修改一下 pages/index.tsx,從 /api/records 介面獲取資料

// pages/index.tsx
import type { NextPage } from 'next'
import Head from 'next/head'
import { useEffect, useState } from 'react'
import { Card } from '../components/card'
import { Record } from '../types'

const Home: NextPage = () => {

  const [ records, setRecords ] = useState<Record[] | null>(null)
  useEffect(() => {
    fetch('/api/records')
      .then(res => res.json())
      .then(setRecords)
  }, [])

  const renderCards = () => {
    if (!records) {
      return null
    }
    return records.map(x => <Card key={ `${x.date}${x.title}${x.year}` } { ...x } />)
  }

  return (
    <div className='mx-[3.5rem] min-w-[15rem] max-w-full sm:mx-auto sm:w-[30rem] font-sans'>
      <Head>
        <title>我看過的</title>
        <meta name="viewport" content="width=device-width" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <h1 className='text-slate-300 flex justify-between items-center text-xl sm:text-5xl my-8 sm:my-20'>
        <span>我看過的</span>
        <span className='text-xs sm:text-xl'>電影 / 動漫 / 劇 / 書</span>
      </h1>

      <div>
        { renderCards() }
      </div>
    </div>
  )
}

export default Home

部署到 Vercel

我們的應用已經可以在本地執行了,下一步讓我們把它部署到 Vercel 上。

  1. 將我們的程式碼提交到 git 倉庫(如 Github、GitLab、BitBucket)
  2. 將 Next.js 專案匯入 Vercel
  3. 在匯入期間設定環境變數
  4. 點選“Deploy”

Vercel 將自動檢測您正在使用 Next.js 併為您的部署啟用正確的設定。最後,您的應用程式部署在類似 xxx.vercel.app 的 URL 上。

新增資料

現在我們的應用已經執行在公網上了,我們可以在 LeanCloud 上嘗試新增幾條資料,然後重新整理頁面看看是否能夠正常顯示。

總結

在本教程中,我們能夠建立一個 Next.js 應用程式,通過 Tailwind CSS 美化介面,顯示從 LeanCloud 動態獲取的資料列表。

本文轉載自我的部落格 https://www.zehao.me/full-sta...

相關文章