CMS全棧項目之Vue和React篇(下)(含源碼)

今天給你們介紹的主要是咱們全棧CMS系統的未講解完的後臺部分和前臺部分,若是對項目背景和技術棧不太瞭解,能夠查看我以前的文章javascript

基於nodeJS從0到1實現一個CMS全棧項目(上)css

基於nodeJS從0到1實現一個CMS全棧項目(中)html

基於nodeJS從0到1實現一個CMS全棧項目的服務端啓動細節前端

摘要

本文將主要介紹以下內容:vue

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

因爲每個技術點實現的細節不少,建議先學習相關內容,不懂的能夠和我交流。若是隻想了解vue或react相關的內容,能夠直接跳到文章的第4部分。java

正文

1.實現自定義的koa中間件和restful API

Koa 應用程序是一個包含一組中間件函數的對象,它是按照相似堆棧的方式組織和執行的。咱們可使用koa提供的use接口和async函數去自定義一些中間件。一個用來實現打印log的中間件以下:node

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

有關koa的更多介紹能夠去官網學習,咱們開始正式進入實現中間件的環節。react

在我第一章介紹CMS時剖出了目錄結構和層級,咱們在源碼中找到middlewares目錄,首先咱們來看看common.js,這個文件是存放咱們通用中間件的地方,一共定義了以下中間件:webpack

源碼以下:ios

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文件。大體分爲幾塊內容:

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

2.koa路由和service層實現

這一塊主要採用MVC模式,咱們在以前定義了基礎的路由類,這樣咱們就能夠正式處理服務端業務,咱們能夠按模塊定義不一樣的業務接口,經過路由控制器統一管理。

咱們實現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

  1. layout/default

pug採用縮進的方式來規定代碼層級,可使用繼承等語法,感興趣能夠參考pug官網學習。這裏不作詳細介紹。

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

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

因爲後臺大部分是動態配置的數據,並且還會有預覽功能,因此涉及到大量數據共享的狀況,這裏咱們統一採用vuex來管理狀態,vuex的模型以下:

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內容以下:

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來作分佈式緩存。

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前臺主要有:

這幾部分都是經過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作多站點的配置以及如何優化代碼的內容我會用整篇文件作一個詳細的介紹,但願你們有所收穫,若是想學習項目源碼,能夠關注公衆號《趣談前端》加入咱們一塊兒學習討論。

更多推薦

相關文章
相關標籤/搜索