vue-element-admin源碼解讀——項目組件佈局及狀態管理


vue-element-admin作爲一款優秀的前端解決方案,整個框架的結構非常清晰,同時利用VuexVue Router來實現SPA(單頁面應用)開發模式,只需要簡單的編寫組件和配置文件即可完成項目的初步搭建,這對某些需要簡單的前端界面的團隊無疑是個福利。不過對於需要高度自定義的公司可能還需要更加深入的瞭解一下,所以這篇文章將帶你一步步的摸索整個項目的組件是如何加載的。

基礎頁面

要摸索當然首先就要找到入口,對於界面而言當然就是index.html的作爲入口了。

public\index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
    <meta name="renderer" content="webkit">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <title><%= webpackConfig.name %></title>
  </head>
  <body>
    <div id="app"></div>
    <!-- built files will be auto injected -->
  </body>
</html>

這個界面很簡單就是定義了一個#app的節點,而是誰在給該節點渲染子節點呢。

src\router\index.js

import Vue from 'vue'

import Cookies from 'js-cookie'

import 'normalize.css/normalize.css' // a modern alternative to CSS resets

import Element from 'element-ui'
import './styles/element-variables.scss'
import enLang from 'element-ui/lib/locale/lang/en'// 如果使用中文語言包請默認支持,無需額外引入,請刪除該依賴

import '@/styles/index.scss' // global css

import App from './App'
import store from './store'
import router from './router'

import './icons' // icon
import './permission' // permission control
import './utils/error-log' // error log

import * as filters from './filters' // global filters

//如果是開發模式,則啓動本地mock服務器
if (process.env.NODE_ENV === 'production') {
  const { mockXHR } = require('../mock')
  mockXHR()
}

//使用element-ui插件
Vue.use(Element, {
  size: Cookies.get('size') || 'medium', // set element-ui default size
  locale: enLang // 如果使用中文,無需設置,請刪除
})

//指定組件過濾器
Object.keys(filters).forEach(key => {
  Vue.filter(key, filters[key])
})

Vue.config.productionTip = false

//爲index.html綁定基礎組件App.vue,同時綁定路由和狀態管理插件
new Vue({
  el: '#app',
  router,
  store,
  render: h => h(App)
})

// App.vue
<template>
  <div id="app">
    <router-view />
  </div>
</template>

<script>
export default {
  name: 'App'
}
</script>

到這裏我們遇到了第一個路由組件<router-view />,接下來我們來看看路由配置信息加載了什麼路由組件到這個位置。

src\main.js

import router from './router'

new Vue({
  el: '#app',
  router,
  store,
  render: h => h(App)
})

路由配置信息

import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router)

/* Layout */
import Layout from '@/layout'

/* Router Modules */
import componentsRouter from './modules/components'
import chartsRouter from './modules/charts'
import tableRouter from './modules/table'
import nestedRouter from './modules/nested'

export const constantRoutes = [
  {
    path: '/redirect',
    component: Layout,
    hidden: true,
    children: [
      {
        path: '/redirect/:path(.*)',
        component: () => import('@/views/redirect/index')
      }
    ]
  },
  {
    path: '/login',
    component: () => import('@/views/login/index'),
    hidden: true
  },
  {
    path: '/auth-redirect',
    component: () => import('@/views/login/auth-redirect'),
    hidden: true
  },
  {
    path: '/404',
    component: () => import('@/views/error-page/404'),
    hidden: true
  },
  {
    path: '/401',
    component: () => import('@/views/error-page/401'),
    hidden: true
  },
  {
    path: '/',
    component: Layout,
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        component: () => import('@/views/dashboard/index'),
        name: 'Dashboard',
        meta: { title: 'Dashboard', icon: 'dashboard', affix: true }
      }
    ]
  },
  ...
]

/**
 * asyncRoutes
 * the routes that need to be dynamically loaded based on user roles
 */
export const asyncRoutes = [
  {
    path: '/permission',
    component: Layout,
    redirect: '/permission/page',
    alwaysShow: true, // will always show the root menu
    name: 'Permission',
    meta: {
      title: 'Permission',
      icon: 'lock',
      roles: ['admin', 'editor'] // you can set roles in root nav
    },
    children: [
      {
        path: 'page',
        component: () => import('@/views/permission/page'),
        name: 'PagePermission',
        meta: {
          title: 'Page Permission',
          roles: ['admin'] // or you can only set roles in sub nav
        }
      },
      {
        path: 'directive',
        component: () => import('@/views/permission/directive'),
        name: 'DirectivePermission',
        meta: {
          title: 'Directive Permission'
          // if do not set roles, means: this page does not require permission
        }
      },
      {
        path: 'role',
        component: () => import('@/views/permission/role'),
        name: 'RolePermission',
        meta: {
          title: 'Role Permission',
          roles: ['admin']
        }
      }
    ]
  },

  /** when your routing map is too long, you can split it into small modules **/
  componentsRouter,
  chartsRouter,
  nestedRouter,
  tableRouter,
  .....
  { path: '*', redirect: '/404', hidden: true }
]

const createRouter = () => new Router({
  //切換界面後自動滾動到頂部
  scrollBehavior: () => ({ y: 0 }),
  routes: constantRoutes
})

const router = createRouter()

export function resetRouter() {
  const newRouter = createRouter()
  router.matcher = newRouter.matcher // reset router
}

export default router

vue-element-admin框架將路由分爲靜態路由(constantRoutes)動態路由(asyncRoutes)兩部分,靜態路由指定的是這些訪問路徑不需要權限就可以訪問,比如404/401/login這些界面。而動態路由是指需要獲取相應權限的纔可以訪問的路徑,例如:文章編輯界面權限管理界面等等都是需要有權限才能訪問的。

這裏存在靜態路由的原因是本項目是作爲一個示例Demo運行的,而實際項目大部分都是利用當前用戶的權限查詢之後,然後動態添加到Vue Router中的(後面會談到)

靜態路由

我們以/dashboard爲例,該路徑配置配置信息如下:

src\router\index.js

{
    path: '/',
    component: Layout,
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        component: () => import('@/views/dashboard/index'),
        name: 'Dashboard',
        meta: { title: 'Dashboard', icon: 'dashboard', affix: true }
      }
    ]
},

根據Vue Router的文檔可知,當瀏覽器訪問到http://localhost:9527/#/dashboard路徑時,App.vue文件中的<router-view />將會被組件Layout給替換。這裏多了個#是因爲本項目默認採用的是 hashHistory,還有一種browserHistory

兩者的區別簡單來說是對路由方式的處理不一樣,hashHistory 是以 # 後面的路徑進行處理,通過HTML 5 History 進行前端路由管理,而 browserHistory 則是類似我們通常的頁面訪問路徑,並沒有 #,但要通過服務端的配置,能夠訪問指定的 url 都定向到當前頁面,從而能夠進行前端的路由管理。詳情請看這裏

src\layout\index.vue

<template>
  <div :class="classObj" class="app-wrapper">
    <div v-if="device==='mobile'&&sidebar.opened" class="drawer-bg" @click="handleClickOutside" />
    <sidebar class="sidebar-container" />
    <div :class="{hasTagsView:needTagsView}" class="main-container">
      <div :class="{'fixed-header':fixedHeader}">
        <navbar />
        <tags-view v-if="needTagsView" />
      </div>
      <app-main />
      <right-panel v-if="showSettings">
        <settings />
      </right-panel>
    </div>
  </div>
</template>

<script>
import RightPanel from '@/components/RightPanel'
import { AppMain, Navbar, Settings, Sidebar, TagsView } from './components'
import ResizeMixin from './mixin/ResizeHandler'
import { mapState } from 'vuex'

export default {
  name: 'Layout',
  components: {
    AppMain,
    Navbar,
    RightPanel,
    Settings,
    Sidebar,
    TagsView
  },
  mixins: [ResizeMixin],
  computed: {
    ...mapState({
      sidebar: state => state.app.sidebar,
      device: state => state.app.device,
      showSettings: state => state.settings.showSettings,
      needTagsView: state => state.settings.tagsView,
      fixedHeader: state => state.settings.fixedHeader
    }),
    classObj() {
      return {
        hideSidebar: !this.sidebar.opened,
        openSidebar: this.sidebar.opened,
        withoutAnimation: this.sidebar.withoutAnimation,
        mobile: this.device === 'mobile'
      }
    }
  },
  methods: {
    handleClickOutside() {
      this.$store.dispatch('app/closeSideBar', { withoutAnimation: false })
    }
  }
}
</script>
<!-- 省略SASS樣式 -->

一看這個模板就能看出整個項目的佈局結構了,大致分佈如下:
圖片來源於官方文檔
其中sidecartabs-viewbreadcrumd(麪包屑)navbar等等結構,框架都已經自動完成填充,只需要在路由配置中增添相應的內容,對應內容就會顯示在指定位置,我們需要定製的就是AppMain這一塊的內容。

src\layout\components\AppMain.vue

<template>
  <section class="app-main">
    <transition name="fade-transform" mode="out-in">
      <keep-alive :include="cachedViews">
        <router-view :key="key" />
      </keep-alive>
    </transition>
  </section>
</template>

<script>
export default {
  name: 'AppMain',
  computed: {
    cachedViews() {
      return this.$store.state.tagsView.cachedViews
    },
    key() {
      return this.$route.path
    }
  }
}
</script>
<!-- 省略SASS樣式 -->

打開AppMain組件之後你會發現他就定義一個簡單的佈局,並留有一個<router-view :key="key" />(key主要是用於區分不同路由下的相同組件的)子節點,用於路由時的填充。還是之前的那個示例:

{
    path: '/',
    component: Layout,
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        component: () => import('@/views/dashboard/index'),
        name: 'Dashboard',
        meta: { title: 'Dashboard', icon: 'dashboard', affix: true }
      }
    ]
},

可以看出在該路由下,AppMain中的<router-view>將被填入@/views/dashboard/index這個組件。

views/dashboard/index

<template>
  <div class="dashboard-container">
    <component :is="currentRole" />
  </div>
</template>

<script>
import { mapGetters } from 'vuex'
import adminDashboard from './admin'
import editorDashboard from './editor'

export default {
  name: 'Dashboard',
  components: { adminDashboard, editorDashboard },
  data() {
    return {
      currentRole: 'adminDashboard'
    }
  },
  computed: {
    ...mapGetters([
      'roles'
    ])
  },
  created() {
    if (!this.roles.includes('admin')) {
      this.currentRole = 'editorDashboard'
    }
  }
}
</script>

這裏呢,index.vue做了一下權限判斷處理,來決定加載哪一個數據面板界面,我們假設我們是以admin角色登入,那麼adminDashboard組件就將被渲染在<component :is="currentRole" />的位置。所以我們再進一步看一下adminDashboard組件。

src\views\dashboard\admin\index.vue

<template>
  <div class="dashboard-editor-container">
    <github-corner class="github-corner" />

    <panel-group @handleSetLineChartData="handleSetLineChartData" />

    <el-row style="background:#fff;padding:16px 16px 0;margin-bottom:32px;">
      <line-chart :chart-data="lineChartData" />
    </el-row>

    <el-row :gutter="32">
      <el-col :xs="24" :sm="24" :lg="8">
        <div class="chart-wrapper">
          <raddar-chart />
        </div>
      </el-col>
      <el-col :xs="24" :sm="24" :lg="8">
        <div class="chart-wrapper">
          <pie-chart />
        </div>
      </el-col>
      <el-col :xs="24" :sm="24" :lg="8">
        <div class="chart-wrapper">
          <bar-chart />
        </div>
      </el-col>
    </el-row>

    <el-row :gutter="8">
      <el-col :xs="{span: 24}" :sm="{span: 24}" :md="{span: 24}" :lg="{span: 12}" :xl="{span: 12}" style="padding-right:8px;margin-bottom:30px;">
        <transaction-table />
      </el-col>
      <el-col :xs="{span: 24}" :sm="{span: 12}" :md="{span: 12}" :lg="{span: 6}" :xl="{span: 6}" style="margin-bottom:30px;">
        <todo-list />
      </el-col>
      <el-col :xs="{span: 24}" :sm="{span: 12}" :md="{span: 12}" :lg="{span: 6}" :xl="{span: 6}" style="margin-bottom:30px;">
        <box-card />
      </el-col>
    </el-row>
  </div>
</template>

<script>
import GithubCorner from '@/components/GithubCorner'
import PanelGroup from './components/PanelGroup'
import LineChart from './components/LineChart'
import RaddarChart from './components/RaddarChart'
import PieChart from './components/PieChart'
import BarChart from './components/BarChart'
import TransactionTable from './components/TransactionTable'
import TodoList from './components/TodoList'
import BoxCard from './components/BoxCard'

const lineChartData = {
  newVisitis: {
    expectedData: [100, 120, 161, 134, 105, 160, 165],
    actualData: [120, 82, 91, 154, 162, 140, 145]
  },
  messages: {
    expectedData: [200, 192, 120, 144, 160, 130, 140],
    actualData: [180, 160, 151, 106, 145, 150, 130]
  },
  purchases: {
    expectedData: [80, 100, 121, 104, 105, 90, 100],
    actualData: [120, 90, 100, 138, 142, 130, 130]
  },
  shoppings: {
    expectedData: [130, 140, 141, 142, 145, 150, 160],
    actualData: [120, 82, 91, 154, 162, 140, 130]
  }
}

export default {
  name: 'DashboardAdmin',
  components: {
    GithubCorner,
    PanelGroup,
    LineChart,
    RaddarChart,
    PieChart,
    BarChart,
    TransactionTable,
    TodoList,
    BoxCard
  },
  data() {
    return {
      lineChartData: lineChartData.newVisitis
    }
  },
  methods: {
    handleSetLineChartData(type) {
      this.lineChartData = lineChartData[type]
    }
  }
}
</script>
<!-- 省略樣式表 -->

對比着之前的截圖,這個組件中的節點就按照代碼中組織的那樣排列整齊。

動態路由

之前描述都是靜態路由的組件渲染加載工程,動態路由也是一樣的過程,只不過動態路由的配置信息是後面用戶登錄過後動態添加到Vue Router而已。

由於動態路由是在用戶登錄之後被添加的,所以我們需要從登錄的最初始的地方開發探索。當然首先不能放過,作者配置的導航守衛,也就是攔截器。

src/permission.js

const whiteList = ['/login', '/auth-redirect']

router.beforeEach(async(to, from, next) => {
  //啓動導航進度條
  NProgress.start()

  //設置頁面標題
  document.title = getPageTitle(to.meta.title)

  //獲取從cookie中獲取token
  const hasToken = getToken()

  if (hasToken) {
    if (to.path === '/login') {
      //如果有token的情況下進入login界面,則直接默認登錄跳轉到Dashboard界面
      next({ path: '/' })
      NProgress.done()
    } else {
      //如果是其他界面,則查詢該用戶的角色信息
      const hasRoles = store.getters.roles && store.getters.roles.length > 0
      if (hasRoles) {
        //有角色信息則允許繼續路由
        next()
      } else {
        try {
          //嘗試查詢角色信息
          const { roles } = await store.dispatch('user/getInfo')

          //這裏就是根據角色信息添加動態路由,這裏是異步的
          const accessRoutes = await store.dispatch('permission/generateRoutes', roles)

          router.addRoutes(accessRoutes)

        
          next({ ...to, replace: true })
        } catch (error) {
          //如果出錯則說明token有問題需要重新獲取
          await store.dispatch('user/resetToken')
          Message.error(error || 'Has Error')
          next(`/login?redirect=${to.path}`)
          NProgress.done()
        }
      }
    }
  } else {
    // 沒有獲取token的說明第一次登錄,重定向到登錄界面
    // 如果是login、auth-redirect則允許繼續路由
    if (whiteList.indexOf(to.path) !== -1) {
      next()
    } else {
      next(`/login?redirect=${to.path}`)
      NProgress.done()
    }
  }
})

從上面可以看出動態的路由信息是利用Vuex的異步action獲取的,即調用permission/generateRoutes.

src\store\modules\permission.js

const actions = {
  generateRoutes({ commit }, roles) {
    return new Promise(resolve => {
      let accessedRoutes
      if (roles.includes('admin')) {
        accessedRoutes = asyncRoutes || []
      } else {
        //這裏是根據每個路由配置信息中的meta元信息中的rules過濾
        accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)
      }
      //同步調用下面的mutations方法
      commit('SET_ROUTES', accessedRoutes)
      resolve(accessedRoutes)
    })
  }
}

const mutations = {
  SET_ROUTES: (state, routes) => {
    state.addRoutes = routes
    //合併靜態路由和動態路由
    state.routes = constantRoutes.concat(routes)
  }
}

到這裏整個路由的構成和功能就介紹完畢了。

狀態管理

vue-element-admin中使用了很多Vuex提供的getteractionmutations方法,所以掌握這些信息的管理方式也是很有必要的。當然,我們首先要找到狀態管理插件信息的傳遞入口:

src\main.js

import store from './store'

new Vue({
  el: '#app',
  router,
  store,
  render: h => h(App)
})

src\store\index.js

import Vue from 'vue'
import Vuex from 'vuex'
import getters from './getters'

//使用Vuex插件
Vue.use(Vuex)

//獲取store目錄的下的modules目錄的所有文件
const modulesFiles = require.context('./modules', true, /\.js$/)

//遍歷上述文件
const modules = modulesFiles.keys().reduce((modules, modulePath) => {
  //將每個文件註冊爲文件名對應的模塊
  const moduleName = modulePath.replace(/^\.\/(.*)\.\w+$/, '$1')
  const value = modulesFiles(modulePath)
  modules[moduleName] = value.default
  return modules
}, {})

const store = new Vuex.Store({
  modules,
  getters
})

export default store

src\store目錄的結構如下:
在這裏插入圖片描述

我們查看其中一個模塊文件

src\store\module\app.js

import Cookies from 'js-cookie'

const state = {
  sidebar: {
    opened: Cookies.get('sidebarStatus') ? !!+Cookies.get('sidebarStatus') : true,
    withoutAnimation: false
  },
  device: 'desktop',
  size: Cookies.get('size') || 'medium'
}

const mutations = {
  TOGGLE_SIDEBAR: state => {
    state.sidebar.opened = !state.sidebar.opened
    state.sidebar.withoutAnimation = false
    if (state.sidebar.opened) {
      Cookies.set('sidebarStatus', 1)
    } else {
      Cookies.set('sidebarStatus', 0)
    }
  },
  ......
}

const actions = {
  toggleSideBar({ commit }) {
    commit('TOGGLE_SIDEBAR')
  },
  ......
}

export default {
  namespaced: true,
  state,
  mutations,
  actions
}

這個模塊中定義了mutationsactions等,可以通過commit()dispatch()等方法來調用,就像前面那樣.這些各個模塊通過在index.js中遍歷彙總,最終一併傳入Vue中。而各個模塊中的state較爲散亂,作者還特意將各個state彙總到如下文件中,提供一個統一的訪問接口。

src\store\getter.js

const getters = {
  sidebar: state => state.app.sidebar,
  size: state => state.app.size,
  device: state => state.app.device,
  visitedViews: state => state.tagsView.visitedViews,
  cachedViews: state => state.tagsView.cachedViews,
  token: state => state.user.token,
  avatar: state => state.user.avatar,
  name: state => state.user.name,
  introduction: state => state.user.introduction,
  roles: state => state.user.roles,
  permission_routes: state => state.permission.routes,
  errorLogs: state => state.errorLog.logs
}
export default getters

通過這種方式,我們只需要通過mapGetters輔助函數就可以將store 中的對應 getter 映射到局部計算屬性中,或者通過如下方式訪問到:

import { mapGetters } from 'vuex'

export default {
  // ...
  computed: {
    ...mapGetters([
      //如果計算屬性和getter中命名相同,
      // 則可以直接寫爲'sidebar',
      sidebar:'sidebar',
      // ...
    ])
  }
}

//上述方法等效於如下:
export default {
  // ...
  computed: {
    sidebar(){
      return this.$store.getters.sidebar;
    }
  }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章