1 数据库设计与搭建

1.1 需求分析

  • 主要的表有userspostscomments
    • 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)
  • 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 注册表单的数据校验

  • 例如用户名密码是否为空的校验比较简单,这里不做赘述

  • 对于用户名唯一性的校验可以有两种思路:

    • 数据库层校验:利用migrationusername列加上索引,再添加索引前,确保使用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

 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 { getDatabaseConnection } from "lib/getDatabaseConnection";
import md5 from "md5";
import { User } from "src/entity/User";

export default class SignIn {
  username: string;
  password: string;
  user: User;
  constructor(username: string, password: string) {
    this.username = username;
    this.password = password;
  }
  errors = { username: [] as string[], password: [] as string[] };
  async validate() {
    if (this.username.trim() === "") {
      this.errors.username.push("请填写用户名");
    }
    const connection = await getDatabaseConnection();
    const user = await connection.manager.findOne(User, {
      where: { username: this.username },
    });
    this.user = user;
    if (user) {
      if (user.passwordDigest !== md5(this.password)) {
        this.errors.password.push("密码不匹配");
      }
    } else {
      this.errors.username.push("用户名不存在");
    }
  }
  hasErrors() {
    return !!Object.values(this.errors).flat(Infinity).length;
  }
}

api/v1/sessions.tsx负责发起校验并处理返回数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import { NextApiHandler } from "next";
import SignIn from "src/model/SignIn";

const Sessions: NextApiHandler = async (req, res) => {
  const { username, password } = req.body;
  res.setHeader("Content-Type", "application/json;charset:utf-8");
  const signIn = new SignIn(username, password);
  await signIn.validate();
  if (signIn.hasErrors()) {
    res.statusCode = 422;
    res.end(JSON.stringify(signIn.errors));
  } else {
    res.statusCode = 200;
    res.end(JSON.stringify(signIn.user));
  }
};
export default Sessions;

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)),
          },
        };
      }
    );
    
  • 消除getServerSidePropswithSession@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:

1
Error: connect ECONNREFUSED 127.0.0.1:5432 at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1144:16)
  • 解决:ormconfig.json中的host更改为宿主机的ip地址

“host”: “10.30.36.113”,

1
2
3
4

- 在运行数据库与next项目的容器时,应在docker run命令后跟上`--network=host`属性,这样`ormconfig`中的host就可以写为`localhost`
- 该属性意味着**容器共享宿主机网络栈**,双方在网络名称空间并没有隔离(只在Linux操作系统主机上可用,不支持MacWindows。)