使用 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 開發的博客系統

相關文章
相關標籤/搜索