如何构建一个大型Vue应用程序
在刚过去的 VueConf 2019 上,Vue 公布了 3.0 的最新进展,其背后有着颇为活跃的社区支持,和完善的配套工具。那么在如今丰富而复杂的 Vue 生态下,我们又如何搭建一个前端项目呢?
搭建什么?
你可能会问:「搭建什么?刚刚不是已经说过了?当然是搭建 Vue 前端项目啊!」但如果再往下想一步,前端项目也有很多种,面向的场景也不一样。比如:
快速原型开发
纯静态页面项目(如组件文档、产品首页)
业务前端项目(支撑业务变化,有前后端交互,要经过测试环境验证才能上线)
而今天我们要讨论的是第三种。业务项目是面向生产的,它对搭建策略有着较高的要求,往往需要兼顾开发体验、维护成本、生产稳定性、扩展空间等不同维度。
用什么搭建?
前端项目最基础的职责,就是管理前端静态资源,这些资源包含模版文件、样式文件、脚本文件和其他媒体文件。因此资源打包工具是项目中十分重要的一员。
目前,业界占有统治地位的前端打包工具,无疑是 Webpack。然而随着成百上千 Webpack 插件的争鸣,如今想合理地配置 Webpack ,从 0 开始往往要琢磨很久。
有没有一个工具能帮我整合业界最佳配置呢?答案是有,它就是 vue-cli 脚手架。vue-cli 基于 webpack 工具链,不但整合了默认配置,还提供了简洁的接口,让你在必要时调整配置,贴合自己的业务。
此外,脚手架还可以帮你创建目录结构,拉取依赖资源,生成 demo 页面等。而完成一系列准备工作,只需以下简单三步:
#安装vue-cli
npm install -g @vue/cli
#创建项目
mkdir projects && cd projects
vue ui # 通过图形界面创建项目
vue create create-vue-app-in-2019 # or 通过命令行创建项目
#选择项目偏好,生成项目
这就像是为大厨准备好厨房、厨具、调味料,大厨只要关心怎么给客人做菜。
那么,现在怎么做好菜呢?
怎么搭建?
创建项目
为了方便讲解,我们采用图形界面创建项目。通过 vue ui 命令启动 vue cli service 后,访问 http://localhost:8000/project/create 可以直接进入项目创建流程。
步骤中有些地方值得一提
详情:包管理器指定 npm,后续在npm 配置文件(.npmrc)中可手动指定国内镜像源,无需安装其他包管理器。
预设:「默认」预设中的功能比较单薄,我们选择「手动」预设,勾上一些适用于业务项目的功能。
功能:
默认勾上的「Babel」负责 JS 和 Vue 模版语法解析,建议打开。
「Router」 负责前端路由功能,业务项目必备。
「CSS Pre-processors」负责样式文件的预编译,使用 sass/less/stylus 写样式必备。
「Linter / Formatter」负责代码规范,业务项目涉及多人长期维护,必备。
「使用配置文件」负责将不同功能的配置拆分到根目录下,便于维护,建议打开。
配置:Lint 格式化过后的代码与自己写的原始代码往往有不同,这里建议在保存时就观察变化,勾上「lint on save」在命令行中报警;而对于「 Lint and fix on commit」个人认为过于自动,提交 commit 还是以自己审查过的版本为好,谨慎选择。 小技巧:使用 vscode 插件「ESLint」,它会读取根目录下的 .eslintrc.js 文件,保存时自动格式化代码,保证代码书写效率。
创建步骤完成后,我们就得到了一个完整的项目(查看源码)。
模块规划
目前我们创建的项目完全可用于开发原型项目,但离一个的业务项目还有距离。在业务项目中,随着时间的推移,项目会加入一个又一个的页面、模块,所以我们至少可以从几个方面规划这些模块。
清晰的目录,统一的资源引用方式。
根据路由懒加载模块,提高首次访问速度。
开发环境下只编译自己关心的业务模块,保证 Hot reload 效率。
打包分析,便于定位大模块,作出优化决策。
对于目录结构,我们在初始项目的基础上新增一些业务内容。
对于路由规划,细心的你可能在初始项目的 src/router.js 中发现了这样一段代码:
// ...
{
path: "/about",
name: "about",
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () =>
import(/* webpackChunkName: "about" */ "./views/About.vue")
}
// ...
显然 vue cli 已经在有意引导你做路由级别的代码分片(code-splitting),但这还不够。在业务项目场景下,项目承载多个业务模块,向下治理几十甚至几百个页面,是十分常见的。
所以在路由分片的基础上,我们设计了一个业务模块层,以便分块开发。
上图中,路由部分真实对应着代码里的 router 对象与 vue 组件。它们是这样的:
{
path: "/business-a/page-a",
name: "business-a-page-a",
component: () =>
import(/* webpackChunkName: "business-a-page-a" */ "path/to/business-a-page-a/index.vue")
}
而业务部分与页面部分是我们根据业务需求虚拟出的层级,那么它们在代码里怎么体现呢?答案就在 src/router.js 与 各业务模块(如「business-a」 )的 index.js 中。 在看具体代码前,我们回顾一下初始项目的 src/router.js,显然,router 入口文件里直接治理所有页面的路由。 而基于前面的路由治理方案,我们将具体页面路由下放给各业务模块分治,router 入口只负责治理业务模块:
// src/router.js
import Vue from 'vue';
import Router from "vue-router";
Vue.use(Router);
export default new Router({
routes: process.env.VUE_APP_MODULE
? [
// 根据环境变量编译单个业务模块
...require(`./views/${process.env.VUE_APP_MODULE}`).default,
]
: [
// 环境变量没有指定,则编译全部业务模块
...require('./views/business-a').default, // A 业务路由入口
...require('./views/business-b').default, // B 业务路由入口
...require('./views/business-n').default, // n 业务路由入口
],
});
业务模块的 index.js 负责连接组件和导出路由配置:
// src/views/business-a/index.js 文件
export default [
{
path: '/business-a/page-a',
name: "business-a-page-a",
component: () =>
import(/* webpackChunkName: "business-a-page-a" */ './business-a-page-a/index.vue'),
},
{
path: '/business-a/page-b',
name: "business-a-page-b",
component: () =>
import(/* webpackChunkName: "business-a-page-b" */ './business-a-page-b/index.vue'),
},
//...
];
对于部分编译模块,上面 src/router.js 中有提到,通过设置一个 process.env.VUE_APP_MODULE 环境变量,可以指定模块名来构建一个模块,不用全部构建,从而提升开发效率。
你可以在根目录下创建一个 .env.development.local 文件在开发时指定一个模块作为环境变量:
#.env.development.local
VUE_APP_MODULE=business-a
当然也可以通过撰写一个启动脚本来做到更好的启动体验。
对于打包分析,在编码过程中,借助「Import Cost」 vscode 插件,我们可以实时感知引入模块的大小。
而打包后,我们可以借助 webpack 插件 「webpack-bundle-analyzer」可视化地分析打包结果。
其配置:
// vue.config.js
const BundleAnalyzerPlugin = require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
module.exports = {
configureWebpack: {
plugins: [
new BundleAnalyzerPlugin()
// 其他 plugins ...
]
}
};
npm run serve 启动主项目后,我们可以从 Analyzer 监听的端口(默认是 8888)访问到打包后的可视化结果。
从上图 Analyzer 输出的结果中,我们可以看到类似 Vue(vue.rumtime.esm.js)、VueRouter(vue-router.esm.js)这样的三方库占用了大面积打包资源。这样的三方库随着项目迭代,可能会越来越多(如 elementUI、moment 等),而它们又不跟随业务的变化而改动代码,不需要打包。我们可以借助 webpack 的 externals 选项将他们抽离出来:
// vue.config.js
module.exports = {
configureWebpack: {
// ...
externals: {
vue: "window.Vue",
"vue-router": "window.VueRouter"
// 其他三方库 ...
}
// ...
}
};
当然别忘了以 CDN 的方式将它们挂到 window 上
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="en">
<!-- ... -->
<body>
<!-- ... -->
<div id="app"></div>
<script src="//shadow.elemecdn.com/npm/vue@2.5.16/dist/vue.runtime<%= process.env.NODE_ENV === 'production' ? '.min.js' : '.js' %>" crossorigin="anonymous"></script>
<script src="//shadow.elemecdn.com/npm/vue-router@3.0.1/dist/vue-router<%= process.env.NODE_ENV === 'production' ? '.min.js' : '.js' %>" crossorigin="anonymous"></script>
<!-- built files will be auto injected -->
</body>
</html>
重新 npm run serve 后,再次查看 Analyzer,Vue 和 VueRouter 已不在打包资源中。
Webpack 打包优化是一个有趣且富有细节的话题,不乏详细介绍的文章,限于篇幅,今天我们只介绍分析工具和典型的优化手段。
好了,经过模块规划后,目前的项目相比初始项目有了一些变化,具体可以查看这里。
前后端交互
在前端 HTTP 客户端工具完善,后端普遍服务化的今天,Web 应用的前后端交互模式比较典型的是,前端静态资源和服务端分别部署在不同域下,前端跨域调用 API 服务,组装数据,渲染页面。而我们希望开发业务项目时:
调用方式一致,请求代码流程清晰
匹配开发、测试、生产不同环境
跨域支持
请求错误处理一致
HTTP 客户端中,axios(支持 IE11+) 是最受欢迎的 JS 客户端,如果要兼容低版本浏览器,则可以考虑 vue-resource(支持 IE9+)。它们都支持 promise 调用、拦截器等较现代的请求功能,这里我们引入 axios 作为 HTTP 客户端。
支持 promise 调用的好处之一是,在 babel 插件的支持下,我们可以无缝使用 async/await 语法(经 babel 转化后就是 promise)。我们将业务 API 统一收口到前面提到的 src/api 目录下,导出供业务页面使用。
// src/api/douban-movies.js
import axios from "axios";
import { douban } from "@/config/hosts";
// 导出 API 资源调用函数
export const getTop250Movies = params =>
axios.get(`${douban}/v2/movie/top250`, { params });
// src/views/douban-movies/top250/index.vue
// script 部分
import { getTop250Movies } from "@/api/douban-movies";
export default {
name: "Top250",
data() {
return {
movies: []
};
},
async created() {
// 调用 API 资源
const { subjects } = await getTop250Movies({
start: 0,
count: 20
});
this.movies = subjects;
}
};
以上,src/api/${business}.js 只负责导出不同 API 的调用函数,而不去关心在不同业务下是怎么调用,传了什么参数。而对于业务页面,不同页面引入 API 资源的方式一致,API 资源可以多处复用。此外 async/await 语法可保证以同步代码的形式书写异步代码逻辑,流程上比 promise 和 callback 都要直观。
处理前端资源部署在不同环境(往往是通过域名区分)时,我们把管理域名的任务交给 src/config/hosts 文件。例如:
const getEnv = () => {
/**
* 这里写判断环境代码,
* 最终返回对应的环境标识
*/
return "prod";
};
const env = getEnv();
// 不同环境标识输出不同 host
export const douban = {
prod: "//douban.uieee.com",
pre: "//douban-api.now.sh",
test: "//douban-api.uieee.com"
}[env];
这里我们假设豆瓣 api 分别有三个环境,test、pre 和 prod,分别对应三个环境能调通的域名(这里只是假设,实际它们都是线上域名)。getEnv 会按照不同需求判断环境标识,比如当前页面部署在 https://test.douban.com/ 下,getEnv 匹配到 "test" 并返回,则导出的 douban 变量的值是 //douban-api.uieee.com。
如果你的前端资源不是部署到 API 的域名下,那么显然是跨域调用 API。除了要联系服务端配置 CORS ,允许前端资源所在域名的请求外,还需要给 HTTP 客户端设置 withCredentials,确保跨域请求时带上 Cookie 等身份信息用来给服务端鉴权。
API 统一错误处理 并不是靠前端单独完成的,而是需要两端协商,遵守标准 HTTP Code(或约定好的其他标准),再基于约定,通过 axios 的拦截器统一处理错误,例如:
// src/utils/net.js
//这里使用 element-ui 的消息UI组件
import { Notification } from "element-ui";
import axios from "axios";
/**
* 注册全局 Axios 拦截器
*/
export function registerInterceptor(options) {
// ...
axios.interceptors.response.use(
res => {
const ret = res.data;
return Promise.resolve(ret);
},
err => {
const status = err.response && err.response.status;
let message = "";
switch (status) {
case 404:
message = "请求资源未找到";
break;
case 401:
message = "无权限";
setTimeout(() => {
//处理登录失效,例如跳转到登陆页
}, 1000);
break;
case 500:
message = "服务器异常";
break;
// 其他错误...
default:
break;
}
Notification.error({
title: "错误",
message: message || err.message
});
return Promise.reject(err);
}
);
}
在入口文件中注册拦截器。
// src/main.js
// ...
import { registerInterceptor } from '@/utils/net';
//...
registerInterceptor();
new Vue({
router,
render: h => h(App),
}).$mount('#app');
前后端交互部分可以点击这里参看源码。
输出生产资源
在开发环境验证应用没有问题后,我们就可以执行 npm run build 命令构建生产版本了。构建完成后,Analyzer 依然会自动打开浏览器,显示打包结果,与此同时,你可以在 dist 目录下得到资源打包结果。
关于部署,默认情况下,在打包过程中通过插件自动注入 dist/index.html 文件内的资源,会以 /js/app.22d24e62.js 这样的相对路径作为资源路径。如果你的网站访问入口和前端资源部署在同一域名下,没有什么问题。
然而,为了提升静态资源访问速度,降低主域名下服务器的流量压力,我们通常会将静态资源从主域名下剥离出来,托管在 CDN 上。例如访问 https://h5.ele.me ,其页面内的静态资源就托管在 https://shadow.elemecdn.com 下。这种情况下,相对路径无法工作。我们要在配置中指定 publicPath 以指定静态资源的域名。
// vue.config.js
module.exports = {
//..
publicPath: process.env.NODE_ENV === "production" ? "//some.cdn.com" : "/",
};
我们看一下生产构建结果,相对路径前已经添加了 CDN 域名。
<!-- dist/html 文件内注入的脚本 -->
<script src=//some.cdn.com/js/chunk-vendors.f4718524.js></script>
<script src=//some.cdn.com/js/app.84426696.js></script>
小结
围绕怎么搭建 Vue 前端项目这个问题,今天我们从搭建什么项目,用什么搭建项目,怎么搭建项目三个角度梳理了一个通用 Web 应用的搭建过程。初步搭建了一个支撑业务变化,有前后端交互,适配不同环境的 Vue 应用。希望它能帮助你在前端生态的繁杂今天,迅速搭建起属于你的应用。