如何构建全栈 Next.js 应用(使用 Storybook 和 TailwindCSS)
本教程中的所有代码作为完整包均可在此存储库中找到。
如果您觉得本教程有用,请与您的朋友和同事分享!想了解更多类似内容,您可以在YouTube上订阅我或在Twitter上关注我。
如果您喜欢这种格式,本教程可以作为视频课程提供:
目录
- 先决条件
- 介绍
- 添加 Tailwind
- Storybook 对 Tailwind 的支持
- 范围和要求
- 前端规划
- 前端:搜索组件
- 前端:页眉和页脚
- 前端:布局
- 前端:结果
- 后端规划
- 后端:搜索数据
- 后端:API 路由
- Next.js 中的静态和动态页面
- 前端收尾工作
- 主题和设计系统
- 后续步骤
- 总结
先决条件
重要提示:本教程是上一教程的延续。
如果您希望将存储库与本教程的开始对齐,请克隆存储库并git checkout 6630ca95c25e66d7b6c7b1aad92151b481c1b9c5
。
检出该提交后,创建一个新分支来继续本教程。例如,git branch fullstack-tutorial
如下所示git checkout fullstack-tutorial
:
如果您选择不使用以前设置中的所有配置,那么应该可以使用一个新的空白项目来遵循本教程,但我建议您至少在开始之前通读文章以了解项目架构。
如果您希望尝试从一个新的 Next.js 项目开始,请运行以下命令来设置核心项目:
npx create-next-app --ts
然后你还需要安装 Storybook。请在新项目中按照这些说明操作,以便与本教程的开头保持一致。
我们还基于一个包含样式、故事和模拟数据的基础模板创建了所有组件。您可以从这里获取该模板。
祝你好运,希望你喜欢本教程。
介绍
本教程是关于构建可扩展的 Next.js 架构的系列教程中的第二篇。
在第一部分中,我们完全专注于基础项目设置,我们实际上并没有开始构建应用程序,只是一个简单的组件模板来展示流程。
在下一阶段,我们将着眼于实际构建一个应用程序。我们将研究 Next.js 如何处理一些基本问题,例如路由、图像优化、静态页面与动态页面的比较、API 构建,当然还有样式解决方案。
我们将使用当前的“热门商品” Tailwind CSS作为组织我们的设计系统的工具,并快速实现样式,同时保持产品的一致外观和感觉。
最后,或许也是最重要的一点,本教程也致力于尝试复制真实的软件开发流程。因此,我们不会直接开始构建,而是会根据目标来分析需求,确定项目范围,并提前规划如何构建前端和后端。
在本教程结束时,我们的目标是拥有一个功能齐全的全栈 Next.js 应用程序,我们可以将其推送到生产站点,并在未来与开发人员团队按照一致的系统继续进行迭代。
如果您觉得这些听起来不错,那就让我们立即开始吧!
添加 Tailwind
Tailwind CSS 自我描述如下:
一个实用优先的 CSS 框架,包含 flex、pt-4、text-center 和 rotate-90 等类,可直接在您的标记中组合以构建任何设计。
因此,从根本上来说,这是一种加强一致性和便利性的方法,同时也使大多数样式更接近您正在开发的组件。
Tailwind 的编译器将分析您的所有代码,并仅根据您实际使用的类捆绑原始 CSS,因此它需要一些依赖项才能启动和运行。
在开始之前,我强烈推荐VS Code 的Tailwind CSS IntelliSense扩展。它能自动完成 Tailwind 样式,显示实际应用的 CSS 值,并与自定义主题集成,总体来说,它能让 Tailwind 的使用更加流畅。
现在,让我们开始在项目的根目录中运行以下命令:
yarn add -D tailwindcss postcss autoprefixer
Tailwind 将编译成最终版本的常规 CSS,因此它不需要作为项目中的运行时依赖项存在。
postcss和autoprefixer是 Tailwind 用来转换 CSS 的工具。
Tailwind 安装完成后,我们需要初始化它。
npx tailwindcss init -p
这将自动postcss.config.js
为您创建一个文件。此外,您还需要tailwind.config.js
在项目根目录中创建一个文件。默认情况下也可能创建一个文件。其内容应包括:
tailwind.config.js
module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}',
],
// Ensure these match with .storybook/preview.js
theme: {
screens: {
xs: '375px',
sm: '600px',
md: '900px',
lg: '1200px',
xl: '1536px',
},
},
plugins: [],
};
请注意,我上面使用的模式与我们的/components
和/pages
目录对齐。这些是我计划放置 React 组件的唯一位置(因此 Tailwind 样式也包含在组件中)。
如果您计划在将来添加更多顶级组件目录,请确保更新此配置。
我们差不多可以测试了。我们只需要在文件中添加一些默认的基线值global.css
。现在,我将把它移动到目录中,因为我们将完全使用 Tailwind 构建此应用,并且不需要全局样式目录。(请注意,如果这样做,/pages
您可能还需要更新导入)。.storybook/main.js
如果您选择不使用 Tailwind,您可以保留该styles
目录,甚至可以选择删除它并将您的.modules.css
(或 SCSS 或 styled-components)保留在组件本身旁边。
请特别注意@tailwind
顶部的值。
pages/global.css
@tailwind base;
@tailwind components;
@tailwind utilities;
您可以删除全局中任何其他浏览器规范化的 CSS,Tailwind 将为您处理该问题。
我还更新了我们的index.tsx
以摆脱Home.module.css
并删除该文件:
pages/index.tsx
import CatCard from '../components/cards/cat/CatCard';
import { mockCatCardProps } from '../components/cards/cat/CatCard.mocks';
import PrimaryLayout from '../components/layouts/primary/PrimaryLayout';
import SidebarLayout from '../components/layouts/sidebar/SidebarLayout';
import { NextPageWithLayout } from './page';
const Home: NextPageWithLayout = () => {
return (
<section className="bg-gradient-to-r from-cyan-500 to-blue-500">
<h1>
Welcome to <a href="https://nextjs.org">Next.js!</a>
</h1>
<CatCard {...mockCatCardProps.base} />
</section>
);
};
export default Home;
Home.getLayout = (page) => {
return (
<PrimaryLayout>
<SidebarLayout />
{page}
</PrimaryLayout>
);
};
现在让我们测试以确保 Tailwind 已正确安装和配置。
注意到className
上面主页上的 section 组件了吗?这就是 Tailwind,本质上只是你已经熟悉的 CSS 属性的简写。
如果没有安装和配置 Tailwind,它们将不会执行任何操作,但有了 Tailwind,我们应该会看到蓝色/青色线性渐变背景。
Next.js 的优点在于它会帮你处理所有构建过程,你甚至无需思考。只需启动你的开发服务器即可(如果已经在运行,可能需要重启才能启动):
yarn dev
看起来一切都设置好了。只有一个问题:如果你尝试运行 Storybook,你将看不到你的样式。你的 Next.js 已设置为处理你的 Tailwind 类,但 Storybook 默认没有。
Storybook 对 Tailwind 的支持
如果您尚未安装和配置 Storybook,请记住阅读本指南的先决条件部分。
首先为 Storybook 添加 PostCSS 插件:
yarn add -D @storybook/addon-postcss
可选:如果您还想继续使用 CSS 模块:
yarn add -D storybook-css-modules-preset
然后将您的.storybook/main.js
文件更新为:
.storybook/main.js
module.exports = {
stories: ['../**/*.stories.mdx', '../**/*.stories.@(js|jsx|ts|tsx)'],
/** Expose public folder to storybook as static */
staticDirs: ['../public'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'storybook-css-modules-preset',
{
/**
* Fix Storybook issue with PostCSS@8
* @see https://github.com/storybookjs/storybook/issues/12668#issuecomment-773958085
*/
name: '@storybook/addon-postcss',
options: {
postcssLoaderOptions: {
implementation: require('postcss'),
},
},
},
],
framework: '@storybook/react',
core: {
builder: '@storybook/builder-webpack5',
},
};
我刚刚将蓝色/青色渐变添加到BaseTemplate.tsx
组件中,以便在 Storybook 中进行测试,以确保它正确编译 Tailwind 样式(测试后我立即再次删除了该类)。
是时候承诺我们的进步了git commit -m 'feat: implement tailwind css'
如果您想与本教程的这一步保持一致,请克隆存储库并使用git checkout 6630ca95c25e66d7b6c7b1aad92151b481c1b9c5
。
范围和要求
我希望本教程至少能从宏观层面涵盖通用的软件开发生命周期。当然,这个主题可以写成整篇文章甚至整本书,但我认为讲解这些概念非常重要,尤其是对于那些学习本教程但可能缺乏行业实际项目经验的开发者来说。这正是本系列教程的目标之一。
因此考虑到这一点,我将把它当作一个真正的项目来对待。
首先我需要问客户(这里客户就是我自己):你的目标是什么?你想达到什么目的?“有可能(尽管可能性很小),一旦详细讨论,这个挑战实际上可以在根本不构建新软件的情况下解决。也许已经有一个现成的工具可以满足他们的需求,只是他们不知道?
在我们的场景中,我的目标是“教人们如何使用 Next.js 构建应用程序”。好吧。我认为,为了实现这个目标,我需要构建一个 Next.js 应用程序。
事实证明,我(客户)有一份特定主题的列表,希望在本教程中向读者讲解。这些概念几乎是每个构建专业 Next.js 应用的人在开发过程中都会遇到的。
必备品:
- 造型
- 路由
- API 路由
- 静态和动态页面
- 图像优化
锦上添花:
- 在路线之间共享状态
- 验证
- 国际化
- 单元和端到端测试
- 数据持久性(数据库)
注意:两个单独的页脚不是必需的。只需一个(显示位置)即可。
太好了。这真的帮我确定了项目范围。
由于我正在撰写多篇博客文章,所以我会立即将所有“可有可无”的内容(在我们这里指的是未来的博客文章)分配到项目的第二阶段。第一阶段的范围将涵盖所有“必须有”的内容。
但是,我要构建什么样的项目才能满足这些要求呢?我正在寻找一个最小可行示例,它能够让我演示每一个需求,并满足客户的需求,而不会超出时间和预算。
在花了一些时间查看热门网站以获取想法之后,我决定在本教程中制作一个非常简单的Google 克隆。
为什么?让我们回顾一下要求:
- 样式(Google 的设计很简单,我们将使用 Tailwind CSS 来重新创建它)
- 路由(我们将演示两个路由,主“主页”和“结果”页面)
- API 路由(我们将使用
fetch
API 通过 API 路由查询一些模拟搜索数据) - 静态和动态页面(主页可以是静态的,搜索页面可以根据搜索查询动态)
- 图像优化(Google 徽标)
太棒了!我们确定了需求和范围,现在可以开始工作了。
前端规划
在深入研究并开始制作组件之前,我们先花点时间全面地了解一下整个项目,并了解一下我们需要哪些组件。通常情况下,你应该让设计师参与到你的流程中,并使用Figma这样的行业级工具,在开始考虑代码之前就规划和设计好你需要的组件。
幸运的是,我们已经拥有了我们可能要求的最佳设计:一个可通过https://www.google.com访问的完全交互式设计。
所以,这个项目就让设计师休息一下,我们自己动手吧!我还是想弄清楚需要哪些组件,所以先来看看我们要创建的两个主要页面,了解一下组件是什么,然后建立一个思维模型,看看哪些部分会在多个地方重复使用。
(请注意,当我在这里说“组件”时,我指的是组件的一般概念,比如组成某物的各个部分。我还没有讲到 React 特定的代码“组件”)。
因此,您可以看到,在上面我至少隔离了几个组件:
- 布局(可能需要主页和结果变体)
- 搜索(包括输入的功能部分,将是一个表单)
- 导航(页眉和页脚变体,唯一的区别在于背景颜色和顶部/底部位置。元素可以是子组件)
- 搜索结果(呈现搜索结果的所有内容的结构和排版,包括标题、文本、网址等)
以上只是无限可能方法中的一种,即使对于如此简单的事情也是如此。这是项目设计阶段,说实话,对于如何做到这一点,并没有一个正确的答案。大多数人在职业生涯中,经过几年的编程之后,才发现这才是真正的挑战。
一个好的应用程序会让团队投入更多时间进行设计和规划,从而尽可能减少实现目标所需的编码量。编码和开发阶段通常不仅是最昂贵的,而且如果需求在第一次就不正确,那么“撤消”的成本也是最高的,而且非常复杂。
我不会深入探讨其中的官僚主义,因为现实情况当然不会如此简单明了,但希望你能明白我的意思。如果可能的话,请一次做好,并坚持下去。其他开发者(以及你未来的自己)都会感谢你的。
解决了这个问题之后,我想我们终于可以开始开发前端组件了!
前端:搜索组件
我们将在 Storybook 中完成所有组件的设计和测试。
你会发现,这将是我们开发过程中反复出现的主题。这是一种很好的方法,可以确保我们构建的组件在单独运行时看起来是正确的,这样我们就可以在不受应用程序其他部分干扰的情况下进行验证,然后在验证通过后将它们放入我们的应用程序中。
因此,我可以灵活地选择自己喜欢的组件。我Search
先从组件开始。
/utility
创建一个名为inside 的新目录/components
。和前面一样,我们首先将我们的 复制templates/base
到该components/utility
目录中,以启动我们的组件。
如果您不确定我所描述的内容,您可以参考我们创建BaseTemplate组件的原始教程,或者直接从项目repo中获取它。
对复制文件夹中的每个 运行查找并替换BaseTemplate
,并将其替换为Search
,包括文件内容和文件名本身。最后将title
中的更改Search.stories.tsx
为utility/Search
。完成后,它应该如下所示:
在 Storybook 中:
yarn storybook
(您可能仍然在模板上保留了一些可以删除的 Tailwind 测试样式。另请注意,我将.module.css
模板保留在这里,以供那些选择不使用 Tailwind 的用户使用,但我们不会在本教程中使用它)
好了,现在开始构建组件吧!这就是我在上面的原始规划设计中用绿色勾勒出的,标题为Search
。
搜索步骤 01:HTML 结构
我先从 HTML 结构开始,不写样式或功能逻辑。“搜索”按钮和输入框意味着我需要一个表单。
components/utility/base/Search.tsx
export interface ISearch {}
const Search: React.FC<ISearch> = () => {
return (
<form>
<input type="text" />
<button type="submit">Google Search</button>
<button type="submit">I'm Feeling Lucky</button>
</form>
);
};
export default Search;
看看那个Search
组件,是不是很棒?点击 Storybook 中的提交按钮,却报错,因为你没有后端来处理它。我感觉它基本完成了……也可能还没完成。
不过我对它的结构很满意,功能方面也满足了我们所有的需求。接下来我们来设计一下样式,让它看起来和感觉都跟上进度。
步骤 02:CSS 结构
如果您不熟悉 Tailwind CSS,我建议您先阅读他们的文档,熟悉一下其语法。如果您熟悉 CSS,应该会发现它非常简单,大部分内容都是一些方便的简写。只需使用搜索栏ctrl + F
即可快速找到您所需的 Tailwind 版本。
坦白说:我使用 Tailwind 总共大概……48 小时了。它对我来说也是全新的!但我承认这一点,这并非我的缺点,而是一个优点,因为它表明,当你已经掌握了基础知识后,学习起来是多么简单。
我选择 Tailwind 有两个原因:易于开发(快速获得样式)和一致性(基本主题和预设值有助于确保我们应用程序中的不同部分看起来和感觉相同)。
说了这么多,现在就开始添加这些类吧!这个组件和上面一样,只是添加了一些 Tailwind 样式(以及一个按钮的包装元素)。
components/utility/base/Search.tsx
export interface ISearch {}
const Search: React.FC<ISearch> = () => {
return (
<form className="flex flex-col items-center gap-y-5">
<input
type="text"
className="rounded-full border-2 w-5/6 sm:w-96 h-12 px-3"
/>
<div className="space-x-3">
<button
type="submit"
className="border-0 p-2 px-6 bg-slate-100 rounded-md"
>
Google Search
</button>
<button
type="submit"
className="border-0 p-2 px-6 bg-slate-100 rounded-md"
>
I'm Feeling Lucky
</button>
</div>
</form>
);
};
export default Search;
我们可以将按钮上的重复类抽象为单独的@apply
指令,以避免重复。
注意:请通读 Tailwind关于这个概念的极其出色的文档,因为它讨论了在很多情况下该@apply
解决方案实际上可以降低未来的可维护性,因此您只需首先确保它是正确的决定。
我在这里使用它是因为我只是希望你了解它以及知道如何去做,其次他们使用全局按钮样式的示例作为应该使用它的时间之一,所以我有信心在这个例子中使用它。
我们只需要删除那些重复的按钮样式并将它们放入pages/global.css
并替换为实际的类名,如下所示:
pages/global.css
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.btn-primary {
@apply border-0 p-2 px-6 bg-slate-100 rounded-md;
}
}
components/utility/base/Search.tsx
export interface ISearch {}
const Search: React.FC<ISearch> = () => {
return (
<form className="flex flex-col items-center gap-y-5">
<input
type="text"
className="rounded-full border-2 w-5/6 sm:w-96 h-12 px-3"
/>
<div className="space-x-3">
<button type="submit" className="btn-primary">
Google Search
</button>
<button type="submit" className="btn-primary">
I'm Feeling Lucky
</button>
</div>
</form>
);
};
export default Search;
太棒了!我们的Search
组件终于在视觉上完成了(我选择不使用放大镜图标,因为它嵌入在输入元素中,这会使 CSS 比本教程的预期范围更复杂一些。)
尝试使用 Storybook 中的屏幕尺寸按钮(您可以sm
在屏幕截图中看到它已设置为)在不同的移动断点处进行测试。请注意,我们在输入端使用了默认的 5/6 宽度,但sm:w-96
一旦屏幕开始拉伸,我们就会将其设置为 ,以防止屏幕变得过大。
简化响应式设计是 Tailwind 真正擅长的事情之一。
搜索步骤 03:逻辑和状态
最后一部分是实现搜索状态的管理(基本上跟踪用户迄今为止所写的内容)。
最简单的方法是使用useState钩子。
(再次提醒,这不是 React 教程,如果你不熟悉,useState
那么你可能太快进入了 Next.js。不用担心!你很快就能学会,新的React 文档专注于 hooks,这可能是直接从源代码学习的最佳方式)
components/utility/base/Search.tsx
import { useState } from 'react';
export interface ISearch {}
const Search: React.FC<ISearch> = () => {
const [searchTerm, setSearchTerm] = useState<string>();
return (
<form
className="flex flex-col items-center gap-y-5"
onSubmit={(e) => {
e.preventDefault();
alert(`Action requested. Search for term: ${searchTerm}`);
}}
>
<input
type="text"
className="rounded-full border-2 w-5/6 sm:w-96 h-12 px-3"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className="space-x-3">
<button type="submit" className="btn-primary">
Google Search
</button>
<button type="submit" className="btn-primary">
I'm Feeling Lucky
</button>
</div>
</form>
);
};
export default Search;
以上代码将允许您跟踪搜索表单中searchTerm
变量的变化并做出响应。我还添加了一个基于 JavaScript 的表单处理程序(与默认的 HTML 行为不同),以便我们以后需要时使用。这些preventDefault
步骤阻止了正常的表单提交行为,即向服务器发起 POST 请求。
目前,我们不确定搜索词是否需要在应用的其他地方进行管理(其他组件可能需要读取它),或者如何提交表单。通常情况下,这属于规划过程的一部分,我会在编写代码之前就了解,但我在这里包含这个默认行为,是为了作为示例展示,如果需要,我们稍后可以如何进行重构。
我们的组件目前就完成了,Search
直到我们进一步了解它的功能为止。除了alert()
它似乎完成了我们需要的所有功能之外,并且在所有断点处渲染都没有视觉问题,所以我们可以认为它暂时完成了(通常情况下,你应该更新你的工单并提交给 QA 审核,确认执行结果与设计相符)。
是时候承诺我们的进步了git commit -m 'feat: create Search component'
如果您想与本教程的这一步保持一致,请克隆存储库并使用git checkout 676a71b50755d859f46a12e54f8ea3484bf1f208
。
前端:页眉和页脚
我们要在这里稍微加快速度,以将剩余的基本组件安装到位。
我决定暂时将Header
和构建为单独的组件。它们之间肯定存在一些共享的行为,可以抽象成各自的组件(例如,在屏幕两侧用 flex 水平分隔成一行的链接/按钮)。Footer
space-between
然而,仍然有很多独特之处,例如内容、位置和背景颜色。为了简单起见,我决定在本演示中将它们分开。
让我们开始建造吧。
记住,在每种情况下我们都在使用BaseTemplate。Header
故事的标题是navigation/Header
。
components/navigation/header/Header.tsx
import Link from 'next/link';
export interface IHeader extends React.ComponentPropsWithoutRef<'header'> {}
const Header: React.FC<IHeader> = ({ className, ...headerProps }) => {
return (
<header
{...headerProps}
className={`w-full flex flex-row justify-between ${className}`}
>
<div className="space-x-5 m-5">
<Link href="/">
<a className="hover:underline">About</a>
</Link>
<Link href="/">
<a className="hover:underline">Store</a>
</Link>
</div>
<div className="space-x-5 m-5">
<Link href="/">
<a className="hover:underline hidden sm:inline">Gmail</a>
</Link>
<Link href="/">
<a className="hover:underline hidden sm:inline">Images</a>
</Link>
<button className="border-1 p-2 px-4 sm:px-6 bg-blue-500 rounded text-white">
Sign In
</button>
</div>
</header>
);
};
export default Header;
上述功能的一个很酷的功能是,Gmail 和图片链接在最小屏幕尺寸下会消失。在实际应用中,我们会有一个包含这些项目的菜单,这样在移动设备上就不会无法访问它们,但在更大的屏幕上,我们可以通过快捷方式访问它们。
您可能还会注意到 Next.js 提供了一个特殊<Link />
组件,可以替代<a>
锚标签。这些链接是 Next 中路由之间保持状态所必需的,我们稍后会讲到。点击此处了解更多信息。
现在我们转到页脚。
组件/导航/页眉/页脚.tsx
export interface IFooter extends React.ComponentPropsWithoutRef<'footer'> {}
const Footer: React.FC<IFooter> = ({ className, ...footerProps }) => {
return (
<footer
{...footerProps}
className={`w-full p-5 bg-slate-100 text-slate-500 ${className}`}
>
<p>Canada</p>
</footer>
);
};
export default Footer;
我们的需求中提到只需要一个页脚。目前,我们将其值硬编码为Canada
,但我们可以稍后再修改。现在只需关注样式即可。
前端:布局
假设您一直在关注上一篇博客/教程,那么您已经在 中准备好布局组件了components/layouts/primary/PrimaryLayout.tsx
。这很重要,因为我们已经将该布局设置为在页面路由之间保持不变,因此当您从一个页面转换到另一个页面时,它不会重新加载相同的布局和导航栏。
您可以完全删除注释components/layouts/sidebar
,我们的新Header
注释Footer
将替换它。请记住在代码SidebarLayout
导入的其他位置删除它。您也可以pages/about.tsx
出于同样的原因删除它。这只是一个演示路由的示例,在我们的应用中不再需要。
至于PrimaryLayout.tsx
我们将按如下方式更新它(首先删除或空白PrimaryLayout.module.css
),然后:
components/layouts/primary/PrimaryLayout.tsx
import Head from 'next/head';
import Footer from '../../navigation/footer/Footer';
import Header from '../../navigation/header/Header';
export interface IPrimaryLayout {}
const PrimaryLayout: React.FC<IPrimaryLayout> = ({ children }) => {
return (
<>
<Head>
<title>NextJs Fullstack App Template</title>
</Head>
<div className="min-h-screen flex flex-col items-center">
<Header />
<main>{children}</main>
<div className="m-auto" />
<Footer />
</div>
</>
);
};
export default PrimaryLayout;
布局完成后,我们就可以构建实际的主页了。
Next.js 处理路由的方式非常简单直接,开箱即用。与传统的 Web 服务器类似,您只需创建目录即可。
您创建的目录结构将与您网站的路径结构相匹配,并且它加载的页面只是该目录内的内容,就像 Web 服务器默认index.tsx
查找的一样。index.html
/
对于我们网站基础路由可访问的主页,我们只需使用pages.index.tsx
。我们已经创建了页眉、页脚、搜索组件和布局,因此主页需要做的就是将它们放在一起,并添加徽标和语言切换链接。
pages/index.tsx
import Image from 'next/image';
import Link from 'next/link';
import { useRouter } from 'next/router';
import PrimaryLayout from '../components/layouts/primary/PrimaryLayout';
import Search from '../components/utility/search/Search';
import { NextPageWithLayout } from './page';
const Home: NextPageWithLayout = () => {
const { locale } = useRouter();
return (
<section className="flex flex-col items-center gap-y-5 mt-12 sm:mt-36">
<Image
src="/Google.png"
alt="Google Logo"
width={272}
height={92}
priority
/>
<Search />
<p>
Google offered in:{' '}
<Link href="/" locale={locale === 'en' ? 'fr' : 'en'}>
<a className="underline text-blue-600"> Français</a>
</Link>
</p>
</section>
);
};
export default Home;
Home.getLayout = (page) => {
return <PrimaryLayout>{page}</PrimaryLayout>;
};
(请注意,我已经从Wikipedia 页面下载了此版本的 Google 徽标,对其进行了命名并将其放在项目的Google.png
根目录中)public
这里展示了两个新的 Next.js 特定组件,我想介绍一下:
- 链接- Next 提供了一种特殊的链接,用作
<a>
锚标签的增强版。您仍然可以使用锚标签,但通过将其包裹在 中<Link>
,href
Next 将以一种特殊的方式处理对该链接的点击,从而保留应用程序的状态,而无需完整的页面加载和刷新(以及文档中描述的其他好处)。
我们还利用了useRouterlocale
钩子中的值来高效地处理语言环境之间的切换。您可以自行尝试一下(由于您无法访问 Storybook 中的路由,因此需要运行服务器进行测试),但它非常适合在不同语言之间切换。yarn dev
请记住,我们应用的可用语言环境可以在字段中自定义next.config.js
。i18n
目前我们没有任何翻译,因此只有 URL 可以切换(更新文本副本以提供i18n
支持将在未来的教程中讨论)。
- 图像- Web 开发中的图像处理异常复杂,因此,Next 创建了一个特殊
<Image>
标签来替代标准标签<img>
,它有助于在构建时优化服务器上的图像,并确定最适合提供给用户的图像。它最大的直接好处是加载时间(质量优化,例如 PNG 到 WEBP 的转换)以及解决累积布局偏移问题。我强烈建议您点击文档链接了解更多信息。在本例中,我们仅使用了一小部分可用功能。
除了图像组件 API 文档之外,Next 还包括一个专门的部分,讨论如何管理图像优化,非常值得一读。
感谢一些方便的 Tailwind 类,通过上述版本,pages/index.tsx
我们现在拥有一个完全适合桌面和移动设备的(简化)Google 主页克隆版,您可以在开发服务器上查看。
(可选)Pages 故事书
有人可能会说 Storybook 并不适合测试完整的页面。它更注重单个组件,而不是所有组件的完整集成。
尽管如此,Storybook 确实完全支持页面并提供了如何处理页面的建议,因此考虑到这一点,如果您想在 Storybook 中测试您的页面,那么我将向您展示您需要的工具(在此阶段)以使其正常工作。
主要的挑战始终是模拟函数依赖。例如,Next 的路由器在 Storybook 中不存在。未来的其他挑战将是身份验证和国际化。
尽管可以使用提供合理默认值的模拟函数单独管理其中的每一个,但大多数流行的函数(包括 Next 路由器)都有插件来为您处理大部分配置。
以下是如何在 Storybook 中支持 Next Router。首先安装该插件并阅读其文档。
yarn add -D storybook-addon-next-router
然后更新您的配置文件:
.storybook/main.js
module.exports = {
...
addons: [
...
'storybook-addon-next-router',
],
};
.storybook/preview.js
import { RouterContext } from 'next/dist/shared/lib/router-context';
...
export const parameters = {
..
nextRouter: {
Provider: RouterContext.Provider,
},
};
然后为你的页面创建一个故事。由于你不想将故事放在 pages 目录中,以免干扰 NExt 的路由器,从而可能导致错误,所以我__stories__
专门创建了一个目录来存放页面故事。
__stories__/pages/index.stories.tsx
import { ComponentMeta, ComponentStory } from '@storybook/react';
import Home from '../../pages';
export default {
title: 'pages/Home',
component: Home,
argTypes: {},
} as ComponentMeta<typeof Home>;
const Template: ComponentStory<typeof Home> = (args) => <Home {...args} />;
export const Base = Template.bind({});
就是这样。记住,布局(页眉和页脚)是通过 Next 单独调用的函数应用的,所以我们这里只有实际的页面内容可供测试。如果你想测试布局,请使用layouts/PrimaryLayout
story。
一切都处于良好状态,所以是时候承诺我们的进展了git commit -m 'feat: build home page'
如果您想与本教程的这一步保持一致,请克隆存储库并使用git checkout 9ff325aceb0e2096fa618d78489beec2c00dea12
。
前端:结果
我们仍然有“结果”页面要做,但好处是有很多重叠,所以我们实际上只需要构建一个自定义组件(搜索结果)以及设置布局的变体(主页位于页面中央,而结果左对齐)。
首先复制BaseTemplate
,重命名base
为search-result
,然后将的每个实例替换BaseTemplate
为SearchResult
。
components/utility/search-result/SearchResult
import Link from 'next/link';
export interface ISearchResult extends React.ComponentPropsWithoutRef<'div'> {
url: string;
title: string;
text: string;
}
const SearchResult: React.FC<ISearchResult> = ({
url,
title,
text,
className,
...divProps
}) => {
return (
<div
{...divProps}
className={`flex flex-col w-5/6 max-w-screen-md space-y-1 ${className} `}
>
<Link href={url}>
<a
className="cursor:pointer hover:underline"
target="_blank"
rel="noopener noreferrer"
>
<p>{url}</p>
<p className="text-blue-600 text-xl ">{title}</p>
</a>
</Link>
<p>{text}</p>
</div>
);
};
export default SearchResult;
然后模拟数据:
components/utility/search-result/SearchResult.mocks.ts
import { ISearchResult } from './SearchResult';
const base: ISearchResult = {
url: 'https://www.google.com',
title: 'This is a link to a search result about product or service',
text: 'The topic of this link is product or service. Description of the search result. The description might be a bit long and it will tell you everything you need to know about the search result.',
};
export const mockSearchResultProps = {
base,
};
最后将故事重命名为utility/SearchResult
Storybook 并加载,我们的组件将看起来像一个真正的 Google 搜索结果(或者对于我们的目的来说足够接近):
有了结果,我们就可以创建结果页面了。只需在目录/results
中创建一个目录,Next 会帮你处理路由。/pages
pages/results/index.tsx
import PrimaryLayout from '../../components/layouts/primary/PrimaryLayout';
import SearchResult from '../../components/utility/search-result/SearchResult';
import { mockSearchResultProps } from '../../components/utility/search-result/SearchResult.mocks';
import { NextPageWithLayout } from '../page';
const Results: NextPageWithLayout = () => {
return (
<section className="flex flex-col items-center gap-y-5">
<div className={`flex flex-col space-y-8`}>
{[...new Array(6)].map((_, idx) => {
return <SearchResult key={idx} {...mockSearchResultProps.base} />;
})}
</div>
</section>
);
};
export default Results;
Results.getLayout = (page) => {
return <PrimaryLayout justify="items-start">{page}</PrimaryLayout>;
};
由于/results
页面布局为左对齐,我们需要更新 outPrimaryLayout.tsx
以支持条件属性。我justify
在下面创建了可选属性,并使用 Typescript 为用户提供了两个选项:items-center
(default) 和items-start
。
components/layouts/primary/PrimaryLayout.tsx
import Head from 'next/head';
import Footer from '../../navigation/footer/Footer';
import Header from '../../navigation/header/Header';
export interface IPrimaryLayout extends React.ComponentPropsWithoutRef<'div'> {
justify?: 'items-center' | 'items-start';
}
const PrimaryLayout: React.FC<IPrimaryLayout> = ({
children,
justify = 'items-center',
...divProps
}) => {
return (
<>
<Head>
<title>NextJs Fullstack App Template</title>
</Head>
<div {...divProps} className={`min-h-screen flex flex-col ${justify}`}>
<Header />
<main className="px-5">{children}</main>
<div className="m-auto" />
<Footer />
</div>
</>
);
};
export default PrimaryLayout;
现在启动你的开发服务器yarn dev
并转到http://localhost:3000/results
这是承诺我们取得进展的好时机git commit -m 'feat: create results page and SearchResult component'
为了简单起见,我将从克隆版本中移除一些东西。从技术上讲,Google 的搜索结果页面仍然包含搜索栏,甚至在滚动时将其放在标题中。
您可以轻松创建该组件的修改版本,并将其作为子元素放入此页面和标题中,但就本教程而言,我们不会通过这样做真正触及任何新的 Next.js 特定主题(而这正是本教程的重点),因此为了让事情继续进展,如果您愿意,我会将其作为可选挑战留给您。
如果您想与本教程的这一步保持一致,请克隆存储库并使用git checkout 3c4cf387cfd9112fe26c5dd268c293d7c1c00f5f
。
后端规划
现在我们已经掌握了应用程序视觉方面的基本功能(我们在此阶段了解的),是时候转到后端了。
Next.js 的伟大之处在于它确实是一个完整的全栈解决方案。由于页面是在服务器上渲染的,这显然意味着您可以访问服务器环境,并且可以安全地执行诸如直接访问数据库之类的操作,而无需向客户端浏览器公开凭据。
Next.js 用于执行此操作的主要工具取决于您的后端函数是否设计为直接向正在呈现的页面提供数据,或者它们是否是仅将数据以任何形式(通常是 JSON,但不一定)返回到任何源的标准 API。
对于前者页面,我们将使用getServerSideProps,对于后者页面,我们将使用API 路由。
为了教导它们如何工作,我们将在这个例子中同时使用它们。
首先,我们来思考一下,如果我们真的要查询真实数据,我们的应用会如何工作。Google 的做法,一个非常简单的 ELI5 版本是:它会抓取网络上所有公开的数据,并对其进行索引,以便快速搜索(一个简单的例子就是按字母顺序排列)。
该索引将由 Google 存储在某种数据库中。我们姑且忽略我们虚拟的小型数据库与其使用的全球分布式数据中心之间的明显差异,将其简化为“在某个数据库中搜索现有文本”。
添加真实数据库超出了本教程的范围(尽管它将很快在以后的教程中介绍,可能会使用Prisma和PostgreSQL),所以我们将创建一个足够接近的小型模拟数据库,以便我们至少可以教授基础知识。
与 Web 开发的许多其他方面一样,一旦您掌握了基础知识,您就可以很快学会使用任何特定工具或实现这些想法。
规划后端的方法有很多,但我个人认为最重要的第一步是从数据模型开始。从这里开始,你可以构建这些数据模型之间的关系,并根据需求进行必要的修改。
如果您足够幸运,一开始就拥有一个各方都一致的坚如磐石的数据模型,以及一个强制正确性的模式,那么您将处于非常有利的位置来构建您的应用程序。
在我们的案例中,我们可以控制数据(因为我们正在创建它),因此我将简单地设计它以与标准 Google 搜索结果提供的信息保持一致:
我们在构建SearchResult
组件时就已经开始了这项工作,因此为了简单起见,我将坚持使用这些值。您当然可以提出description
比更贴切的说法text
。再次强调,您可以自由地设计您的架构,无需完全照搬我之前的做法。
现在我们已经决定了如何构建搜索数据的模型,我们只需要决定应用程序如何将这些数据传送到结果页面。
我的旅行计划如下:
home
用户在页面输入表单中输入搜索值词- 表单提交重定向到
results
页面,并将用户的搜索值作为 URL 中的查询参数 - 在服务器端渲染时,
results
页面将在函数内部查询 API 路由(我们将其称为/api/search
)getServerSideProps
,该函数从 URL 查询参数中提取搜索值并将其传递给 API 路由。 - API 路由将使用搜索值查询我们的模拟数据库,并将按搜索值过滤的结果返回给页面
getServerSideProps
上的函数results
。 getServerSideProps
页面上的功能将results
接收其搜索结果,然后将这些结果作为道具传递给results
页面组件,以便为用户呈现数据。
需要注意的是,从技术上讲,在这个流程中,results
页面可以直接在 中查询数据库getServerSideProps
。然而,我选择不这样做主要有两个原因:
- 在实际应用中,其他页面甚至外部服务可能有理由使用搜索值来查询搜索结果,因此我不想将搜索逻辑专门绑定到
results
页面 getServerSideProps
就我个人而言,我想在本教程中演示如何使用 API 路由。
现在所有规划都已就绪,我想我们已经准备好开始建设了。
后端:搜索数据
我们将从模拟数据库开始。在使用 Node/Javascript/Typescript 等语言时,大多数查询真实数据库的操作都是使用 Node 驱动程序,这些驱动程序会返回 JSON 格式的结果。JSON 是 Web 上最流行的数据传输格式之一(即使不是最流行的格式),因此,如果您的应用能够处理 JSON 负载,那么您将能够很好地处理来自不同来源的数据。
这就是我选择将模拟数据放入 JSON 文件中的原因。
我们将开始使用/lib
根目录中的目录。如果您还记得最初的教程,那就是我创建的文件夹,它将存储我们应用程序处理的所有域和业务逻辑以及数据。
如果“组件”和“页面”是前端目录,那么“lib”就是我们的后端目录(尽管我们将从两方面利用它以获得随之而来的所有好处,因此我们正在构建全栈应用程序)。
/search
在 中创建一个目录/lib
。我们将在这里放置与搜索数据和结果概念相关的所有逻辑。在其中,我们将创建一个名为 的文件database.json
,并使用以下虚拟数据填充它:
lib/search/database.json
[
{
"url": "https://en.wikipedia.org/wiki/Cat",
"title": "This is a link to a search result about cats",
"text": "Did you know their whiskers can sense vibrations in the air? Description of the search result. The description might be a bit long and it will tell you everything you need to know about the search result."
},
{
"url": "https://en.wikipedia.org/wiki/Dog",
"title": "This is a link to a search result about dogs",
"text": "They sure do love to bark. Description of the search result. The description might be a bit long and it will tell you everything you need to know about the search result."
},
{
"url": "https://en.wikipedia.org/wiki/Cats_%26_Dogs",
"title": "This is a link to a search result about both cats and dogs",
"text": "Both of them have tails. Description of the search result. The description might be a bit long and it will tell you everything you need to know about the search result."
},
{
"url": "https://en.wikipedia.org/wiki/Broccoli",
"title": "This is a link to a search result about broccoli",
"text": "Broccoli was invented by crossing cauliflower with pea seeds. Description of the search result. The description might be a bit long and it will tell you everything you need to know about the search result."
},
{
"url": "https://en.wikipedia.org/wiki/Cauliflower",
"title": "This is a link to a search result about cauliflower",
"text": "Who invented cauliflower? Description of the search result. The description might be a bit long and it will tell you everything you need to know about the search result."
}
]
我稍微修改了标题和文本值,以便我们能够对数据执行真正的搜索并查看过滤后的结果。
我还将创建一个与此数据模型兼容的 Typescript 接口。我们将在应用程序中的各个地方使用它,以最大限度地减少处理这些数据时的错误。
lib/search/types.ts
export interface ISearchData {
url: string;
title: string;
text: string;
}
现在,此接口是应用中所有与搜索数据相关的真实来源。如果我们每次更改或添加新字段,都会在此处添加,然后我希望看到应用中所有使用该数据的 API 和组件立即中断,并发出警告,提示我必须更新它们以处理架构更改。
因此,有一个地方我需要更新。我们的SearchResult.tsx
组件有其自己的显式 url / title / text 类型。与其这样,我打算重构它来扩展这个类型,让它们始终保持一致:
components/utility/search-result/SearchResult.tsx
import Link from 'next/link';
import { ISearchData } from '../../../lib/search/types';
export type ISearchResult = ISearchData & React.ComponentPropsWithoutRef<'div'>;
...
组件省略号下面的其他所有内容都相同,只有类型和导入已更新。
后端:API 路由
我将从数据入手,逐步解决。我已经在模拟数据库中创建了数据。下一个连接到该数据的点是我们的API 路由,它将加载数据,并向查询者返回经过筛选的版本。
Next 中的所有 API 路由默认都以/api
前缀开头,以区别于您预期访问并接收 HTML 页面的路由。我们的搜索查询 API 将是/api/search
,因此现在创建该结构以及一个index.ts
文件。由于这是一个处理数据的 API,而不是 React 组件,因此我们只需使用.ts
扩展名即可:
/pages/api/search/index.ts
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next';
import database from '../../../lib/search/database.json';
import { ISearchData } from '../../../lib/search/types';
interface IApiSearchRequest extends NextApiRequest {
body: { searchTerm?: string };
}
export type IApiSearchResponseData = ISearchData[];
export default function handler(
req: IApiSearchRequest,
res: NextApiResponse<IApiSearchResponseData>
) {
const {
body: { searchTerm },
} = req;
if (req.method === 'POST' && searchTerm && searchTerm.length > 0) {
// Creates a regex search pattern for a case insensitive match from the user's search term
const searchPattern = new RegExp(searchTerm, 'i');
const filteredResults = database.filter((result) => {
return (
// Check the user's search term again either the title or the text of the database entry
searchPattern.test(result.title) || searchPattern.test(result.text)
);
});
res.status(200).json(filteredResults);
} else {
res.status(400).json([]);
}
}
让我们来分析一下上述内容。
我们先从 开始database
。如今我们拥有如此强大的工具,真是太幸运了。默认情况下,Typescript 能够处理原始 JSON 文件的导入,甚至可以根据它在文件中字段上检测到的模式为我们提供类型。我们甚至不需要显式地进行类型转换。
此行为是通过项目根目录中的文件中esModuleInterop
和resolveJsonModule
值启用的tsconfig.json
,这两个值在我们使用的 Next.js Typescript 模板中默认启用。
第二,我们决定期望用户searchTerm
在请求正文中POST
获取搜索结果。如果POST
请求本身不存在,或者 searchTerm 缺失或为空,我们将返回400 Bad Request
一个空的 JSON 数组,以指示由于格式错误或请求无效而导致没有结果。这样做的好处是,无论搜索条件是什么,我们都能处理响应中预期的数组,无论其是否为空。
最后一个关键部分是实际搜索的逻辑。我们将用户的搜索词转换为带有 不区分大小写标志的 JavaScript正则表达式(又称 regex)对象。"i"
如果您不熟悉或不熟悉正则表达式,则可以选择另一种方法来实现相同的结果,即检查:
result.title.toLowerCase().includes(searchTerm.toLowerCase());
字符串比较的结果用于筛选出所有搜索结果的完整列表。显然,如果我们使用真实的网络索引,我们不可能在处理之前加载所有可能的搜索结果,但这只是一个例子,而且我们确切地知道当前数据的大小,因此我们的实现在这个范围内是安全的。
现在,在继续下一步之前,我们先测试一下我们的端点。如果您不熟悉 API 测试,我建议您研究一下市面上一些优秀的工具。Postman曾经是最好的,但现在他们开始通过注册墙来严格限制一切。不过,它仍然有一个可用的免费版本。Insomnia也是一个不错的选择。
如果您熟悉命令行并且您使用的是 Linux 或 Mac 机器(或带有命令行版本的 Windows),那么最快的方法就是使用cURL。
下面是向您的 API 发出搜索术语的命令dog
。
我在屏幕截图中添加了几个,echo;
只是为了添加换行符以使其更具可读性——如果您想查找它们并变得非常花哨,也有工具可以在命令行上显示格式化的 JSON,但我们现在关心的是有效载荷是否返回并且是否正确。
curl -X POST -H "Content-type: application/json" -H "Accept: application/json" -d '{"searchTerm":"dog"}' "http://localhost:3000/api/search"
这就是我们的结果!仔细看的话,它从我们的模拟数据库中返回了 2/5 个条目,一个是关于“狗”的,另一个是关于“猫和狗”的。
由于我们的搜索词是,dog
我想说这是一个好兆头,事情进展顺利。
让我们转换一下思路,设置您的results
页面以使用此端点并显示搜索结果。
Next.js 中的静态和动态页面
现在我们准备介绍第一个getServerSideProps函数。我们将把它添加到结果页面,以便从初始请求的 URL 中获取搜索词,并使用它来获取渲染页面所需的搜索数据。
一旦引入此功能,页面就不再是静态生成的候选对象,这是 Next 中页面的默认行为。如果可能的话,页面将在您构建应用时始终生成,并假设它们对每个用户的外观始终相同。我们的home
页面就是一个例子。
然而,我们的results
页面会根据搜索词的不同而不断变化,因此 Next 必须在每次用户请求时动态渲染该页面。这样做的好处显然是动态数据,而缺点则是增加了页面加载时间。
我们将首先通过getServerSideProps
使用一个简单的虚拟道具来对该函数进行简单的测试。
components/utility/search-result/SearchResult.tsx
import { GetServerSideProps } from 'next';
import PrimaryLayout from '../../components/layouts/primary/PrimaryLayout';
import SearchResult from '../../components/utility/search-result/SearchResult';
import { ISearchData } from '../../lib/search/types';
import { IApiSearchResponseData } from '../api/search';
import { NextPageWithLayout } from '../page';
export interface IResults {
searchResults: ISearchData[];
}
export const getServerSideProps: GetServerSideProps<IResults> = async ({
query,
}) => {
let searchResults: IApiSearchResponseData = [];
// 1
const searchTerm = query.search;
if (searchTerm && searchTerm.length > 0) {
// 2
const response = await fetch(`http://localhost:3000/api/search`, {
body: JSON.stringify({ searchTerm }),
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
});
searchResults = await response.json();
}
return {
// 3
props: {
// Will be passed to the page component as props
searchResults,
},
};
};
const Results: NextPageWithLayout<IResults> = ({ searchResults }) => {
const hasResults = searchResults.length > 0;
return (
<>
<section className="flex flex-col items-center gap-y-5">
{hasResults ? (
<div className={`flex flex-col space-y-8`}>
{searchResults.map((result, idx) => {
// 4
return <SearchResult key={idx} {...result} />;
})}
</div>
) : (
<p>No results found.</p>
)}
</section>
</>
);
};
export default Results;
Results.getLayout = (page) => {
return <PrimaryLayout justify="items-start">{page}</PrimaryLayout>;
};
希望您能够理解上面示例中数据是如何传递的。如果您还没有阅读文档,我建议您阅读一下。
在我们讨论实际页面的作用之前,需要理解和解释一些关键的事情。
首先,需要注意的是,这getServerSideProps
是一个特殊函数,其名称必须与 Next 在页面构建过程中自动运行的名称完全一致。因此,您不应该期望能够在 Storybook 中为此页面创建 Story。
不妨想想这是一件好事,我们讨论的是从 API 中获取数据,而现在我们已经偏离了 Storybook 的真正用途。理想情况下,它不应该通过 API 调用数据。当然,我们可以创建一个模拟函数版本getServerSideProps
,并配置 Storybook 来使用它,但这超出了本教程的范围。
目前,当我们在后端工作时,我们将通过运行对开发版本进行所有测试yarn dev
。
在运行开发服务器之前,我们先来聊聊具体发生了什么。这里有很多内容,所以我在上面的代码中添加了四个编号为 1-2-3-4 的注释来进行说明。
-
query
上下文对象中接收该字段的字段将getServerSideProps
包含来自 URL 的查询参数。因此,此页面期望接收类似这样的 URL,/results?search=something
并且该“内容”将可用,就像query.search
我们提取到searchTerm
变量中那样。 -
这里我们正在查询我们自己创建的 APi!值和标头与我们在 cURL 测试中使用的相同。搜索词是我们从 URL 中提取的内容,我们将结果保存在
searchResults
一个默认为空的数组中。 -
我们必须返回一个包含字段值的对象,这就是页面组件将接收的内容。所有这些都是类型安全的,包括返回值,请密切关注接口在整个过程中使用的
props
三个地方。IResults
-
我们获取返回的所有搜索数据并将其映射到我们的
SearchResult
组件。我们已经知道返回数据与预期的 props 匹配,因此我们可以使用扩展运算符轻松地一次性传递每个 props。
现在我们准备运行
yarn dev
并打开 URL http://localhost:3000/results?search=dog
注意到我添加到 URL 中的查询参数了吗?它起作用了!试着把它改成其他关键词,看看结果会不会有所不同。模拟数据库中的一些示例是broccoli
和bark
。
是时候承诺我们的进步了git commit -m 'feat: implement search API and results page query'
如果您想与本教程的这一步保持一致,请克隆存储库并使用git checkout f7321a266c51528d2369bf5d5862bc4ace4fdfcb
。
前端收尾工作
我将不得不稍微回溯一下,结果发现在转到后端之前我忘记了一项前端任务。
我们需要配置我们的Search
组件以重定向到结果页面,并在重定向时将搜索词放入 URL 中,以便我们的搜索栏能够真正发挥作用。
这很容易做到,组件所需的更新Search.tsx
如下所示:
components/utility/search/Search.tsx
import { useRouter } from 'next/router';
import { useState } from 'react';
export interface ISearch {}
const Search: React.FC<ISearch> = () => {
// 1
const router = useRouter();
const [searchTerm, setSearchTerm] = useState<string>('');
return (
<form
className="flex flex-col items-center gap-y-5"
onSubmit={(e) => {
e.preventDefault();
// 2
router.push(`/results?search=${searchTerm}`);
}}
>
<input
type="text"
className="rounded-full border-2 w-5/6 sm:w-96 h-12 px-3"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className="space-x-3">
<button type="submit" className="btn-primary">
Google Search
</button>
<button
onClick={() => alert('FEATURE COMING SOON!')}
className="btn-primary"
>
I'm Feeling Lucky
</button>
</div>
</form>
);
};
export default Search;
我在代码上添加了一些编号注释以供参考。
-
我们导入 Next 的路由器,它允许我们导航到不同的页面,同时保留所有状态。
-
在
onSubmit
函数中我们使用路由器的push
功能导航到结果页面,并将搜索查询参数设置searchTerm
为输入字段设置的当前值。
我还添加了一个愚蠢的功能即将推出!提醒“我感觉很幸运”按钮,但不要对此抱有太大希望。
我觉得我们终于可以测试一下整个应用了。启动开发服务器yarn dev
,然后访问http://localhost:3000
这有多酷?我们刚刚开发出了自己的搜索引擎。现在准备好去谷歌或NASA工作了吗?
需要记住几个小功能:点击“主页”链接即可返回主页并再次搜索。您也可以通过输入值并按下“Enter”键进行搜索,因为它是一个<form>
元素,浏览器会通过触发 来自动处理该行为onSubmit
。
是时候承诺我们的进步了git commit -m 'feat: connect search input to results page'
如果您想与本教程的这一步保持一致,请克隆存储库并使用git checkout
。
主题和设计系统
尽管根据本文的范围,该应用程序是“功能齐全的”,但还有一个我认为绝对关键的最后一个相关主题我想谈谈:主题。
我上面创建的链接并不特定于 Tailwind 或任何特定主题的实现,因为在将其应用到我们的应用程序之前,我想首先讨论主题作为一个概念的重要性。
随着你变得更有经验并构建更多的应用程序,你会发现你的 CSS 自然开始看起来像这样:
.card {
background-color: red;
padding: 12px;
}
.nav-bar {
background-color: red;
}
.content-section {
padding: 12px 24px;
}
.title {
font-size: 24px;
}
这是一个很牵强的例子,但你大概能明白我的意思。随着你的应用和 CSS 的增长,你最终会一遍又一遍地使用相同的值。
当然,使用现代 CSS,您可以执行类似的操作--primary-color: red;
,background-color: var(--primary-color)
这本身已经是一个很大的进步,但通常您想要的是创建一个一致的设计系统,该系统会自动被应用程序的各个部分用作默认值,甚至不必明确说明。
每个需要颜色的核心组件都应该默认使用,无需您明确指定。只有在需要覆盖颜色时才需要这样做。间距也类似,如果元素之间的间距都是某个值(例如或)--primary-color
的倍数,您的应用会感觉更加一致。4px
8px
这就是创建设计系统(例如 Material Design)的目的。为你的数字产品构建一致的外观,并围绕它构建一个有意义的框架。一个好的设计系统将带来更一致、更可预测的用户体验,并为开发者提供实现它所需的最小阻力路径。
这只是一个非常基本的介绍,我自己绝对不是设计师,但我喜欢与优秀的设计师一起工作,因为他们使我的工作更轻松,让我们的产品更好。
本教程的最后一部分将介绍 Tailwind CSS 设计系统的具体实现以及如何使用它来使您的应用程序变得更好。
使用 Tailwind 进行设计系统
和所有事情一样,在开始之前,我总是建议你先阅读文档。Tailwind 的文档非常棒,可以帮助你快速上手。
xs
sm
md
实际上,我们已经在 Tailwind 安装部分创建了一个基本主题,并在其中设置了应用不同屏幕断点的值。该主题已存在tailwind.config.js
,我们将对其进行扩展。
我再次访问了Google,看看是否可以进行一些小的更改以使样式更加一致,一些简单的更改是:Google 使用Arial
字体,并且搜索栏比我们默认提供的最大 Tailwind 静态宽度稍宽一些(w-96
)
因此,我们不需要明确地覆盖我们的组件,而是更新我们的主题,以便应用程序的其余部分可以从这些约定中受益!
tailwind.config.js
module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}',
],
theme: {
// Ensure these match with .storybook/preview.js
screens: {
xs: '375px',
sm: '600px',
md: '900px',
lg: '1200px',
xl: '1536px',
},
fontFamily: {
sans: ['Arial', 'sans-serif'],
serif: ['Garamond', 'serif'],
},
extend: {
colors: {
blue: {
500: '#1a73e8',
},
},
spacing: {
128: '32rem',
},
},
},
plugins: [],
};
我fontFamily
通过在对象上设置值来全局更新了theme
。在该主题对象中,我还有一个名为 的嵌套对象extends
。
我在主题上放置的任何值都将完全取代 Tailwind 的默认值,但在内部对相同值设置值extends
将在现有值的基础上添加这些值。
blue-500
我使用 Firefox 中方便的吸管(更多工具 -> 吸管)将颜色覆盖为 Google 在其按钮上使用的实际颜色。
这就是我对新宽度 128 所做的操作,它将转换为Tailwind 类。让我们在组件上w-128
替换这个w-96
值:w-128
Search
components/utility/search/Search.tsx
...
<input
type="text"
className="rounded-full border-2 w-5/6 sm:w-128 h-12 px-3"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
...
就是这样!
主题还有很多我们这里没有提到的酷炫功能。颜色相关的文档值得一看,使用自引用函数获取主题值的概念也值得一看。
例如,如果您想设置一种blue
颜色,然后在主题本身上引用背景上的确切颜色theme('color.blue')
。
页面间共享状态
对于大型 Next.js 应用程序至关重要的一个主题是我们尚未解决的,那就是在页面之间共享状态的能力。
在传统的单页 React 应用中,传递 props 或将应用包装在上下文中非常简单,但是在转换到完全独立的页面时,下一步该如何处理呢?
答案是,我们利用顶层_app.tsx
组件来管理状态。只要我们使用 Next 的内置路由器或特殊的 Next<Link>
组件,Next 就能处理应用中页面间状态的持久化。
React 状态的通用规则仍然适用:如果用户刷新页面或手动输入 URL,状态将会丢失。在这种情况下,如果您需要持久化,可以考虑localStorage或包含本地存储支持的状态管理打包解决方案,例如Recoil。
为了快速演示如何使用,我们将实现一个模拟的“身份验证”状态,该状态由“登录”按钮控制。我们的目标是,即使点击搜索按钮并导航到页面,您的身份验证状态仍然保持不变/results
。
我们将使用React 上下文来实现这一点。将来,当你实现真正的身份验证服务时,你甚至可以将它连接到我们将要创建的组件,并用真实数据替换模拟数据,同时仍然使用我们的上下文解决方案来控制 UI 状态。
首先,我认为是时候创建一个额外的根目录了。我们需要一个地方来存储 React 特定的逻辑(比如上下文和自定义钩子),它不同于纯 UI(组件)或领域逻辑和服务(lib)。
合理的项目结构至关重要,网上有很多很棒的资源。我希望在过于紧凑(一个目录中包含太多不相关的内容)和过于抽象(无论多小,每个概念都包含一个目录)之间找到合适的平衡。
对于我们的用例,我将创建一个名为 的根目录/state
,用于保存自定义 hooks 和 React 上下文。这两者通常紧密相关,所以我暂时将它们放在一起。
我将在其中/state
创建一个名为的目录,/auth
该目录将管理与我们应用程序中的身份验证状态相关的所有内容。
state/auth/AuthContext.tsx
import { createContext, useState } from 'react';
interface IAuthContext {
authenticated: boolean;
login: () => void;
logOut: () => void;
}
const defaultValue: IAuthContext = {
authenticated: false,
login: () => undefined,
logOut: () => undefined,
};
const AuthContext = createContext<IAuthContext>(defaultValue);
export const AuthProvider: React.FC = ({ children }) => {
const [authenticated, setAuthenticated] = useState(
defaultValue.authenticated
);
const login = () => setAuthenticated(true);
const logOut = () => setAuthenticated(false);
return (
<AuthContext.Provider value={{ authenticated, login, logOut }}>
{children}
</AuthContext.Provider>
);
};
export default AuthContext;
上述组件将为整个应用提供上下文,任何组件都可以使用它来检查用户是否已通过身份验证才能查看特定内容。当身份验证状态发生变化时(使用我们提供的两个便捷的 login/logOut 函数之一),上下文提供程序的所有子组件都将重新渲染并更新其状态。
(请注意,当我说所有子级时,我的意思是所有子级,即使是那些不使用经过身份验证的上下文值的子级。这是一个需要理解的重要概念,如果您不熟悉这个概念,我建议您阅读更多相关内容。这是一个起点。这也是为什么像 Redux 和 Recoil 这样的全局状态管理库如此广泛使用的原因之一,因为它们有办法在需要时解决此行为)
我们将创建一个名为 的新按钮组件AuthButton
。此组件将依赖于 提供的上下文AuthContext
,因此我们需要记住,当我们在组件树的某个位置使用此按钮时,我们需要一个AuthContext.Provider
组件来使其工作——诀窍在于记住,这不仅适用于我们的应用程序,也适用于 Storybook!不过现在,我们先来构建组件。
将我们的BaseComponent
文件复制到/components/button
目录中,并将其重命名为auth
。我们将用 替换所有 ,BaseComponent
包括AuthButton
文件名。请确保将故事标题也更改为 ,buttons/AuthButton
并从模板中删除所有数据。
的结构AuthButton
已经存在,我们将把它从我们的Header
组件中提取到它自己的组件中,如下所示:
components/buttons/auth/AuthButton.tsx
import { useContext } from 'react';
import AuthContext from '../../../state/auth/AuthContext';
import styles from './AuthButton.module.css';
export interface IAuthButton extends React.ComponentPropsWithoutRef<'button'> {}
const AuthButton: React.FC<IAuthButton> = ({ className, ...buttonProps }) => {
const { authenticated, login, logOut } = useContext(AuthContext);
return (
<button
onClick={authenticated ? logOut : login}
className={`${styles.container} ${className} border-1 p-2 px-4 sm:px-6 bg-blue-500 rounded text-white w-28`}
{...buttonProps}
>
{authenticated ? 'Sign Out' : 'Sign In'}
</button>
);
};
export default AuthButton;
注意调用useContext
,这就是我们使用<AuthProvider>
上下文来包装整个应用程序的方式。我们最后再讲到这部分。下一步是将这个新的身份验证按钮用于我们的Header
:
import Link from 'next/link';
import AuthButton from '../../buttons/auth/AuthButton';
export interface IHeader extends React.ComponentPropsWithoutRef<'header'> {}
const Header: React.FC<IHeader> = ({ className, ...headerProps }) => {
return (
<header
{...headerProps}
className={`w-full flex flex-row justify-between ${className}`}
>
<div className="space-x-5 m-5">
<Link href="/">
<a className="hover:underline">Home</a>
</Link>
<Link href="/">
<a className="hover:underline">Store</a>
</Link>
</div>
<div className="space-x-5 m-5">
<Link href="/">
<a className="hover:underline hidden sm:inline">Gmail</a>
</Link>
<Link href="/">
<a className="hover:underline hidden sm:inline">Images</a>
</Link>
<AuthButton />
</div>
</header>
);
};
export default Header;
最后,我们需要更新_app.tsx
封装整个应用的组件。我们希望应用的每个部分都能访问 Auth 上下文,所以现在这里是最佳位置。
从技术上讲,每次身份验证更新时,应用程序都会重新渲染,但这是可以的,因为大概真实用户每个会话只会登录一次。
pages/_app.tsx
import type { AppProps } from 'next/app';
import { AuthProvider } from '../state/auth/AuthContext';
import './globals.css';
import { NextPageWithLayout } from './page';
interface AppPropsWithLayout extends AppProps {
Component: NextPageWithLayout;
}
function MyApp({ Component, pageProps }: AppPropsWithLayout) {
// Use the layout defined at the page level, if available
const getLayout = Component.getLayout || ((page) => page);
return <AuthProvider>{getLayout(<Component {...pageProps} />)}</AuthProvider>;
}
export default MyApp;
最后,如果我们希望在 Storybook 中运行组件时能够访问这些上下文值,我们需要创建一个包含该上下文的默认故事模板。
为此,我们使用 Storybook 装饰器。只需导出一个名为 const 的decorators
React 组件,该组件用于包装你所有的故事。
import { AuthProvider } from '../state/auth/AuthContext';
...
export const decorators = [
(Story) => (
<AuthProvider>
<Story />
</AuthProvider>
),
];
就这样!现在运行yarn dev
并加载http://localhost:3000
如果一切正常,点击“登录”按钮后,它会切换到“退出”,模拟登录网站的功能。这是切换按钮状态的基本 React 行为。
我们所做的特别之处在于,当你在搜索栏中输入一个词并点击搜索时,它会导航到一个完全不同的页面,即结果页面。但是,由于 React 身份验证上下文包装器的存在,如果你已经在主页上登录过,你的按钮仍然会显示“退出”。
这就是 Next.js 中路由之间的持久状态
总结
请记住,本教程中的所有代码作为完整包都可以在此存储库中找到。
请查看我的其他学习教程。如果您觉得其中有任何内容有用,请随时发表评论或提出问题并与他人分享: