使用 ThinkJS + Vue.js 开发博客系统

编者注:ThinkJS 做为一款 Node.js 高性能企业级 Web 框架,收到了愈来愈多的用户的喜好。今天咱们请来了 ThinkJS 用户 @lscho 同窗为咱们分享他基于 ThinkJS 开发一款类 CMS 的博客系统的心得。下面就赶忙让咱们来看看 ThinkJS 和 Vue.js 能擦除怎样的火花吧!javascript

前言

前段时间利用闲暇时间把博客重写了一遍,除了实现博客基本的文章系统、评论系统外还完成了一个简单的插件系统。博客采用 ThinkJS 完成了服务端功能,Vue.js 完成了先后端分离的后台管理功能,而博客前台部分考虑到搜索引擎的问题,仍是放在了服务端作渲染。在这里记录一下主要实现的功能与遇到的问题。php

功能分析

一个完整的博客系统大概须要用户登陆、文章管理、标签、分类、评论、自定义配置等,根据这些功能,初步预计须要这些表:html

  1. 文章表
  2. 评论表
  3. 文章分类表
  4. 标签表
  5. 文章与分类映射表(一对多)
  6. 文章与标签映射表(多对多)
  7. 配置表
  8. 用户表

共8张表,而后参考 Typecho 的设计,再结合 ThinkJS 的模型关联功能,作了一下精简,分类表与标签表合并,两个映射表合并,最终获得如下6张表设计方案。前端

内容表 - content
关系表 - relationship
项目表 - meta
评论表 - comment
配置表 - config
用户表 - user
复制代码

ThinkJS 的模型关联功能能够很方便的处理这种表结构的分类和标签关系,好比咱们在内容模型即 src/model/content.js 写以下关联关系,便可在使用模型查询文章时将分类和标签数据查到,而不用手工执行屡次查询。java

get relation() {
    return {
        category: {
            type: think.Model.BELONG_TO,
            model: 'meta',
            key: 'category_id',
            fKey: 'id',
            field: 'id,name,slug,description,count'
        },
        tag: {
            type: think.Model.MANY_TO_MANY,
            model: 'meta',
            rModel: 'relationship',
            rfKey: 'meta_id',
            key: 'id',
            fKey: 'content_id',
            field: 'id,name,slug,description,count'
        }
    };
}
复制代码

接口鉴权

表结构设计好了以后剩下就要开始开发接口了。接口方面由于使用了 RESTful 接口规范,因此基本上就是 CURD 功能,具体的就很少表了,这里咱们主要说一下如何对全部接口进行权限验证。node

由于后台部分是先后端分离的,因此鉴权部分使用了 JWT 鉴权。JWT 以前大概了解过,以前本身也实现过相似的功能,搜索了一下,找到了 node-jsonwebtoken 这个包,使用起来很简单,主要就是加密和解密两个功能一番折腾以后成功运行。webpack

偶然去 ThinkJS 仓库看了一下,居然有发现了 think-session-jwt 这个插件,也是基于 node-jsonwebtoken 的。这个就更好用了,配置完以后直接用 ThinkJS 的 ctx.session 方法就能够生成和验证。配置的时候须要注意一下 tokenType 这个参数,他决定了如何获取 token ,我这里用的是 header ,也就是说后面会从每一个请求的 header 中找 token,key 值为配置的 tokenName。ios

后端权限认证

由于 API 接口遵循 RESTful 风格,并且也没有复杂的角色权限概念,因此简单的对非 GET 类型的请求,都验证 token 是否有效,ThinkJS 的控制器提供了前置操做 __before。在src/controller/rest.js中作一下逻辑判断,经过的才会继续执行。git

async __before() {
    this.userInfo = await this.session('userInfo').catch(_ => ({}));
    
    const isAllowedMethod = this.isMethod('GET');
    const isAllowedResource = this.resource === 'token';
    const isLogin = !think.isEmpty(this.userInfo);
    
    if(!isAllowedMethod && !isAllowedResource && !isLogin) {
        return this.ctx.throw(401, '请登陆后操做');
    }
}
复制代码

这里遇到一个问题,就是当 token 错误时,node-jsonwebtoken 会抛出一个异常,因此这里用了 try catch 捕获处理一下。github

前端身份失效检测

为了安全起见,咱们的 token 通常设置的都有效期,因此有三种状况须要咱们进行处理.

  1. token 不存在,这种很好处理,直接在路由的前置操做中判断是否存在,存在则放行,不存在则转向登陆界面
beforeEnter:(to, from, next)=>{
    if(!localStorage.getItem('token')){
        next({ path: '/login' });
    }else{
        next();
    }
}
复制代码

2.token 错误。这种须要后端检测以后才能知道该 token 是否有效。这里服务端检测失效以后会返回 401 状态码以便前端识别。咱们在 axios 的请求响应拦截器中进行判断便可,由于 4XX 的状态码会抛出异常,因此代码以下

axios.interceptors.response.use(data => {
    //这里能够对成功的请求进行各类处理
    return data;
},error=>{
    if (error.response) {
        switch (error.response.status) {
            case 401:
                store.commit("clearToken");
                router.replace("/login");
            break;
        }
    }
    return Promise.reject(error.response.data)
})
复制代码

3.token 过时。这种状况也能够不用处理,由于咱们在 axios 的响应拦截器中已经判断过,若是返回状态码为401的话也会跳转到登陆页面。可是在实际使用中却发现体验很差的地方,由于客户端中 token 是保存在 localStorage 中,不会自动清理,因此咱们在 token 过时以后直接打开后台的话,界面会先显示后台,而后请求返回401,页面才跳转到登陆界面。包括阿里云控制台、七牛云控制台等用了相似鉴权方式其实都存在这种现象,对于强迫症来讲可能有点不爽。这种状况也是能够解决掉的。

咱们先来看一下 JWT 的相关知识,JWT 包含了使用.分隔的三部分: Header 头部,Payload 负载,Signature 签名,其结构看起来是这样的 Header.Payload.Signature。抛开Header、Signature不去介绍,Payload 实际上是一段明文数据通过 base64 转码以后获得的。而其中就包含了咱们设置的信息,通常都会有过时时间。在路由前置操做中进行判断便可得知token是否过时,这样就能够避免页面两次跳转的问题。咱们对 Payload 解码以后会获得:

{"userInfo":{"id":1},"iat":1534065923,"exp":1534109123}
复制代码

能够看到 exp 就是过时时间,对这个时间进行判断,便可得知是否过时.

let tokenArray = token.split('.')
if (tokenArray.length !== 3) {
    next('/login')
}
let payload = Base64.decode(tokenArray[1])
if (Date.now() > payload.exp * 1000) {
    next('/login')
}
复制代码

另外这里顺便提一下,由于 Payload 是明文数据,因此千万不要在 jwt 中保存敏感数据

插件机制

除了正常的增删改查功能以外,在个人博客系统中我还实现了一个简单的插件机制,方便我对代码进行解耦,提升代码灵活性。举个例子,有时候咱们会针对某个点扩展出不少功能,好比在用户评论以后,咱们可能须要更新缓存、邮件通知、文章评论数量更新等等,咱们可能会写下以下代码。

let insertId = await model.add(data);
if(insertId){
    await this.updateCache();
    await this.push();
    ...
}
复制代码

后面一旦这些方法发生改变,修改起来就太麻烦了。用过 php 博客系统的同窗应该都知道,插件机制强大又方便,因此我决定实现一个插件功能。

指望功能是在程序某个点留下标识(通常都称为钩子),便可对这个点进行扩展,以下。

let insertId = await model.add(data);
if(insertId){
    await this.hook('commentCreate',data);
}
复制代码

由于程序是自用的,只是方便本身之后扩展功能,只须要实现核心功能便可。因此并无增长某个目录做为插件目录,而是放在 src/service/ 下面,符合 ThinkJS 的文件结构,而后作了一个约定。只要在 src/service/ 下面的 js 文件,而且有 registerHook 方法,那么就能够做为插件被调用。如 src/service/email.js 这个文件用来处理邮件通知,那么给他增长一个方法:

static registerHook() {
    return {
        'comment': ['commentCreate']
    };
}
复制代码

就表示在 commentCreate 这个功能点下,会调用 src/service/email.jscomment方法。

而后咱们扩展一下 controller ,增长一个 hook 方法,用来根据不一样的标识调用对应的插件。咱们能够遍历一下 src/service/ 找到对应的文件,而后调用其方法便可。可是考虑到文件遍历可能出现的异常和性能的损耗,我把这部分功能转移到了服务启动时即检测插件并保存到配置中。看一下 ThinkJS 的运行流程,能够放到 src/bootstrap/worker.js 这个文件中。大体代码以下。

const hooks = [];

for (const Service of Object.values(think.app.services)) {
  const isHookService = think.isFunction(Service.registerHook);
  if (!isHookService) {
    continue;
  }

  const service = new Service();
  const serviceHooks = Service.registerHook();
  for (const hookFuncName in serviceHooks) {
    if (!think.isFunction(service[hookFuncName])) {
      continue;
    }
    
    let funcForHooks = serviceHooks[hookFuncName];
    if (think.isString(funcForHooks)) {
      funcForHooks = [funcForHooks];
    }
    
    if (!think.isArray(funcForHooks)) {
      continue;
    }
    
    for (const hookName of funcForHooks) {
      if (!hooks[hookName]) {
          hooks[hookName] = [];
      }
    
      hooks[hookName].push({ service, method: hookFuncName });
    }
  }
}
think.config('hooks', hooks);
复制代码

而后在 src/extend/controller.js 中的 hook 中对插件列表遍历并依次执行便可。

//src/extend/controller.js
module.exports = {
    async hook(...args) {
        const { hooks } = think.config();
        const hookFuncs = hooks[name];
        if (!think.isArray(hookFuncs)) {
            return;
        }
        for(const {service, method} of hookFuncs) {
            await service[method](...args);
        };
    }
}
复制代码

至此,简单的插件功能完成。

固然若是想实现像 Wordpress 、Typecho 那种完整的插件功能也很简单。后台增长一个插件管理,能够进行上传,而后给插件增长一个激活函数和一个禁用函数。点击插件管理中的激活与禁用就分别调用这两个方法,能够保存默认配置等等。若是插件须要建立数据表,能够在激活函数中执行相关 sql 语句。激活完成后重启进程让代码生效便可。重启功能能够参考子进程如何通知主进程重启服务?

其余

项目的开发过程当中或多或少也存在一些问题,这里我也分享一下我碰到的一些问题,但愿能帮助到你们。

编辑器及文件上传

markdown 编辑器用了 mavonEditor 配置很方便,很少说,主要说一下文件上传遇到的一个问题。

前端代码

<mavon-editor ref=md @imgAdd="imgAdd" class="editor" v-model="formItem.content"></mavon-editor>
复制代码
imgAdd(pos, $file){
   var formdata = new FormData();
   formdata.append('image', $file); 
   image.upload(formdata).then(res=>{
        if(res.errno==0&&res.data.url){
            this.$refs.md.$img2Url(pos, res.data.url);
        }
   });               
}
复制代码

后端处理

const file = this.file('image');
const extname=path.extname(file.name);
const filename = path.basename(file.path);
const basename=think.md5(filename)+extname;
const savepath = '/upload/'+basename;
const filepath = path.join(think.ROOT_PATH, "www"+savepath);
think.mkdir(path.dirname(filepath));
await rename(file.path, filepath);
复制代码

最初使用了 ThinkJS 官网的上传示例代码,使用 rename 进行文件转移,而在 windows 下临时目录可能和项目目录不在同一盘符下,进行移动的话就会抛出一个异常:Error: EXDEV, cross-device link not permitted,没有权限移动,这时候就只能先读文件,再写文件。因此这里也用了一个 try catch 来捕获异常,主要是由于 ThinkJS 会将上传的文件先放到临时目录中。关于跨盘 rename 的问题,在 github.com/nodejs/node… 找到了缘由,大意是操做系统限制 rename 仅仅是重命名路径引用地址,并无将数据移动过去,重命名不能跨文件系统操做,因此若是跨文件系统操做须要先复制、而后删除旧数据。

后来在群里聊天,@阿特 大佬提到,上传是 payload 这个中间件处理的, 能够对 payload 这个中间件设置指定临时目录为项目下的某个目录,这样就保证临时目录和项目目录在同一盘符下。

{
	handle: 'payload',
	options: {
		uploadDir: path.join(think.ROOT_PATH, 'runtime/data')
	}
}
复制代码

这样就能够直接使用 rename 来操做了。

iView 按需加载

由于 iView 默认是做为插件所有加载进来,因此打包出来的文件很大。须要调整为按需加载。按照www.iViewui.com/docs/guide/…搞定以后出现了一个问题,就是执行 npm run build 时会报一个错。ERROR in js/index.c26f6242.js? from UglifyJs 大概是这个样子,看了一下错误缘由,大概是由于按需加载以后,是直接加载的 iView 模块下 src 的 js文件,里面采用的都是 ES6 语法,形成压缩失败。去 Issue 搜了一下,找到了解决方案 github.com/iView/iView…

部署

若是先后端不分离的话,用 webpack 将前端的入口页面 index.html 编译到 ThinkJS 后端项目的首页模版位置,而后把资源编译到后端项目资源文件夹下,对应路径设置好。这样就把前端项目整合进了后端项目,而后再按照 ThinkJS 部署方式来部署,也是能够的。

若是是先后端分离,做为两个项目部署的话,前端路由使用普通模式的话也很好处理,若是使用 history 模式,就要要将请求转发至 index.html 入口页面处理,跟有些 mvc 框架单入口是一个概念。这时候其实就是前端项目接管了路由。

location / {
	try_files $uri $uri/ /index.html;
}
复制代码

而后还要处理一下后端请求部分,若是不是同一域名,就要解决跨域问题。这里先后端使用同一个域名,针对 api 请求作一下反向代理便可。注意这部分要写在请求转发的上面。

set $node_port 8360;
	location ~ ^/api/ {
    proxy_pass http://127.0.0.1:$node_port$request_uri;

}
复制代码

后端使用 pm2 守护进程便可。

后记

以上就是我整个项目的开发过程以及遇到的一些问题的总结,若是有什么疑问欢迎你们留言讨论。最后欢迎你们 Star 基于 ThinkJS + Vue 开发的博客系统

相关文章
相关标签/搜索