CMS全棧專案之Vue和React篇(下)(含原始碼)

徐小夕發表於2019-09-29

今天給大家介紹的主要是我們全棧CMS系統的未講解完的後臺部分和前臺部分,如果對專案背景和技術棧不太瞭解,可以檢視我之前的文章

基於nodeJS從0到1實現一個CMS全棧專案(上)

基於nodeJS從0到1實現一個CMS全棧專案(中)

基於nodeJS從0到1實現一個CMS全棧專案的服務端啟動細節

CMS全棧專案之Vue和React篇(下)(含原始碼)

摘要

本文將主要介紹如下內容:

  • 實現自定義的koa中介軟體和restful API
  • koa路由和service層實現
  • 模版引擎pug的基本使用及技巧
  • vue管理後臺頁面的實現及原始碼分享
  • react客戶端前臺的具體實現及原始碼分享
  • pm2部署以及nginx伺服器配置

由於每一個技術點實現的細節很多,建議先學習相關內容,不懂的可以和我交流。如果只想瞭解vue或react相關的內容,可以直接跳到文章的第4部分。

正文

1.實現自定義的koa中介軟體和restful API

Koa 應用程式是一個包含一組中介軟體函式的物件,它是按照類似堆疊的方式組織和執行的。我們可以使用koa提供的use介面和async函式去自定義一些中介軟體。一個用來實現列印log的中介軟體如下:

// logger
app.use(async (ctx, next) => {
  await next();
  const rt = ctx.response.get('X-Response-Time');
  console.log(`${ctx.method} ${ctx.url} - ${rt}`);
});
複製程式碼

有關koa的更多介紹可以去官網學習,我們開始正式進入實現中介軟體的環節。

在我第一章介紹CMS時剖出了目錄結構和層級,我們在原始碼中找到middlewares目錄,首先我們來看看common.js,這個檔案是存放我們通用中介軟體的地方,一共定義瞭如下中介軟體:

CMS全棧專案之Vue和React篇(下)(含原始碼)

原始碼如下:

import logger from 'koa-logger';
import koaBody from 'koa-body';
import session from 'koa-session';
import cors from 'koa2-cors';
import sessionStore from '../lib/sessionStore';
import redis from '../db/redis';
import statisticsSchema from '../db/schema/statistics';

// 設定日誌
export const Logger = app => app.use(logger())
// 處理請求體
export const KoaBody = app => app.use(koaBody())

// 配置跨域資源共享
export const Cors = app => app.use(cors({
    origin: function(ctx) {
      if (ctx.url.indexOf('/api') > -1) {
        return false;
      }
      return '*';
    },
    exposeHeaders: ['WWW-Authenticate', 'Server-Authorization'],
    maxAge: 5,
    credentials: true,
    allowMethods: ['GET'],
    allowHeaders: ['Content-Type', 'Authorization', 'Accept', 'X-Requested-With'],
  })
)

// 設定session
export const Session = app => {
    app.keys = ['xujiang']
    const SESSION_CONFIG = {
        key: 'zxzkCMS',
        maxAge: 12 * 60 * 60 * 1000,   // session的失效時間,設定為半天
        store: new sessionStore(redis),
        signed: true
    }

    app.use(session(SESSION_CONFIG, app));
}

// 統計網站資料
export const siteStatistics = app => app.use(async (ctx, next) => {
  if(ctx.url.indexOf('articleList?iSaJAx=isAjax') > -1) {
    const views = await statisticsSchema.hget('views')
    statisticsSchema.hmset('views', +views + 1)
  }
  await next()
})
複製程式碼

其實實現一箇中介軟體很簡單,我們只需要在app.use的引數中建立自己的async業務函式就好了,比如siteStatistics,可以參考此方法去做自定義的中介軟體。

關於restful API的實現,我們在基礎架構層來實現。可以看原始碼的lib下的descorator.js檔案。大致分為幾塊內容:

CMS全棧專案之Vue和React篇(下)(含原始碼)

這塊實現會涉及到更多的es6+知識,包括修飾器,symbol等,如有不懂的可以和我交流溝通。

2.koa路由和service層實現

這一塊主要採用MVC模式,我們在之前定義了基礎的路由類,這樣我們就可以正式處理服務端業務,我們可以按模組定義不同的業務介面,通過路由控制器統一管理。

CMS全棧專案之Vue和React篇(下)(含原始碼)
我們實現router和service分離的模式如上圖,在api router下我們只會定義請求相應相關的內容,具體的業務邏輯和資料操作統一在service層處理,這樣做的好處是方便後期擴充套件和管理業務邏輯,讓程式碼更可讀。當然也可以把資料操作和http統一放在router裡,但是這樣會造成程式碼耦合度過高,不利於專案管理。我們來看看具體的實現方式:

  1. router層
// router/statistics
import { controller, get } from '../lib/decorator'
import {
    getSiteStatistics
} from '../service/statistics'

@controller('/api/v0/siteStatistics')
class statisticsController {
    /**
     * 獲取所有統計資料
     * @param {*} ctx 
     * @param {*} next 
     */
    @get('/all')
    async getSiteStatistics(ctx, next) {
        const res = await getSiteStatistics()
        if(res && !Array.isArray(res)) {
            ctx.status = 200
            ctx.body = {
                data: res,
                state: 200
            }
        }else {
            ctx.status = 500
            ctx.body = {
                data: res ? res.join(',') : '伺服器錯誤',
                state: 500
            }
        }
    }
}

export default statisticsController
複製程式碼
  1. service層
// service用來處理業務邏輯和資料庫操作
import statisticsSchema from '../db/schema/statistics'

export const getSiteStatistics = async () => {
    const result = await statisticsSchema.hgetall()
    return result
}
複製程式碼

這裡我們舉了個簡單的例子方便大家理解,至於admin和config等模組的開發也類似,可以結合自己的業務需要去處理。其他模組的程式碼已寫好,可以在我的github中找到。如有不懂,可以和我交流。

3.模版引擎pug的基本使用及技巧

模版引擎這塊不是專案中的重點,在專案中也沒有涉及到諸如jade,ejs這些模版引擎,但是作為前端,這些多瞭解還是很好的。我在這裡簡單介紹一下pug(也就是jade的升級版)。

為了在koa專案中使用模版引擎,我們可以使用koa-views來做渲染,具體使用方式如下:

/***** koa-view基本使用 *****/
 import views from 'koa-views';
 app.use(views(resolve(__dirname, './views'), { extension: 'pug' }));
 app.use(async (ctx, next) => {
     await ctx.render('index', {
         name: 'xujiang',
         years: '248歲'
     })
 });
複製程式碼

具體頁面的pug檔案:

  1. index.pug

CMS全棧專案之Vue和React篇(下)(含原始碼)

  1. layout/default

CMS全棧專案之Vue和React篇(下)(含原始碼)

pug採用縮排的方式來規定程式碼層級,可以使用繼承等語法,感興趣可以參考pug官網學習。這裡不做詳細介紹。

4.vue管理後臺頁面的實現及原始碼分享

首先我們看看vue管理後臺的組織架構:

CMS全棧專案之Vue和React篇(下)(含原始碼)
CMS全棧專案之Vue和React篇(下)(含原始碼)
由於後臺大部分是動態配置的資料,而且還會有預覽功能,所以涉及到大量資料共享的情況,這裡我們統一採用vuex來管理狀態,vuex的模型如下:

CMS全棧專案之Vue和React篇(下)(含原始碼)
state用來定義初始化store,mutation主要用來處理同步action,action用來處理非同步action,type是用來定義state型別的介面檔案,如下:

// type.ts
export interface State {
    name: string;
    isLogin: boolean;
    config: Config;
    [propName: string]: any;  // 用來定義可選的額外屬性
}

export interface Config {
    header: HeaderType,
    banner: Banner,
    bannerSider: BannerSider,
    supportPay: SupportPay
}

export interface HeaderType {
    columns: string[],
    height: string,
    backgroundColor: string,
    logo: string
}

export interface Banner {
    type: string,
    label: string[],
    bgUrl: string,
    bannerList: any[]
}

export interface BannerSider {
    tit: string,
    imgUrl: string,
    desc: string
}

export interface SupportPay {
    tit: string,
    imgUrl: string
}

// 處理相應的型別
export interface Response {
    [propName: string]: any;
}
複製程式碼

mutation內容如下:

CMS全棧專案之Vue和React篇(下)(含原始碼)
action如下:

//action.ts
import { 
    HeaderType,
    Banner,
    BannerSider,
    SupportPay,
    Response
 } from './type'
import http from '../utils/http'
import { uuid, formatTime } from '../utils/common'
import { message } from 'ant-design-vue'

export default {
    /**配置 */
    setConfig(context: any, paylod: HeaderType) {
        http.get('/config/all').then((res:Response) => {
            context.commit('setConfig', res.data)
        }).catch((err:any) => {
            message.error(err.data)
        })
    },

    /**header */
    saveHeader(context: any, paylod: HeaderType) {
        http.post('/config/setHeader', paylod).then((res:Response) => {
            message.success(res.data)
            context.commit('saveHeader', paylod)
        }).catch((err:any) => {
            message.error(err.data)
        })  
    },

    /**banner */
    saveBanner(context: any, paylod: Banner) {
        http.post('/config/setBanner', paylod).then((res:Response) => {
            message.success(res.data)
        }).catch((err:any) => {
            message.error(err.data)
        })  
    },

    /**文章列表 */
    getArticles(context: any) {
        http.get('article/all').then((res:Response) => {
            context.commit('getArticles', res.data);
        }).catch((err:any)=>{
            message.error(err.data)
        })
    },

    addArticle(context: any, paylod: any) {
        paylod.id = uuid(8, 10);
        paylod.time = formatTime(Date.now(), '/');
        paylod.views = 0;
        paylod.flover = 0;
        return new Promise((resolve:any, reject:any) => {
            http.post('/article/saveArticle', paylod).then((res:Response) => {
                context.commit('addArticle', paylod)
                message.success(res.data)
                resolve()
            }).catch((err:any) => {
                message.error(err.data)
                reject()
            })
        })  
    }
    // ...
};
複製程式碼

這裡大致列舉了幾個典型的action,方便大家學習和理解,再進一步的化,我們可以基於它去封裝baseAction,這要可以減少大部分複用資訊,這裡大家可以試試做封裝一波。 最後我們統一在index裡統一引入:

import Vue from 'vue';
import Vuex from 'vuex';
import { state } from './state';
import mutations from './mutation';
import actions from './action';

Vue.use(Vuex);

export default new Vuex.Store({
  state,
  mutations,
  actions,
});
複製程式碼

通過這種方式管理vuex,對於後期可擴充套件性和可維護性,也有一定的幫助。

vue頁面部分大家根據之前node篇的用例和資料模型可以知道大致的頁面模組和功能點,這裡就不在細談。我們來看看幾個關鍵點:

  • 如何保證頁面重新整理導航可以正確定位
  • 如何切換頁面時做自定義快取
  • 如何實現模擬pc端,移動端預覽
  • 如何使用vuex高階api實現資料監聽機制
  • 如何做登入鑑權

接下來我直接剖出我的方案,大家可以參考。

1.如何保證頁面重新整理導航可以正確定位
// layout.vue
// 頁面路由表
const routeMap: any = {
    '/': '1',
    '/banner': '2',
    '/bannerSider': '3',
    '/article': '4',
    '/addArticle': '4',
    '/support': '5',
    '/imgManage': '6',
    '/videoManage': '7',
    '/websiteAnalysis': '8',
    '/admin': '9',
};

// 監聽路由變化,匹配當前選中導航
@Watch('$route')
private routeChange(val: Route, oldVal: Route) {
  //  do something
  if(val.path.indexOf('/preview') < 0) {
    this.curSelected = routeMap[val.path] || routeMap[oldVal.path];
  }
}
複製程式碼
2.如何切換頁面時做自定義快取

我們使用keep-alive做快取,被他包裹的路由檢視下傳遞key值來確定下次是否被走快取:

<template>
  <div id="app">
    <keep-alive>
      <router-view :key="key" />
    </keep-alive>
  </div>
</template>

<script lang="ts">
import { Vue } from 'vue-property-decorator';
import Component from 'vue-class-component';

@Component
export default class App extends Vue {
  get key() {
    // 快取除預覽頁面之外的其他頁面
    console.log(this.$route.path)
    if(this.$route.path.indexOf('/preview') > -1) {
      return '0'
    }else if(this.$route.path === '/login') {
      return '1'
    }else {
      return '2'
    }
  }
}
</script>
複製程式碼

由於我們的業務是預覽和管理頁面切換的時候要更新到最新資料,所以我們在這兩個模組切換時不走快取,呼叫最新資料。登入同理,通過設定不同的key來做分散式快取。

CMS全棧專案之Vue和React篇(下)(含原始碼)

CMS全棧專案之Vue和React篇(下)(含原始碼)

CMS全棧專案之Vue和React篇(下)(含原始碼)

3.如何實現模擬pc端,移動端預覽

實現預覽主要我採用基於寬度來做的模擬,通過定義預覽路由,來定義pc和移動的螢幕。如果有不懂的,可以和我交流,當然你們也可以採用iframe用模擬。

4.如何使用vuex高階api實現資料監聽機制

這裡直接剖程式碼:

public created() {
    let { id, label } = this.$route.query;
    this.type = id ? 1 : 0;
    if(id) {
        // 監聽vuex中文章資料的變化,變化則觸發action顯示文章資料
        // 注:這裡這麼做是為了防止頁面重新整理資料丟失
        let watcher = this.$store.watch(
            (state,getter) => {
                return state.articles
            },
            () => {
                this.getDetail(id, label, watcher)
            }
        )

        if(Object.keys(this.$store.state.articles).length) {
            this.getDetail(id, label, watcher)
        }
    }
  }
複製程式碼

我們使用vuex的watch去監聽store的變化,然後去做相應的處理,watch API接受兩個回撥引數,第一個回撥返回一個值,如果值變化了,就會觸發第二個引數的回撥,這有點類似與react hooks的memo和callback。

5.如何做登入鑑權

登入鑑權主要是和後端服務協商一套規則,後臺通過校驗是否登入或者是否有許可權操作某個模組,一般通過response的相應資料通知給前端,這裡我們主要講一下登入鑑權的,如果當前使用者沒登入或者session過期,node服務端會返回401,這樣前端就可以去做重定向操作了:

//http模組封裝
import axios from 'axios'
import qs from 'qs'

axios.interceptors.request.use(config => {
  // loading
  return config
}, error => {
  return Promise.reject(error)
})

axios.interceptors.response.use(response => {
  return response
}, error => {
  return Promise.resolve(error.response)
})

function checkStatus (response) {
  // loading
  // 如果http狀態碼正常,則直接返回資料
  if(response) {
    if (response.status === 200 || response.status === 304) {
      return response.data
      // 如果不需要除了data之外的資料,可以直接 return response.data
    } else if (response.status === 401) {
      location.href = '/login';
    } else {
      throw response.data
    }
  } else {
    throw {data:'網路錯誤'}
  }
  
}

// axios預設引數配置
axios.defaults.baseURL = '/api/v0';
axios.defaults.timeout = 10000;

export default {
  post (url, data) {
    return axios({
      method: 'post',
      url,
      data: qs.stringify(data),
      headers: {
        'X-Requested-With': 'XMLHttpRequest',
        'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
      }
    }).then(
      (res) => {
        return checkStatus(res)
      }
    )
  },
  get (url, params) {
    return axios({
      method: 'get',
      url,
      params, // get 請求時帶的引數
      headers: {
        'X-Requested-With': 'XMLHttpRequest'
      }
    }).then(
      (res) => {
        return checkStatus(res)
      }
    )
  },
  del (url, params) {
    return axios({
      method: 'delete',
      url,
      params, // get 請求時帶的引數
      headers: {
        'X-Requested-With': 'XMLHttpRequest'
      }
    }).then(
      (res) => {
        return checkStatus(res)
      }
    )
  }
}
複製程式碼

至於具體的axios請求攔截器和響應攔截器的設定,我們可以根據具體業務來操作和新增自定義邏輯。

5.react客戶端前臺的具體實現及原始碼分享

react部分我主要採用自己搭建的webpack做模組打包,想學習webpack的可以參考我的webpack配置,目前打包檔案可以相容到ie9+。 react前臺主要有:

CMS全棧專案之Vue和React篇(下)(含原始碼)

CMS全棧專案之Vue和React篇(下)(含原始碼)

CMS全棧專案之Vue和React篇(下)(含原始碼)
這幾部分都是通過vue後臺配置出來的,大家也可以配置符合自己風格的網站。 react前臺我們主要使用react hooks來搭建,沒有采用redux等狀態管理庫,如果想學習redux相關知識,可以進入我們的學習群一起學習。 首頁程式碼如下:

import React, { useState, useEffect } from "react"
import { Carousel } from 'antd'
import ArticleItem from '../../components/ArticleItem'
import { isPC, ajax, unparam } from 'utils/common'

import './index.less'

function Home(props) {
    let [articles, setArticles] = useState([])
    let { search } = props.location

    function getArticles(cate = '', num = 10, page = 0) {
        ajax({
            url: '/article/articleList',
            method: 'get',
            data: { cate, num, page }
        }).then(res => {
            setArticles(res.data || [])
        }).catch(err => console.log(err))
    }

    if(search && sessionStorage.getItem('prevCate') !== search) {
        getArticles(unparam(search).cate)
        sessionStorage.setItem('prevCate', search)
    }

    useEffect(() => {
        getArticles()
        return () => {
            sessionStorage.removeItem('prevCate')
        }
    }, [])
    return <div className="home-wrap">
        <div className="banner-wrap">
            {
                isPC ?
                <React.Fragment>
                    <div className="banner-sider">
                        <div className="tit">{ props.bannerSider.tit }</div>
                        <img src={props.bannerSider.imgUrl} alt="" />
                        <div className="desc">{ props.bannerSider.desc }</div>
                    </div>
                    {
                        +props.banner.type ?
                        <Carousel autoplay className="banner">
                            {
                                props.banner.bannerList.map((item, i) => (
                                    <div key={i}>
                                        <a className="banner-img" href="" style={{ backgroundImage: 'url('+ item.imgUrl +')'}}>
                                            <p className="tit">{ item.tit }</p>
                                        </a>
                                    </div>
                                ))
                            }
                        </Carousel>
                        :
                        <div className="banner">
                            <div className="banner-img" style={{backgroundImage: 'url('+ props.banner.bgUrl +')'}}>
                                {
                                    props.banner.label.map((item, i) => (
                                        <span className="banner-label" style={{left: 80*(i+1) + 'px'}} key={i}>
                                            { item }
                                        </span>
                                    ))
                                }
                            </div>
                        </div>
                    }
                </React.Fragment>
                :
                <Carousel autoplay className="banner">
                    {
                        props.banner.bannerList.map((item, i) => (
                            <a className="banner-img" href="" key={i} style={{ backgroundImage: 'url('+ item.imgUrl +')'}}>
                                <p className="tit">{ item.tit }</p>
                            </a>
                        ))
                    }
                </Carousel>
            }
        </div>
        <div className="article-list">
            <div className="tit">最新文章</div>
            {
                articles.map((item, i) => (
                    <ArticleItem {...item} key={i} />
                ))
            }
        </div>
    </div>
}

export default Home
複製程式碼

文章詳情:

import React, { useState, useEffect } from "react"
import { Button, Modal, Skeleton, Icon } from 'antd'
import { ajax, unparam } from 'utils/common'
import QTQD from 'images/logo.png'
import './index.less'

function ArticleDetail(props) {
    let [isPayShow, setIsPayShow] = useState(false)
    let [detail, setDetail] = useState(null)
    let [likeNum, setLikeNum] = useState(0)
    let [articleContent, setArticleContent] = useState(null)
    let [isShowLike, setShowLike] = useState(false)

    function toggleModal(flag) {
        setIsPayShow(flag)
    }

    function getcontent(url) {
        ajax({
            url
        }).then(res => {
            setArticleContent(res.content)
            
        })
    }

    function showLike() {
        if(!isShowLike) {
            ajax({
                url: `/article/likeArticle/${unparam(props.location.search).id}`,
                method: 'post'
            }).then(res => {
                setShowLike(true)
                setLikeNum(prev => prev + 1)
            })
        }
    }

    useEffect(() => {
        ajax({
            url: `/article/${unparam(props.location.search).id}`
        }).then(res => {
            setDetail(res.data)
            setLikeNum(res.data.flover)
            getcontent(res.data.articleUrl)
        })
        return () => {
            
        };
    }, [])

    return !detail ? <Skeleton active /> 
        :
    <div className="article-wrap">
        <div className="article">
            <div className="tit">{ detail.tit }</div>
            <div className="article-info">
                <span className="article-type">{ detail.label }</span>
                <span className="article-time">{ detail.time }</span>
                <span className="article-views"><Icon type="eye" />&nbsp;{ detail.views }</span>
                <span className="article-flover"><Icon type="fire" />&nbsp;{ likeNum }</span>
            </div>
            <div className="article-content" dangerouslySetInnerHTML={{__html: articleContent}}></div>
            <div className="article-ft">
                <div className="article-label">

                </div>
                <div className="support-author">
                    <p>給作者打賞,鼓勵TA抓緊創作!</p>
                    <div className="support-wrap">
                        <Button className="btn-pay" type="danger" ghost onClick={() => toggleModal(true)}>讚賞</Button>
                        <Button className="btn-flover" type="primary" onClick={showLike} disabled={isShowLike}>{ !isShowLike ? '點贊' : '已贊'}({likeNum})</Button>
                        {
                            isShowLike && <Icon type="like" className="like-animation" />
                        }
                    </div>
                </div>
            </div>
        </div>
        <div className="sider-bar">
            <h2>友情贊助</h2>
            <div className="sider-item">
                <img src={QTQD} alt=""/>
                <p>公眾號《趣談前端》</p>
            </div>
        </div>
        <Modal 
            visible={isPayShow} 
            onCancel={() => toggleModal(false)} 
            width="300px"
            footer={null}
        >
            <div className="img-wrap">
                <img src={props.supportPay.imgUrl} alt={props.supportPay.tit} />
                <p>{ props.supportPay.tit }</p>
            </div>
        </Modal>
    </div>
}

export default ArticleDetail
複製程式碼

由於前臺實現起來比較簡單,至於如何定義router,如何使用骨架屏,我都在程式碼裡寫了完整註釋,感興趣的可以和我交流。

6.pm2部署以及nginx伺服器配置

pm2做伺服器持久化以及nginx做多站點的配置以及如何優化程式碼的內容我會用整篇檔案做一個詳細的介紹,希望大家有所收穫,如果想學習專案原始碼,可以關注公眾號《趣談前端》加入我們一起學習討論。

CMS全棧專案之Vue和React篇(下)(含原始碼)

更多推薦

相關文章