vue 快速入門 系列 —— 使用 vue-cli 3 搭建一個專案(下)

彭加李發表於2021-11-15

其他章節請看:

vue 快速入門 系列

使用 vue-cli 3 搭建一個專案(下)

上篇 我們已經成功引入 element-uiaxiosmockiconfontnprogress,本篇繼續介紹 許可權控制佈局多環境(.env)跨域vue.config.js,一步一步構建我們自己的架構。

許可權控制

後端系統一開始就得考慮許可權和安全的問題。

大概思路:

  • 前端持有一份路由表,表示每個路由可以訪問的許可權(路由表也可以由後端生成,但感覺前端被後端支配,前端的許可權也總是不安全的,所以後端許可權少不了,所以這份路由表做在前端也沒關係)

  • 使用者輸入使用者名稱和密碼進行登入,伺服器返回一個 token(登入標識)。前端將 token 存入 cookie,使用者再次重新整理頁面也能記住登入狀態

  • 通過 token 從伺服器取得使用者對應的 role(角色資訊),根據 role 計算出相對應的路由,在用 router.addRoute 動態掛載這些路由

最基礎的許可權實現

需求:

  • 涉及兩個頁面,登入頁和首頁
  • 在登入頁點選登入按鈕,成功則進入首頁
  • 在首頁中,點選登出按鈕,則又回到登入頁
  • 從其他頁面訪問(/about),如果沒有許可權則會去到登入頁,登入成功後會再次來到 /about

核心思路:

  • 建立登入頁,裡面有兩個 input 用於輸入使用者名稱和密碼,點選登入,登入成功,取得 token,並存入 cookie,跳轉到主頁(或 redirect)
  • 建立全域性前置守衛,分為已登入和未登入
    • 已登入(能取得 token),如果訪問登入頁則直接轉去主頁,如果訪問非登入頁,如果已經獲取過使用者資訊(例如 name),則直接放行(next()),否則就去獲取使用者資訊,獲取成功則放行,獲取失敗則重置 token,並跳轉到登入頁,並攜帶現在的 path
    • 未登入,如果是白名單(例如 /login)則直接放行,非白名單,則跳轉到登入頁(/login),並攜帶現在的 path

核心程式碼:

  • Login.vue,登入頁
  • store/index.js,資料和操作都通過 vuex 全域性控制
  • permission.js,全域性前置守衛
// views/Login.vue
<template>
  <div>
    <p>name: <input type="text" /></p>
    <p>password: <input type="passwored" /></p>
    <p><button @click="handleLogin">登入</button></p>
  </div>
</template>
<script>
export default {
  data() {
    return {
      redirect: ''
    }
  },
  watch: {
    $route: {
      handler: function(route) {
        this.redirect = route.query && route.query.redirect
      },
      immediate: true
    }
  },
  methods: {
    handleLogin(){
      // 登入
      this.$store.dispatch('login').then(() => {
        console.log('頁面跳轉')
        // 頁面跳轉
        this.$router.push({ path: this.redirect || '/' })
          // 解決報錯:Uncaught (in promise) Error: Redirected when going from...
          .catch(() => {});
      }).catch(() => {
        alert('登入失敗')
      })
    }
  },
}
</script>
// store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import { getToken, setToken, removeToken } from '@/utils/auth'
import { resetRouter } from '@/router'
Vue.use(Vuex)

const getDefaultState = () => ({
  token: getToken(),
  name: ''
})

export default new Vuex.Store({
  state: getDefaultState(),

  mutations: {
    SET_TOKEN: (state, token) => {
      state.token = token
    },
    RESET_STATE: (state) => {
      Object.assign(state, getDefaultState())
    },
    SET_NAME: (state, name) => {
      state.name = name
    },
  },

  getters: {
    name: (state) => state.name
  },

  actions: {
    // user login
    login({ commit }, userInfo) {
      return new Promise((resolve, reject) => {
        // 登入成功(此處應該是 ajax)
        Promise.resolve({ code: 200, data: { token: new Date() } }).then(response => {
          const { data } = response
          // vuex 儲存 token
          commit('SET_TOKEN', data.token)
          // 將 token 儲存 localStorage 中(你也可以存入 cookie)
          setToken(data.token)
          resolve()
        }).catch(error => {
          reject(error)
        })
      })
    },

    // 登出
    logout({ commit, state }) {
      return new Promise((resolve, reject) => {
        // 登出(此處應該是 ajax)
        Promise.resolve().then(() => {
          removeToken() // must remove  token  first
          resetRouter()
          commit('RESET_STATE')
          resolve()
        }).catch(error => {
          reject(error)
        })
      })
    },

    // 獲取使用者資訊
    getInfo({ commit, state }) {
      return new Promise((resolve, reject) => {
        // 傳送 token 給後端,取得使用者資訊
        Promise.resolve({ code: 200, data: { name: 'ph' } }).then(response => {
          const { data } = response

          if (!data) {
            // 驗證失敗,請重新登入。
            return reject('驗證失敗,請重新登入。')
          }

          const { name } = data
          commit('SET_NAME', name)
          resolve(data)
        }).catch(error => {
          reject(error)
        })
      })
    },
    
    // remove token
    resetToken({ commit }) {
      return new Promise(resolve => {
        // 從 Cookie 中刪除 token
        removeToken() // must remove  token  first
        // 重置 vuex 資料,包括重置 token
        commit('RESET_STATE')
        resolve()
      })
    },
  },
})
// src/permission.js
import router from './router'
import store from './store'
import { getToken } from '@/utils/auth'

const whiteList = ['/login'] // no redirect whitelist

router.beforeEach(async (to, from, next) => {
  // 使用者是否已經登入
  const hasToken = getToken()
  // 已登入
  if (hasToken) {
    // 再次訪問登入頁面,則直接進入主頁
    if (to.path === '/login') {
      next({ path: '/' })
      return
    }

    // 訪問登入頁以外的其他頁面
    // 是否已經獲取過使用者資訊
    const hasGetUserInfo = store.getters.name
    // 有使用者資訊,則直接訪問該頁面
    if (hasGetUserInfo) {
      next()
      return
    }

    // 前一個非同步操作失敗,也不中斷後面的非同步操作,就可以使用 try...catch
    try {
      // 獲取使用者資訊
      await store.dispatch('getInfo')
      next()
    } catch (error) {
      // 刪除 token 並轉到登入頁面重新登入
      await store.dispatch('resetToken')
      console.log(error || 'Error')
      next(`/login?redirect=${to.path}`)
    }
    return
  }

  // 未登入
  whiteList.includes(to.path)
    // 白名單,直接通過
    ? next()
    // 非白名單,轉去登入頁面
    : next(`/login?redirect=${to.path}`)
})

其他相關程式碼:

  • main.js,引入 permission.js
  • router.js,配置登入頁和主頁的路由
  • auth.js,用於將 token 存入本地
  • TestHome.vue,主頁
// main.js
import './permission'
// router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import TestHome from '../views/TestHome'
import Login from '../views/Login'

Vue.use(VueRouter)

const routes = [
  {
    path: '/login',
    component: Login,
  },
  {
    path: '/',
    component: TestHome,
    // 命名路由
    name: 'home-page'
  },
]

const createRouter = () => new VueRouter({
  // mode: 'history', // require service support
  scrollBehavior: () => ({ y: 0 }),
  routes: routes
})

const router = createRouter()

// Detail see: https://github.com/vuejs/vue-router/issues/1234#issuecomment-357941465
export function resetRouter() {
  const newRouter = createRouter()
  router.matcher = newRouter.matcher // reset router
}

export default router
// src/utils/auth
import Cookies from 'js-cookie'

const TokenKey = 'myself-vue-admin-template-token'

export function getToken() {
  return Cookies.get(TokenKey)
}

export function setToken(token) {
  return Cookies.set(TokenKey, token)
}

export function removeToken() {
  return Cookies.remove(TokenKey)
}
// views/TestHome.vue
<template>
  <div class="test-home">
    <h2>主頁</h2>
    <p><button @click="logout">退出</button></p>
  </div>
</template>
<script>
export default {
  methods: {
    // 登出
    async logout(){
      await this.$store.dispatch('logout')
      this.$router.push(`/login?redirect=${this.$route.fullPath}`)
    }
  },
}
</script>

根據角色生成使用者可以訪問的路由

在上一節,我們在路由中定義的只是通用路由,也就是所有角色都可以訪問。

接下來我們定義一些需要許可權訪問的路由,然後根據使用者角色,過濾生成使用者最終可以訪問的路由表。

需求:有如下非通用(需要許可權才能訪問)路由,根據角色(admin 或 editor)生成使用者可以訪問的路由表,將此功能加入 最基礎的許可權實現 一節中的例子裡面。

const asyncRoutes = [
  {
    path: '/article/:id',
    component: Article,
    // 用於描述資料的資料
    meta: {
      roles: ['admin', 'editor']
    },
    // children 是巢狀路由
    children: [
      {
        // 當 /user/:id/ca 匹配成功,
        path: 'ca',
        component: ArticleComponentA,
        roles: ['admin']
      },
      {
        path: 'cb',
        component: ArticleComponentB
      }
    ]
  },
]

這裡定義了三個路由,/article/:id 可以被角色 admin 或 editor 訪問,/article/:id/ca 只能被 admin 訪問,而 /article/:id/ca 能被任何角色訪問。

:對於巢狀路由,如果外面的路由(/article/:id)不能訪問,但裡面的路由(/article/:id/ca)卻有許可權訪問,那麼應該都不能訪問。

思路:

  1. src/router/index.js 中定義非同步路由
  2. src/store/index.js 中生成使用者最終能訪問的非同步路由
  3. src/permission.js 中將生成的路由通過 addRoute 加入 router 中

核心程式碼如下(在 axios 和 mock 的基礎上寫):

// src/router/index.js
import Article from '../views/Article'
import ArticleComponentA from '../views/ArticleComponentA'
import ArticleComponentB from '../views/ArticleComponentB'

export const asyncRoutes = [
  {
    path: '/article/:id',
    component: Article,
    // 用於描述資料的資料
    meta: {
      roles: ['admin', 'editor']
    },
    // children 是巢狀路由
    children: [
      {
        // 當 /user/:id/ca 匹配成功,
        path: 'ca',
        component: ArticleComponentA,
        meta: {
          roles: ['admin']
        }
      },
      {
        path: 'cb',
        component: ArticleComponentB
      }
    ]
  },
]

Tip: Article.vueArticleComponentA.vueArticleComponentB.vue只是很簡單的頁面

// views/Article.vue
<template>
  <div>
    <p>文章列表頁</p>
    <!-- 子路由 -->
    <router-view></router-view>
  </div>
</template>

// views/ArticleComponentA.vue
<template>
  <div style="border: 1px solid">
    <p>我是A</p>
    <p>
      這是對應 <em>{{ $route.params.id }}</em> 的文章
    </p>
  </div>
</template>

// views/ArticleComponentB.vue 與 ArticleComponentA.vue 類似
// src/store/index.js
import { asyncRoutes } from '@/router'

function hasPermission(route, roles) {
  return (route.meta && route.meta.roles)  // 如果路由定義了角色的限制
    ? roles.some(role => route.meta.roles.includes(role))
    : true
}

function filterRoutes(routes, roles) {
  const res = []
  routes.forEach(route => {
    // 淺拷貝,更安全
    const tmp = { ...route }
    if (!hasPermission(tmp, roles)) {
      return
    }
    // 先處理孩子
    if (tmp.children) {
      tmp.children = filterRoutes(tmp.children, roles)
    }
    res.push(tmp)
  })
  return res
}

export default new Vuex.Store({
  actions: {
    // 生成路線
    generateRoutes({ commit }, roles) {
      return new Promise(resolve => {
        let accessedRoutes = roles.includes('admin') // admin 都能訪問,無需過濾
          ? (asyncRoutes || [])
          : filterRoutes(asyncRoutes, roles)

        resolve(accessedRoutes)
      })
    }
  },
})
// src/permission.js
// 修改的程式碼只涉及 try 內
router.beforeEach(async (to, from, next) => {
  // 使用者是否已經登入
  const hasToken = getToken()
  // 已登入
  if (hasToken) {
    ...
    try {
      // 獲取使用者資訊
      let roles = await store.dispatch('getInfo')
      // 此處僅模擬
      roles = ['editor']
      const accessRoutes = await store.dispatch('generateRoutes', roles)
      accessRoutes.forEach(route => {
        router.addRoute(route)
      })
      next()
    } catch (error) {
      ...
    }
  }
})

測試:

訪問:http://localhost:8080/#/article/1/cb
輸出:
文章列表頁

我是B

這是對應 1 的文章
訪問:http://localhost:8080/#/article/1/ca
沒有許可權,無法訪問

404

需求:在上一節的基礎上,訪問 http://localhost:8080/#/article/1/ca 跳轉到 404 頁面,即訪問不存在的路由都轉到 404。

直接上程式碼:

// views/404.vue
<template>
  <h1>404 頁面</h1>
</template>
// src/router/index.js
const routes = [
  // 注:有人說:404頁面一定要放在最後,這裡放在開頭好似沒有影響
+ { path: '*', redirect: '/404', hidden: true },
+ {
    path: '/404',
    component: () => import('@/views/404')
  },
  {
    path: '/login',
    component: Login,
  },
  {
    path: '/',
    component: TestHome,
    // 命名路由
    name: 'home-page'
  }
]

測試:

輸入: http://localhost:8080/#/article/1/cb
顯示正常

輸入: http://localhost:8080/#/article/1/ca
頁面顯示:404 頁面

:有人說:404頁面一定要放在最後,這裡放在開頭好似沒有影響

佈局

頁面整體佈局是一個產品最外層的框架結構,往往包含導航、側邊欄、麵包屑等等。

模板專案如何佈局

模板專案中絕大部分頁面基於一個 layout.vue,除了個別頁面,如 login、404 沒有使用該 layout。如果需要在專案中有多種不同的 layout 也很方便,只要在一級路由中選擇不同的 layout 即可

加入自己的佈局

需求:一個專案有多種佈局是很正常的,我們建立 2 種佈局,一種上下佈局,一種左右佈局。登入(login.vue)頁面無需使用佈局,而 page1.vuepage2.vue 各應用於一種佈局。

page1.vue 應用於左右佈局
+----------------+
|   |            |
|   |            |
|   |            |
+----------------+

page2.vue 應用於上下佈局
+----------------+
|                |
+----------------+
|                |
|                |
|                |
+----------------+

直接上程式碼:

// App.vue(vue-cli 自動生成,無需變動)
<template>
  <div id="app">
    <router-view />
  </div>
</template>
// src/layout/layout1.vue
<template>
  <!-- 左右佈局 -->
  <div>
    <div class="side">導航</div>
    <div class="main">
      <router-view></router-view>
    </div>
  </div>
</template>
// src/layout/layout2.vue
<template>
  <!-- 上下佈局 -->
  <div>
    <div>導航</div>
    <div class="main">
      <router-view></router-view>
    </div>
  </div>
</template>
// router/index.js
// 略
import Layout1 from '@/layout/layout1'
import Layout2 from '@/layout/layout2'
const routes = [
  {
    path: '/login',
    component: Login,
  },
  // page1.vue 應用於 Layout1
  {
    path: '/',
    component: Layout1,
    // 重定向到 /page1
    redirect: '/page1',
    children: [{
      path: 'page1',
      name: 'Page1',
      component: () => import('@/views/page1'),
    }]
  },
  // page2.vue 應用於 Layout2
  {
    path: '/example',
    component: Layout2,
    redirect: '/example/page2',
    children: [{
      path: 'page2',
      name: 'Page2',
      component: () => import('@/views/page2'),
    }]
  },
]
// views/page1.vue
<template>
  <p>主頁</p>
</template>
// views/page2.vue
<template>
  <p>page2</p>
</template>

測試:

輸入   http://localhost:8080/#/
跳轉到 http://localhost:8080/#/page1
顯示:

導航
主頁
輸入   http://localhost:8080/#/example
跳轉到 http://localhost:8080/#/example/page2
顯示:

導航
page2

多環境

vue-cli 只提供了兩種環境,開發和生產:

// package.json
{
  "name": "myself-vue-admin-template",
  "scripts": {
    // 開發環境
    "serve": "vue-cli-service serve",
    // 生產環境
    "build": "vue-cli-service build",
  },
}

有時我們需要一個預釋出環境,可以這樣:

{
  "scripts": {
    // 開發環境
    "serve": "vue-cli-service serve",
    // 生產環境
    "build": "vue-cli-service build",
    // 預釋出
    "build:stage": "vue-cli-service build --mode staging",
  },
}

執行 npm run build:stage 會讀取 .env.staging 檔案中的變數。

.env 檔案

在模板專案中,有三個 .env 檔案:

// .env.development
# just a flag
ENV = 'development'

# base api
VUE_APP_BASE_API = '/dev-api'
// .env.production
# just a flag
ENV = 'production'

# base api
VUE_APP_BASE_API = '/prod-api'
// .env.staging
NODE_ENV = production

# just a flag
ENV = 'staging'

# base api
VUE_APP_BASE_API = '/stage-api'

Tip:前兩個 .env 檔案沒有定義 NODE_ENV 變數,那麼 NODE_ENV 的值取決於模式,例如,在 production 模式下被設定為 production

這三個 .env 檔案分別對應著模板專案的這三個命令:

{
  "name": "vue-admin-template",
  "scripts": {
    "dev": "vue-cli-service serve",
    "build:prod": "vue-cli-service build",
    "build:stage": "vue-cli-service build --mode staging",
  },
}

Tip:更多細節請看vue-cli 下 -> 模式和環境變數

base_url

測試環境和線上的 base_url 不相同是很正常的,我們可以通過配置 base_url 解決。

我們直接分析模板專案:

在上一節(.env 檔案)我們發現,在 3 個 .env 檔案中都定義了一個變數 VUE_APP_BASE_API

// .env.development
# base api
VUE_APP_BASE_API = '/dev-api'

// .env.production
# base api
VUE_APP_BASE_API = '/prod-api'

// .env.staging
# base api
VUE_APP_BASE_API = '/stage-api'

在 axios 中有如下一段程式碼:

// src/utils/request.js
// create an axios instance
const service = axios.create({
  baseURL: process.env.VUE_APP_BASE_API, // url = base url + request url
  // withCredentials: true, // send cookies when cross-domain requests
  timeout: 5000 // request timeout
})

從註釋得知,我們發出的 url 包含兩部分,而其中的 base url 就會根據不同環境取不同的值,例如在 staging 模式下將等於 /stage-api

Tip: 執行命令(例如 npm run build:prod)就會替換 process.env.VUE_APP_BASE_API

如果某個請求的 baseURL 和其他的不同,可以這樣:

// 會自動覆蓋你在建立例項時寫的引數
export function getArticle() {
  return fetch({
    baseURL: https://www.baidu.com
    url: '/article',
    method: 'get',
  });
}

跨域

跨域方式很多,但現在主流的有兩種:

  1. 開發環境和生成環境都用 cors
  2. 開發環境用 Proxy,生成環境用 nginx

最推薦的是 cors(跨域資源共享),能決定瀏覽器是否阻止前端 JavaScript 程式碼獲取跨域請求的響應。前端無需變化,工作量在後端。而後端的開發環境和生成環境是一套程式碼,所以也很方便使用。如果後端不願意,前端就用 proxy + nginx

proxy

需求:新建一個頁面(Test.vue),裡面有 2 個按鈕,點選按鈕能發出相應的請求,一個是非跨域請求,一個是跨域請求。

核心程式碼如下:

// views/Test.vue
<template>
  <div>
    <button @click="request1">非跨域</button>
    <button @click="request2">跨域</button>
  </div>
</template>

<script>
import { getList, getArticle } from '@/api/table'

export default {
  methods: {
    request1 () {
      getList()
    },
    request2(){
      getArticle()
    }
  }
}
</script>
// src/api/table.js
import request from '@/utils/request'

// 非跨域請求
export function getList(params) {
  return request({
    url: '/vue-admin-template/table/list',
  })
}

// 跨域請求
export function getArticle(params) {
  return request({
    url: '/pengjiali/p/14561119.html',
    method: 'get',
    params
  })
}
// vue.config.js
module.exports = {
  devServer: {
    proxy: {
      // 只有 /pengjiali 的請求會被代理
      '/pengjiali': {
        target: 'https://www.cnblogs.com/',
        // changeOrigin: true
      },
    }
  }
}
// src/router/index.js
import Test from '../views/Test'

const routes = [
  {
    path: '/test',
    component: Test
  },
]

測試:

訪問:http://localhost:8080/test#/test
顯示兩個按鈕:非跨域 跨域

點選“非跨域”
- 請求 http://localhost:8080/vue-admin-template/table/list
- 響應 {"code":20000,"data":{"total":3,"items":4}}

點選“跨域”
- 請求 http://localhost:8080/pengjiali/p/15419402.html
- 響應 <!DOCTYPE html>...

:設定 proxy 中的 changeOrigin: true,在瀏覽器中是看不到效果的。或許瀏覽器只顯示第一層的請求,也就是發給代理的請求,而代理做的事情,瀏覽器就沒顯示了。

nginx

有時我們需要在生成環境下跨域請求,這時就用不了 proxy(proxy 是在本地伺服器中配置的),我們可以使用 nginx

Tip: nginx 是一款輕量級的 web 伺服器,能很快的處理靜態資源(js、html、css);同時也是反向代理伺服器;還有負載均衡的作用,並有多種策略以供選擇。

我們模擬一下 nginx 跨域,步驟如下

  • 通過 anywhere(通過 npm 安裝即可) 啟動服務
myself-vue-admin-template\src> anywhere -p 8095
Running at http://192.168.85.1:8095/
Also running at https://192.168.85.1:8096/
// 啟動 nginx
nginx-1.21.3> .\nginx.exe

// 關閉 nginx
// nginx-1.21.3>  .\nginx.exe -s stop

Tip:筆者使用 powershell 輸入 .\nginx.exe 啟動 nginx,沒有任何提示說明啟動成功,我們可以通過瀏覽器輸入 http://localhost/ 來驗證。

  • 修改 nginx 配置檔案:
// nginx-1.21.3\conf\nginx.conf
http {
    server {
        # 修改埠為 8090,用於跨域的實驗
        listen       8090;
        server_name  localhost;

        location / {
            # 將 nginx 的目錄直接指定到我們的專案
            root   myself-vue-admin-template\dist;
            index  index.html index.htm;
        }

        # 請求 /src 的都被代理到 anywhere 伺服器中
        location  /src {
             proxy_pass   http://192.168.85.1:8095/;
         }
    }
}
  • 修改請求 url
// src/aip/table.js
export function getArticle(params) {
  return request({
    // url: 'pengjiali/p/15419402.html',
    url: 'src/main.js',
    method: 'get',
    params
  })
}
  • 修改 nginx 配置需要 reload
nginx-1.21.3> .\nginx.exe -s reload
  • 驗證
// 打包生成 dist
myself-vue-admin-template> npm run build:prod

瀏覽器進入 http://localhost:8090/#/test
頁面顯示 `非跨域` 和 `跨域` 按鈕

點選`跨域` 按鈕
發出請求 http://localhost:8090/src/main.js,資源 main.js 成功載入

Tip:筆者最初使用 nginx 代理到部落格園,而不是代理到 anywhere 開啟的服務,但是請求卻以 404 而失敗,發出的請求不知道什麼原因,總是給我加了一個 p

期待:
http://localhost:8080/pengjiali/p/15419402.html

實際:
http://localhost:8080/pengjialip/p/15419402.html

vue.config.js

首先我們通過分析模板專案的配置(vue.config.js),然後在我們的專案中也實現類似效果的配置。

Tip: vue-cli 已經對 webpack 封裝過了,可以通過命令檢視我們配置的是否正確:

// 生成環境的配置
vue-admin-template> vue inspect > output.prod.js --mode=production
// 開發環境的配置
vue-admin-template> vue inspect > output.dev.js 

模板專案的配置

// vue-admin-template/vue.config.js

'use strict'

// 載入 node 的 path 模組
const path = require('path')
// 引入 setting.js 模組
const defaultSettings = require('./src/settings.js')

// 返回一個絕對路徑
function resolve(dir) {
  /*
  每個模組都有 __dirname,表示該檔案的目錄,是一個絕對路徑
  path.join() 方法使用特定於平臺的分隔符作為分隔符,將所有給定的路徑段連線在一起,然後對結果路徑進行規範化
  > path.join('/foo', 'bar', 'baz/asdf', 'quux');
  \\foo\\bar\\baz\\asdf\\quux
  */
  return path.join(__dirname, dir)
}

// 定義一個 title,在 public/index.html 中可以獲取
const name = defaultSettings.title || 'vue Admin Template' // page title

/*
定義本地服務的埠,預設是 9528
process 程式,作為全域性可用
process.env.port 會讀取 .env 檔案中的 port 屬性
npm_config_port 可以通過下面命令重置為 9191

vue-admin-template> npm config set port 9191
vue-admin-template> npm config list

*/
const port = process.env.port || process.env.npm_config_port || 9528 // dev port

// 所有配置可以在這裡找到:https://cli.vuejs.org/config/
module.exports = {
  // 請始終使用 publicPath 而不要直接修改 webpack 的 output.publicPath
  // 預設情況下,Vue CLI 會假設你的應用是被部署在一個域名的根路徑上,例如 `https://www.my-app.com/`。
  // 如果應用被部署在一個子路徑上,你就需要用這個選項指定這個子路徑
  publicPath: '/',
  // 當執行 vue-cli-service build 時生成的生產環境構建檔案的目錄
  // 請始終使用 outputDir 而不要修改 webpack 的 output.path
  outputDir: 'dist',
  // 放置生成的靜態資源 (js、css、img、fonts) 的 (相對於 outputDir 的) 目錄。
  assetsDir: 'static',
  // 是否在開發環境下通過 eslint-loader 在每次儲存時 lint 程式碼
  // 設定為 true 或 'warning' 時,eslint-loader 會將 lint 錯誤輸出為編譯警告。預設情況下,警告僅僅會被輸出到命令列,且不會使得編譯失敗
  // 當 `lintOnSave` 是一個 `truthy` 的值時,`eslint-loader` 在開發和生產構建下都會被啟用。這裡在生產構建時禁用 eslint-loader
  lintOnSave: process.env.NODE_ENV === 'development',
  // 如果你不需要生產環境的 source map,可以將其設定為 false 以加速生產環境構建
  productionSourceMap: false,
  // 配置開發伺服器
  devServer: {
    port: port,
    // 預設開啟瀏覽器
    open: true,
    // overlay 只顯示錯誤,不顯示警告
    overlay: {
      warnings: false,
      errors: true
    },
    // 提供在伺服器內部的所有其他中介軟體之前執行自定義中介軟體的能力。這可用於定義自定義處理程式
    // 可以看看:https://www.jianshu.com/p/c4883c04acb3
    before: require('./mock/mock-server.js')
  },
  // 如果這個值是一個物件,則會通過 webpack-merge 合併到最終的配置中
  configureWebpack: {
    // 在 webpack 的名稱欄位中提供應用程式的標題,以便它可以在 index.html 中訪問以注入正確的標題。
    name: name,
    resolve: {
      alias: {
        // 新增 @ 別名
        '@': resolve('src')
      }
    }
  },
  // Vue CLI 內部的 webpack 配置是通過 webpack-chain 維護的。這個庫提供了一個 webpack 原始配置的上層抽象
  chainWebpack(config) {
    // 可以提高首屏速度,建議開啟預載入(preload)
    // 預設情況下,一個 Vue CLI 應用會為所有初始化渲染需要的檔案自動生成 preload 提示
    config.plugin('preload').tap(() => [
      {
        rel: 'preload',
        // 忽略 runtime.js
        // runtime.js 在瀏覽器執行過程中,webpack 用來連線模組化應用程式所需的所有程式碼
        // https://github.com/vuejs/vue-cli/blob/dev/packages/@vue/cli-service/lib/config/app.js#L171
        // 檔案黑名單
        fileBlacklist: [/\.map$/, /hot-update\.js$/, /runtime\..*\.js$/],
        include: 'initial'
      }
    ])

    // 移除 prefetch 外掛
    // 預獲取,告訴瀏覽器在頁面載入完成後,利用空閒時間提前獲取使用者未來可能會訪問的內容
    // 預設情況下,一個 Vue CLI 應用會為所有作為 async chunk 生成的 JavaScript 檔案 (通過動態 `import()` 按需 code splitting 的產物) 自動生成 prefetch 提示。
    // Prefetch 連結將會消耗頻寬。
    config.plugins.delete('prefetch')

    // svg 的處理:
    // 1. file-loader 不處理 src/icons 資料夾中的 svg 檔案
    // 2. 使用 svg-sprite-loader,即 svg 精靈
    config.module
      .rule('svg') // 取得 svg 規則
      .exclude.add(resolve('src/icons')) // 排除 src/icons
      .end()

    // 新的 loader
    config.module
      .rule('icons')
      .test(/\.svg$/)
      .include.add(resolve('src/icons'))
      .end()
      .use('svg-sprite-loader')
      .loader('svg-sprite-loader')
      .options({
        symbolId: 'icon-[name]'
      })
      .end()

    // 不是 development 才會生效
    config
      .when(process.env.NODE_ENV !== 'development',
        config => {
          // script-ext-html-webpack-plugin 是 html-webpack-plugin 的擴充套件外掛
          // 此處主要是將 runtime.js 內聯到模板頁面(public/index.html)中
          // 正因為要內聯,所以在 preload 中將 runtime.js 加入黑名單
          config
            .plugin('ScriptExtHtmlWebpackPlugin')
            .after('html')
            .use('script-ext-html-webpack-plugin', [{
              // `runtime` 必須與 runtimeChunk 名稱相同。 預設是`執行時`
              // 指令碼 'runtime.(.*).js' 是內聯的,而所有其他指令碼都是非同步和預載入的:
              inline: /runtime\..*\.js$/
            }])
            .end()

          // 分塊策略,用於生成環境的快取
          config
            .optimization.splitChunks({
              // 設定為 all 可能特別強大,因為這意味著 chunk 可以在非同步和非非同步 chunk 之間共享
              chunks: 'all',
              cacheGroups: {
                // 將依賴於 node_modules 中的第三方庫打包成 chunk-libs.js
                // 這部分程式碼是很穩定的
                libs: {
                  name: 'chunk-libs',
                  test: /[\\/]node_modules[\\/]/,
                  priority: 10,
                  // 只打包最初依賴的第三方
                  chunks: 'initial' // only package third parties that are initially dependent
                },
                // 將 node_modules 中的 element-ui 庫獨立出來,因為這個庫比較大,600多Kb
                elementUI: {
                  name: 'chunk-elementUI', // split elementUI into a single package
                  // 權重需要大於libs和app,否則會打包成libs或app
                  priority: 20, // the weight needs to be larger than libs and app or it will be packaged into libs or app
                  // 為了適應cnpm
                  test: /[\\/]node_modules[\\/]_?element-ui(.*)/ // in order to adapt to cnpm
                },
                // 比如自定義了一個元件,有十來個頁面都引入了,分塊策略預設是 30kb 才會拆分
                // 加入我們的元件只有20kb,那麼每個頁面都會包括這 20kb,所以,這裡將其拆出來
                commons: {
                  name: 'chunk-commons',
                  // components 通用的元件?
                  test: resolve('src/components'), // can customize your rules
                  minChunks: 3, //  minimum common number
                  priority: 5,
                  reuseExistingChunk: true
                }
              }
            })

          // https:// webpack.js.org/configuration/optimization/#optimizationruntimechunk
          // 值 "single" 會建立一個在所有生成 chunk 之間共享的執行時檔案
          // 構建後你會看到 dist/static/js/runtime.xxx.js
          config.optimization.runtimeChunk('single')
        }
      )
  }
}

我們專案的配置

下面的配置和模板專案的配置效果幾乎相同。兩者的差異有:

  1. 有些配置 vue-cli 預設就有,無需修改。例如 publicPathoutputDir,以及 @ 別名
  2. 標題 title ,相對於模板專案,換了一種實現方式
  3. runtime.js 沒有使用內聯,所以無需在 preload 外掛中加入黑名單
  4. svg 換了一種方式實現,這裡使用的是 vue-clisvg 外掛
// myself-vue-admin-template/vue.config.js

'use strict'

const path = require('path')

// 返回路徑
function resolve (dir) {
  return path.join(__dirname, dir)
}

// 定義標題
const title = 'myself-vue-admin-template'

// 本地伺服器的埠
const port = process.env.port || process.env.npm_config_port || 9528

const assetsDir = 'static'
module.exports = {
  // 放置生成的靜態資源 (js、css、img、fonts) 的 (相對於 outputDir 的) 目錄。
  assetsDir,
  lintOnSave: process.env.NODE_ENV === 'development',
  // 如果你不需要生產環境的 source map,可以將其設定為 false 以加速生產環境構建
  productionSourceMap: false,
  // 配置開發伺服器
  devServer: {
    port: port,
    // 預設開啟瀏覽器
    open: true,
    // overlay 只顯示錯誤,不顯示警告
    overlay: {
      warnings: false,
      errors: true
    },
    proxy: {
      '/pengjiali': {
        target: 'https://www.cnblogs.com/'
      }
    }
  },
  // Vue CLI 內部的 webpack 配置是通過 webpack-chain 維護的
  chainWebpack: config => {
    // 移除 prefetch 外掛
    config.plugins.delete('prefetch')

    // svg
    config.module
      .rule('svg-sprite')
      .use('svg-sprite-loader')
      .loader('svg-sprite-loader')
      .tap(options => {
        // svg 放在 static 目錄下
        options.spriteFilename = `${assetsDir}/img/icons.[hash:8].svg`
        return options
      })
      .end()
      .use('svgo-loader')
      .loader('svgo-loader')

    // 定義標題
    config
      .plugin('html')
      .tap(args => {
        // 定義title
        // 用法:<%= htmlWebpackPlugin.options.title %>
        args[0].title = title
        return args
      })

    // 不是 development 才會生效
    config
      .when(process.env.NODE_ENV !== 'development',
        config => {
          // 分塊策略,用於生成環境的快取
          config
            .optimization.splitChunks({
              // 設定為 all 可能特別強大,因為這意味著 chunk 可以在非同步和非非同步 chunk 之間共享
              chunks: 'all',
              cacheGroups: {
                // 將依賴於 node_modules 中的第三方庫打包成 chunk-libs.js
                // 這部分程式碼是很穩定的
                libs: {
                  name: 'chunk-libs',
                  test: /[\\/]node_modules[\\/]/,
                  priority: 10,
                  // 只打包最初依賴的第三方
                  chunks: 'initial'
                },
                // 將 node_modules 中的 element-ui 庫獨立出來,因為這個庫比較大,600 多Kb
                elementUI: {
                  name: 'chunk-elementUI',
                  priority: 20,
                  test: /[\\/]node_modules[\\/]_?element-ui(.*)/ // in order to adapt to cnpm
                },
                // 比如自定義了一個元件,有十來個頁面都引入了,分塊策略預設是 30kb 才會拆分
                // 假如我們的元件只有20kb,那麼每個頁面都會包括這 20kb,所以,這裡將其拆出來生成 runtime.xx.js
                commons: {
                  name: 'chunk-commons',
                  test: resolve('src/components'),
                  minChunks: 3,
                  priority: 5,
                  reuseExistingChunk: true
                }
              }
            })

          // 構建後你會看到 dist/static/js/runtime.xxx.js
          config.optimization.runtimeChunk('single')
        }
      )
  }
}

其他章節請看:

vue 快速入門 系列

相關文章