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

語言: CN / TW / HK

通過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

總結

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