Vue + ElementUI 手擼後臺管理網站基本框架(三)登入及導航選單載入

harsima發表於2017-09-12

登入授權


登入及安全

從前端看來,使用者的登入和授權看起來感覺十分簡單,無非就是輸入使用者名稱和密碼,傳給後臺確認登入。但其實這裡面還是有很多需要注意的問題,這裡簡單列舉一下:

  • 所有資料的傳輸過程應當保證安全,保證資料不會在傳輸過程中洩露或劫持
  • 應當有一種機制來校驗請求發起人是否是之前登入的使用者
  • 應當有一種過期機制使使用者不能保持永久登入狀態

以上這些問題實質上都是web開發中需要注意的安全性問題。雖然這些問題大部分由後端人員解決,前端只需要配合完成即可,但如果瞭解這些問題那就更好了。接下來我針對這三個問題逐條簡單分析一下,期間涉及到的相關知識點大家可以自行搜尋,此處不再進行深入說明(否則就變成安全介紹了)。

第一種問題的實質是資料傳輸的安全,防止資訊洩露。針對於這種情況,目前最好的辦法就是使用HTTPS,而不是使用HTTP。

第二種問題實際上是在Web開發中經常遇到的安全問題——跨站請求偽造(CSRF/XSRF)。即攻擊者可以利用漏洞在其他網站上傳送請求偽裝成本站的正常請求,這樣攻擊者可以在使用者完全不知情的情況下進行任何操作。這種問題目前的解決方法就是使用Token機制。當使用者登入後服務端返回一個Token,之後的每次請求都要攜帶這個Token,如果在服務端Token不匹配則意味著授權失敗。

第三種問題實際上就是為了讓使用者不能永久處於登入狀態,否則使用者登入一次就能獲得永久授權。在實際中就是Token本身要具備時效性,要有過期機制。至於過期後是返回登入頁面重新登入亦或是自動續期亦或是自動重新整理,這就是由後端決定的了,前端只要配合好即可。

為了和後端人員配合好,前端必須做出一定的修改,接下來將說明這些的問題的前端解決方案。請注意,這些方法實質上都是對axios的設定。

為每個請求增加Token

在上一章節中,我們其實有提到Token的攜帶方法,即介面許可權控制部分。這裡不在細說,直接上之前的程式碼即可。不過有一點需要注意的是Token也需要同步記錄到Cookie中,否則頁面重新整理後,js內部的變數值將還原成初始狀態。如果記錄到Cookie後,則在頁面重新整理後可以直接從Cookie中取值,再賦給js變數。

const service = axios.create()

// http request 攔截器
// 每次請求都為http頭增加Authorization欄位,其內容為Token
service.interceptors.request.use(
    config => {
        config.headers.Authorization = `${Token}`
        return config
    }
);
export default service

Token超時及重新整理機制

Token超時自動消失的方法可以直接通過設定Cookie的失效時間來確定。這樣,在每次請求發起時,校驗Cookie中是否有Token,如果沒有則需要對Token進行重新整理;如果有則直接請求介面即可。

而Token的重新整理目前總結為兩種方式:

  • 每次請求時判斷Token是否超時,若超時則獲取新Token
  • 每次請求時判斷Token是否超時,若超時則跳轉到授權頁面

這兩種方式在新Token的獲取上實際並沒有太大的區別,無非就是自動呼叫介面及跳到單獨頁面呼叫介面而已。但是如果頁面中當前有多個請求被髮起,那麼則會出現較大的差異。前者需要對所有請求進行延遲處理,保證介面新Token的介面獲取到資料後再執行之前的請求,否則這些請求將因Token超時直接失敗;後者則是需要中斷當前的所有的請求,並立即跳轉到對應頁面。

檢視axios中斷請求方法,在官方文件中搜尋cancelToken:axios

// 第一種方式的校驗函式
// 設定getToken鎖,如果當前正在獲取新Token, 則其他請求做延遲處理
var getTokenLock = false

function checkToken(callback){
    // 檢測Token是否過期
    if(!hasToken()){
        // 如果當前有請求正在獲取Token
        if(getTokenLock){
            setTimeout(function(){
                checkToken(callback)
            }, 500)
        } else {
            getTokenLock = true
            getNewToken().then(() => {
                callback()
                getTokenLock = false
            })
        }
    } else {
        // token未過期
        callback()
    }
}

// axios 攔截器
service.interceptors.request.use(
    config => {
        checkToken(function(){
            config.headers.Authorization = `${Token}`
        })
        return config
    }
);
var CancelToken = axios.CancelToken
var cancel

// 第二種方式的校驗函式
function checkToken(callback){
    // 檢測Token是否過期
    if(!hasToken()){
        // 中斷當前請求
        cancel()
        // 跳轉到固定的授權頁面
        router.push('/auth')
    } else {
        // token未過期
        callback()
    }
}

// axios攔截器
service.interceptors.request.use(
    config => {
        config.cancelToken = new CancelToken(function executor(c) {
            cancel = c;
        })
        checkToken(function(){
            config.headers.Authorization = `${Token}`
        })
        return config
    }
);

建立系統選單


在建立選單前,我們需要先確定一下我們選單中具體的細節:當點選選單時,只能有一個子選單保持展開;如果點選一級選單,其他子選單也應該收回。而在element官網的展示中,第二個需求並沒有實現。所以這裡我會逐步說明如何實現這樣的選單。

模擬選單資料

上個章節中,我們已經模擬過一次使用者登入後的返回資料。不過當時只是根據該資料進行許可權判斷,並沒有在UI上生成對應的系統選單。這裡我們再來看一下模擬的選單列表資料

var data = [
    {
        path: '/home',
        name: '首頁'
    },
    {
        name: '系統元件',
        child: [
            {
                name: '介紹',
                path: '/components'
            },
            {
                name: '功能類',
                child: [
                    {
                        path: '/components/permission',
                        name: '詳細鑑權'
                    },
                    {
                        path: '/components/pageTable',
                        name: '表格分頁'
                    }
                ]
            }
        ]
    }
]    

看著這樣的資料,我們要把它生成UI選單,其實質就是遞迴。

生成選單

為了實現我們需要的選單,這裡列舉三個非常重要的屬性:

router模式:啟用導航時以 index 欄位作為 path 進行路由跳轉。
default-active:當前啟用的導航,以index欄位為值。如果元件渲染前有預設值,則渲染後會按照該值來展開的導航。
unique-opened:保證只有一個子選單展開。同樣以index欄位作為索引,如果某些選單的index重複或者沒有則會使該功能失效

想要同時使用者三種模式,則必須保證所有選單節點即el-menu-item都具有index,且index不能重複。所以我們最主要的就是在遞迴過程中生成不同的index:對那些非葉子節點(即選單分組)直接算出index,葉子節點(即實際的展示頁面)則直接將跳轉路徑賦值給index

所以通過遞迴方式得到的選單樹結構應該是這樣的(注意index欄位的不同):

<el-menu>
    <el-menu-item :index="/home">首頁</el-menu-item>
    <el-submenu :index="1">
        <template slot="title">系統元件</template>
        <el-menu-item :index="/components">介紹</el-menu-item>
        <el-submenu :index="1-1">
            <template slot="title">功能類</template>
            <el-menu-item :index="/components/permission">詳細鑑權</el-menu-item>
            <el-menu-item :index="/components/pageTable">表格分頁</el-menu-item>
        </el-submenu>
    </el-submenu>
    ...
</el-menu>

在明確遞迴後應該產生何種結構的樹後,我們就可以開始編寫選單生成的程式碼了。

// nav.vue
// navList為選單列表資料
<template>
    <el-menu router unique-opened  :default-active="onRoutes">
        // 迴圈navList陣列,將每項的值及index傳給nav-item元件
        <nav-item v-for="(item, index) in navList"  :item="item" :navIndex="String(index)" :key="index">
        </nav-item>
    </el-menu>
</template>

export default {
    computed: {
        // 首次進入頁面時展開當前頁面所屬的選單
        onRoutes(){
            return this.$route.path
        }
    }
}
// navItem.vue
<template>
    // 如果當前item中有子節點
    <el-submenu v-if="item.child && item.child.length" :index="navIndex"> 
        <!-- 建立選單分組 -->
        <template slot="title">{{ item.name }}</template>
        <!-- 遞迴呼叫自身,直到subItem不含有子節點 -->
        <nav-item v-for="(subItem,i) in item.child" :key="navIndex+'-'+i" :navIndex="navIndex+'-'+i" :item="subItem" >
        </nav-item>
    </el-submenu>

    // 如果當前item不含有子節點
    <el-menu-item v-else :index="item.path">{{ item.name }}</el-menu-item>
</template>
<script>
    export default {
        // 遞迴元件必須有name
        name: 'navItem',
        props: ['item','navIndex']
    }
</script>

實現點選一級選單收回所有其他子選單

由於我們過於苛刻的要求,element官網中並沒有提供相關的示例,同時也沒有提供任何可以手動關閉選單的方法。所以這裡我們需要通過ref來調取官網文件中未標明,但確存在於<el-menu>元件中的方法。

// nav.vue
<template>
    // 增加ref欄位,直接訪問子元件方法。同時註冊select事件,當選單點選時觸發
    <el-menu router unique-opened ref="navbar" :default-active="onRoutes" @select="selectMenu">
        <nav-item v-for="(item, n) in navList" :item="item" :navIndex="String(n)" :key="n">
    </el-menu>
</template>

export default {
    computed: {
        onRoutes(){
            return this.$route.path
        }
    },
    methods: {
        selectMenu(index, indexPath){
            let openedMenus = this.$refs.navbar.openedMenus
            let openMenuList
            // 如果點選的是二級選單,則獲取其後已經開啟的選單
            if(indexPath.length > 1){
                let parentPath = indexPath[indexPath.length-2]
                openMenuList = openedMenus.slice(openedMenus.indexOf(parentPath)+1)
            } else{
                openMenuList = openedMenus
            }
            openMenu = openMenu.reverse()
            openMenu.forEach((ele) => {
                this.$refs.navbar.closeMenu(ele)
            })
        }
    }
}

NEXT——主題切換


在下一章中將講述如何實現主題切換,同時歡迎大家討論更好的方法,或者解決文章中提出的問題。感激不盡。

原始碼


當前原始碼地址:https://github.com/harsima/vue-backend
請注意,該原始碼會不斷更新(因為工作很忙不能保證定期更新)。原始碼涉及到的東西有超出本篇教程的部分,請酌情閱讀。

本系列目錄


相關文章