基於Vue2.x的前端架構,我們是這麼做的

街角小林發表於2022-02-16

通過Vue CLI可以方便的建立一個Vue專案,但是對於實際專案來說還是不夠的,所以一般都會根據業務的情況來在其基礎上新增一些共效能力,減少建立新專案時的一些重複操作,本著學習和分享的目的,本文會介紹一下我們Vue專案的前端架構設計,當然,有些地方可能不是最好的方式,畢竟大家的業務不盡相同,適合你的就是最好的。

除了介紹基本的架構設計,本文還會介紹如何開發一個Vue CLI外掛和preset預設。

ps.本文基於Vue2.x版本,node版本16.5.0

建立一個基本專案

先使用Vue CLI建立一個基本的專案:

vue create hello-world

然後選擇Vue2選項建立,初始專案結構如下:

image-20220126101820738.png

接下來就在此基礎上添磚加瓦。

路由

路由是必不可少的,安裝vue-router

npm install vue-router

修改App.vue檔案:

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

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

<style>
* {
  padding: 0;
  margin: 0;
  border: 0;
  outline: none;
}

html,
body {
  width: 100%;
  height: 100%;
}
</style>
<style scoped>
#app {
  width: 100%;
  height: 100%;
  display: flex;
}
</style>

增加路由出口,簡單設定了一下頁面樣式。

接下來新增pages目錄用於放置頁面, 把原本App.vue的內容移到了Hello.vue

image-20220126140342614.png

路由配置我們選擇基於檔案進行配置,在src目錄下新建一個/src/router.config.js

export default [
  {
    path: '/',
    redirect: '/hello',
  },
  {
    name: 'hello',
    path: '/hello/',
    component: 'Hello',
  }
]

屬性支援vue-router構建選項routes的所有屬性,component屬性傳的是pages目錄下的元件路徑,規定路由元件只能放到pages目錄下,然後新建一個/src/router.js檔案:

import Vue from 'vue'
import Router from 'vue-router'
import routes from './router.config.js'

Vue.use(Router)

const createRoute = (routes) => {
    if (!routes) {
        return []
    }
    return routes.map((item) => {
        return {
            ...item,
            component: () => {
                return import('./pages/' + item.component)
            },
            children: createRoute(item.children)
        }
    })
}

const router = new Router({
    mode: 'history',
    routes: createRoute(routes),
})

export default router

使用工廠函式和import方法來定義動態元件,需要遞迴對子路由進行處理。最後,在main.js裡面引入路由:

// main.js
// ...
import router from './router'// ++
// ...
new Vue({
  router,// ++
  render: h => h(App),
}).$mount('#app')

選單

我們的業務基本上都需要一個選單,預設顯示在頁面左側,我們有內部的元件庫,但沒有對外開源,所以本文就使用Element替代,選單也通過檔案來配置,新建/src/nav.config.js檔案:

export default [{
    title: 'hello',
    router: '/hello',
    icon: 'el-icon-menu'
}]

然後修改App.vue檔案:

<template>
  <div id="app">
    <el-menu
      style="width: 250px; height: 100%"
      :router="true"
      :default-active="defaultActive"
    >
      <el-menu-item
        v-for="(item, index) in navList"
        :key="index"
        :index="item.router"
      >
        <i :class="item.icon"></i>
        <span slot="title">{{ item.title }}</span>
      </el-menu-item>
    </el-menu>
    <router-view />
  </div>
</template>

<script>
import navList from './nav.config.js'
export default {
  name: 'App',
  data() {
    return {
      navList,
    }
  },
  computed: {
    defaultActive() {
      let path = this.$route.path
      // 檢查是否有完全匹配的
      let fullMatch = navList.find((item) => {
        return item.router === path
      })
      // 沒有則檢查是否有部分匹配
      if (!fullMatch) {
        fullMatch = navList.find((item) => {
          return new RegExp('^' + item.router + '/').test(path)
        })
      }
      return fullMatch ? fullMatch.router : ''
    },
  },
}
</script>

效果如下:

image-20220126145352732.png

當然,上述只是意思一下,實際的要複雜一些,畢竟這裡連巢狀選單的情況都沒考慮。

許可權

我們的許可權顆粒度比較大,只控制到路由層面,具體實現就是在選單配置和路由配置裡的每一項都新增一個code欄位,然後通過請求獲取當前使用者有許可權的code,沒有許可權的選單預設不顯示,訪問沒有許可權的路由會重定向到403頁面。

獲取許可權資料

許可權資料隨使用者資訊介面一起返回,然後儲存到vuex裡,所以先配置一下vuex,安裝:

npm install vuex --save

新增/src/store.js

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
    state: {
        userInfo: null,
    },
    actions: {
        // 請求使用者資訊
        async getUserInfo(ctx) {
            let userInfo = {
                // ...
                code: ['001'] // 使用者擁有的許可權
            }
            ctx.commit('setUserInfo', userInfo)
        }
    },
    mutations: {
        setUserInfo(state, userInfo) {
            state.userInfo = userInfo
        }
    },
})

main.js裡面先獲取使用者資訊,然後再初始化Vue

// ...
import store from './store'
// ...
const initApp = async () => {
  await store.dispatch('getUserInfo')
  new Vue({
    router,
    store,
    render: h => h(App),
  }).$mount('#app')
}
initApp()

選單

修改nav.config.js新增code欄位:

// nav.config.js
export default [{
    title: 'hello',
    router: '/hello',
    icon: 'el-icon-menu'
    code: '001',
}]

然後在App.vue裡過濾掉沒有許可權的選單:

export default {
  name: 'App',
  data() {
    return {
      navList,// --
    }
  },
  computed: {
    navList() {// ++
      const { userInfo } = this.$store.state
      if (!userInfo || !userInfo.code || userInfo.code.length <= 0) return []
      return navList.filter((item) => {
        return userInfo.code.includes(item.code)
      })
    }
  }
}

這樣沒有許可權的選單就不會顯示出來。

路由

修改router.config.js,增加code欄位:

export default [{
        path: '/',
        redirect: '/hello',
    },
    {
        name: 'hello',
        path: '/hello/',
        component: 'Hello',
        code: '001',
    }
]

code是自定義欄位,需要儲存到路由記錄的meta欄位裡,否則最後會丟失,修改createRoute方法:

// router.js
// ...
const createRoute = (routes) => {
    // ...
    return routes.map((item) => {
        return {
            ...item,
            component: () => {
                return import('./pages/' + item.component)
            },
            children: createRoute(item.children),
            meta: {// ++
                code: item.code
            }
        }
    })
}
// ...

然後需要攔截路由跳轉,判斷是否有許可權,沒有許可權就轉到403頁面:

// router.js
// ...
import store from './store'
// ...
router.beforeEach((to, from, next) => {
    const userInfo = store.state.userInfo
    const code = userInfo && userInfo.code && userInfo.code.length > 0 ? userInfo.code : []
    // 去錯誤頁面直接跳轉即可,否則會引起死迴圈
    if (/^\/error\//.test(to.path)) {
        return next()
    }
    // 有許可權直接跳轉
    if (code.includes(to.meta.code)) {
        next()
    } else if (to.meta.code) { // 路由存在,沒有許可權,跳轉到403頁面
        next({
            path: '/error/403'
        })
    } else { // 沒有code則代表是非法路徑,跳轉到404頁面
        next({
            path: '/error/404'
        })
    }
})

error元件還沒有,新增一下:

// pages/Error.vue

<template>
  <div class="container">{{ errorText }}</div>
</template>

<script>
const map = {
  403: '無許可權',
  404: '頁面不存在',
}
export default {
  name: 'Error',
  computed: {
    errorText() {
      return map[this.$route.params.type] || '未知錯誤'
    },
  },
}
</script>

<style scoped>
.container {
    width: 100%;
    height: 100%;
    display: flex;
    justify-content: center;
    align-items: center;
    font-size: 50px;
}
</style>

接下來修改一下router.config.js,增加錯誤頁面的路由,及增加一個測試無許可權的路由:

// router.config.js

export default [
    // ...
    {
        name: 'Error',
        path: '/error/:type',
        component: 'Error',
    },
    {
        name: 'hi',
        path: '/hi/',
        code: '無許可權測試,請輸入hi',
        component: 'Hello',
    }
]

因為這個code使用者並沒有,所以現在我們開啟/hi路由會直接跳轉到403路由:

2022-02-10-14-01-59.gif

麵包屑

和選單類似,麵包屑也是大部分頁面都需要的,麵包屑的組成分為兩部分,一部分是在當前選單中的位置,另一部分是在頁面操作中產生的路徑。第一部分的路徑因為可能會動態的變化,所以一般是通過介面隨使用者資訊一起獲取,然後存到vuex裡,修改store.js

// ...
async getUserInfo(ctx) {
    let userInfo = {
        code: ['001'],
        breadcrumb: {// 增加麵包屑資料
            '001': ['你好'],
        },
    }
    ctx.commit('setUserInfo', userInfo)
}
// ...

第二部分的在router.config.js裡面配置:

export default [
    //...
    {
        name: 'hello',
        path: '/hello/',
        component: 'Hello',
        code: '001',
        breadcrumb: ['世界'],// ++
    }
]

breadcrumb欄位和code欄位一樣,屬於自定義欄位,但是這個欄位的資料是給元件使用的,元件需要獲取這個欄位的資料然後在頁面上渲染出面包屑選單,所以儲存到meta欄位上雖然可以,但是在元件裡面獲取比較麻煩,所以我們可以設定到路由記錄的props欄位上,直接注入為元件的props,這樣使用就方便多了,修改router.js

// router.js
// ...
const createRoute = (routes) => {
    // ...
    return routes.map((item) => {
        return {
            ...item,
            component: () => {
                return import('./pages/' + item.component)
            },
            children: createRoute(item.children),
            meta: {
                code: item.code
            },
            props: {// ++
                breadcrumbObj: {
                    breadcrumb: item.breadcrumb,
                    code: item.code
                } 
            }
        }
    })
}
// ...

這樣在元件裡宣告一個breadcrumbObj屬性即可獲取到麵包屑資料,可以看到把code也一同傳過去了,這是因為還要根據當前路由的code從使用者介面獲取的麵包屑資料中取出該路由code對應的麵包屑資料,然後把兩部分的進行合併,這個工作為了避免讓每個元件都要做一遍,我們可以寫在一個全域性的mixin裡,修改main.js

// ...
Vue.mixin({
    props: {
        breadcrumbObj: {
            type: Object,
            default: () => null
        }
    },
    computed: {
        breadcrumb() {
            if (!this.breadcrumbObj) {
                return []
            }
            let {
                code,
                breadcrumb
            } = this.breadcrumbObj
            // 使用者介面獲取的麵包屑資料
            let breadcrumbData = this.$store.state.userInfo.breadcrumb
            // 當前路由是否存在麵包屑資料
            let firstBreadcrumb = breadcrumbData && Array.isArray(breadcrumbData[code]) ? breadcrumbData[code] : []
            // 合併兩部分的麵包屑資料
            return firstBreadcrumb.concat(breadcrumb || [])
        }
    }
})

// ...
initApp()

最後我們在Hello.vue元件裡面渲染一下面包屑:

<template>
  <div class="container">
    <el-breadcrumb separator="/">
      <el-breadcrumb-item v-for="(item, index) in breadcrumb" :key="index">{{item}}</el-breadcrumb-item>
    </el-breadcrumb>
    // ...
  </div>
</template>

image-20220210152155551.png

當然,我們的麵包屑是不需要支援點選的,如果需要的話可以修改一下面包屑的資料結構。

介面請求

介面請求使用的是axios,但是會做一些基礎配置、攔截請求和響應,因為還是有一些場景需要直接使用未配置的axios,所以我們預設建立一個新例項,先安裝:

npm install axios

然後新建一個/src/api/目錄,在裡面新增一個httpInstance.js檔案:

import axios from 'axios'

// 建立一個新例項
const http = axios.create({
    timeout: 10000,// 超時時間設為10秒
    withCredentials: true,// 跨域請求時是否需要使用憑證,設定為需要
    headers: {
        'X-Requested-With': 'XMLHttpRequest'// 表明是ajax請求
    },
})

export default http

然後增加一個請求攔截器:

// ...
// 請求攔截器
http.interceptors.request.use(function (config) {
    // 在傳送請求之前做些什麼
    return config;
}, function (error) {
    // 對請求錯誤做些什麼
    return Promise.reject(error);
});
// ...

其實啥也沒做,先寫出來,留著不同的專案按需修改。

最後增加一個響應攔截器:

// ...
import { Message } from 'element-ui'
// ...
// 響應攔截器
http.interceptors.response.use(
    function (response) {
        // 對錯誤進行統一處理
        if (response.data.code !== '0') {
            // 彈出錯誤提示
            if (!response.config.noMsg && response.data.msg) {
                Message.error(response.data.msg)
            }
            return Promise.reject(response)
        } else if (response.data.code === '0' && response.config.successNotify && response.data.msg) {
            // 彈出成功提示
            Message.success(response.data.msg)
        }
        return Promise.resolve({
            code: response.data.code,
            msg: response.data.msg,
            data: response.data.data,
        })
    },
    function (error) {
        // 登入過期
        if (error.status === 403) {
            location.reload()
            return
        }
        // 超時提示
        if (error.message.indexOf('timeout') > -1) {
            Message.error('請求超時,請重試!')
        }
        return Promise.reject(error)
    },
)
// ...

我們約定一個成功的響應(狀態碼為200)結構如下:

{
    code: '0',
    msg: 'xxx',
    data: xxx
}

code不為0即使狀態碼為200也代表請求出錯,那麼彈出錯誤資訊提示框,如果某次請求不希望自動彈出提示框的話也可以禁止,只要在請求時加上配置引數noMsg: true即可,比如:

axios.get('/xxx', {
    noMsg: true
})

請求成功預設不彈提示,需要的話可以設定配置引數successNotify: true

狀態碼在非[200,300)之間的錯誤只處理兩種,登入過期和請求超時,其他情況可根據專案自行修改。

多語言

多語言使用vue-i18n實現,先安裝:

npm install vue-i18n@8

vue-i18n9.x版本支援的是Vue3,所以我們使用8.x版本。

然後建立一個目錄/src/i18n/,在目錄下新建index.js檔案用來建立i18n例項:

import Vue from 'vue'
import VueI18n from 'vue-i18n'

Vue.use(VueI18n)
const i18n = new VueI18n()

export default i18n

除了建立例項其他啥也沒做,別急,接下來我們一步步來。

我們的總體思路是,多語言的源資料在/src/i18n/下,然後編譯成json檔案放到專案的/public/i18n/目錄下,頁面的初始預設語言也是和使用者資訊介面一起返回,頁面根據預設的語言型別使用ajax請求public目錄下的對應json檔案,呼叫VueI18n的方法動態進行設定。

這麼做的目的首先是方便修改頁面預設語言,其次是多語言檔案不和專案程式碼打包到一起,減少打包時間,按需請求,減少不必要的資源請求。

接下來我們新建頁面的中英文資料,目錄結構如下:

image-20220211103104133.png

比如中文的hello.json檔案內容如下(忽略筆者的低水平翻譯~):

image-20220211103928440.png

index.js檔案裡匯入hello.json檔案及ElementUI的語言檔案,併合並匯出:

import hello from './hello.json'
import elementLocale from 'element-ui/lib/locale/lang/zh-CN'

export default {
    hello,
    ...elementLocale
}

為什麼是...elementLocale呢,因為傳給Vue-i18n的多語言資料結構是這樣的:

image-20220211170320562.png

我們是把index.js的整個匯出物件作為vue-i18n的多語言資料的,而ElementUI的多語言檔案是這樣的:

image-20220211165917570.png

所以我們需要把這個物件的屬性和hello屬性合併到一個物件上。

接下來我們需要把它匯出的資料到寫到一個json檔案裡並輸出到public目錄下,這可以直接寫個js指令碼檔案來做這個事情,但是為了和專案的原始碼分開我們寫成一個npm包。

建立一個npm工具包

我們在專案的平級下建立一個包目錄,並使用npm init初始化:

image.png

命名為-tool的原因是後續可能還會有類似編譯多語言這種需求,所以取一個通用名字,方便後面增加其他功能。

命令列互動工具使用Commander.js,安裝:

npm install commander

然後新建入口檔案index.js

#!/usr/bin/env node

const {
    program
} = require('commander');

// 編譯多語言檔案
const buildI18n = () => {
    console.log('編譯多語言檔案');
}

program
    .command('i18n') // 新增i18n命令
    .action(buildI18n)

program.parse(process.argv);

因為我們的包是要作為命令列工具使用的,所以檔案第一行需要指定指令碼的解釋程式為node,然後使用commander配置了一個i18n命令,用來編譯多語言檔案,後續如果要新增其他功能新增命令即可,執行檔案有了,我們還要在包的package.json檔案裡新增一個bin欄位,用來指示我們的包裡有可執行檔案,讓npm在安裝包的時候順便給我們建立一個符號連結,把命令對映到檔案。

// hello-tool/package.json
{
    "bin": {
        "hello": "./index.js"
    }
}

因為我們的包還沒有釋出到npm,所以直接連結到專案上使用,先在hello-tool目錄下執行:

npm link

然後到我們的hello world目錄下執行:

npm link hello-tool

現在在命令列輸入hello i18n試試:

image.png

編譯多語言檔案

接下來完善buildI18n函式的邏輯,主要分三步:

1.清空目標目錄,也就是/public/i18n目錄

2.獲取/src/i18n下的各種多語言檔案匯出的資料

3.寫入到json檔案並輸出到/public/i18n目錄下

程式碼如下:

const path = require('path')
const fs = require('fs')
// 編譯多語言檔案
const buildI18n = () => {
    // 多語言源目錄
    let srcDir = path.join(process.cwd(), 'src/i18n')
    // 目標目錄
    let destDir = path.join(process.cwd(), 'public/i18n')
    // 1.清空目標目錄,clearDir是一個自定義方法,遞迴遍歷目錄進行刪除
    clearDir(destDir)
    // 2.獲取源多語言匯出資料
    let data = {}
    let langDirs = fs.readdirSync(srcDir)
    langDirs.forEach((dir) => {
        let dirPath = path.join(srcDir, dir)
        // 讀取/src/i18n/xxx/index.js檔案,獲取匯出的多語言物件,儲存到data物件上
        let indexPath = path.join(dirPath, 'index.js')
        if (fs.statSync(dirPath).isDirectory() && fs.existsSync(indexPath)) {
            // 使用require載入該檔案模組,獲取匯出的資料
            data[dir] = require(indexPath)
        }
    })
    // 3.寫入到目標目錄
    Object.keys(data).forEach((lang) => {
        // 建立public/i18n目錄
        if (!fs.existsSync(destDir)) {
            fs.mkdirSync(destDir)
        }
        let dirPath = path.join(destDir, lang)
        let filePath = path.join(dirPath, 'index.json')
        // 建立多語言目錄
        if (!fs.existsSync(dirPath)) {
            fs.mkdirSync(dirPath)
        }
        // 建立json檔案
        fs.writeFileSync(filePath, JSON.stringify(data[lang], null, 4))
    })
    console.log('多語言編譯完成');
}

程式碼很簡單,接下來我們執行命令:

image.png

報錯了,提示不能在模組外使用import,其實新版本的nodejs已經支援ES6的模組語法了,可以把檔案字尾換成.mjs,或者在package.json檔案裡增加type=module欄位,但是都要做很多修改,這咋辦呢,有沒有更簡單的方法呢?把多語言檔案換成commonjs模組語法?也可以,但是不太優雅,不過好在babel提供了一個@babel/register包,可以把babel繫結到noderequire模組上,然後可以在執行時進行即時編譯,也就是當require('/src/i18n/xxx/index.js')時會先由babel進行編譯,編譯完當然就不存在import語句了,先安裝:

npm install @babel/core @babel/register @babel/preset-env

然後新建一個babel配置檔案:

// hello-tool/babel.config.js
module.exports = {
  'presets': ['@babel/preset-env']
}

最後在hello-tool/index.js檔案裡使用:

const path = require('path')
const {
    program
} = require('commander');
const fs = require('fs')
require("@babel/register")({
    configFile: path.resolve(__dirname, './babel.config.js'),
})
// ...

接下來再次執行命令:

image.png

image.png

可以看到編譯完成了,檔案也輸出到了public目錄下,但是json檔案裡存在一個default屬性,這一層顯然我們是不需要的,所以require('i18n/xxx/index.js')時我們儲存匯出的default物件即可,修改hello-tool/index.js

const buildI18n = () => {
    // ...
    langDirs.forEach((dir) => {
        let dirPath = path.join(srcDir, dir)
        let indexPath = path.join(dirPath, 'index.js')
        if (fs.statSync(dirPath).isDirectory() && fs.existsSync(indexPath)) {
            data[dir] = require(indexPath).default// ++
        }
    })
    // ...
}

效果如下:

image.png

使用多語言檔案

首先修改一下使用者介面的返回資料,增加預設語言欄位:

// /src/store.js
// ...
async getUserInfo(ctx) {
    let userInfo = {
        // ...
        language: 'zh_CN'// 預設語言
    }
    ctx.commit('setUserInfo', userInfo)
}
// ...

然後在main.js裡面獲取完使用者資訊後立刻請求並設定多語言:

// /src/main.js
import { setLanguage } from './utils'// ++
import i18n from './i18n'// ++

const initApp = async () => {
  await store.dispatch('getUserInfo')
  await setLanguage(store.state.userInfo.language)// ++
  new Vue({
    i18n,// ++
    router,
    store,
    render: h => h(App),
  }).$mount('#app')
}

setLanguage方法會請求多語言檔案並切換:

// /src/utils/index.js
import axios from 'axios'
import i18n from '../i18n'

// 請求並設定多語言資料
const languageCache = {}
export const setLanguage = async (language = 'zh_CN') => {
    let languageData = null
    // 有快取,使用快取資料
    if (languageCache[language]) {
        languageData = languageCache[language]
    } else {
        // 沒有快取,發起請求
        const {
            data
        } = await axios.get(`/i18n/${language}/index.json`)
        languageCache[language] = languageData = data
    }
    // 設定語言環境的 locale 資訊
    i18n.setLocaleMessage(language, languageData)
    // 修改語言環境
    i18n.locale = language
}

然後把各個元件裡顯示的資訊都換成$t('xxx')形式,當然,選單和路由都需要做相應的修改,效果如下:

2022-02-12-11-01-36.gif

可以發現ElementUI元件的語言並沒有變化,這是當然的,因為我們還沒有處理它,修改很簡單,ElementUI支援自定義i18n的處理方法:

// /src/main.js
// ...
Vue.use(ElementUI, {
  i18n: (key, value) => i18n.t(key, value)
})
// ...

image-20220212111252574.png

通過CLI外掛生成初始多語言檔案

最後還有一個問題,就是專案初始化時還沒有多語言檔案怎麼辦,難道專案建立完還要先手動執行命令編譯一下多語言?有幾種解決方法:

1.最終一般會提供一個專案腳手架,所以預設的模板裡我們就可以直接加上初始的多語言檔案;

2.啟動服務和打包時先編譯一下多語言檔案,像這樣:

"scripts": {
    "serve": "hello i18n && vue-cli-service serve",
    "build": "hello i18n && vue-cli-service build"
  }

3.開發一個Vue CLI外掛來幫我們在專案建立完時自動執行一次多語言編譯命令;

接下來簡單實現一下第三種方式,同樣在專案同級新建一個外掛目錄,並建立相應的檔案(注意外掛的命名規範):

image.png

根據外掛開發規範,index.jsService外掛的入口檔案,Service外掛可以修改webpack配置,建立新的 vue-cli service命令或者修改已經存在的命令,我們用不上,我們的邏輯在generator.js裡,這個檔案會在兩個場景被呼叫:

1.專案建立期間,CLI外掛被作為專案建立preset的一部分被安裝時

2.專案建立完成時通過vue addvue invoke單獨安裝外掛時呼叫

我們需要的剛好是在專案建立時或安裝該外掛時自動幫我們執行多語言編譯命令,generator.js需要匯出一個函式,內容如下:

const {
    exec
} = require('child_process');

module.exports = (api) => {
    // 為了方便在專案裡看到編譯多語言的命令,我們把hello i18n新增到專案的package.json檔案裡,修改package.json檔案可以使用提供的api.extendPackage方法
    api.extendPackage({
        scripts: {
            buildI18n: 'hello i18n'
        }
    })
    // 該鉤子會在檔案寫入硬碟後呼叫
    api.afterInvoke(() => {
        // 獲取專案的完整路徑
        let targetDir = api.generator.context
        // 進入專案資料夾,然後執行命令
        exec(`cd ${targetDir} && npm run buildI18n`, (error, stdout, stderr) => {
            if (error) {
                console.error(error);
                return;
            }
            console.log(stdout);
            console.error(stderr);
        });
    })
}

我們在afterInvoke鉤子裡執行編譯命令,因為太早執行可能依賴都還沒有安裝完成,另外我們還獲取了專案的完整路徑,這是因為通過preset配置外掛時,外掛被呼叫時可能不在實際的專案資料夾,比如我們在a資料夾下通過該命令建立b專案:

vue create b

外掛被呼叫時是在a目錄,顯然hello-i18n包是被安裝在b目錄,所以我們要先進入專案實際目錄然後執行編譯命令。

接下來測試一下,先在專案下安裝該外掛:

npm install --save-dev file:完整路徑\vue-cli-plugin-i18n

然後通過如下命令來呼叫外掛的生成器:

vue invoke vue-cli-plugin-i18n

效果如下:

image.png

image.png

可以看到專案的package.json檔案裡面已經注入了編譯命令,並且命令也自動執行生成了多語言檔案。

Mock資料

Mock資料推薦使用Mock,使用很簡單,新建一個mock資料檔案:

image.png

然後在/api/index.js裡引入:

image.png

就這麼簡單,該請求即可被攔截:

image-20220212150450209.png

規範化

有關規範化的配置,比如程式碼風格檢查、git提交規範等,筆者之前寫過一篇元件庫搭建的文章,其中一個小節詳細的介紹了配置過程,可移步:【萬字長文】從零配置一個vue元件庫-規範化配置小節

其他

請求代理

本地開發測試介面請求時難免會遇到跨域問題,可以配置一下webpack-dev-server的代理選項,新建vue.config.js檔案:

module.exports = {
    devServer: {
        proxy: {
            '^/api/': {
                target: 'http://xxx:xxx',
                changeOrigin: true
            }
        }
    }
}

編譯node_modules內的依賴

預設情況下babel-loader會忽略所有node_modules中的檔案,但是有些依賴可能是沒有經過編譯的,比如我們自己編寫的一些包為了省事就不編譯了,那麼如果用了最新的語法,在低版本瀏覽器上可能就無法執行了,所以打包的時候也需要對它們進行編譯,要通過Babel顯式轉譯一個依賴,可以在這個transpileDependencies選項配置,修改vue.config.js

module.exports = {
    // ...
    transpileDependencies: ['your-package-name']
}

環境變數

需要環境變數可以在專案根目錄下新建.env檔案,需要注意的是如果要通過外掛渲染.開頭的模板檔案,要用_來替代點,也就是_env,最終會渲染為.開頭的檔案。

腳手架

當我們設計好了一套專案結構後,肯定是作為模板來快速建立專案的,一般會建立一個腳手架工具來生成,但是Vue CLI提供了preset(預設)的能力,所謂preset指的是一個包含建立新專案所需預定義選項和外掛的 JSON物件,所以我們可以建立一個CLI外掛來建立模板,然後建立一個preset,再把這個外掛配置到preset裡,這樣使用vue create命令建立專案時使用我們的自定義preset即可。

建立一個生成模板的CLI外掛

新建外掛目錄如下:

image-20220212162638048.png

可以看到這次我們建立了一個generator目錄,因為我們需要渲染模板,而模板檔案就會放在這個目錄下,新建一個template目錄,然後把我們前文配置的專案結構完整的複製進去(不包括package.json):

image.png

現在我們來完成/generator/index.js檔案的內容:

1.因為不包括package.json,所以我們要修改vue專案預設的package.json,新增我們需要的東西,使用的就是前面提到的api.extendPackage方法:

// generator/index.js

module.exports = (api) => {
    // 擴充套件package.json
    api.extendPackage({
        "dependencies": {
            "axios": "^0.25.0",
            "element-ui": "^2.15.6",
            "vue-i18n": "^8.27.0",
            "vue-router": "^3.5.3",
            "vuex": "^3.6.2"
        },
        "devDependencies": {
            "mockjs": "^1.1.0",
            "sass": "^1.49.7",
            "sass-loader": "^8.0.2",
            "hello-tool": "^1.0.0"// 注意這裡,不要忘記把我們的工具包加上
        }
    })
}

新增了一些額外的依賴,包括我們前面開發的hello-tool

2.渲染模板

module.exports = (api) => {
    // ...
    api.render('./template')
}

render方法會渲染template目錄下的所有檔案。

建立一個自定義preset

外掛都有了,最後讓我們來建立一下自定義preset,新建一個preset.json檔案,把我們前面寫的template外掛和i18n外掛一起配置進去:

{
    "plugins": {
        "vue-cli-plugin-template": {
            "version": "^1.0.0"
        },
        "vue-cli-plugin-i18n": {
            "version": "^1.0.0"
        }
    }
}

同時為了測試這個preset,我們再建立一個空目錄:

image.png

然後進入test-preset目錄執行vue create命令時指定我們的preset路徑即可:

vue create --preset ../preset.json my-project

效果如下:

image.png

image.png

image.png

遠端使用preset

preset本地測試沒問題了就可以上傳到倉庫裡,之後就可以給別人使用了,比如筆者上傳到了這個倉庫:https://github.com/wanglin2/Vue_project_design,那麼你可以這麼使用:

vue create --preset wanglin2/Vue_project_design project-name

總結

如果有哪裡不對的或是更好的,評論區見~

相關文章