vue 快速入门 系列 —— 使用 vue-cli 3 搭建一个项目(下)

时间:2024-03-04 15:06:05

其他章节请看:

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 快速入门 系列