21

简单做个博客。
功能点:注册、登录、cookie、权限控制、文章列表、文章详情、文章目录、点赞、评论、分页加载
整体架构:
image.png

前端

使用vue-cli3创建项目

npm install -g @vue/cli
vue create hello-world

为了避开烦人的 eslint,选择了手动选择特性:
image.png
同时没有选择 Linter/Formatter,使用 vscode 中的插件 prettier 和 vetur 配合格式化代码。
image.png

使用axios封装http请求方法

参考了vue中Axios的封装和API接口的管理,对网络请求进行了发封装。
image.png
网络请求统一放在/src/request中,config.js中是基本配置信息:
image.png
http.js中封装请求拦截、响应拦截、错误统一处理。
api.js中是网站所有接口,将其导出后,挂载到 vue.prototype.$api 上,这样全局可以使用 this.$api.xxx 使用接口:
image.png
然后在main.js引入并挂载:
image.png

cookie/session

博客采用cookie/session的方式来记录会话状态,在app.vuecreated生命周期函数中通过checkLogin()接口查询用户登录状态,具体的逻辑为:
当当网流程图3.png

首页分页加载

  let documentEle = document.documentElement
  let needLoadMore =
    documentEle.scrollTop + documentEle.clientHeight + 50 > documentEle.scrollHeight;
  if (needLoadMore && !this.nomore) {
    this.loadingMore = true;
    //暂时不能再滚动加载数据
    this.nomore = true;
    this.loadMoreArticle();
  }

document.documentElement.scrollTop:文档滚动的距离
document.documentElement.clientHeight:文档在可视范围内的高度
document.documentElement.scrollHeight:文档总高度

判断思路是:视口的高度 + 文档的滚动距离 >= 文档总高度,可以预留50的距离做预加载。前端每一次判断触底后,会向后端请求下一页数据,并返回 nomore 字段,表示是否还有未加载文章。这里有个细节是:每一次触底后手动将 nomore 设为 true,后端返回 nomore 后再修改其值,这样做的目的是防止在请求返回前再次发送请求。
参考:搞清clientHeight、offsetHeight、scrollHeight、offsetTop、scrollTop

如何展示博文

没有实际做博客之前,以为每一篇博文都是通过单独写html标签的方式排版的...然后通过别人的项目,发现了正确的做法是:
使用markdown/富文本编辑器编辑文章,生成html片段,在vue中使用v-html语法插入该html片段,文章便以html标签的形式渲染出来可。博客中使用的markdown编辑器是mavonEditor,另外使用highlightjs高亮代码。

<div v-html="articleInfo.contentHtml" class="article-container" ref="content"></div>

提取目录

    extractCatalog() {
      let contentElementRef = Array.from(
        this.$refs.content.querySelectorAll("h1,h2,h3,h4,h5,h6")
      );
      contentElementRef.forEach((item, index) => {
        item.id = item.localName + "-" + index;
        this.catalogList.push({
          tagName: item.localName,
          href: `#${item.localName}-${index}`,
          text: item.innerText
        });
      });
    }

上一小节中,将文章的html片段渲染到了div容器元素中,接下来可以使用querySelectorAll("h1,h2,h3,h4,h5,h6")函数来找出文中所有的标题Dom节点,querySelectorAll()的好处在于它会按照传入参数的顺序进行查找,所以不用担心乱序。获取到目录后对各级目录编号,以便生成锚点进行跳转。

判断对应节是否出现

    watchPageScrollFunc() {
      let contentElementRef = Array.from(
        this.$refs.content.querySelectorAll("h1,h2,h3,h4,h5,h6")
      );
      for (let index = 0; index < contentElementRef.length; index++) {
        const viewPortHeight =
          window.innerHeight ||
          document.documentElement.clientHeight ||
          document.body.clientHeight;
        const elementHeight = contentElementRef[index].clientHeight;
        const el = contentElementRef[index];
        const top =
          el.getBoundingClientRect() &&
          el.getBoundingClientRect().top + elementHeight;
        if (top <= viewPortHeight && top > 0) {
          this.firstVisibleElemetHref = this.catalogList[index].href;
          break;
        }
      }
    }
    
    window.addEventListener("scroll", this.watchPageScrollFunc);

判断方法:元素上边到视口上边的距离 + 元素自身高度 <= 视口高度 && 元素上边到视口上边的距离 + 元素自身高度 > 0
其中,获取元素到视口上边的距离用到:getBoundingClientRect(),某个元素相对于视窗的位置集合。集合中有top, right, bottom, left等属性:

rectObject = object.getBoundingClientRect();

rectObject.top // 元素上边到视窗上边的距离;

rectObject.right // 元素右边到视窗左边的距离;

rectObject.bottom // 元素下边到视窗上边的距离;

rectObject.left // 元素左边到视窗左边的距离;

在整个文章的Dom结构中,使用querySelectorAll("h1,h2,h3,h4,h5,h6")去搜索所有标题类的Dom节点,每一次滚动都去依次动态检查这些标题元素距离视口上方的距离,找到第一个出现在视口中的Dom元素就返回。另外加上节流函数可优化性能。

参考:如何判断元素是否在可视区域ViewPort

评论的实现

评论数据采用的数据结构如下,这里只做到了二级评论,数据结构定下来,具体的实现还是比较简单的。

let comments = [
  {
    author: "admin",
    content: "留言1",
    articleId: "9",
    time: "2020-04-12 10:59",
    id: "0",
    replyList: [
      {
        author: "ghm",
        content: "回复留言1",
        articleId: "9",
        replyTo: "admin",
        time: "2020-04-12 10:59",
        id: "0-0"
      }
    ]
  },
  {
    author: "admin",
    content: "留言2",
    articleId: "9",
    time: "2020-04-12 10:59",
    id: "1",
    replyList: [
      {
        author: "ghm",
        content: "回复留言2",
        articleId: "9",
        replyTo: "admin",
        time: "2020-04-12 11:00",
        id: "1-0"
      }
    ]
  }
];

可添加表情的输入框

第一版的实现方式:在<input>中直接插入emojiunicode,展示效果如下所示:
image.png
这样做的问题在于:展示效果没有图片好,并且在不同的浏览器中会展现出不同的效果。果断换用图片形式展示表情,但是<input>是不能插入<img>标签的,于是参考了掘金的实现方式,使用contenteditable这个css属性将普通Dom元素变为可编辑的Dom元素,这样就可以使用appendChild()的方式将<img>插入,最终的显示效果也是明显优于直接使用emoji的。

  <div
    class="textarea"
    ref="inputContent"
    contenteditable="true"
    autocomplete="off"
    :placeholder="placeholder"
    draggable="false"
    spellcheck="false"
  ></div>

image.png

后端

数据库表的设计

表1:文章列表:
image.png

表2: 文章详情:
image.png

表3:用户详情
image.png

webServer

使用了 eggjs 作为 web服务器,eggjs 的文档很完善,照着文档撸代码就够了。eggjs奉行约定优于配置,有以下优点:

  1. 统一目录结构
  2. 统一分层设计:router-controller-service-model
  3. 全套安全、日志、测试方案
  4. 高扩展性的插件机制

初始化

npm init egg --type=simple
npm i

目录结构

image.png

路由

框架约定在app/router.js文件中统一配置所有路由

// app/router.js
module.exports = app => {
  const { router, controller } = app;
  router.get('/user/:id', controller.user.info);
};

完整的理由定义如下:

router.verb('router-name', 'path-match', middleware1, ..., middlewareN, app.controller.action);

注意事项:

  • 在 Router 定义中, 可以支持多个 Middleware 串联执行
  • Controller 必须定义在 app/controller 目录中。
  • 一个文件里面也可以包含多个 Controller 定义,在定义路由的时候,可以通过 ${fileName}.${functionName} 的方式指定对应的 Controller。
  • Controller 支持子目录,在定义路由的时候,可以通过 ${directoryName}.${fileName}.${functionName} 的方式制定对应的 Controller。

框架中一些常用的路由用法:

  • 获取查询参数

    // curl http://127.0.0.1:7001/search?name=egg
    ctx.query.name
  • 获取路径参数

    // app/router.js
    module.exports = app => {
      app.router.get('/user/:id/:name', app.controller.user.info);
    };
    
    // app/controller/user.js
    exports.info = async ctx => {
      ctx.body = `user: ${ctx.params.id}, ${ctx.params.name}`;
    };
    
    // curl http://127.0.0.1:7001/user/123/xiaoming
    
  • post请求boby的获取

    ctx.request.body
  • 重定向

     // 内部路由重定向
     app.router.redirect('/', '/home/index', 302);
     // 外部路由重定向
     ctx.redirect(\`http://cn.bing.com\`);

控制器(Controller)

简单的说 Controller 负责解析用户的输入,处理后返回相应的结果。框架推荐 Controller 层主要对用户的请求参数进行处理(校验、转换),然后调用对应的service方法处理业务,得到业务结果后封装并返回:

  1. 获取用户通过 HTTP 传递过来的请求参数。
  2. 校验、组装参数。
  3. 调用 Service 进行业务处理,必要时处理转换 Service 的返回结果,让它适应用户的需求。
  4. 通过 HTTP 将结果响应给用户。

定义:
所有的 Controller 文件都必须放在 app/controller 目录下,可以支持多级目录,访问的时候可以通过目录名级联访问。

// app/controller/post.js
const Controller = require('egg').Controller;
class PostController extends Controller {
  async create() {
    const { ctx, service } = this;
    const createRule = {
      title: { type: 'string' },
      content: { type: 'string' },
    };
    // 校验参数
    ctx.validate(createRule);
    // 组装参数
    const author = ctx.session.userId;
    const req = Object.assign(ctx.request.body, { author });
    // 调用 Service 进行业务处理
    const res = await service.post.create(req);
    // 设置响应内容和响应状态码
    ctx.body = { id: res.id };
    ctx.status = 201;
  }
}
module.exports = PostController;

上面定义的PostController的方法可以通过文件名和方法名的方式使用。

// app/router.js
module.exports = app => {
  const { router, controller } = app;
  router.post('createPost', '/api/posts', controller.post.create);
}

定义的 Controller 类,会在每一个请求访问到 server 时实例化一个全新的对象,而项目中的 Controller 类继承于 egg.Controller,会有下面几个属性挂在 this 上:this.ctxthis.appthis.servicethis.configthis.logger

服务(Service)

Service 就是在复杂业务场景下用于做业务逻辑封装的一个抽象层,提供这个抽象有以下几个好处:

  • 保持 Controller 中的逻辑更加简洁。
  • 保持业务逻辑的独立性,抽象出来的 Service 可以被多个 Controller 重复调用。
  • 将逻辑和展现分离,更容易编写测试用例

使用场景:

  • 复杂数据的处理,比如要展现的信息需要从数据库获取,还要经过一定的规则计算,才能返回用户显示。或者计算完成后,更新到数据库。
  • 第三方服务的调用,比如 GitHub 信息获取等。

Service 文件必须放在 app/service 目录,可以支持多级目录,访问的时候可以通过目录名级联访

app/service/biz/user.js => ctx.service.biz.user

由于它继承于 egg.Service,故拥有下列属性方便我们进行开发:this.ctxthis.appthis.servicethis.configthis.logger

MySQL

egg提供了 egg-mysql插件来访问 MySQL 数据库

安装插件:

$ npm i --save egg-mysql

开启插件:

// config/plugin.js
exports.mysql = {
  enable: true,
  package: 'egg-mysql',
};

最后在 config/config.${env}.js 配置各个环境的数据库连接信息

// config/config.${env}.js
exports.mysql = {
  // 单数据库信息配置
  client: {
    // host
    host: 'mysql.com',
    // 端口号
    port: '3306',
    // 用户名
    user: 'test_user',
    // 密码
    password: 'test_password',
    // 数据库名
    database: 'test',
  },
  // 是否加载到 app 上,默认开启
  app: true,
  // 是否加载到 agent 上,默认关闭
  agent: false,
};

Sequelize

Sequelize 是 nodejs 社区广泛使用的 ORM 框架,将关系数据库的表结构映射为对象,让开发者使用 js 语法完成数据库操作。另外Sequelize 提供了 Migrations,帮助开发者管理数据库的每一次变更,因此每一次库表的变动都应通过 Migrations 来实现。

egg 提供了集成 Sequelize 的脚手架:

npm init egg --type=sequelize

上线

配置服务器环境

1.一台linux服务器
作者买的阿里云最便宜的ECS云服务器,配置为1核2G,1M带宽,作为学习使用完全够了,操作系统选择的CentOS。然后参照阿里云给出的教程在服务器上部署NodeJs环境部署mysql
2.购买域名
在阿里云上购买域名后,按照新手引导设置域名解析,同时设置 www 和 @,网站便可通过 www.xxx.com 和 xxx.com 访问。同时,想要使用域名访问网站,还需要对域名进行备案。
3.本地数据库迁移到云服务器
作者使用的数据库可视化工具是navicat,在navicat中对选中的数据库做转储SQL文件处理,再在云服务器的mysql中新建数据库,运行此SQL文件,便完成了数据库的迁移。
image.png

上传文件

1.下载文件传输工具 Xftp
2.部署前端代码

npm run build

打包好后,使用 Xftp 将 dist 文件夹上传到服务器中
3.部署后端代码
将后端代码 git clone 到服务器中

npm i
npm start

配置nginx

1.安装nginx

yum install nginx

安装好的 nginx 会在 /etc/nginx
2.使用 Xftp 修改 nginx 配置
找到/etc/nginx目录下的 nginx.conf文件,使用记事本打开编辑:

user  root;
worker_processes  1;

events {
    worker_connections  1024;
}

http {
  include       mime.types;
  default_type  application/octet-stream;

  sendfile        on;
  keepalive_timeout  65;

  server {
    listen       80;
    server_name  localhost;
    root  /root/project/dt_blog/frontend/dist/;
    index  index.html;
    add_header Access-Control-Allow-Origin *;
      
    location /api {
      proxy_pass  http://127.0.0.1:7001;
      proxy_set_header  HOST $host;
    }
        
    location / {
      try_files $uri $uri/ @router;
      index index.html;
    }

    location @router {
      rewrite ^.*$ /index.html last;
    }
  }
}

其中这两个配置很重要:

location / {
  try_files $uri $uri/ @router;
  index index.html;
}

location @router {
  rewrite ^.*$ /index.html last;
}

网站部署好后,可以正常访问,但是打开二级页面后再刷新,就会404,原因是:v-router设置的路径并不是真实存在的路由,工程中的路由跳转都是通过 js 实现的,而将前端打包好的 dist 目录部署在服务器后,在浏览器中访问根路径会默认打到 index.html 中,但是直接访问其他路径,就没有一个真实的路径与其对应。(参考:https://www.cnblogs.com/kevingrace/p/6126762.html

做好以上配置后,就可以通过 服务器ip:80 来访问部署好的网站了,但是一直通过 ip 地址访问网站不太合理,那么就需要给网站配置域名。

配置域名


Leon
1.4k 声望1k 粉丝

« 上一篇
闭包理解