NextJS 介绍
Next.js 是一个用于构建 React 应用程序的 React 框架。它的目标是使 React 应用的开发变得更简单、更灵活。下面是一些 Next.js 的关键特性:
服务器渲染 (SSR): Next.js 支持服务器渲染,这意味着页面可以在服务器上生成,然后再发送到浏览器,有助于提高应用程序的性能和搜索引擎优化(SEO)。
静态生成 (Static Generation): 除了服务器渲染外,Next.js 还支持静态生成,可以在构建时预先生成页面,然后将它们作为静态文件提供,这对于构建性能高效的静态网站非常有用。
自动代码拆分 (Automatic Code Splitting): Next.js 会自动将应用程序的代码拆分成小块,只加载当前页面所需的代码,提高加载速度。
热模块替换 (Hot Module Replacement): 在开发模式下,Next.js 支持热模块替换,允许在运行时更新代码,无需重新加载整个页面。
项目介绍
使用 Next.js+React,实现一个 SSR 服务器渲染的博客项目
环境搭建
技术选型
- Next.js
- Mysql
- React
- Ant Design
- typeorm
创建项目
- 首先在 github 上创建一个项目仓库,比如:nextjs-blog
- 将 nextjs-blog 仓库使用 git 拉取到本地 git clone xxx.nextjs-blog.git
- 然后进入项目目录 cd nestjs-blog
- 接着使用 next.js 提供的脚手架创建项目,这里我们使用 typescript 开发,所以使用 typescript 的模板 yarn create next-app —typescript
配置 eslint
1.安装 lint
pnpm i eslint -D -w
2.初始化
npx eslint --init
3.手动安装其他包
pnpm i -D -w typesript @typescript-eslint/eslint-plugin@latest @typescript-eslint/parser@latest
4.修改 eslint 配置
{
"env": {
"browser": true,
"es2021": true,
"node": true,
"jest": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier",
"plugin:prettier/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["@typescript-eslint", "prettier"],
"rules": {
"prettier/prettier": "error",
"no-case-declarations": "off",
"no-constant-condition": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-var-requires": "off",
"no-unused-vars": "off"
}
}
5.安装 ts lint
pnpm i -D -w @typescript-eslint/eslint-plugin
配置 prettier
1.安装 prettier
pnpm i -D -w prettier
2.新建.pretterrc.json
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": true,
"singleQuote": true,
"semi": true,
"trailingComma": "none",
"bracketSpacing": true
}
3.将 pretter 集成到 eslint 中
pnpm i -D -w eslint-config-prettier eslint-plugin-prettier
4.在 scripts 中增加 lint 命令
"lint": "eslint --ext .ts,.jsx,.tsx --fix --quiet ./packages"
5.安装 eslint pretter 两个 vscode 插件
6.在 vscode settings 中设置 format:pretter 和 on save
检查 commit
1.安装 husky
pnpm i -D -w husky
2.初始化 husky
npx husky install
3.将 lint 增加到 husky 中
npx husky add .husky/pre-commit "pnpm lint "
在 commit 的时候会执行 pnpm lint
检查 commit msg
1.安装包
pnpm i -D -w commitlint @commitlint/cli @commitlint/config-conventional
2.新建.commitlintrc.js
module.exports = {
extends: ['@commitlint/config-conventional']
};
3.集成到 husky 中
在终端执行下面命令
npx husky add .husky/commit-msg "npx --no-install commitlint -e $HUSKY_GIT_PARAMS"
TypeScript 配置
在根目录新建 tsconfig.json
{
"compileOnSave": true,
"include": ["./packages/**/*"],
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM"],
"moduleResolution": "Node",
"strict": true,
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"noEmit": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": false,
"skipLibCheck": true,
"baseUrl": "./packages",
"paths": {
"hostConfig": ["./react-dom/src/hostConfig.ts"]
}
}
}
这样,我们的项目开发环境就配置好了。
Next.js 路由介绍
看下面这张图:
从上图可以看到
在 pages 目录下来创建文件夹,文件夹的名称就代表路由。俗称约定式路由。现在很多框架都支持约定式路由,比如 Umi 框架。
普通路由
1.比如 pages/index.js,那么这个的路由就是 根路由
2.比如在 pages 下面新建 blog 文件夹,在 blog 文件夹下面新建 index.js,那此时这个文件对应的页面利用就是/blog
嵌套路由
1.在 pages 目录下新建 blog 目录,在 blog 目录下新建 first-post.js,注意此时不是 index.js,那此时的文件夹是嵌套的,那么对应的路由也是嵌套的,路由也是根据嵌套的文件夹的名称而来,所以这个 first-post.js 文件页面对应的路由就是/blog/first-post
动态路由
动态路由在实际业务中非常常见,接下来看下 next.js 中提供的动态路由。
1.在 pages 目录下新建 blog 文件夹,在文件夹下 新建 id.js,这个 id 就表示是动态路由,那展现的路由就是这个样子 /blog/:id ,这个里面的 :id 可以换成任意的路由,例如 /blog/1 , /blog/2
2.第二种是动态路由在中间,在 pages 目录下新建 id 文件夹,在 id 文件夹下面 创建 setting.js, 那此时的动态路由就是 /:id/setting, :id 就是动态,例如 /1/setting, /2/setting
3.第三种动态路由是 任意匹配的路由,在 pages 目录下新建 post 文件夹,在 post 文件夹下面新建…all.js,此时这个 …all 表现的动态路由就是 /post/ ,这个 就代表任意路由,丽日: /post/2020/id/title
实现 Layout 布局
我们开始实现整体页面的布局。这里来讲解如何实现 Layout 布局,采用上中下的布局。
上中下的布局就是:上方 就是 导航区域,中间是内容区域,下方是 底部区域。
整个系统使用 Antd Design UI 组件库。
我们先安装下 antd design
pnpm install antd
Layout
- 首先在根目录创建 components 文件夹,这里来放 各个组件。在 compoents 文件夹 新建 layout 文件夹,在 layout 文件夹新建 index.tsx。
mkdir components
cd components
mkdir layout
touch index.tsx
2.在 compoents 文件夹 新建 Navbar 文件夹,在 Navbar 文件夹新建 index.tsx,同时创建 index.module.scss
cd components
mkdir Navbar
cd Navbar
touch index.tsx
touch index.module.scss
3.在 compoents 文件夹 新建 Footer 文件夹,在 Footer 文件夹新建 index.tsx,同时创建 index.module.scss
cd components
mkdir Footer
cd Footer
touch index.tsx
touch index.module.scss
这样先把 Layout,Navbar, Footer 的架子 搭建起来。
然后开始写 Layout 的布局
在 layout/index.tsx 中写入, 中间的内容区域,由 props 的 children 来填充,这样的话 ,就实现了 上中下的布局
import type { NextPage } from 'next';
import Navbar from 'components/Navbar';
import Footer from 'components/Footer';
const Layout: NextPage = ({ children }) => {
return (
<div>
<Navbar />
<main>{children}</main>
<Footer />
</div>
);
};
export default Layout;
写好上面代码以后,需要再入口文件引入 layout
import Layout from 'components/layout';
import { NextPage } from 'next';
return (
<Layout>
<Component />
</Layout>
);
Navbar
接下来 来开发 上部导航区域
先看下要实现的效果图,如下:这里采用 flex 布局
- 先把博客系统的名称写下,在 Navbar/index.tsx 文件下
<div className={styles.navbar}>
<section className={styles.logoArea}>BLOG</section>
</div>
2.然后开始写标签,这几个标签,采用配置的方式,这里我们再 Navbar 文件夹下新建 config.ts 来 存放 这几个导航数据
interfacee NavProps {
label: string;
value: string;
}
export const navs: NavProps[] = [
{
label: '首页',
value: '/',
},
{
label: '咨询',
value: '/info',
},
{
label: '标签',
value: '/tag',
},
];
3.在 Navbar/index.tsx 拿到 config 中的导航数据,然后遍历渲染出来。
同时引入 next 提供的 link,来进行路由跳转
import Link from 'next/link';
import { navs } from './config';
<section className={styles.linkArea}>
{navs?.map((nav) => (
<Link key={nav?.label} href={nav?.value}>
<a className={pathname === nav?.value ? styles.active : ''}>{nav?.label}</a>
</Link>
))}
</section>;
4.最后再添加两个 写文章 和登录的按钮
<section className={styles.operationArea}>
<Button onClick={handleGotoEditorPage}>写文章</Button>
<Button type="primary" onClick={handleLogin}>
登录
</Button>
</section>
5.最后整体的样式文件如下:
.navbar {
height: 60px;
background-color: #fff;
border-bottom: 1px solid #f1f1f1;
display: flex;
align-items: center;
justify-content: center;
.logoArea {
font-size: 30px;
font-weight: bolder;
margin-right: 60px;
}
.linkArea {
a {
font-size: 18px;
padding: 0 20px;
color: #515767;
}
.active {
color: #1e80ff;
}
}
.operationArea {
margin-left: 150px;
button {
margin-right: 20px;
}
}
}
这样 导航部分的 初始页面就完成了。
Footer
接下来简单写下 Footer 部分
在 components/Footer/index.tsx 中写入如下代码:
import type { NextPage } from 'next';
import styles from './index.module.scss';
const Footer: NextPage = () => {
return (
<div className={styles.footer}>
<p>博客系统</p>
</div>
);
};
export default Footer;
样式文件代码:
.footer {
text-align: center;
color: #72777b;
padding: 20px;
}
这样简单的 footer 部分就完成了
最后看下 这样写下来的效果
登录模块
接下来我们要开发登录模块的开发,首先看下效果图:
登录弹窗
1.首先在 components 创建 Login 文件夹,在 Login 文件夹创建 index.tsx 文件和 index.modules.scss
cd components
mkdir Login
cd Login
touch index.tsx
touch index.module.scss
2.在 Navbar 组件中的 登录按钮 添加点击事件
<Button type="primary" onClick={handleLogin}>
登录
</Button>
3.定义一个 state 来控制 登录弹窗 是否显示。
const [isShowLogin, setIsShowLogin] = useState(false);
4.将 isShowLogin 当做 props 传入 登录组件
<Login isShowLogin={isShowLogin} />
5.接下来开发登录弹窗的布局代码
return isShow ? (
<div className={styles.loginArea}>
<div className={styles.loginBox}>
<div className={styles.loginTitle}>
<div>手机号登录</div>
<div className={styles.close} onClick={handleClose}>
x
</div>
</div>
<input
name="phone"
type="text"
placeholder="请输入手机号"
value={form.phone}
onChange={handleFormChange}
/>
<div className={styles.verifyCodeArea}>
<input
name="verify"
type="text"
placeholder="请输入验证码"
value={form.verify}
onChange={handleFormChange}
/>
<span className={styles.verifyCode} onClick={handleGetVerifyCode}>
{isShowVerifyCode ? <CountDown time={10} onEnd={handleCountDownEnd} /> : '获取验证码'}
</span>
</div>
<div className={styles.loginBtn} onClick={handleLogin}>
登录
</div>
<div className={styles.otherLogin} onClick={handleOAuthGithub}>
使用 Github 登录
</div>
<div className={styles.loginPrivacy}>
注册登录即表示同意
<a href="https://moco.imooc.com/privacy.html" target="_blank" rel="noreferrer">
隐私政策
</a>
</div>
</div>
</div>
) : null;
6.对应的样式代码如下:
.loginArea {
position: fixed;
top: 0;
left: 0;
z-index: 1000;
width: 100vw;
height: 100vh;
background-color: rgb(0 0 0 / 30%);
.loginBox {
width: 320px;
height: 320px;
background-color: #fff;
position: relative;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 20px;
input {
width: 100%;
height: 37px;
margin-bottom: 10px;
padding: 10px;
border-radius: 5px;
border: 1px solid #888;
outline: none;
}
input:focus {
border: 1px solid #1e80ff;
}
.verifyCodeArea {
position: relative;
cursor: pointer;
.verifyCode {
color: #1e80ff;
position: absolute;
right: 20px;
top: 8px;
font-size: 14px;
}
}
}
.loginTitle {
font-size: 20px;
font-weight: bold;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.close {
color: #888;
cursor: pointer;
}
}
.loginBtn {
height: 40px;
line-height: 40px;
border-radius: 5px;
margin-top: 15px;
background-color: #007fff;
color: #fff;
text-align: center;
cursor: pointer;
}
.otherLogin {
margin-top: 15px;
font-size: 14px;
color: #1e80ff;
cursor: pointer;
}
.loginPrivacy {
margin-top: 10px;
color: #333;
font-size: 14px;
a {
color: #1e80ff;
}
}
}
接下来 编写 点击逻辑
1.首先 当点击关闭的时候,把弹窗关闭
使用 props 中的 onClose 方法,onClose 方法在父组件 Navbar 通过 isShowLogin 控制隐藏
// Login/index.tsx
const { onClose } = props;
const handleClose = () => {
onClose && onClose();
};
在入口引入
<Login isShow={isShowLogin} onClose={handleClose} />;
const handleClose = () => {
setIsShowLogin(false);
};
接下来开始编写 获取验证码的 逻辑
获取验证码 需要提前编写一个倒计时的组件
接下来开始编写 倒计时组件
cd components
mkdir CountDown
cd CountDown
touch index.tsx
touch index.module.scss
在 index.tsx 中编写如下代码:
思路是: 提供一个 time,表示倒计时的时间。提供一个 onEnd 回调函数,表示当倒计时结束的时候,进行一些回调处理。
这里需要注意下, 当 time 时间为 0 的时候,需要主动 调 一些 onEnd,表示结束。
import { useState, useEffect } from 'react';
import styles from './index.module.scss';
interface IProps {
time: number;
onEnd: Function;
}
const CountDown = (props: IProps) => {
const { time, onEnd } = props;
const [count, setCount] = useState(time || 60);
useEffect(() => {
const id = setInterval(() => {
setCount((count) => {
if (count === 0) {
clearInterval(id);
onEnd && onEnd();
return count;
}
return count - 1;
});
}, 1000);
return () => {
clearInterval(id);
};
}, [time, onEnd]);
return <div className={styles.countDown}>{count}</div>;
};
export default CountDown;
这样完成了倒计时组件的开发。接着编写获取验证码的逻辑。
1.首先 通过 isShowVerifyCode 控制 显示 验证码文字 还是倒计时
<span className={styles.verifyCode} onClick={handleGetVerifyCode}>
{isShowVerifyCode ? <CountDown time={10} onEnd={handleCountDownEnd} /> : '获取验证码'}
</span>
2.接着当点击 获取验证码的时候,校验一下 手机号是否输入, 如果手机号没有输入,提示用户输入手机号
<span className={styles.verifyCode} onClick={handleGetVerifyCode}>
获取验证码
</span>;
const handleGetVerifyCode = () => {
if (!form?.phone) {
message.warning('请输入手机号');
return;
}
};
3.如果 手机号输入,则开始 调 获取验证码的接口
const handleGetVerifyCode = () => {
if (!form?.phone) {
message.warning('请输入手机号');
return;
}
request
.post('/api/user/sendVerifyCode', {
to: form?.phone,
templateId: 1,
})
.then((res: any) => {
if (res?.code === 0) {
setIsShowVerifyCode(true);
} else {
message.error(res?.msg || '未知错误');
}
});
};
获取验证码
接下来开始编辑 获取 验证码 接口的逻辑
这里采用 云 的 验证码接口
1.根据 云的 接入文档,拼成 url
const session: ISession = req.session;
const { to = '', templateId = '1' } = req.body;
const AppId = 'xxx'; // 接入自己的AppId
const AccountId = 'xxx'; // 接入自己的AccountId
const AuthToken = 'xxx'; // 接入自己的AuthToken
const NowDate = format(new Date(), 'yyyyMMddHHmmss');
const SigParameter = md5(`${AccountId}${AuthToken}${NowDate}`);
const Authorization = encode(`${AccountId}:${NowDate}`);
const verifyCode = Math.floor(Math.random() * (9999 - 1000)) + 1000;
const expireMinute = '5';
const url = `https://xxx.com:8883/2013-12-26/Accounts/${AccountId}/SMS/TemplateSMS?sig=${SigParameter}`;
2.使用 request 调用接口,参数 to 代表手机号,templateId 代表是 通过手机号进行登录,appId 和 datas 按文档传入
const response = await request.post(
url,
{
to,
templateId,
appId: AppId,
datas: [verifyCode, expireMinute],
},
{
headers: {
Authorization,
},
},
);
3.获取 response,根据 response 进行处理。当接口调用成功的时候,将验证码保存到 session 中,同时返回 200 状态码和成功的数据,当失败的时候,返回失败的原因
const { statusCode, templateSMS, statusMsg } = response as any;
if (statusCode === '000000') {
session.verifyCode = verifyCode;
await session.save();
res.status(200).json({
code: 0,
msg: statusMsg,
data: {
templateSMS,
},
});
} else {
res.status(200).json({
code: statusCode,
msg: statusMsg,
});
}
4.当验证码调成功的时候,显示 倒计时
request
.post('/api/user/sendVerifyCode', {
to: form?.phone,
templateId: 1,
})
.then((res: any) => {
if (res?.code === 0) {
setIsShowVerifyCode(true);
} else {
message.error(res?.msg || '未知错误');
}
});
效果如下:
开始倒计时,并成功收到验证码
登录逻辑
当成功获取验证码,然后开始进行登录
在用户输入手机号和验证码,点击登录按钮的时候,去调用登录的接口
接口为:/api/user/login
传入表单数据,当成功的时候 将 用户的信息 存入到 store 中,并且调用 onClose 将弹窗关闭
const handleLogin = () => {
request
.post('/api/user/login', {
...form,
identity_type: 'phone',
})
.then((res: any) => {
if (res?.code === 0) {
// 登录成功
store.user.setUserInfo(res?.data);
onClose && onClose();
} else {
message.error(res?.msg || '未知错误');
}
});
};
接下来开始编写 登录接口的逻辑
1.首先从 session 中获取验证码
const session: ISession = req.session;
2.从 body 中获取传入的验证码
const { phone = '', verify = '', identity_type = 'phone' } = req.body;
3.比较两个验证码是否相等,如果不相等,则返回 验证码错误
4.如果两个验证码相等,则去用户表中查找,判断用户是否存在,如果用户不存在,则表示注册,如果存在,则表示登录。
// 验证码正确,在 user_auths 表中查找 identity_type 是否有记录
const userAuth = await userAuthRepo.findOne(
{
identity_type,
identifier: phone,
},
{
relations: ['user'],
},
);
5.当用户存在的时候,从数据库中读取除用户信息,存入 session 和 cookie 中,并将用户信息返回
// 已存在的用户
const user = userAuth.user;
const { id, nickname, avatar } = user;
session.userId = id;
session.nickname = nickname;
session.avatar = avatar;
await session.save();
setCookie(cookies, { id, nickname, avatar });
res?.status(200).json({
code: 0,
msg: '登录成功',
data: {
userId: id,
nickname,
avatar,
},
});
6.当用户不存在的时候,将输入的信息 存入到数据库,session 和 cookie 中,表示用户注册,返回用户信息
// 新用户,自动注册
const user = new User();
user.nickname = `用户_${Math.floor(Math.random() * 10000)}`;
user.avatar = '/images/avatar.jpg';
user.job = '暂无';
user.introduce = '暂无';
const userAuth = new UserAuth();
userAuth.identifier = phone;
userAuth.identity_type = identity_type;
userAuth.credential = session.verifyCode;
userAuth.user = user;
const resUserAuth = await userAuthRepo.save(userAuth);
const {
user: { id, nickname, avatar },
} = resUserAuth;
session.userId = id;
session.nickname = nickname;
session.avatar = avatar;
await session.save();
setCookie(cookies, { id, nickname, avatar });
res?.status(200).json({
code: 0,
msg: '登录成功',
data: {
userId: id,
nickname,
avatar,
},
});
点击登录,即可登录成功。
数据库操作
我们这里使用 typeorm 数据库
首先在根目录创建 db 文件夹,在 db 文件建创建 entity 文件夹,entity 存放各个模块的表模型
在 db 文件夹创建 index.ts,用来导出各个模块的表模型
新建 db/entity/user.ts
1.Entity 指定数据库中的哪个数据表,这里指定 users 数据表
import { Entity, BaseEntity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity({ name: 'users' })
export class User extends BaseEntity {
@PrimaryGeneratedColumn()
readonly id!: number;
@Column()
nickname!: string;
@Column()
avatar!: string;
@Column()
job!: string;
@Column()
introduce!: string;
}
2.使用 typeorm 链接 mysql
3.从 typeorm 引入
import { Connection, getConnection, createConnection } from 'typeorm';
4.引入数据表
import { User, UserAuth, Article, Comment, Tag } from './entity/index';
5.链接 mysql 数据库
import 'reflect-metadata';
import { Connection, getConnection, createConnection } from 'typeorm';
import { User, UserAuth, Article, Comment, Tag } from './entity/index';
const host = process.env.DATABASE_HOST;
const port = Number(process.env.DATABASE_PORT);
const username = process.env.DATABASE_USERNAME;
const password = process.env.DATABASE_PASSWORD;
const database = process.env.DATABASE_NAME;
let connectionReadyPromise: Promise<Connection> | null = null;
console.log('username', username)
export const prepareConnection = () => {
if (!connectionReadyPromise) {
connectionReadyPromise = (async () => {
try {
const staleConnection = getConnection();
await staleConnection.close();
} catch (error) {
console.log(error);
}
const connection = await createConnection({
type: 'mysql',
host,
port,
username,
password,
database,
entities: [User, UserAuth, Article, Comment, Tag],
synchronize: false,
logging: true,
},6.
return connection;
})();
}
return connectionReadyPromise;
};
6.在接口侧 引入数据库
import { prepareConnection } from 'db/index';
const db = await prepareConnection();
7.引入数据表,使用 db 获取 指定的数据表,userAuthRepo 来操作 mysql
import { User, UserAuth } from 'db/entity/index';
const db = await prepareConnection();
const userAuthRepo = db.getRepository(UserAuth);
8.从 users 表查询数据
const userAuth = await userAuthRepo.findOne(
{
identity_type,
identifier: phone,
},
{
relations: ['user'],
},
);
9.如果 userAuth 有数据,则表示登录,没有数据则表示注册
10.如果是登录,从 user 中获取当前用户的信息,将这些信息一方面存入 session,一方面存入 cookie,最后返回 200 状态码,同时将用户信息返回
11.如果是注册,将这些输入的用户信息,存入 users 表中,同时将这些信息存入到 session 和 cookie 中,同时返回 200 状态码和这些用户信息
if (userAuth) {
// 已存在的用户
const user = userAuth.user;
const { id, nickname, avatar } = user;
session.userId = id;
session.nickname = nickname;
session.avatar = avatar;
await session.save();
setCookie(cookies, { id, nickname, avatar });
res?.status(200).json({
code: 0,
msg: '登录成功',
data: {
userId: id,
nickname,
avatar,
},
});
} else {
// 新用户,自动注册
const user = new User();
user.nickname = `用户_${Math.floor(Math.random() * 10000)}`;
user.avatar = '/images/avatar.jpg';
user.job = '暂无';
user.introduce = '暂无';
const userAuth = new UserAuth();
userAuth.identifier = phone;
userAuth.identity_type = identity_type;
userAuth.credential = session.verifyCode;
userAuth.user = user;
const resUserAuth = await userAuthRepo.save(userAuth);
const {
user: { id, nickname, avatar },
} = resUserAuth;
session.userId = id;
session.nickname = nickname;
session.avatar = avatar;
await session.save();
setCookie(cookies, { id, nickname, avatar });
res?.status(200).json({
code: 0,
msg: '登录成功',
data: {
userId: id,
nickname,
avatar,
},
});
}
发布文章
1.当点击 写文章的时候,先判断用户是否登录,如果没有登录,则提示用户先登录,如果已经登录,则跳到新建文章页面
<Button onClick={handleGotoEditorPage}>写文章</Button>;
const handleGotoEditorPage = () => {
if (userId) {
push('/editor/new');
} else {
message.warning('请先登录');
}
};
2.在 pages 目录下创建 editor/new.tsx,表示 新建文章的页面
3.首先编写 markdown 编辑器,这里使用 开源的一款 markdown 编辑器,@uiw/react-md-editor
安装
yarn add @uiw/react-md-editor
4.引入编辑器
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false });
import '@uiw/react-md-editor/markdown-editor.css';
import '@uiw/react-markdown-preview/markdown.css';
<MDEditor />;
5.定义 state 表示编辑器的内容
const [content, setContent] = useState('');
<MDEditor value={content} height={1080} />;
6.添加 change 事件
<MDEditor value={content} height={1080} onChange={handleContentChange} />;
const handleContentChange = (content: any) => {
setContent(content);
};
7.添加 输入标题 组件
const [title, setTitle] = useState('');
const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
setTitle(event?.target?.value);
};
<Input
className={styles.title}
placeholder="请输入文章标题"
value={title}
onChange={handleTitleChange}
/>;
8.添加 标签选择 组件
<Select
className={styles.tag}
mode="multiple"
allowClear
placeholder="请选择标签"
onChange={handleSelectTag}
>
{allTags?.map((tag: any) => (
<Select.Option key={tag?.id} value={tag?.id}>
{tag?.title}
</Select.Option>
))}
</Select>
9.新增 state 控制 标签
const [allTags, setAllTags] = useState([]);
10.添加 选择 标签的 事件
const handleSelectTag = (value: []) => {
setTagIds(value);
};
11.新建 标签的 数据表
import { Entity, BaseEntity, PrimaryGeneratedColumn, Column, ManyToMany, JoinTable } from 'typeorm';
import { User } from './user';
import { Article } from './article';
@Entity({ name: 'tags' })
export class Tag extends BaseEntity {
@PrimaryGeneratedColumn()
readonly id!: number;
@Column()
title!: string;
@Column()
icon!: string;
@Column()
follow_count!: number;
@Column()
article_count!: number;
@ManyToMany(() => User, {
cascade: true,
})
@JoinTable({
name: 'tags_users_rel',
joinColumn: {
name: 'tag_id',
},
inverseJoinColumn: {
name: 'user_id',
},
})
users!: User[];
@ManyToMany(() => Article, (article) => article.tags)
@JoinTable({
name: 'articles_tags_rel',
joinColumn: {
name: 'tag_id',
},
inverseJoinColumn: {
name: 'article_id',
},
})
articles!: Article[];
}
新增 获取所有标签的接口,新建 api/tag/get.ts
1.从 session 中获取用户信息
2.从 tag 表 查询 所有 标签数据
3.关联 users 表,根据 users 表,查询所有标签,返回 allTags
4.关联 User 表,根据当前登录用户的信息,查询该用户 关注的标签,返回 followTags
import { NextApiRequest, NextApiResponse } from 'next';
import { withIronSessionApiRoute } from 'iron-session/next';
import { ironOptions } from 'config/index';
import { ISession } from 'pages/api/index';
import { prepareConnection } from 'db/index';
import { Tag } from 'db/entity/index';
export default withIronSessionApiRoute(get, ironOptions);
async function get(req: NextApiRequest, res: NextApiResponse) {
const session: ISession = req.session;
const { userId = 0 } = session;
const db = await prepareConnection();
const tagRepo = db.getRepository(Tag);
const followTags = await tagRepo.find({
relations: ['users'],
where: (qb: any) => {
qb.where('user_id = :id', {
id: Number(userId),
});
},
});
const allTags = await tagRepo.find({
relations: ['users'],
});
res?.status(200)?.json({
code: 0,
msg: '',
data: {
followTags,
allTags,
},
});
}
5.在 editor/new.tsx 中 调 获取 标签的接口拿到标签数据
useEffect(() => {
request.get('/api/tag/get').then((res: any) => {
if (res?.code === 0) {
setAllTags(res?.data?.allTags || []);
}
});
}, []);
最后渲染 所有标签
<Select
className={styles.tag}
mode="multiple"
allowClear
placeholder="请选择标签"
onChange={handleSelectTag}
>
{allTags?.map((tag: any) => (
<Select.Option key={tag?.id} value={tag?.id}>
{tag?.title}
</Select.Option>
))}
</Select>
这样页面就出来了,也获取到了 markdown,标签,标题的数据
发布文章
1.先判断是否输入标题,如果没有输入标题,就提示用户输入标题
2.然后调 发布文章的接口,参数就是 标题,markdown 数据,标签
3.当接口调取成功的时候,提示发布成功,并跳到用户中心 的页面
4.当接口调取失败的时候,提示发布失败
const handlePublish = () => {
if (!title) {
message.warning('请输入文章标题');
return;
}
request
.post('/api/article/publish', {
title,
content,
tagIds,
})
.then((res: any) => {
if (res?.code === 0) {
userId ? push(`/user/${userId}`) : push('/');
message.success('发布成功');
} else {
message.error(res?.msg || '发布失败');
}
});
};
现在写下 发布文章的接口
新建 api/artice/publish.ts
1.引入数据库和 user, tag, article 三张数据表
import { prepareConnection } from 'db/index';
import { User, Article, Tag } from 'db/entity/index';
2.链接三个数据表
const db = await prepareConnection();
const userRepo = db.getRepository(User);
const articleRepo = db.getRepository(Article);
const tagRepo = db.getRepository(Tag);
3.从 req.body 中获取传入的参数
const { title = '', content = '', tagIds = [] } = req.body;
4.从 session 中获取用户信息
const session: ISession = req.session;
5.根据 session 从 user 表中查询当前用户信息
const user = await userRepo.findOne({
id: session.userId,
});
6.根据传入的标签,获取所有的标签
const tags = await tagRepo.find({
where: tagIds?.map((tagId: number) => ({ id: tagId })),
});
7.将传入的数据 存入到 article 表中, 如果有用户信息,将用户信息也存入表,并且标签数量增加
const article = new Article();
article.title = title;
article.content = content;
article.create_time = new Date();
article.update_time = new Date();
article.is_delete = 0;
article.views = 0;
if (user) {
article.user = user;
}
if (tags) {
const newTags = tags?.map((tag) => {
tag.article_count = tag?.article_count + 1;
return tag;
});
article.tags = newTags;
}
const resArticle = await articleRepo.save(article);
if (resArticle) {
res.status(200).json({ data: resArticle, code: 0, msg: '发布成功' });
} else {
res.status(200).json({ ...EXCEPTION_ARTICLE.PUBLISH_FAILED });
}
这样就完成了文章发布
SSR原理
服务端渲染react代码页面
首先创建 ssr-react目录,进入ssr-react目录,初始化一个npm项目
mkdir ssr-react
cd ssr-react
npm init -y
在根目录创建src文件夹,在src文件夹下创建server.js
采用node的一个框架 express来写。
首先安装express
yarn add express
接下来 用express写一个最简单的服务
const express = require('express');
const app = express();
const port = process.env.port || 3000;
app.get('*', (req, res) => {
res.writeHead(200,{
'content-type': 'text/html;charset=utf8'
})
res.end('你好ssr')
})
app.listen(port, () => {
console.log('http://localhost:3000')
})
写完以后运行 node src/server.js就能在http://localhost:3000 看到 页面上的输入
因为要做服务端渲染,要在server.js中引入React等前端的包,也就是import,但是 node不认识 import
这个时候我们使用webpack来让node认识import
在根目录创建config文件夹,在config文件夹创建webpack.server.js
const path = require('path')
const webpackExternals = require('webpack-node-externals')
module.exports = {
target: 'node',
mode: process.env.NODE_ENV === 'production' ? 'production': 'development',
entry: path.resolve(__dirname,'../src/server.js'),
output: {
path: path.resolve(__dirname,'../dist'),
filename: 'bundle_server.js'
},
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
exclude: '/node_modules/'
}
]
},
externals: [webpackExternals()] // 不会把node_module中的源码打包
}
这里同时使用了webpack-node-externals这个插件,这个插件功能是 在webpack打包的时候,不打包node_modules里面的源码。
为了在node中适配react和ES6的高级语法,我们需要使用babel来编译,安装babel插件
yarn add @babel/core @babel/preset-env "@babel/preset-react babel-loader
同时在根目录创建.babelrc文件
{
"presets": [
"@babel/preset-react",
"@babel/preset-env"
]
}
接着编写下scripts命令
"scripts": {
"webpack:server": "webpack --config ./config/webpack.server.js --watch",
"webpack:start": "nodemon --watch dist --exec node dist/bundle_server.js",
"dev": "npm-run-all --parallel webpack:*"
},
1.webpack:server 这个命令来打包 入口文件 server.js
2.webpack:start 这个命令来监听打包后的 bundle_server.js
3.dev 这个命令,使用npm-run-all第三方库 来监听所有的命令
接下来开始,写react组件,在node中进行渲染
首先在src目录下创建Home和Person两个组件
// src/pages/Home.js
import React from 'react';
const Home = () => {
return <div>home</div>
}
export default Home;
// src/pages/Person.js
import React from 'react';
const Person = () => {
return <div>Person</div>
}
export default Person;
然后开始编写路由,对应的查找这两个组件
在pages目录下创建routes.js文件
import React from 'react';
import { Routes, Route, Link } from 'react-router-dom'
import Home from './pages/Home';
import Person from './pages/Person';
const RoutesList = () => {
return (
<div>
<ul>
<li>
<Link to='/'>首页</Link>
</li>
<li>
<Link to='/person'>个人中心</Link>
</li>
</ul>
<Routes>
<Route exact path='/' element={<Home />} />
<Route exact path='/person' element={<Person />} />
</Routes>
</div>
)
}
export default RoutesList;
最后在server.js中编写 react代码,能够让react代码在node中渲染
1.react-dom库中有个server库,就是react-dom/server,来专门在node中渲染react
2.在react-router-dom下也有个server库,就是react-router-dom/server,来渲染react路由
首先引入这两个库,以及路由文件
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server'
import Routes from './routes'
然后通过ReactDOMServer中的renderToString来渲染react代码,而路由文件使用StaticRouter进行包裹,
代码如下:
const content = ReactDOMServer.renderToString(
<StaticRouter location={req.url}>
<Routes />
</StaticRouter>
)
最后将 content 写成 html的格式,进行输出
const html = `
<html>
<head></head>
<body>
<div id="root">${content}</div>
</body>
</html>
`
res.writeHead(200,{
'content-type': 'text/html;charset=utf8'
})
res.end(html)
看下现在的效果
当切换的首页的路由时:
当切换到个人中心的路由时:
前端注水:
比如在 Home 组件中 添加一个点击事件
import React from 'react';
const Home = () => {
const handleClick = () => {
console.log('click')
}
return <div>home
<button onClick={handleClick}>点击</button>
</div>
}
export default Home;
当在页面点击的时候,日志没有被打印。
这是因为,Home组件是服务端渲染的,点击事件是在客户端进行的,客户端接收不到 这个点击事件,所以日志没有被打印。
下面通过让客户端 拦截 路由 实现 事件点击
首先在pages下创建client.js
在react-dom中有hydrate可以进行注水,也就是拦截。
通过hydrate进行注水,并且绑定到 id为root的div下面
代码如下:
import React from 'react';
import ReactDom from 'react-dom';
import { BrowserRouter } from 'react-router-dom'
import Routes from './routes';
ReactDom.hydrate(
<BrowserRouter>
<Routes />
</BrowserRouter>,
document.getElementById('#root')
)
这个时候我们需要将这个clent.js文件进行打包
在config目录下创建webpack.client.js,来进行client.js的打包
注意:这个时候需要把webpack-node-externals去掉,因为这个时候是打包的react客户端
const path = require('path')
module.exports = {
target: 'web',
mode: process.env.NODE_ENV === 'production' ? 'production': 'development',
entry: path.resolve(__dirname,'../src/client.js'),
output: {
path: path.resolve(__dirname,'../dist/public'),
filename: 'bundle_client.js'
},
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
exclude: '/node_modules/'
}
]
}
}
然后在scripts中配置下命令
"webpack:client": "webpack --config ./config/webpack.client.js --watch"
最后在输出的html中引入打包后的client.js
const html = `
<html>
<head></head>
<body>
<div id="root">${content}</div>
<script src="bundle_client.js"></script>
</body>
</html>
`
这样重新 打包后,就能在页面上进行点击事件了
看下效果:
初始化 reactStore
使用 react-redux来管理状态
首先安装下redux
yarn add redux react-redux
在src目录下创建store文件夹
在store文件夹下创建index.js来管理store入口
在strore文件夹下创建 actions文件夹,actions文件夹下分别创建 home.js和 person.js来管理这两个的action
在store文件夹下创建reducers文件夹,在reducers文件夹下分别创建home.js和person.js来管理这两个的reducer
首先来写下action
// actions/home.js
export const FETCH_HOME_DATA = 'fetch_home_data';
export const fetchHomeData = async (dispatch) => {
const data = await new Promise((resolve, reject) => {
setTimeout(() => {
resolve({
articles: [
{
id: 1,
title: 'title1',
content: 'content1'
},
{
id: 2,
title: 'title2',
content: 'content2'
}
]
})
},2000)
})
dispatch({
type: FETCH_HOME_DATA,
payload: data
})
}
export const FETCH_PERSON_DATA = 'fetch_person_data';
export const fetchPersonData = async (dispatch) => {
const data = await new Promise((resolve, reject) => {
setTimeout(() => {
resolve({
userInfo: {
username: 'curry',
job: '前端工程师'
}
})
},2000)
})
dispatch({
type: FETCH_PERSON_DATA,
payload: data
})
}
让开始写reducers
// reducers/home.js
import { FETCH_HOME_DATA } from '../actions/home';
const initState = {
articles: []
}
export default (state = initState ,action) => {
switch(action?.type){
case FETCH_HOME_DATA:
return action.payload;
default:
return state;
}
}
// reducers/person.js
import { FETCH_PERSON_DATA } from '../actions/person';
const initState = {
info: {}
}
export default (state = initState ,action) => {
switch(action?.type){
case FETCH_PERSON_DATA:
return action.payload;
default:
return state;
}
}
最后将这两个reducer合并起来
在 reducers/index.js中将两个合并
import { combineReducers } from 'redux'
import homeReducer from './home'
import personReducer from './person'
export default combineReducers({
home: homeReducer,
person: personReducer
})
最后在stroe中引入redux
import { createStore } from 'redux'
import reducer from './reducers'
const store = createStore(reducer)
export default store;
开始使用store
在client.js中使用store
在使用store的时候,需要使用到react-redux提供的Provider,相当于context中的provider,
将Provider包裹住,将store传入Provider,这样的话,才能在组件中接受到store
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux'
import Routes from './routes';
import store from './store'
ReactDOM.hydrate(
<Provider store={store}>
<BrowserRouter>
<Routes />
</BrowserRouter>
</Provider>,
document.querySelector('#root')
);
同时也需要在server.js中引入Provider,并将store传入Provider
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server'
import { Provider } from 'react-redux'
import Routes from './routes'
import store from './store'
const express = require('express');
const app = express();
const port = process.env.port || 3000;
app.use(express.static('dist/public'))
app.get('*', (req, res) => {
const content = ReactDOMServer.renderToString(
<Provider store={store}>
<StaticRouter location={req.url}>
<Routes />
</StaticRouter>
</Provider>
)
const html = `
<html>
<head></head>
<body>
<div id="root">${content}</div>
<script src="bundle_client.js"></script>
</body>
</html>
`
res.writeHead(200,{
'content-type': 'text/html;charset=utf8'
})
res.end(html)
})
app.listen(port, () => {
console.log('http://localhost:3000')
})
reduxThunk中间件
接下来我们在home组件中使用store
我们使用react-redux提供的hooks来使用
引入两个hooks
import { useSelector, useDispatch } from 'react-redux'
使用useDispatch这个hooks来获取dispatch
const dispatch = useDispatch();
使用useSelector这个hooks来获取reducer中的数据
const homeData = useSelector((state) => state.home)
接下来 我们使用 csr的方式 来获取数据
使用useEffect
import { fetchHomeData } from '../store/actions/home'
useEffect(() => {
dispatch(fetchHomeData)
},[])
当我们刷新页面的时候,看到页面有报错
这个报错也提示,需要使用redux-thunk
因为 我们在action中 使用了 异步方式,所以要使用react-thunk来加载异步
首先来安装下redux-thunk
yarn add redux-thunk
redux提供了一个中间件来使用thunk,就是applyMiddleware中间件
最后在store中使用applyMiddleware来包裹这个thunk
import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk';
import reducer from './reducers'
const store = createStore(reducer, applyMiddleware(thunk))
export default store;
这样 页面就不会报错了
我们在home组件中 通过 点击事件,来渲染 异步获取的数据
最后看下效果
使用ssr方式来异步加载数据
首先在routers.js中 写一个 路由配置
export const routesConfig = [
{
path: '/',
component: Home,
},
{
path: '/person',
component: Person
}
]
参照一下next.js中的做法,next.js是提供了一个方法,来获取数据
我们也可以在 组件中 挂载一个方法 ,来获取数据
用Home组件来写
在home组件,因为home是一个函数,所有可以 挂载一个getInitData方法,参数是store,使用方法和csr一样,
通过store.dispatch(fetchHomeData)来获取数据
// home.js
Home.getInitData = async (store) => {
return store.dispatch(fetchHomeData)
}
然后在sever.js中引入
可以通过req获取当前访问的url,然后遍历路由的配置,当 当前访问的url和路由配置的一个匹配的时候,
就执行组件中的getInitData方法,同时传入store参数,这个时候返回的是promise
然后通过Promise.all方法,来执行所有的promise,渲染页面的数据
import Routes, { routesConfig } from './routes'
const url =req.url;
const promises = routesConfig.map(route => {
const component = route.component;
if(route.path === url && component.getInitData){
return component.getInitData(store)
}else{
return null;
}
})
Promise.all(promises).then(() => {
const content = ReactDOMServer.renderToString(
<Provider store={store}>
<StaticRouter location={req.url}>
<Routes />
</StaticRouter>
</Provider>
)
const html = `
<html>
<head></head>
<body>
<div id="root">${content}</div>
<script src="bundle_client.js"></script>
</body>
</html>
`
res.writeHead(200,{
'content-type': 'text/html;charset=utf8'
})
res.end(html)
})
最后看下效果
如下:是通过csr的方式渲染的数据
看下网页源代码:
这个是通过ssr的方式渲染的
因为 客户端不知道服务端已经渲染了数据,所有csr和ssr都渲染了数据。
这个时候来改造下
首先改造下store
这里给createStore传入一个默认的状态
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import reducer from './reducers';
export default function createStoreInstance(preloadedState = {}) {
return createStore(reducer, preloadedState, applyMiddleware(thunk));
}
然后改造server.js
1.首先引入store
2.在执行promise的时候通过store的getState方法,获取到异步获取后的stete,就是preloadedState
3.将preloadedState 注入到全局的变量PRELOAD_STATE中
import createStoreInstance from './store';
const store = createStoreInstance();
Promise.all(promises).then(() => {
const preloadedState = store.getState();
const content = ReactDOMServer.renderToString(
<Provider store={store}>
<StaticRouter location={req.url}>
<Routes />
</StaticRouter>
</Provider>
)
const html = `
<html>
<head></head>
<body>
<div id="root">${content}</div>
<script>
window.__PRELOAD_STATE__=${JSON.stringify(preloadedState)}
</script>
<script src="bundle_client.js"></script>
</body>
</html>
`
res.writeHead(200,{
'content-type': 'text/html;charset=utf8'
})
res.end(html)
})
最后改造client.js
1.引入store
2.使用createStoreInstance方法,参数从全局中获取PRELOAD_STATE,这个时候ssr已经将PRELOAD_STATE的数据注入到了window中,这个时候在csr就可以直接获取数据,存放到store中,
然后将store传入provder
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux'
import Routes from './routes';
// import store from './store'
import createStoreInstance from './store';
const store = createStoreInstance(window?.__PRELOAD_STATE__);
ReactDOM.hydrate(
<Provider store={store}>
<BrowserRouter>
<Routes />
</BrowserRouter>
</Provider>,
document.querySelector('#root')
);
最后看下效果:
页面数据会很快,因为现在是ssr渲染的数据
ssr 渲染首页文章列表
nextjs 提供 getServerSideProps 来获取数据,返回到 props 中,然后在 react 组件中通过 props 获取数据进行渲染,达到 ssr 效果。
1.引入数据库和 tag,article 两张表
import { prepareConnection } from 'db/index';
import { Article, Tag } from 'db/entity';
2.链接数据库
const db = await prepareConnection();
3.根据 关联的 user 和 tag 查询出 所有 文章
const articles = await db.getRepository(Article).find({
relations: ['user', 'tags'],
});
4.根据 关联的 user 查询出 标签
const tags = await db.getRepository(Tag).find({
relations: ['users'],
});
5.最后将 文章和标签通过 props 返回
return {
props: {
articles: JSON.parse(JSON.stringify(articles)) || [],
tags: JSON.parse(JSON.stringify(tags)) || [],
},
};
6.在 react 组件中 通过 props 获取 文章和标签
const { articles = [], tags = [] } = props;
7.默认将 获取的 文章,存放到所有文章的 state 中
const [showAricles, setShowAricles] = useState([...articles]);
8.然后渲染当前所有的文章
<div className="content-layout">
{showAricles?.map((article) => (
<>
<DynamicComponent article={article} />
<Divider />
</>
))}
</div>
9.上面的文章列表通过 异步加载的方式加载
const DynamicComponent = dynamic(() => import('components/ListItem'));
10.新建 components/ListItem/index.tsx components/ListItem/index.module.scss
通过 props 可以获取到 从 父组件传过来的 article 和 user 信息
拿到这两个信息后,将这两个字段里面的内容 渲染处理即可
需要注意的是,需要点击谋篇文章的时候,跳转到该文章的详情页面,所以需要使用 Link
另外一个需要注意的地方是,渲染文章的时候,文章是 markdown 格式
所以使用 markdown-to-txt 第三方包 来加载 markdown 格式的数据
所以代码是这样的
import Link from 'next/link';
import { formatDistanceToNow } from 'date-fns';
import { IArticle } from 'pages/api/index';
import { Avatar } from 'antd';
import { EyeOutlined } from '@ant-design/icons';
import { markdownToTxt } from 'markdown-to-txt';
import styles from './index.module.scss';
interface IProps {
article: IArticle;
}
const ListItem = (props: IProps) => {
const { article } = props;
const { user } = article;
return (
// eslint-disable-next-line @next/next/link-passhref
<Link href={`/article/${article.id}`}>
<div className={styles.container}>
<div className={styles.article}>
<div className={styles.userInfo}>
<span className={styles.name}>{user?.nickname}</span>
<span className={styles.date}>
{formatDistanceToNow(new Date(article?.update_time))}
</span>
</div>
<h4 className={styles.title}>{article?.title}</h4>
<p className={styles.content}>{markdownToTxt(article?.content)}</p>
<div className={styles.statistics}>
<EyeOutlined />
<span className={styles.item}>{article?.views}</span>
</div>
</div>
<Avatar src={user?.avatar} size={48} />
</div>
</Link>
);
};
export default ListItem;
11.css 代码
.container {
margin: 0 atuo;
background-color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
cursor: pointer;
.article {
width: 90%;
.userInfo {
margin-bottom: 10px;
display: flex;
align-items: center;
span {
padding: 0 10px;
border-right: 1px solid #e5e6eb;
}
span:first-of-type {
padding-left: 0;
}
span:last-of-type {
border-right: 0;
}
.name {
color: #4e5969;
}
.name:hover {
text-decoration: underline;
color: #1e80ff;
}
.date {
color: #86909c;
}
}
.title {
font-size: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.content {
font-size: 16px;
color: #86909c;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.statistics {
display: flex;
align-items: center;
.item {
margin-left: 5px;
}
}
}
}
看下效果:
ssr 渲染文章详情页
这里需要使用 nextjs 中的动态路由
1.在 pages/article 新建 id.tsx,表示 文章详情页的入口文件
同时新建 pages/article/index.module.scss
2.通过 url 获取 文章的 id 字段
3.然后根据通过 ssr 获取文章详情数据
4.根据 id 去数据表中查询当前文章的详情
5.这里增加一个功能,就是浏览次数,当前查询的时候,浏览次数增加 1 次
整体代码如下:
export async function getServerSideProps({ params }: any) {
const articleId = params?.id;
const db = await prepareConnection();
const articleRepo = db.getRepository(Article);
const article = await articleRepo.findOne({
where: {
id: articleId,
},
relations: ['user', 'comments', 'comments.user'],
});
if (article) {
// 阅读次数 +1
article.views = article?.views + 1;
await articleRepo.save(article);
}
return {
props: {
article: JSON.parse(JSON.stringify(article)),
},
};
}
通过以上 ssr 代码就拿到了 当前文章的数据
然后渲染这些基本信息
这里 markdown 的内容 使用 markdown-to-jsx 第三方库 来加载
<div className="content-layout">
<h2 className={styles.title}>{article?.title}</h2>
<div className={styles.user}>
<Avatar src={avatar} size={50} />
<div className={styles.info}>
<div className={styles.name}>{nickname}</div>
<div className={styles.date}>
<div>{format(new Date(article?.update_time), 'yyyy-MM-dd hh:mm:ss')}</div>
<div>阅读 {article?.views}</div>
</div>
</div>
</div>
<MarkDown className={styles.markdown}>{article?.content}</MarkDown>
</div>
接着增加 是否显示编辑的逻辑
通过 store 拿到 当前登录的用户信息
const store = useStore();
const loginUserInfo = store?.user?.userInfo;
当 用户登录的时候,显示编辑按钮
并且 点击 编辑 按钮 跳转到 文章 编辑页面
{
Number(loginUserInfo?.userId) === Number(id) && <Link href={`/editor/${article?.id}`}>编辑</Link>;
}
编辑文章
文章渲染
因为 编辑文章是编辑不同的文章,所以这里需要 使用动态 路由
1.首先新建 pages/editor/id.tsx 和 index.module.scss
2.编辑文章 首先 需要 把 当前的文章详情 回显到页面上
这里通过 url 获取 当前 文章的 id,然后通过 ssr 渲染的方式进行渲染
3.根据 文章 id 和 关联的 用户表,链接 文章的 数据表,查询出来 属于 当前用户发布的这篇文章
最后将 查询出来的 文章详情返回
export async function getServerSideProps({ params }: any) {
const articleId = params?.id;
const db = await prepareConnection();
const articleRepo = db.getRepository(Article);
const article = await articleRepo.findOne({
where: {
id: articleId,
},
relations: ['user'],
});
return {
props: {
article: JSON.parse(JSON.stringify(article)),
},
};
}
在 react 客户端组件中,通过 props 获取 article 数据
1.将 文章标题,文章内容通过 state 来控制,初始值是 props 获取的数据
const [title, setTitle] = useState(article?.title || '');
const [content, setContent] = useState(article?.content || '');
2.通过 useRouter hooks 获取 文章 Id
const { push, query } = useRouter();
const articleId = Number(query?.id);
3.将获取的文章数据渲染出来
return (
<div className={styles.container}>
<div className={styles.operation}>
<Input
className={styles.title}
placeholder="请输入文章标题"
value={title}
onChange={handleTitleChange}
/>
<Select
className={styles.tag}
mode="multiple"
allowClear
placeholder="请选择标签"
onChange={handleSelectTag}
>
{allTags?.map((tag: any) => (
<Select.Option key={tag?.id} value={tag?.id}>
{tag?.title}
</Select.Option>
))}
</Select>
<Button className={styles.button} type="primary" onClick={handlePublish}>
发布
</Button>
</div>
<MDEditor value={content} height={1080} onChange={handleContentChange} />
</div>
);
4.修改标题,通过 state 控制
const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
setTitle(event?.target?.value);
};
5.修改 文章内容的时候,也是通过 state 控制
const handleContentChange = (content: any) => {
setContent(content);
};
6.这里 新增一个 获取所有标签的接口
首先 调用 标签接口,将标签数据存到 state 中
useEffect(() => {
request.get('/api/tag/get').then((res: any) => {
if (res?.code === 0) {
setAllTags(res?.data?.allTags || []);
}
});
}, []);
接下来编写下 获取标签的接口
新建 pages/api/tag/get.ts
1.首先通过 session 获取当前用户信息
const session: ISession = req.session;
const { userId = 0 } = session;
2.链接 标签的数据表
const db = await prepareConnection();
const tagRepo = db.getRepository(Tag);
3.根据当前关联的用户表,查询出来所有标签
const allTags = await tagRepo.find({
relations: ['users'],
});
4.根据用户 id 查询出来 当前用户关注的标签
const followTags = await tagRepo.find({
relations: ['users'],
where: (qb: any) => {
qb.where('user_id = :id', {
id: Number(userId),
});
},
});
5.最后将所有的标签 和 当前用户 关注的 标签 返回
res?.status(200)?.json({
code: 0,
msg: '',
data: {
followTags,
allTags,
},
});
6.在客户端 拿到 所有标签数据后渲染出来
<Select
className={styles.tag}
mode="multiple"
allowClear
placeholder="请选择标签"
onChange={handleSelectTag}
>
{allTags?.map((tag: any) => (
<Select.Option key={tag?.id} value={tag?.id}>
{tag?.title}
</Select.Option>
))}
</Select>
更新文章
1、当点击更新的时候,首先判断一下 是否 输入了标题,如果没有输入标题,则提示用户输入标题
if (!title) {
message.warning('请输入文章标题');
return;
}
2、然后传参数调用更新文章的接口
3、传的参数包括 文章 id、标题、内容、标签
4、当调用更新文章接口成功的时候提示更新文章成功并跳到当前文章
5、如果失败,则提示发布失败
request
.post('/api/article/update', {
id: articleId,
title,
content,
tagIds,
})
.then((res: any) => {
if (res?.code === 0) {
articleId ? push(`/article/${articleId}`) : push('/');
message.success('更新成功');
} else {
message.error(res?.msg || '发布失败');
}
});
6、接着编写 更新文章的接口,新建 pages/api/article/update.ts
7、通过 body 获取 前端传过来的数据
const { title = '', content = '', id = 0, tagIds = [] } = req.body;
8、链接文章和标签的数据库
const articleRepo = db.getRepository(Article);
const tagRepo = db.getRepository(Tag);
9、根据文章的 id,关联用户表和标签表,查询出来当前文章
const article = await articleRepo.findOne({
where: {
id,
},
relations: ['user', 'tags'],
});
10、判断查询出来的 article 是否存在,如果不存在,则提示文章不存在
res.status(200).json({ ...EXCEPTION_ARTICLE.NOT_FOUND });
11、如果存在,则将传过来的文章数据 覆盖之前的数据,如果保存成功,则提示成功,否则提示失败
if (article) {
article.title = title;
article.content = content;
article.update_time = new Date();
article.tags = newTags;
const resArticle = await articleRepo.save(article);
if (resArticle) {
res.status(200).json({ data: resArticle, code: 0, msg: '更新成功' });
} else {
res.status(200).json({ ...EXCEPTION_ARTICLE.UPDATE_FAILED });
}
}
12、这里需要根据传过来的标签 id,查询出来所有标签,然后将标签数量加 1
const tags = await tagRepo.find({
where: tagIds?.map((tagId: number) => ({ id: tagId })),
});
const newTags = tags?.map((tag) => {
tag.article_count = tag.article_count + 1;
return tag;
});
13、最后记得将 需要的 第三方库引入进来
import { NextApiRequest, NextApiResponse } from 'next';
import { withIronSessionApiRoute } from 'iron-session/next';
import { ironOptions } from 'config/index';
import { prepareConnection } from 'db/index';
import { Article, Tag } from 'db/entity/index';
import { EXCEPTION_ARTICLE } from 'pages/api/config/codes';
这样就完成了编辑文章的前后端开发。
发布评论
评论渲染
1.首先 先编写 发布评论 和评论列表的页面,只有登录的用户才能发布评论,所以这里有个判断,判断只有获取到用户的信息,才显示 发布评论的 按钮
const store = useStore();
const loginUserInfo = store?.user?.userInfo;
{
loginUserInfo?.userId && (
<div className={styles.enter}>
<Avatar src={avatar} size={40} />
<div className={styles.content}>
<Input.TextArea
placeholder="请输入评论"
rows={4}
value={inputVal}
onChange={(event) => setInputVal(event?.target?.value)}
/>
<Button type="primary" onClick={handleComment}>
发表评论
</Button>
</div>
</div>
);
}
2.然后 获取 所有的 评论 列表,渲染到页面上
<div className={styles.display}>
{comments?.map((comment: any) => (
<div className={styles.wrapper} key={comment?.id}>
<Avatar src={comment?.user?.avatar} size={40} />
<div className={styles.info}>
<div className={styles.name}>
<div>{comment?.user?.nickname}</div>
<div className={styles.date}>
{format(new Date(comment?.update_time), 'yyyy-MM-dd hh:mm:ss')}
</div>
</div>
<div className={styles.content}>{comment?.content}</div>
</div>
</div>
))}
</div>
评论发布接口
这里 有 两个逻辑接口,一个是 发布评论的接口,一个是 获取所有评论数据的接口
首先 编写 发布评论的接口
1.首先获取 参数,一个参数是文章的 id,一个是评论的内容
2.将这两个参数 传给 发布评论的接口
post('/api/comment/publish', {
articleId: article?.id,
content: inputVal,
});
3.接下来 看下 发布评论的接口
4.新建 pages/api/comment/publish.ts
5.引入 数据库 和 session 的配置
import { NextApiRequest, NextApiResponse } from 'next';
import { withIronSessionApiRoute } from 'iron-session/next';
import { ironOptions } from 'config/index';
import { ISession } from 'pages/api/index';
import { prepareConnection } from 'db/index';
import { User, Article, Comment } from 'db/entity/index';
import { EXCEPTION_COMMENT } from 'pages/api/config/codes';
6.通过 传过来的参数 获取 文章 id 和 评论的内容
const { articleId = 0, content = '' } = req.body;
7.链接 评论接口的 数据库
const db = await prepareConnection();
const commentRepo = db.getRepository(Comment);
const comment = new Comment();
8.实例化 Comment 类,根据 session 信息,从 users 表中查询 当前用户,根据文章 id,查询文章信息,将这些信息全部添加到 comment 实例中,保存到 comment 表中
const comment = new Comment();
comment.content = content;
comment.create_time = new Date();
comment.update_time = new Date();
const user = await db.getRepository(User).findOne({
id: session?.userId,
});
const article = await db.getRepository(Article).findOne({
id: articleId,
});
if (user) {
comment.user = user;
}
if (article) {
comment.article = article;
}
const resComment = await commentRepo.save(comment);
9.如果保存成功,则提示发布成功,否则提示发布失败
if (resComment) {
res.status(200).json({
code: 0,
msg: '发表成功',
data: resComment,
});
} else {
res.status(200).json({
...EXCEPTION_COMMENT.PUBLISH_FAILED,
});
}
10.当调用发布接口成功的时候,提示发布成功,并且将新发布的评论 添加到 评论列表中,显示在评论中。同时把评论框的内容清空。注意这个将 新发布的评论 添加到 评论列表的时候,使用 react 的不可变原则,使用 concat 方法。
request
.post('/api/comment/publish', {
articleId: article?.id,
content: inputVal,
})
.then((res: any) => {
if (res?.code === 0) {
message.success('发表成功');
const newComments = [
{
id: Math.random(),
create_time: new Date(),
update_time: new Date(),
content: inputVal,
user: {
avatar: loginUserInfo?.avatar,
nickname: loginUserInfo?.nickname,
},
},
].concat([...(comments as any)]);
setComments(newComments);
setInputVal('');
} else {
message.error('发表失败');
}
});
11.最后拿到最新的 评论列表,将评论列表 遍历 渲染到页面上
<div className={styles.display}>
{comments?.map((comment: any) => (
<div className={styles.wrapper} key={comment?.id}>
<Avatar src={comment?.user?.avatar} size={40} />
<div className={styles.info}>
<div className={styles.name}>
<div>{comment?.user?.nickname}</div>
<div className={styles.date}>
{format(new Date(comment?.update_time), 'yyyy-MM-dd hh:mm:ss')}
</div>
</div>
<div className={styles.content}>{comment?.content}</div>
</div>
</div>
))}
</div>
标签管理
首先 新建 pages/tag/index.tsx 和 pages/tag/index.module.scss 分别 存放 标签的 页面和样式
这个页面 我们采用 csr 的方式来渲染页面,看看和 ssr 渲染页面的方式有何不同
在这个页面 我们设计成 全部标签 和关注的标签,页面效果如下:
首先 我们 先 编写接口, 来获取 全部标签和已关注的标签
新建 pages/api/tag/get.ts
1.首先 引入 数据库等的配置
import { NextApiRequest, NextApiResponse } from 'next';
import { withIronSessionApiRoute } from 'iron-session/next';
import { ironOptions } from 'config/index';
import { ISession } from 'pages/api/index';
import { prepareConnection } from 'db/index';
import { Tag } from 'db/entity/index';
2.通过 session 获取 当前用户的 id,因为我们需要根据用户 id 获取该用户的标签数据
const { userId = 0 } = session;
3.链接 标签 数据库的 配置
const db = await prepareConnection();
const tagRepo = db.getRepository(Tag);
4.首先 获取 全部标签的数据,这个我们只需要 根据 关联 的用户表去 标签的 数据表 查询即可
const allTags = await tagRepo.find({
relations: ['users'],
});
5.接下来 获取 关注的标签,关注的标签逻辑是,根据当前用户的 id 去查询标签数据,这样获取的数据就是该用户关注的标签数据
const followTags = await tagRepo.find({
relations: ['users'],
where: (qb: any) => {
qb.where('user_id = :id', {
id: Number(userId),
});
},
});
6.最后将 获取的 所有标签数据 和 关注的标签数据 返回
res?.status(200)?.json({
code: 0,
msg: '',
data: {
followTags,
allTags,
},
});
7.接下来 我们在客户端 使用 csr 的方式 来获取 全部标签和已关注的标签数据。同 followTags 和 allTags 来分别存储全部标签数据和已关注的标签数据
const [followTags, setFollowTags] = useState<ITag[]>();
const [allTags, setAllTags] = useState<ITag[]>();
useEffect(() => {
request('/api/tag/get').then((res: any) => {
if (res?.code === 0) {
const { followTags = [], allTags = [] } = res?.data || {};
setFollowTags(followTags);
setAllTags(allTags);
}
});
}, [needRefresh]);
8.接下来 来渲染 全部标签的数据,这里有个逻辑,就是 显示 关注 还是已关注。当 当前用户 id 能够在 接口返回的 users 中返回的 id 中能够找打,则表明 当前用户 已关注了 这个标签,则页面上显示 已关注,否则显示关注。当显示已关注的时候,按钮事件则是 取消关注的逻辑,否则则是 关注的逻辑。
<TabPane tab="全部标签" key="all" className={styles.tags}>
{allTags?.map((tag) => (
<div key={tag?.title} className={styles.tagWrapper}>
<div>{(ANTD_ICONS as any)[tag?.icon]?.render()}</div>
<div className={styles.title}>{tag?.title}</div>
<div>
{tag?.follow_count} 关注 {tag?.article_count} 文章
</div>
{tag?.users?.find((user) => Number(user?.id) === Number(userId)) ? (
<Button type="primary" onClick={() => handleUnFollow(tag?.id)}>
已关注
</Button>
) : (
<Button onClick={() => handleFollow(tag?.id)}>关注</Button>
)}
</div>
))}
</TabPane>
9.首先 编写 关注 标签的逻辑,新建 pages/api/tag/follow.ts
10.首先引入数据库配置
import { NextApiRequest, NextApiResponse } from 'next';
import { withIronSessionApiRoute } from 'iron-session/next';
import { ironOptions } from 'config/index';
import { ISession } from 'pages/api/index';
import { prepareConnection } from 'db/index';
import { Tag, User } from 'db/entity/index';
import { EXCEPTION_USER, EXCEPTION_TAG } from 'pages/api/config/codes';
export default withIronSessionApiRoute(follow, ironOptions);
11.从 session 获取用户的 id
const session: ISession = req.session;
const { userId = 0 } = session;
12.从 body 中获取 前端传过来的参数,一共两个参数,一个 type,值分别是 follow 和 unfollow,表示是取消关注还是关注,另外一个参数数标签的 id
const { tagId, type } = req?.body || {};
13.链接 标签和用户的数据库
const db = await prepareConnection();
const tagRepo = db.getRepository(Tag);
const userRepo = db.getRepository(User);
14.根据用户 id 去用户表中查询该用户信息,如果没找到,则提示当前用户不存在
const user = await userRepo.findOne({
where: {
id: userId,
},
});
if (!user) {
res?.status(200).json({
...EXCEPTION_USER?.NOT_LOGIN,
});
return;
}
15.根据标签 id 从标签的数据表中查询所有标签
const tag = await tagRepo.findOne({
relations: ['users'],
where: {
id: tagId,
},
});
16.如果从标签表中查询出有用户,如果类型是 follow,则表示是关注操作,则将当前用户添加到 关注该标签的用户数据中,并且将关注该标签的数据增加 1,如果类型是 unfollow,则表示取消关注操作,则将当前用户从 关注该标签的用户数据中剔除,并且将关注该标签的数据减 1.
if (tag?.users) {
if (type === 'follow') {
tag.users = tag?.users?.concat([user]);
tag.follow_count = tag?.follow_count + 1;
} else if (type === 'unfollow') {
tag.users = tag?.users?.filter((user) => user.id !== userId);
tag.follow_count = tag?.follow_count - 1;
}
}
17.最后将 标签的数据存入 标签的数据表中,如果成功,则返回 200,否则提示失败
if (tag) {
const resTag = await tagRepo?.save(tag);
res?.status(200)?.json({
code: 0,
msg: '',
data: resTag,
});
} else {
res?.status(200)?.json({
...EXCEPTION_TAG?.FOLLOW_FAILED,
});
}
18.在前端点击关注的时候,传入两个参数,一个参数是 type,值为 follw,另外一个参数是标签 id,如果接口成功,在前端提示关注成功,并且重新调标签的数据,刷新页面
request
.post('/api/tag/follow', {
type: 'follow',
tagId,
})
.then((res: any) => {
if (res?.code === 0) {
message.success('关注成功');
setNeedRefresh(!needRefresh);
} else {
message.error(res?.msg || '关注失败');
}
});
19.取消关注,则是将 type 参数的值改成 unfollow。
这样完成了标签管理功能。
个人中心页面
首先看下页面的效果
接下来 就按照 这个设计 来编写代码
个人中心页面,我们使用 ssr 的方式来渲染
1.首先引入数据库等的配置
/* eslint-disable @next/next/link-passhref */
import React from 'react';
import Link from 'next/link';
import { observer } from 'mobx-react-lite';
import { Button, Avatar, Divider } from 'antd';
import { CodeOutlined, FireOutlined, FundViewOutlined } from '@ant-design/icons';
import ListItem from 'components/ListItem';
import { prepareConnection } from 'db/index';
import { User, Article } from 'db/entity';
2.通过 ssr 的方式获取用户信息和文章相关的数据
3.根据 url 获取当前用户的 id
const userId = params?.id;
4.根据当前用户的 id 查询 从用户表中查询当前用户的信息
const user = await db.getRepository(User).findOne({
where: {
id: Number(userId),
},
});
5.根据用户 id 以及关联的用户表和标签表查询相关联的文章
const articles = await db.getRepository(Article).find({
where: {
user: {
id: Number(userId),
},
},
relations: ['user', 'tags'],
});
6.最后将上面两个数据返回
return {
props: {
userInfo: JSON.parse(JSON.stringify(user)),
articles: JSON.parse(JSON.stringify(articles)),
},
};
7.在前端 通过 props 拿到 数据
const { userInfo = {}, articles = [] } = props;
8.获取 全部文章的 总浏览数
const viewsCount = articles?.reduce((prev: any, next: any) => prev + next?.views, 0);
9.最后将 所有的数据渲染出来
<div className={styles.userDetail}>
<div className={styles.left}>
<div className={styles.userInfo}>
<Avatar className={styles.avatar} src={userInfo?.avatar} size={90} />
<div>
<div className={styles.nickname}>{userInfo?.nickname}</div>
<div className={styles.desc}>
<CodeOutlined /> {userInfo?.job}
</div>
<div className={styles.desc}>
<FireOutlined /> {userInfo?.introduce}
</div>
</div>
<Link href="/user/profile">
<Button>编辑个人资料</Button>
</Link>
</div>
<Divider />
<div className={styles.article}>
{articles?.map((article: any) => (
<div key={article?.id}>
<ListItem article={article} />
<Divider />
</div>
))}
</div>
</div>
<div className={styles.right}>
<div className={styles.achievement}>
<div className={styles.header}>个人成就</div>
<div className={styles.number}>
<div className={styles.wrapper}>
<FundViewOutlined />
<span>共创作 {articles?.length} 篇文章</span>
</div>
<div className={styles.wrapper}>
<FundViewOutlined />
<span>文章被阅读 {viewsCount} 次</span>
</div>
</div>
</div>
</div>
</div>
10.这里有个地方是 编辑 个人资料的 入口,点击 跳转到 编辑个人资料的页面
<Link href="/user/profile">
<Button>编辑个人资料</Button>
</Link>
首先看下 编辑个人资料的页面
这里的逻辑就是 首先 从接口 获取当前用户的信息,然后修改个人信息,最后 保存修改。
1.首先通过接口获取用户信息
useEffect(() => {
request.get('/api/user/detail').then((res: any) => {
if (res?.code === 0) {
console.log(333333);
console.log(res?.data?.userInfo);
form.setFieldsValue(res?.data?.userInfo);
}
});
}, [form]);
2.接着将用户信息渲染到表单中
return (
<div className="content-layout">
<div className={styles.userProfile}>
<h2>个人资料</h2>
<div>
<Form {...layout} form={form} className={styles.form} onFinish={handleSubmit}>
<Form.Item label="用户名" name="nickname">
<Input placeholder="请输入用户名" />
</Form.Item>
<Form.Item label="职位" name="job">
<Input placeholder="请输入职位" />
</Form.Item>
<Form.Item label="个人介绍" name="introduce">
<Input placeholder="请输入个人介绍" />
</Form.Item>
<Form.Item {...tailLayout}>
<Button type="primary" htmlType="submit">
保存修改
</Button>
</Form.Item>
</Form>
</div>
</div>
</div>
);
3.最后调用保存修改的接口 将 修改后的数据 更新到 数据表中
const handleSubmit = (values: any) => {
console.log(99999);
console.log(values);
request.post('/api/user/update', { ...values }).then((res: any) => {
if (res?.code === 0) {
message.success('修改成功');
} else {
message.error(res?.msg || '修改失败');
}
});
};
部署
最后我们使用 vercel 进行部署,体验地址:博客系统
总结
通过这篇文章,我们实操了全栈博客系统开发。
我们应用了前后端技术栈:
· Next.js+React
· Typescript
· Antd
· Node
· MySQL
提高了全栈开发能力:
· 掌握数据表设计基本思想
· 掌握 Next.js 框架的使用
理解并应用 SSR 同构原理:
· 前端注水及页面接管
· 服务端渲染及数据预取
希望这篇文章能够带你进入全栈开发。