Nextjs博客系统
Contents
1 数据库设计与搭建
1.1 需求分析
- 主要的表有
users
、posts
、comments
- users(id/username/passwordDigest)
- posts(id/userId/title/content)
- comments(id/userId/postId/content)
1.2 创建表
-
创建并启动容器,清除之前的开发环境,创建数据库
-
1 2 3 4 5 6 7 8 9 10 11 12
mkdir blog-data // -v 将宿主机目录挂载到容器中 docker run -v "$PWD/blog-data":/var/lib/postgresql/data -p 5432:5432 -e POSTGRES_USER=blog -e POSTGRES_HOST_AUTH_METHOD=trust -d postgres:12.2 docker ps docker kill 容器id docker rm 容器id rm -rf blog-data docker exec -it <id> bash psql -U blog CREATE DATABASE blog_development ENCODING 'UTF8' LC_COLLATE 'en_US.utf8' LC_CTYPE 'en_US.utf8';
-
-
启动项目并启用babel实时编译ts文件
-
1 2 3 4 5 6 7 8
// package.json "dev": "concurrently \"next dev\" \"babel -w ./src --out-dir dist --extensions .ts,.tsx\"", "m:create": "typeorm migration:create", "m:run": "typeorm migration:run", "m:revert": "typeorm migration:revert", "e:create": "typeorm entity:create" // shell yarn dev
-
-
使用migration创建表
-
Package.json
1 2 3
"m:create": "typeorm migration:create", "m:run": "typeorm migration:run", "m:revert": "typeorm migration:revert",
-
创建users
-
shell
1
yarn m:create -n CreateUsers
-
CreateUsers.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
import { MigrationInterface, QueryRunner, Table } from "typeorm"; export class CreateUsers1644649056271 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise<void> { // 别忘加await await queryRunner.createTable( new Table({ name: "users", columns: [ { name: "id", isGenerated: true, type: "int", generationStrategy: "increment", isPrimary: true, }, { name: "username", type: "varchar" }, { name: "password_digest", type: "varchar" }, ], }) ); } public async down(queryRunner: QueryRunner): Promise<void> { // 利用try catch处理回退时的错误,防止错误阻塞回退过程 try { await queryRunner.dropTable("users"); } catch (error) {} } }
-
-
创建posts
-
shell
1
yarn m:create -n CreatePosts
-
CreatePosts.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
await queryRunner.createTable( new Table({ name: "posts", columns: [ { name: "id", type: "int", isGenerated: true, generationStrategy: "increment", isPrimary: true, }, { name: "title", type: "varchar" }, { name: "content", type: "text" }, { name: "author_id", type: "int" }, ], }) ); }
-
-
创建comments
-
shell
1
yarn m:create -n CreateComments
-
CreateComments.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
await queryRunner.createTable(new Table({ name: "comments", columns: [ { name: "id", type: "int", isGenerated: true, generationStrategy: "increment", isPrimary: true, }, { name: "user_id", type: "int" }, { name: "post_id", type: "int" }, { name: "content", type: "text" }, ], }));
-
-
1.3 统一命名风格
-
每个表格添加createdAt和updatedAt字段
-
shell
1
yarn m:create -n AddCreatedAtAndUpdatedAt
-
AddCreatedAtAndUpdatedAt.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; export class AddCreatedAtAndUpdatedAt1644649977354 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise<void> { // 分别向三个表添加CreatedAt和UpdatedAt字段 await queryRunner.addColumns("users", [ new TableColumn({ name: "createdAt", type: "timestamp", isNullable: false, default: "now()", }), new TableColumn({ name: "updatedAt", type: "timestamp", isNullable: false, default: "now()", }), ]); await queryRunner.addColumns("posts", [ new TableColumn({ name: "createdAt", type: "timestamp", isNullable: false, default: "now()", }), new TableColumn({ name: "updatedAt", type: "timestamp", isNullable: false, default: "now()", }), ]); await queryRunner.addColumns("comments", [ new TableColumn({ name: "createdAt", type: "timestamp", isNullable: false, default: "now()", }), new TableColumn({ name: "updatedAt", type: "timestamp", isNullable: false, default: "now()", }), ]); } public async down(queryRunner: QueryRunner): Promise<void> { try { await queryRunner.dropColumn("users", "createdAt"); await queryRunner.dropColumn("users", "updatedAt"); await queryRunner.dropColumn("posts", "createdAt"); await queryRunner.dropColumn("posts", "updatedAt"); await queryRunner.dropColumn("comments", "createdAt"); await queryRunner.dropColumn("comments", "updatedAt"); } catch (error) {} } }
-
-
统一js命名风格,即利用驼峰命名法修改全部下滑线字段
-
shell
1
yarn m:create -n RenameColumns
-
RenameColumns.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
import { MigrationInterface, QueryRunner } from "typeorm"; export class RenameColumns1644650930326 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.renameColumn( "users", "password_digest", "passwordDigest" ); await queryRunner.renameColumn("posts", "author_id", "authorId"); await queryRunner.renameColumn("comments", "user_id", "userId"); await queryRunner.renameColumn("comments", "post_id", "postId"); } public async down(queryRunner: QueryRunner): Promise<void> { try { await queryRunner.renameColumn( "users", "passwordDigest", "password_digest" ); await queryRunner.renameColumn("posts", "authorId", "author_id"); await queryRunner.renameColumn("comments", "userId", "user_id"); await queryRunner.renameColumn("comments", "postId", "post_id"); } catch (error) {} } }
-
1.4 创建实体并创建数据关联
-
分析三个表直接的关联关系
- users
- a user has many posts (oneToMany)
- a user has many comments (oneToMany)
- posts
- a post belongs to a user (manyToOne)
- a post has many comments (oneToMany)
- comments
- a comment belongs to a user (manyToOne)
- a comment belongs to a post (manyToOne)
- users
-
shell
1 2 3
yarn e:create -n User yarn e:create -n Post yarn e:create -n Comment
-
User.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn, } from "typeorm"; import { Post } from "./Post"; import { Comment } from "./Comment"; @Entity("users") export class User { @PrimaryGeneratedColumn("increment") id: number; @Column("varchar") username: string; @Column("varchar") passwordDigest: string; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; // 用户与文章的一对多关系 @OneToMany((type) => Post, (post) => post.author) posts: Post[]; // 用户与评论的一对多关系 @OneToMany((type) => Comment, (comment) => comment.user) comments: Comment[]; }
-
Post.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
import { Column, CreateDateColumn, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn, } from "typeorm"; import { User } from "./User"; import { Comment } from "./Comment"; @Entity("posts") export class Post { @PrimaryGeneratedColumn("increment") id: number; @Column("varchar") title: string; @Column("text") content: string; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; // 文章与用户的多对一关联 @ManyToOne((type) => User, (user) => user.posts) author: User; // 文章与评论的一对多关联 @OneToMany((type) => Comment, (comment) => comment.post) comments: Comment[]; }
-
Comment.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn, } from "typeorm"; import { User } from "./User"; import { Post } from "./Post"; @Entity("comments") export class Comment { @PrimaryGeneratedColumn("increment") id: number; @Column("text") content: string; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; // 评论与用户的多对一关联 @ManyToOne((type) => User, (user) => user.comments) user: User; // 评论与文章的多对一关联 @ManyToOne((type) => Post, (post) => post.comments) post: Post; }
1.5 使用seed填充数据
-
seed.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
import "reflect-metadata"; import { createConnection } from "typeorm"; import { Post } from "./entity/Post"; import { User } from "./entity/User"; import { Comment } from "./entity/Comment"; createConnection() .then(async (connection) => { const { manager } = connection; // 创建user1 const u1 = new User(); u1.username = "gsq"; u1.passwordDigest = "xxx"; await manager.save(u1); // 创建post1 const p1 = new Post(); p1.title = "Post 1"; p1.content = "My First Post"; // 创建关联后,可以直接以对象的方式赋值 p1.author = u1; await manager.save(p1); // 创建comment1 const c1 = new Comment(); c1.content = "Awsome!"; c1.post = p1; c1.user = u1; await manager.save(c1); connection.close(); }) .catch((error) => console.log(error));
-
运行
1 2 3
yarn dev yarn m:run node dist/seed.js
2 配置连接并展示数据
2.1 解决页面新建与获取connection的bug
-
需求:在next.js的页面中获取typeorm的connection,进行数据操作
-
首先想到在index.tsx中利用createConnection这个API创建连接,但会在刷新时报重复创建connection的错误
-
尝试使用getConnection的API获取connection,但第一次运行项目时就会报错,因为没有创建connection
-
于是尝试使用create+get结合来创建并获取connection,
-
为了保证create只在项目开始运行时执行一次,create的方法使用立即执行函数来写,并返回这个connection
-
get方法就将这个create方法返回出去,每次get都返回的是create方法创建的同一个connection
-
第一次运行成功,刷新页面也不会报错
-
但当我们改动index.tsx的代码,next.js发现代码变动后,next.js服务器会重新加载整个index.tsx,包括我们写的getDatabaseConnection方法
-
那么connection将再一次被创建,又报了重复创建connection的错误
-
最终,使用typeorm提供的getConnectionManager创建的manager对象来新建与获取connection,而非直接createConnection,从而解决了重复创建connection的问题
-
这里有一个小细节,就是manager的API是存在于node_modules中的,因此代码变动后nextjs重新加载文件,会忽略node_modules中的文件,使得我们用manager创建的connection被保留下来,不会重置
-
lib/getDatabaseConnection.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
import { createConnection, getConnectionManager } from "typeorm"; import { Post } from "src/entity/Post"; import { User } from "src/entity/User"; import { Comment } from "src/entity/Comment"; // 引入数据库配置文件 import config from "ormconfig.json"; // 引入reflect-metedata import "reflect-metadata"; const create = async () => { // @ts-ignore return createConnection({ ...config, entities: [Post, User, Comment], }); }; const promise = (async function () { // 最终结果总是关闭之前的连接,新增一个新的连接 const manager = getConnectionManager(); const current = manager.has("default") && manager.get("default"); if (current) { await current.close(); } return create(); })(); export async function getDatabaseConnection() { // 将连接暴露出去 return promise; }
2.2 SSR展示数据
-
index.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
import { GetServerSideProps, NextPage } from "next"; import { getDatabaseConnection } from "lib/getDatabaseConnection"; import { Post } from "src/entity/Post"; import Link from "next/link"; type Props = { posts: Post[]; }; const index: NextPage<Props> = (props) => { const { posts } = props; return ( <div> <h1>文章列表</h1> {posts.map((post) => ( <Link key={post.id} href={`/posts/${post.id}`}> <a>{post.title}</a> </Link> ))} </div> ); }; export default index; export const getServerSideProps: GetServerSideProps = async (context) => { const connection = await getDatabaseConnection(); const posts = await connection.manager.find(Post); return { props: { // 将时间格式转换为字符串 posts: JSON.parse(JSON.stringify(posts)), }, }; };
2.3 文章详情页展示
-
posts/[id].tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
import React from "react"; import { GetServerSideProps, NextPage } from "next"; import { getDatabaseConnection } from "lib/getDatabaseConnection"; import { Post } from "src/entity/Post"; type Props = { post: Post; }; const postsShow: NextPage<Props> = (props) => { const { post } = props; return ( <div> <h1>{post.title}</h1> <article dangerouslySetInnerHTML={{ __html: post.content }}></article> </div> ); }; export default postsShow; export const getServerSideProps: GetServerSideProps<any, { id: string }> = async (context) => { const connection = await getDatabaseConnection(); // context.params获取url中的参数 const post = await connection.manager.findOne(Post, context.params.id); return { props: { // 将时间格式转为字符串 post: JSON.parse(JSON.stringify(post)), }, }; };
3 注册页面
3.1 注册页面
-
创建sign_up.tsx,为表单绑定state,并使用axios发送注册请求
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75
import axios from "axios"; import { NextPage } from "next"; import { useCallback, useState } from "react"; const signUp: NextPage = () => { const [formData, setFormData] = useState({ username: "", password: "", passwordConfirm: "", }); const onSubmit = useCallback( (e) => { // 禁用默认事件 e.preventDefault(); axios.post("api/v1/users", formData); }, [formData] ); return ( <> <h1>注册页面</h1> <form onSubmit={onSubmit}> <div> <label> 用户名:{" "} <input type="text" value={formData.username} onChange={(e) => { setFormData({ ...formData, username: e.target.value, }); }} /> </label> </div> <div> <label> 密码:{" "} <input type="password" value={formData.password} onChange={(e) => { setFormData({ ...formData, password: e.target.value, }); }} /> </label> </div> <div> <label> 确认密码:{" "} <input type="password" value={formData.passwordConfirm} onChange={(e) => { setFormData({ ...formData, passwordConfirm: e.target.value, }); }} /> </label> </div> <div> <button type="submit">注册</button> </div> </form> </> ); }; export default signUp;
-
创建
api/v1/users.tsx
,可以在req参数中获取请求体,进行后端数据校验等操作1 2 3 4 5 6 7 8 9
import { NextApiHandler } from "next"; const Users: NextApiHandler = async (req, res) => { // 在req.body中获取请求数据 const { username, password, passwordConfirm } = req.body; // 可以在此进行密码与确认密码校验 ... }; export default Users;
3.2 注册表单的数据校验
-
例如用户名密码是否为空的校验比较简单,这里不做赘述
-
对于用户名唯一性的校验可以有两种思路:
-
数据库层校验:利用
migration
在username
列加上索引,再添加索引前,确保使用delete from users;
清空users表中数据1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
import { MigrationInterface, QueryRunner, TableIndex } from "typeorm"; export class AddUniqueUsernameToUsers1644901939033 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.createIndex( "users", new TableIndex({ // 这里名字随便起,建议与列名相关 name: "users_username", columnNames: ["username"], isUnique: true, }) ); } public async down(queryRunner: QueryRunner): Promise<void> { await queryRunner.dropIndex("users", "users_username"); } }
-
后台应用层校验:寻找并判断是否有重复的username
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
// api/v1/users.tsx import { getDatabaseConnection } from "lib/getDatabaseConnection"; import md5 from "md5"; import { NextApiHandler } from "next"; import { User } from "src/entity/User"; const Users: NextApiHandler = async (req, res) => { // 在req.body中获取请求数据 const { username, password, passwordConfirm } = req.body; // 防止出现中文字符乱码,要加charset res.setHeader("Content-Type", "application/json;Chartset:UTF-8"); const connection = await getDatabaseConnection(); // 错误收集 const errors = { username: [], password: [], passwordConfirm: [], } as any; // 表单校验 if (username.trim() === "") { errors.username.push("不能为空"); } if (!/[a-zA-Z0-9]/.test(username.trim())) { errors.username.push("格式不合法"); } if (username.trim().length > 42) { errors.username.push("太长"); } if (username.trim().length <= 3) { errors.username.push("太短"); } if (password === "") { errors.password.push("不能为空"); } if (password !== passwordConfirm) { errors.passwordConfirm.push("密码不匹配"); } const hasUser = await connection.manager.find(User, { username }); if (hasUser.length > 0) { errors.username.push("用户名已存在"); } // 判断有无错误 const hasError = !!Object.values(errors).flat(Infinity).length; if (hasError) { // 422表示不可处理的实体 res.statusCode = 422; res.write(JSON.stringify(errors)); } else { // 校验通过,保存user至数据库 const user = new User(); user.username = username; // 密码暂时使用md5加密,但不推荐 user.passwordDigest = md5(password); await connection.manager.save(user); res.statusCode = 200; res.write(JSON.stringify(user)); } // 最后关闭响应 res.end(); }; export default Users;
-
-
sign_up.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
// 错误数据 const [errorData, setErrorData] = useState({ username: [], password: [], passwordConfirm: [], }); const onSubmit = useCallback( (e) => { // 禁用默认事件 e.preventDefault(); axios.post("api/v1/users", formData).then( (res) => { if (res.status === 200) { window.alert('注册成功') window.location.href('/sign_in') } }, (error) => { if (error.response) { if (error.response.status === 422) { setErrorData(error.response.data); } } } ); }, [formData] ); ... <div> <label> 用户名:{" "} <input type="text" value={formData.username} onChange={(e) => { setFormData({ ...formData, username: e.target.value, }); }} /> </label> </div> <div> {errorData.username?.length > 0 && errorData.username.join(",")} </div> ...
3.3 在Entity中进行校验
-
校验方法可以放在Entity中作为实例方法来调用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91
import { BeforeInsert, Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn, } from "typeorm"; import { Post } from "./Post"; import { Comment } from "./Comment"; import { getDatabaseConnection } from "lib/getDatabaseConnection"; import md5 from "md5"; import _ from "lodash"; @Entity("users") export class User { @PrimaryGeneratedColumn("increment") id: number; @Column("varchar") username: string; @Column("varchar") passwordDigest: string; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; // 用户与文章的一对多关系 @OneToMany((type) => Post, (post) => post.author) posts: Post[]; // 用户与评论的一对多关系 @OneToMany((type) => Comment, (comment) => comment.user) comments: Comment[]; // 以上为users表的列配置 // 以下为实例属性与方法 errors = { username: [] as string[], password: [] as string[], passwordConfirm: [] as string[], }; // 避免与passwordDigest混淆 password: string; passwordConfirm: string; // 校验方法,涉及到查询数据库,因此是异步的 async validate() { if (this.username.trim() === "") { this.errors.username.push("不能为空"); } if (!/[a-zA-Z0-9]/.test(this.username.trim())) { this.errors.username.push("格式不合法"); } if (this.username.trim().length > 42) { this.errors.username.push("太长"); } if (this.username.trim().length <= 3) { this.errors.username.push("太短"); } if (this.password === "") { this.errors.password.push("不能为空"); } if (this.password !== this.passwordConfirm) { this.errors.passwordConfirm.push("密码不匹配"); } const hasUser = await ( await getDatabaseConnection() ).manager.find(User, { username: this.username }); if (hasUser.length > 0) { this.errors.username.push("用户名已存在"); } } // 是否有错误 hasErrors() { return !!Object.values(this.errors).flat(Infinity).length; } // 在向数据库存入数据前,处理密码加密 @BeforeInsert() generatePasswordDigest() { this.passwordDigest = md5(this.password); } // 类中如果有toJSON方法,其返回值则代表此类的实例stringify后的值 // 也就是说只要返回JSON.stringify(user)都不会有密码相关的数据出现 toJSON() { return _.omit(this, [ "password", "passwordConfirm", "passwordDigest", "errors", ]); } }
-
在
api/v1/users
中调用实例方法进行校验即可1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
import { getDatabaseConnection } from "lib/getDatabaseConnection"; import md5 from "md5"; import { NextApiHandler } from "next"; import { User } from "src/entity/User"; const Users: NextApiHandler = async (req, res) => { // 在req.body中获取请求数据 const { username, password, passwordConfirm } = req.body; res.setHeader("Content-Type", "application/json;Chartset:UTF-8"); const connection = await getDatabaseConnection(); const user = new User(); user.username = username; user.password = password; user.passwordConfirm = passwordConfirm; await user.validate(); if (user.hasErrors()) { res.statusCode = 422; res.write(JSON.stringify(user.errors)); } else { await connection.manager.save(user); res.statusCode = 200; res.write(JSON.stringify(user)); } // 最后关闭响应 res.end(); }; export default Users;
4 登录页面及session
登录的实质就是与服务器创建一个会话
4.1 登录页面
创建登录页面pages/sign_in.tsx
,复制pages/sign_up.tsx稍作改动即可;
4.2 登录校验
将登录校验逻辑作为一个class拆离至src/model/SignIn.ts
中
|
|
api/v1/sessions.tsx
负责发起校验并处理返回数据
|
|
4.3 使用session记录登录状态
4.3.1 session概念
- session和cookie是用来标记用户登录状态的
- 用户第一次向服务器发送登录请求时,服务器端会为用户生成一个session
- 客户端将用户名及密码发送给服务器并验证成功后,服务器端会将用户信息存储到session中,同时将用户数据加密返回(set-cookie)给客户端,客户端再将此数据保存到cookie
- 之后此用户再次向服务器端发送请求时,浏览器会自动将该站点的cookie混入请求头中
- 服务器会对cookie进行解密,并与session对照,从而实现登录状态验证
4.3.2 封装withSession.ts文件
-
由于Next.js对session没有原生支持,我们只能通过第三方库来实现session
-
安装依赖
1
yarn add next-iron-session
-
创建
lib/withSession.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
import { NextApiHandler } from "next"; import { withIronSession } from "next-iron-session"; export default function withSession(handler: NextApiHandler) { return withIronSession(handler, { // cookie加密所需的随机字符串 // password: process.env.SECRET_COOKIE_PASSWORD, password: "56da34b1-fa9c-4c82-868f-0d8b349f797d", cookieName: "blog", cookieOptions: { // 如为true,则只允许https协议操作cookie secure: false, }, }); }
-
利用中间件的原理,将sessions用withSession方法包裹,扩展NextApiHandler的req参数,使其具有session属性
1 2 3
// api/v1/sessions.tsx ... export default withSession(Sessions)
-
由于此插件对ts支持不佳,当访问
req.session
时ts会报错,因此我们需要在类型声明文件中对NextApiRequest类型进行扩展1 2 3 4 5 6 7 8
// next-env.d.ts import * as next from "next"; declare module "next" { import { Session } from "next-iron-session"; interface NextApiRequest { session: Session; } }
4.3.3 生成并保存session
-
登录成功时生成session并保存至服务器端
1 2 3
// 登录成功时将user保存至服务器的session['currentUser'] req.session.set("currentUser", signIn.user); await req.session.save();
-
此时登录成功的响应头会有set-cookie字段,浏览器会将此cookie存入该站点的Cookie中
1 2 3 4 5 6 7 8
HTTP/1.1 200 OK Content-Type: application/json;charset:utf-8 set-cookie: blog=Fe26.2*1*9cc85c3da9b14dcc408a9c80f60265edc0e5ba89c845aa0a20ba3c1f83415e0a*Xp8zXTCzg_IS3Ari5YLeqA*0EnXJifh5oQDGMyIs3m6TX2V0hr0oxvlNuX7IX8kZc-YGFZ9IPG8twFBEsNdBhhGP3X2ppXllfU39DcXNnr56tZDKFtC9ozm2-AJorR6URSQ8upcDRSdgS3uo48Tjm2Y9u40vw4GwzDeB75aktXfHRoNY6wPsz5mfUQ9557SCHSLV_hxDOhmbrfOKqng2aX15IBpFnkH-BEJG8MnXqCCqQ*1646295736104*e6a2d87c292d531d690b4b97e4beff0017d6ec2a89f9c6f0f62e9d2942e94813*XiWncF7phw8vjLsI2WyDr5HmzfikbLqvnbI6SCEFnC0; Max-Age=1295940; Path=/; HttpOnly; SameSite=Lax Vary: Accept-Encoding Date: Wed, 16 Feb 2022 08:22:16 GMT Connection: keep-alive Keep-Alive: timeout=5 Transfer-Encoding: chunked
4.3.4 SSR获取session
-
在
pages/sign_in.tsx
中写getServerSideProps方法后,每次刷新请求页面数据(sign_in.js)时,浏览器都会将本地的cookie放入请求头 -
而我们可以从该请求头中获取并解码该cookie,从而得到currentUser对象
-
pages/sign_in.tsx
1 2 3 4 5 6 7 8 9 10 11 12
... // @ts-ignore export const getServerSideProps: GetServerSideProps = withSession(async (context) => { // @ts-ignore const user = context.req.session.get("currentUser"); return { props: { user: JSON.parse(JSON.stringify(user)), }, }; } );
-
消除
getServerSideProps
中withSession
的@ts-ignore
:1 2 3 4 5 6
// lib/withSession.tsx export default function withSession( handler: NextApiHandler | GetServerSideProps ) // pages/sign_in.tsx export const getServerSideProps: GetServerSideProps = withSession(async (context: GetServerSidePropsContext) => {
4.3.5 Next.js环境变量管理
-
用于加密session的密钥是不能出现在源代码中的,每台服务器都应有自己专属的密钥
-
用两种方式实现
-
利用bash创建环境变量,此种方式创建的优先级更高
1 2 3
export SECRET_COOKIE_PASSWORD=56da34b1-fa9c-4c82-868f-0d8b349f797d // 获取 process.env.SECRET_COOKIE_PASSWORD
-
通过
.env.local
实现本地环境变量管理1 2 3 4
// .env.local SECRET_COOKIE_PASSWORD=56da34b1-fa9c-4c82-868f-0d8b349f797d // 记得将本地文件添加到.gitignore // 获取方式同上
-
5 创建博客
5.1 代码优化
5.1.1 初步抽离Form组件
-
创建博客的页面与登录注册页面一样,都只有一个表单
-
因此可以将每个页面中的form抽离成Form组件
-
components/Form.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
import { ChangeEventHandler, FormEventHandler, ReactChild } from "react"; type Props = { fields: { label: string; type: "text" | "password" | "textarea"; value: string | number; onChange: ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>; errors: string[]; }[]; onSubmit: FormEventHandler; buttons: ReactChild; }; const Form: React.FC<Props> = (props) => { return ( <form onSubmit={props.onSubmit}> {props.fields.map((field) => ( <div> <label> {field.label} <input type={field.type} onChange={field.onChange} value={field.value} /> </label> {field.errors?.length > 0 && <div>{field.errors.join(",")}</div>} </div> ))} <div>{props.buttons}</div> </form> ); }; export default Form;
-
pages/sign_in.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
const signIn: NextPage<{ user: User }> = (props) => { // 表格数据 const [formData, setFormData] = useState(..); // 错误数据 const [errorData, setErrorData] = useState(..); // 表单提交 const onSubmit = useCallback((e) => {...},[formData]); // onChange也可以进一步抽离 const onChange = useCallback( (key, value) => { // key可以用占位符的形式 setFormData({ ...formData, [key]: value }); }, [formData] ); return ( <> {props.user && <div>当前登录的用户为{props.user.username}</div>} <h1>用户登录</h1> <Form onSubmit={onSubmit} fields={[ { label: "用户名", type: "text", value: formData.username, onChange: (e) => onChange("username", e.target.value), errors: errorData.username, }, { label: "密码", type: "password", value: formData.password, onChange: (e) => onChange("password", e.target.value), errors: errorData.password, }, ]} buttons={<button type="submit">登录</button>} /> </> ); }; export default signIn;
5.1.2 进一步抽离成useForm
-
受控组件与非受控组件
- 受控:
<input value={x} onChange={e => x=e.target.value}/>
,数据更新过程由props来主导 - 非受控:
<input defaultValue={x} ref={xRef}>
,数据更新过程由组件内部的state主导,我们只能通过ref绑定的实例获取到x - 最大的区别在于是否参与了组件的数据更新过程
- 受控:
-
web端优化代码的中心思想:方便修改
-
在组件化form表单后,发现各页面还是有重复代码
-
可以将hooks整合并设计为useForm,设计如下
1 2 3 4 5 6
// 将form以非受控组件思想抽离为useForm hook // 传入初始formdata、表单项参数及onSubmit事件,输出处理好的form组件 const { form } = useForm(initFormData, initErrors, fields, onSubmit); return ( <form></form> )
-
hooks/useForm.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
import { FormEventHandler, ReactChild, useCallback, useState } from "react"; type Field<T> = { label: string; type: "text" | "password" | "textarea"; key: keyof T; }; // 由于传入的initFormData类型不能确定,所以用泛型T来表示,在useForm<>中声明泛型T export function useForm<T>( initFormData: T, onSubmit: (formData: T) => void, fields: Field<T>[], buttons: ReactChild ) { const [formData, setFormData] = useState(initFormData); // 根据initFormData生成errorData const [errorData, setErrorData] = useState(() => { // [key in keyof T]表示e中key的类型与T中key的类型相匹配 // 等价于e的下标的类型为('password'|'username') // 由于e的值也不能第一时间确定,因此其下标的类型是可选的? const e: { [key in keyof T]?: string[] } = {}; for (let key in initFormData) { e[key] = []; } return e; }); // onChange接受两个参数,分别是要改变值的key和对应的value const onChange = useCallback( (key: keyof T, value: any) => { // key可以用占位符的形式 setFormData({ ...formData, [key]: value }); }, [formData] ); const _onSubmit = useCallback( (e) => { e.preventDefault(); onSubmit(formData); }, [onSubmit, formData] ); const form = ( <form onSubmit={_onSubmit}> {fields.map((field, index) => ( <div key={index}> <label> {field.label} {field.type === "textarea" ? ( <textarea onChange={(e) => onChange(field.key, e.target.value)}> {formData[field.key]} </textarea> ) : ( <input type={field.type} onChange={(e) => onChange(field.key, e.target.value)} value={formData[field.key].toString()} /> )} </label> {errorData[field.key]?.length > 0 && ( <div>{errorData[field.key].join(",")}</div> )} </div> ))} <div>{buttons}</div> </form> ); return { form, setErrorData }; }
-
useForm应用于登录页面
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
import axios from "axios"; import { useForm } from "hooks/useForm"; import withSession from "lib/withSession"; import { GetServerSideProps, GetServerSidePropsContext, NextPage } from "next"; import { User } from "src/entity/User"; const signIn: NextPage<{ user: User }> = (props) => { const initFormData = { username: "", password: "" }; // 由于initFormData是静态对象数据,因此可以偷懒,直接指定其类型为typeof initFormData即可 const onSubmit = (formData: typeof initFormData) => { axios.post("api/v1/sessions", formData).then( () => { window.alert("登录成功!"); }, (error) => { if (error.response) { if (error.response.status === 422) { setErrorData(error.response.data); } } } ); }; const { form, setErrorData } = useForm( initFormData, onSubmit, [ { label: "用户名", type: "text", key: "username" }, { label: "密码", type: "password", key: "password" }, ], <button type="submit">登录</button> ); return ( <> {props.user && <div>当前登录的用户为{props.user.username}</div>} <h1>用户登录</h1> {form} </> ); }; export default signIn;
5.1.3 究极优化useForm
-
onSubmit的错误处理也是重复的,再次封装为只需提供请求的response与成功提示即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
export function useForm<T>( initFormData: T, submit: { request: (fd: T) => Promise<AxiosResponse<T>>; message: string; }, fields: Field<T>[], buttons: ReactChild ) ... const onSubmit = useCallback( (e) => { e.preventDefault(); submit.request(formData).then( () => { window.alert(submit.message); }, (error) => { if (error.response) { if (error.response.status === 422) { setErrorData(error.response.data); } } } ); }, [submit, formData] );
-
应用于
sign_up
页面1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
import axios from "axios"; import { useForm } from "hooks/useForm"; import { NextPage } from "next"; const signUp: NextPage = () => { const { form } = useForm( { username: "", password: "", passwordConfirm: "" }, { request: (formData) => axios.post("api/v1/users", formData), message: "注册成功", }, [ { label: "用户名", type: "text", key: "username" }, { label: "密码", type: "password", key: "password" }, { label: "确认密码", type: "password", key: "passwordConfirm" }, ], <button type="submit">注册</button> ); return ( <> <h1>注册页面</h1> {form} </> ); }; export default signUp;
5.2 实现创建博客
5.2.1 创建博客页面
-
使用useForm搭建创建博客页面
pages/posts/new.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
import axios from "axios"; import { useForm } from "hooks/useForm"; import { NextPage } from "next"; const createPost: NextPage = () => { const { form } = useForm( { title: "", content: "" }, { request: (formData) => axios.post("/api/v1/posts", formData), message: "提交成功", }, [ { label: "标题", type: "text", key: "title" }, { label: "内容", type: "textarea", key: "content" }, ], <button type="submit">提交</button> ); return <div>{form}</div>; }; export default createPost;
-
pages/api/posts.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
import { getDatabaseConnection } from "lib/getDatabaseConnection"; import withSession from "lib/withSession"; import { NextApiHandler } from "next"; import { Post } from "src/entity/Post"; const Posts: NextApiHandler = withSession(async (req, res) => { // 如果是post方法,说明是新增文章 if (req.method === "POST") { const connection = await getDatabaseConnection(); const { title, content } = req.body; const post = new Post(); post.title = title; post.content = content; const user = req.session.get("currentUser"); post.author = user; await connection.manager.save(post); res.json(post); // res.json相当于以下四行的功能 // res.statusCode = 200; // res.setHeader("Content-Type", "application/json;charset:utf-8"); // res.write(JSON.stringify(req.body)); // res.end() } }); export default Posts;
-
此时会报出
Cannot access 'Post' before initialization
-
这是由于typeorm检测到了环形依赖(即Post与User互相关联),在访问User时,由于User中引用了Post,而此时Post还未初始化,导致报错
-
解决:将装饰器中的类(
type=>User,user=>user.posts
)改写为字符串(‘User’, ‘posts’)
5.2.2 前端统一处理未登录
-
思路:
- 后端如果发现未登录(获取不到currentUser),则返回401
- 前端发现401提示登录,跳转到登录页面并在url中附上return_to参数
- 前端登录成功后,跳转返回return_to
-
hooks/useForm.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
submit: { request: (fd: T) => Promise<AxiosResponse<T>>; success: () => void; }, ... const onSubmit = useCallback( (e) => { e.preventDefault(); submit.request(formData).then(submit.success, (error) => { if (error.response) { if (error.response.status === 422) { setErrorData(error.response.data); } else if (error.response.status === 401) { window.alert("请先登录"); window.location.href = "/sign_in?return_to=/posts/new"; } } }); },[submit, formData]);
-
需要将首页index.tsx与posts/index.tsx代码复用,可以将首页代码复制到posts/index中,在首页引入即可。同时实现创建完成后返回首页
-
pages/index.tsx
1 2 3
import PostIndex, { getServerSideProps } from "pages/posts"; export default PostIndex; export { getServerSideProps };
6 后端分页
- 后端分页常用的方案是在请求url中附上page和pageSize,但不适用于数据变动频率较大的服务器
- 另外一种方案:url参数采用
id+offset
的形式,即从id那条数据开始,向后查询offset条数据 - 对于博客系统,采用第一种方案
6.1 初步实现
-
pages/posts/index.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
import { GetServerSideProps, NextPage } from "next"; import { getDatabaseConnection } from "lib/getDatabaseConnection"; import { Post } from "src/entity/Post"; import Link from "next/link"; import qs from "query-string"; type Props = { posts: Post[]; count: number; page: number; pageSize: number; }; const PostsIndex: NextPage<Props> = (props) => { const { posts, count, page, pageSize } = props; return ( <div> <h1>文章列表</h1> {posts.map((post) => ( <div> <Link key={post.id} href={`/posts/${post.id}`}> <a>{post.title}</a> </Link> </div> ))} <footer> 共{count}篇文章,当前是第{page}页,每页{pageSize}条 <div> <Link href={`?page=${page - 1}`}> <a>上一页</a> </Link> <Link href={`?page=${page + 1}`}> <a>下一页</a> </Link> </div> </footer> </div> ); }; export default PostsIndex; export const getServerSideProps: GetServerSideProps = async (context) => { const connection = await getDatabaseConnection(); // 分页 // 从url中分离出page const { query } = qs.parseUrl(context.req.url); const page = parseInt(query.page?.toString() || "1"); const pageSize = 3; // 计算查询的skip和take,返回posts和总数count const [posts, count] = await connection.manager.findAndCount(Post, { skip: (page - 1) * pageSize, take: pageSize, }); return { props: { // 将数据返回给前端SSR页面 posts: JSON.parse(JSON.stringify(posts)), count, page, pageSize, }, }; };
-
优化翻页按钮,添加限制条件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
<footer> 共{count}篇文章,当前是第{page}页,每页{pageSize}条 <div> {page !== 1 && ( <Link href={`?page=${page - 1}`}> <a>上一页</a> </Link> )} {page < totalPage && ( <Link href={`?page=${page + 1}`}> <a>下一页</a> </Link> )} </div> </footer> // totalPage = Math.ceil(count / pageSize)
6.2 封装usePager
-
hooks/usePager.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70
import Link from "next/link"; import _ from "lodash"; type Options = { page: number; totalPage: number; urlMaker?: (n: number) => string; }; const defaultUrlMaker = (n: number) => `?page=${n}`; export function usePager(options: Options) { const { page, totalPage, urlMaker: _urlMaker } = options; const urlMaker = _urlMaker || defaultUrlMaker; // 显示的页码分别是当前页的+-3与首页和尾页 const numbers = [1, totalPage]; for (let i = page - 3; i <= page + 3; i++) { numbers.push(i); } // 对范围内的数据进行去重排序并筛选页码内的数字 const pageNumbers = _.uniq(numbers) .sort() .filter((i) => i >= 1 && i <= totalPage); // 当位于首页时,会出现[1,2,3,4,6],需要在缺省初补充-1占位 // [1,2,3,4,6] => [1,2,3,4,-1,6] // 思路:reduce初始值为[],我们让每一项值与累加结果数组的最后一项值相减 // 如果差为1,则利用concat将两者拼接并作为结果返回,否则拼接-1与下一项 const result = pageNumbers.reduce( (result, n) => n - (result[result.length - 1] || 0) === 1 ? result.concat(n) : result.concat(-1, n), [] ); const pager = ( <div className="wrapper"> {page !== 1 && ( <Link href={urlMaker(page - 1)}> <a>{"<"}</a> </Link> )} {result.map((i) => i === -1 ? ( <span>...</span> ) : ( <Link href={urlMaker(i)}> <a>{i}</a> </Link> ) )} {page < totalPage && ( <Link href={urlMaker(page + 1)}> <a>{">"}</a> </Link> )} <span> 第 {page} / {totalPage} 页 </span> <style jsx>{` .wrapper { margin: 0 -8px; } .wrapper > a, .wrapper > span { margin: 0 8px; } `}</style> </div> ); return { pager }; }
7 部署到阿里云
7.1 Node应用Docker化
-
创建Dockerfile脚本文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14
# 下载一个含有node12的操作系统 FROM node:12 # 创建工作路径 WORKDIR /usr/src/app # 将本地的package.json复制到工作目录 COPY package.json ./ COPY yarn.lock ./ RUN yarn install # 将本地根目录下的全部文件都拷贝至工作目录 COPY . . # 暴露3000端口 EXPOSE 3000 # 运行命令 CMD [ "yarn", "start"]
-
.dockerignore
1 2
node_modules *.log
-
构建用于部署的操作系统镜像
1 2
$ docker build -t gsq/next-web-app . // 利用当前目录(.)下的Dockerfile构建名为“gsq/next-web-app”的镜像
-
运行镜像
1 2
docker run -p 3000:3000 -d gsq/next-web-app // 以gsq/next-web-app为镜像创建一个新容器并运行,端口映射3000:3000,同时返回容器id
-
访问网址后,出现访问数据库失败的bug:
|
|
-
解决:
ormconfig.json
中的host更改为宿主机的ip地址
“host”: “10.30.36.113”,
|
|
Author gsemir
LastMod 2022-02-12