24

前言

上篇文章:如何架构一个中后台项目的前端部分(技术选型篇)

当我们的前端项目完成了技术选型阶段后,接下来所要做的便是项目的构建和配置。虽然说用脚手架能够帮助我们完成基本的目录构建和一些基础配置,但是其他很多实用的功能及特殊配置都需要自己动手实践,根据实际场景进行针对性的设置。

本文主要介绍下项目使用 Vue CLI 3.x 构建后,如何正确的配置 webpack 及接口部分。

webpack 配置

首先你需要知道的是,Vue CLI 3 的 vue-cli-service 集成了一份 webpack 的主流配置,可以满足基础场景的开发任务。这一份配置你可以通过在项目根目录运行以下命令查看:

vue inspect
# 或者
vue ui # 进入对应项目后点击任务中的 inspect 任务查看

基本配置

如果你不想使用 vue-cli-service 集成的 webpack 默认配置,你可以在根目录的 vue.config.js(没有需自己新建)中修改它。比如说修改一些基础的配置项:

/* vue.config.js */

// 配置化文件
const configs = require('./config')

// 根据环境判断使用哪份配置
const isPro = process.env.NODE_ENV === 'production'
const cfg = isPro ? configs.build : configs.dev

module.exports = {
    ...
    
    publicPath: cfg.BASE_URI, // 部署应用包时的基本 URL
    outputDir: configs.build.outputDir, // 输出目录
    assetsDir: configs.build.assetsDir, // 放置生成的静态资源目录
    lintOnSave: cfg.lintOnSave, // 是否启用 eslint
    productionSourceMap: configs.build.productionSourceMap, // 生产环境是否启用 sourceMap
    
    ...
}

一般情况下我们建议将配置化的东西单独存放到配置文件中进行管理,比如上述的 config 目录,然后根据不同环境引入不同配置,便于修改和查看。

额外配置

除此之外,针对项目的需要,你可能还需要注入一些额外的环境变量,抑或限制下 url-loader 的大小,或者移除 prefetch 插件,设置下 alias,你可以这样配置:

module.exports = {
    ...
    
    chainWebpack: config => {
        // 移除 prefetch 插件
        config.plugins.delete('prefetch')

        // 限制 url-loader 大小
        config.module
            .rule('images')
            .use('url-loader')
            .tap(options => merge(options, {
                limit: 5120,
            }))

        // 注入环境变量
        config.plugin('define')
            .tap(args => {
                let name = 'process.env'

                // 使用 merge 保证原始值不变
                args[0][name] = merge(args[0][name], {
                    ...cfg.env
                })

                return args
            })

        // alias 设置
        config.resolve.alias
            .set('_img', resolve('src/assets/images'))
        
        // 关闭包大小告警提示
        config.performance.set('hints', false)
    },
    
    ...
}

除了我们可以用 chainWebpack 这一函数来对内部的 webpack 配置进行更细粒度的修改外,我们还可以使用 configureWebpack 来合并相应配置,具体可以查看官方文档 简单的配置方式

本地配置

另外,针对一个中后台项目,本地开发时需要用到的一些配置也是必不可少的,比如代理设置(解决本地开发跨域问题),我们可以使用 devServer 来解决:

const proxyTarget = 'http://x.xxx.xxx.com' // 本地 proxy

module.exports = {
    ...
    
    devServer: {
        open: true, // 是否自动打开浏览器页面
        host: configs.dev.host, // 指定使用一个 host。默认是 localhost
        port: configs.dev.port, // 端口地址
        https: false, // 使用https提供服务
        progress: true,
        // string | Object 代理设置
        proxy: {
            '/LOCAL_URL': {
                target: proxyTarget,
                changeOrigin: true,
                pathRewrite: {
                    '^/LOCAL_URL': ''
                }
            }
        },
    }
    
    ...
}

上方我们除了配置了本地启动的 host 和 端口外,还进行了 proxy 的配置。当我们的接口匹配到 /LOCAL_URL (在接口封装篇会讲解) 字段时,就会将请求服务转发到其 target 配置下,同时重写路由地址,将假地址前缀删除,实现接口的转发。

特殊配置

最后,在实现了基本配置、额外配置、本地配置后,我们再来看下特殊配置。特殊配置也就是在特殊场景下进行特殊处理的配置,比如我在架构这一中后台项目时,一套代码会运行在不同站点上(也就是发布到不同服务器上),不同站点有些配置也是不一样的,比如权限、页面展示、接口调用地址等都可能不尽相同。

那么如何在发布前不手动去修改对应站点的配置,而是以一种自动化的方式来解决呢?我们可以跑不同的 npm 命令来实现:

/* package.json */

{
    "scripts": {
        "local": "vue-cli-service serve",
        "a_dev_build": "vue-cli-service build --site a --env development",
        "b_dev_build": "vue-cli-service build --site b --env development",
        "a_build": "vue-cli-service build --site a --env production",
        "b_build": "vue-cli-service build --site b --env production",
    },
}

比如说,当我们要发布 a 站点到预发环境时,我们只需要在发布前(可以交给发布系统运行)运行 npm run a_dev_build 命令,然后 vue.config.js 中去读取相应配置,注入全局环境变量即可:

const site = process.argv.slice(4, 5)[0] || 'a' // 当前运行站点
const env = process.argv.slice(6, 7)[0] || 'development' // 当前运行前端环境
const local = process.env.npm_lifecycle_event === 'local' ? 'on' : 'off' // 是否调用本地接口

module.exports = {
    ...
    
    chainWebpack: config => {
        // 注入环境变量
        config.plugin('define')
            .tap(args => {
                let name = 'process.env'

                // 使用 merge 保证原始值不变
                args[0][name] = merge(args[0][name], {
                    SITE: JSON.stringify(site),
                    LOCAL: JSON.stringify(local),
                    CLIENT_ENV: JSON.stringify(env)
                })

                return args
            })
    }
    
    ...
}

最后在前端环境中再去根据不同的站点、不同的环境运行不同的代码(包括接口、界面显示等)即可。当然你也可以换其他方式实现相同的功能,比如使用 cross-env 或者 mode 这样的工具或参数。

接口封装

前端的动态数据交互离不开服务端提供的接口,在一个前后端分离的中后台项目中,接口的请求和响应是必不可少的。

那么在架构一个中后台系统的时候,我们如何有效的管理和封装接口,提高项目接口调用的统一性、可维护性,以及在后端接口还没有开发完成,在仅有契约的基础上我们如何有效的模拟接口的调用呢?

接下来便会对以上问题提供个人解决方案供大家参考。

1. 不封装存在的问题

首先谈谈接口封装,因为我们使用的请求库是 axios,所以接下来的示例都以 axios 来举例。

那么在没有封装接口的项目中,你可能随处可见接口的直接调用方法,比如像这样:

axios.post('/user', {
    firstName: 'zhang',
    lastName: 'san'
  })
  .then(function (response) {
    console.log(response);
  });
...

axios.get('/user?ID=12345')
    .then(function (response) {
    // handle success
    console.log(response);
  });

这样的写法会存在一些缺点,主要有以下几点:

  • 接口 url 没有统一管理,散落在项目的各个地方
  • 如果需要在接口调用成功和失败时做一些处理,需要在每个地方进行添加
  • 特殊请求头以及取消请求方法需要单独进行编写

2. 修改默认配置

既然会存在上述问题,那么我们就需要去解决。在之前介绍的项目目录结构中,我们会发现有 services 文件夹,这就是用来存放封装的接口和调用的方法的。

在接口封装过程中,首先我们需要修改 axios 的默认配置,如下:

import axios from 'axios'

// 修改默认配置
axios.defaults.headers.post['Content-Type'] = 'application/json'
axios.defaults.headers.get['Content-Type'] = 'application/json'
axios.defaults.withCredentials = true // 表示是否跨域访问请求

可以把你常用的请求头的 Content-Type 设置为默认值,同时开启跨域功能。

3. 设置拦截器

接下来需要编写下请求和响应的拦截器,来对请求和响应进行适时拦截,比如再调用重复接口时,取消上一次未完成的相同请求:

const CancelToken = axios.CancelToken
const httpPending = [] // 用于存储每个ajax请求的取消函数和ajax标识

// 取消请求方法
const cancelHttp = (name, config = {}) => {
    httpPending.forEach((e, i) => {
        if (e.n === name || e.n === config.xhrName) { // 当前请求在数组中存在时执行函数体
            e.f() // 执行取消操作
            httpPending.splice(i, 1) // 把这条记录从数组中移除
        }
    })
}

// 请求拦截器
axios.interceptors.request.use(config => {
    // 取消上一次未完成的相同请求,注意项目中是否存在风险
    cancelHttp(null, config)

    config.cancelToken = new CancelToken(c => {
        if (config.xhrName) {
            httpPending.push({
                n: config.xhrName,
                u: `${config.url}&${config.method}`,
                f: c
            })
        }
    })

    return config
}, error => Promise.reject(error))

// 响应拦截器
axios.interceptors.response.use(res => {
    cancelHttp(null, res.config) // 响应成功把已经完成的请求从 httpPending 中移除

    checkStatus(res) // 校验响应状态
    const response = res.data

    return Promise.resolve(response)
}, error => Promise.reject(error))

上述两个拦截器主要做了重复请求的拦截功能,在请求头中将请求的取消请求方法和标识符号插入数组中,当然之前需要去数组中查找是否存在相同请求,存在则提前取消请求,最后在响应时把已经完成的请求信息从数组中移除。

这里为了避免风险,我们需要在接口调用的地方手动传入一个 xhrName 标识这个请求名称才会取消重复调用该接口的请求,其余接口不做处理。

同时我们也将 cancelHttp 暴露给全局,满足手动取消请求的需要:

Vue.prototype.$cancelHttp = cancelHttp

4. 暴露调用方法

当我们完成了针对 axios 的一些设置后,我们最终的目的是使用它来请求和处理接口,那么是时候对请求调用的方法进行封装和暴露了:

import uriConfig from '@/config/apiUriConf'
import GLOBAL from '@/config/global' // 全局变量

...

export default class Http {
    static async request(method, url, opts, type) {
        // 开启本地 mock 的话,不使用接口域名
        let hostName = GLOBAL.mockLocal ? '' : uriConfig.apiUrl
        
        // 特殊接口域名
        let otherName = GLOBAL.mockLocal ? '' : (uriConfig[type] || '')
        
        // type 存在则使用对应的接口,否则使用通用接口
        let uri = type ? `${otherName}${url}` : `${hostName}${url}`
        
        // 接口别名、请求方式及url
        let params = {
            xhrName: (opts && opts.name) || '',
            method,
            url: uri,
        }
        
        // 请求数据
        params.data = opts.body || {}
        
        // 设置特殊请求头
        if (opts.type === 'formData') {
            params.headers = {
                'Content-Type': 'application/x-www-form-urlencoded'
            }
        }

        return axios(params)
    }

    static get(url, opts) {
        return this.request('GET', url, opts)
    }

    static put(url, opts) {
        return this.request('PUT', url, opts)
    }

    static post(url, opts) {
        return this.request('POST', url, opts)
    }

    static patch(url, opts) {
        return this.request('PATCH', url, opts)
    }

    static delete(url, opts) {
        return this.request('DELETE', url, opts)
    }
}

上方我们将封装了一个 Http 类,其中包含了 get、post 等请求方法,这些请求方法内部都会去调用 request 方法,该方法会通过传入的不同参数执行原始 axios 的请求调用,返回一个 Promise。

5. 引用调用方法

那么哪里去使用这个 Http 类呢,我们可以在 services 文件夹中再建立其他接口管理文件,比如 user.js,用于存放用户相关的接口:

// user.js
import Http from './http'

// 获取用户信息
export const getUserInfo = params => Http.post('/getUserInfo', {
    body: params
})

最后在调用的地方引入 getUserInfo 方法,传入对应的参数对象即可。

import { getUserInfo } from '@services/user'

getUserInfo({
    token: 'xxx'
})

如此我们便完成了接口封装的基本功能。

接口模拟

刚刚在封装接口的时候,我们看到了 GLOBAL.mockLocal 这一全局变量,用于判断是否开启和关闭接口的 mock。

首先什么是接口 mock?意思就是模仿接口返回的数据。那么为什么要模仿呢?因为在接口还没开发完的场景下,前端在得知接口文档和格式后想本地模拟数据请求的功能,这时候开启接口 mock 就会变得十分简单。

这里我们通常会用一个比较实用的库 mockjs 来实现我们的功能,用法很简单,在我们事先创建的 mock 文件夹下创建 index.js 文件:

import GLOBAL from '@/config/global'

// 全局变量
if (GLOBAL.mockLocal) {
    let Mock = require('mockjs')

    // 用户信息接口
    Mock.mock('/getUserInfo', () => ({
        result: {
            name: '张三',
            sex: 'man',
            age: 12,
        },
        status: true, // 数据状态
        statusCode: '200', // 状态码
        message: '请求成功' // 提示信息
    })
}

文件中我们还是以用户信息接口为例,当开启全局 mock 的时候,我们便使用 mockjs 来模拟数据的返回。

当然你还需要的项目的入口文件中引用下:

// main.js
import '@/mock' // 全局变量

之后配合接口封装时判断开启 mock 就不使用接口域名的功能,我们在正常调用接口的时候便能直接获取自己模拟的数据结果。

关于更多 mockjs 的用法可以参考官方文档:https://github.com/nuysoft/Mock/wiki/Getting-Started

结语

本文结合实际情况针对 webpack 进行了不同程度的配置展示,当然除此之外还有很多配置项和配置方法没有一一展示,比如开启 Gzip 压缩、使用包分析工具等,大家需要在此基础上学会举一反三,才能灵活的架构一个中后台项目的 webpack 配置。

同时本文介绍的接口配置和 mock 其实不仅仅适用于中后台系统,大多数前端应用都可以参考。这里大家可以思考其实现的思路,至于具体实现方式都可能不尽相同。

那么下篇文章我会给大家带来《如何架构一个中后台项目的前端部分(国际化 + 路由配置篇)

关于

转载请注明来自 —— 微信公众号:前端呼啦圈(Love-FED)

如果觉得本文对你有帮助,可以关注我的微信公众号,来这里聊点关于前端的事情。


劳卜
2.9k 声望2.1k 粉丝

码梦为生,笔耕不辍。