本章我们将使用 NextJS 开发一个小型的博客系统,你将学习到如何从远程服务器获取数据以及数据过滤、NextJS 常用组件的用法、NextJS 的自动路由等功能。

我们使用json placeholder提供的模拟数据来实现一个小型的、只读的博客系统(因为这个网站虽然提供了写操作的API,但它只是单纯的返回状态码,并不会把数据真实的写入服务器)。

开发之前,我们先熟悉一下 API 及数据结构

json placeholder API说明
/posts100条文章的列表
/posts/:id指定ID的文章详情
/posts/:id/comments指定ID的文章的评论
/users10个用户的列表
/users/:id指定ID的用户详情

模块

模块说明
首页分成两部分:上面部分显示用户列表;下半部分显示文章列表
文章详情显示文章内容及该文章的作者和所有评论
用户详情显示该用户的详情信息及发表的文章

实现

目录结构

使用客户端渲染实现

本节代码:axum-rs-next-lite-blog-client-side-rendering,你可以先点开这个链接,既能看到执行结果,又能方便的查看代码。

首页

import { useState, useEffect } from 'react';
import Head from 'next/head';
import axios from 'axios';
import Link from 'next/link';
import PostList from '../components/PostList';

export default function Home() {
  const [userList, setUserList] = useState([]); // 第 8 行
  const [postList, setPostList] = useState([]); // 第 9 行
  useEffect(() => { // 第10行
    Promise.all([ // 第11行
      axios.get('https://jsonplaceholder.typicode.com/users'),
      axios.get('https://jsonplaceholder.typicode.com/posts'),
    ]).then(([{ data: userListRemote }, { data: postListRemote }]) => { // 第14行
      setUserList(userListRemote);
      setPostList(postListRemote);
    });
  }, []); // 第18行
  return (
    <>
      <Head> {/* 第21行*/}
        <title>AXUM中文网</title>
        <meta name="viewport" content="width=device-width, initial-scale=1" />
      </Head> {/* 第24行*/}
      <main>
        <section>
          <h2>用户列表</h2>
          <ul>
            {userList.map((user) => ( {/* 第29行*/}
              <li key={`user-item-${user.id}`}>
                <Link href={`/users/${user.id}`}>{user.name}</Link>
                <small>({user.username})</small>
              </li>
            ))} {/* 第34行*/}
          </ul>
        </section>
        <section>
          <h2>最新文章</h2>
          <PostList userList={userList} postList={postList} limit={5} /> {/* 第39行*/}
        </section>
      </main>
    </>
  );
}

  • 第10~18行:使用 useEffect hook模拟 compontentDidMount 生命周期,从远程服务器获取数据

    • 第11行:由于我们要同时获取两个接口的数据,所以这里使用 Promise.all来并发地发起两个 HTTP 请求
    • 第14行:这里我们结合了数组解构和对象解构的写法
  • 第21~24行:设置所需要的 Head

  • 第39行:将 userListpostList 传递给 PostList 组件

    思考:代码中还有一个额外的 limit,可以用来控制显示的条数,你有思路实现吗。👉 可以使用 Array.slice()

文章列表、文章项组件

// components/PostList.jsx
import PostItem from './PostItem';

export default function PostList({ userList, postList }) {
  return (
    <ul>
      {postList.map((post) => {
        const author = userList.find((u) => u.id === post.userId); {/* 第8行*/}
        return <PostItem key={post.id} post={post} author={author} />;
      })}
    </ul>
  );
}


// components/PostItem.jsx
import Link from 'next/link';

export default function PostItem({ post, author }) {
  return (
    <li>
      <Link href={`/posts/${post.id}`}>{post.title}</Link> - by{' '}
      <Link href={`/users/${author.id}`}>{author.name}</Link>
    </li>
  );
}

除了第8行,其它没什么值得说的:

  • 第8行,使用 Array.find()来查找符合条件的元素。此例中,是要在所有用户列表中,查找当前文章的作者。

文章详情页

import axios from 'axios';
import Link from 'next/link';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router'; // 第4行

export default function PostDetail() {
  const { id } = useRouter().query; // 第7行
  const [post, setPost] = useState({});
  const [comments, setComments] = useState([]);
  const [author, setAuthor] = useState({});
  useEffect(() => {
    Promise.all([ //第12行
      axios.get(`https://jsonplaceholder.typicode.com/posts/${id}`),
      axios.get(`https://jsonplaceholder.typicode.com/posts/${id}/comments`),
    ]).then(([{ data: post }, { data: comments }]) => {
      setPost(post);
      setComments(comments);
      axios // 第18行
        .get(`https://jsonplaceholder.typicode.com/users/${post.userId}`)
        .then(({ data: author }) => {
          setAuthor(author); 
        });//第22行
    }); // 第23行
  }, []);
  return (
    <div>
      <h1>{post.title}</h1>
      <div>
        作者:<Link href={`/users/${author.id}`}>{author.name}</Link> {/*第29行*/}
      </div>
      <p>{post.body}</p>
      <hr />
      <h3>评论列表</h3>
      {comments.map((c) => (
        <div key={`comments-${post.id}-${c.id}`}>
          <div>
            <a href={`mailto:${c.email}`}>{c.name}</a> 说:{/*第37行*/}
          </div>
          <div>{c.body}</div>
        </div>
      ))}
    </div>
  );
}

  • 第4行:导入 useRouter hook
  • 第7行:通过 useRoter hook,获取动态路由中的参数
  • 第12~23行:从远程服务端获取数据
    • 第12行:并行发起2个 HTTP 请求,当这两个 HTTP请求成功返回之后,设置状态并在18行又一次发起 HTTP 请求
    • 第18行:由于这个请求依赖于12行发起的 HTTP请求的返回结果,所以这里要在上一步HTTP请求拿到结果后再发一次 HTTP 请求
  • 第29行:使用 Link 组件跳转到指定路由
  • 第37行:直接使用原始的 HTML 的 <a>,这里因为这个链接并不是路由跳转,所以不能使用 Link

用户详情页

import axios from 'axios';
import PostItem from '../../components/PostItem';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';

export default function UserDetail() {
  const { id } = useRouter().query;
  const [user, setUser] = useState({});
  const [postList, setPostList] = useState([]);
  useEffect(() => {
    Promise.all([
      axios.get(`https://jsonplaceholder.typicode.com/users/${id}`),
      axios.get('https://jsonplaceholder.typicode.com/posts'),
    ]).then(([{ data: user }, { data: postList }]) => {
      setUser(user);
      setPostList(postList);
    });
  }, []);
  return (
    <div>
      <h1>用户详情</h1>
      <div>用户名:{user.username}</div>
      <div>姓名:{user.name}</div>
      <div>邮箱:{user.email}</div>
      <div>
        {/* 第26行 */}地址:{user.address?.street} {user.address?.suite}, {user.address?.city}{' '}
        ({user.address?.zipcode}) 
      </div>
      <div>电话:{user.phone}</div>
      <div>
        网站:
        <a href="http://{user.website}" target="_blank"> {/* 第32行 */}
          {user.website}
        </a>
      </div>
      <hr />
      <div>发表的文章:</div>
      <ul>
        {postList
          .filter((post) => post.userId === user.id) {/* 第40行 */}
          .map((post) => (
            <PostItem post={post} author={user} /> {/* 第42行 */}
          ))}
      </ul>
    </div>
  );
}
  • 第26行:为了确保在未拿到 HTTP 请求的结果之前,页面能正常渲染,这里使用 ?user.address 进行容错处理
  • 第32行:
    • 因为是链接到外部地址,所以直接使用 <a> 标签
    • 这里存在一个笔误,请读者自行修改:<a href={...}——这里应该用字符串模板拼接
  • 第40行:使用 Array.filter() 对文章列表进行过滤:此处只需要该用户发表的文章
  • 第42行:复用 PostItem——这里漏了 key,请读者自行修改:<PostItem key={...} ... />

页顶

import Link from 'next/link';
import '../styles/globals.css';

function MyApp({ Component, pageProps }) {
  return (
    <> {/*第6行*/}
      <div> {/*第7行*/}
        <Link href="/">首页</Link> {/*第8行*/}
      </div>{/*第9行*/}
      <Component {...pageProps} />
    </>{/*第11行*/}
  );
}

export default MyApp;
  • 为了让每个页面都能快速回到首页,我们在 pages/_app.js中添加了一个类似于导航栏的组件
    • 在布局时,这是一种很常用的方式
  • 为了满足 React 只能有一个根元素的规范,第6行添加了 <>——还记得它是什么吗?不记得的话,找找以前的章节吧。

使用服务端渲染实现

本节代码:axum-rs-next-lite-blog,你可以先点开这个链接,既能看到执行结果,又能方便的查看代码。

服务端渲染和客户端渲染几乎一样,除了获取数据的方式和获取动态路由的参数不同。我们以文章详情为例进行分析。其它内容请点击上面的链接直接查看源码。

import axios from 'axios';
import Link from 'next/link';

export default function PostDetail({ post, comments, author }) { // 第4行
  return (
    <div>
      <h1>{post.title}</h1>
      <div>
        作者:<Link href={`/users/${author.id}`}>{author.name}</Link>
      </div>
      <p>{post.body}</p>
      <hr />
      <h3>评论列表</h3>
      {comments.map((c) => (
        <div key={`comments-${post.id}-${c.id}`}>
          <div>
            <a href={`mailto:${c.email}`}>{c.name}</a> 说:
          </div>
          <div>{c.body}</div>
        </div>
      ))}
    </div>
  );
}

export async function getServerSideProps({ params }) { // 第26行
  const { id } = params; // 第27行
  const [{ data: post }, { data: comments }] = await Promise.all([
    axios.get(`https://jsonplaceholder.typicode.com/posts/${id}`),
    axios.get(`https://jsonplaceholder.typicode.com/posts/${id}/comments`),
  ]);
  const { data: author } = await axios.get(
    `https://jsonplaceholder.typicode.com/users/${post.userId}`
  );
  return { props: { post, comments, author } };
}

  • 第4行:服务端渲染不需要使用 useEffect 来获取数据了,而是由第26行定义的 getServerSideProps() 特殊函数返回
    • 它只要在形参里声明、接收即可
    • 它连ID都不用获取:
      • 一是不需要
      • 二是 如果确实需要,还是可以通过getServerSideProps()来传递
  • 第26行:getServerSideProps({params})函数
    • 它可以接收参数,其中就有 params ,nextjs 会自动把动态路由里的参数通过它传递过来
  • 第27行:从 params 里获取动态路由里的 id 参数
  • 在页面组件里(第4行开始),还是可以像客户端渲染那样,通过 useRouter hook 获取动态路由里的参数
  • 但是在 getServerSideProps() 里,无法使用 useRouter hook,必须通过 params接收

试一试

  • 参照页顶“首页”链接的方法,将 pages/index.js 下的 <Head> 组件放到 pages/_app.js 里,以便让给所有页面统一设置 Head

  • 参照“文章列表”的方式,试着将“用户列表”也封装成组件

  • 让组件目录更规范化,比如:

    - components -- 组件目录
      - Post
        - Items.jsx -- 列表中,单个文章组件
        - List.jsx -- 文章列表组件
    

本章小结

如何获取动态路由中的参数

方式一:所有渲染模式都能用,且客户端渲染模式只能用这种方式

使用 useRouter hook获取。

方式二:服务端渲染和静态网站生成

getServerSideProps/getStaticProps函数里,通过 params 接收。

如何同时(并发的)发起多个 HTTP 请求

使用 Promise.all可以并发地执行多个异步任务。

对于外部链接,NextJS可能会有警告

可以按照它的警告信息,给<a>加上noreferrer等内容。

Tailwind 在召唤你

通过本章的示例,你应该能感受到 NextJS (包括 React)开发的爽快感觉了,回头看一下本章案例——好丑呀。不要慌,马上进入 Tailwind 的学习,有了它之后,不愁它不能美化你的网页,只恨你没有那么多创意。

我和 Tailwind 在下一章等你。