目录结构
├── index.html 入口页面
├── build 构建脚本目录
│ ├── build-server.js 运行本地构建服务器,可以访问构建后的页面
│ ├── build.js 生产环境构建脚本
│ ├── dev-client.js 开发服务器热重载脚本,主要用来实现开发阶段的页面自动刷新
│ ├── dev-server.js 运行本地开发服务器
│ ├── utils.js 构建相关工具方法
│ ├── webpack.base.conf.js wabpack基础配置
│ ├── webpack.dev.conf.js wabpack开发环境配置
│ └── webpack.prod.conf.js wabpack生产环境配置
├── config 项目配置
│ ├── dev.env.js 开发环境变量
│ ├── index.js 项目配置文件
│ ├── prod.env.js 生产环境变量
│ └── test.env.js 测试环境变量
├── mock mock数据目录
│ └── hello.js
├── package.json npm包配置文件,里面定义了项目的npm脚本,依赖包等信息
├── src 项目源码目录
│ ├── main.js 入口js文件
│ ├── app.vue 根组件
│ ├── components 公共组件目录
│ │ └── title.vue
│ ├── assets 资源目录,这里的资源会被wabpack构建
│ │ └── images
│ │ └── logo.png
│ ├── routes 前端路由
│ │ └── index.js
│ ├── store 应用级数据(state)
│ │ └── index.js
│ └── views 页面目录
│ ├── hello.vue
│ └── notfound.vue
├── static 纯静态资源,不会被wabpack构建。
└── test 测试文件目录(unit&e2e)
└── unit 单元测试
├── index.js 入口脚本
├── karma.conf.js karma配置文件
└── specs 单测case目录
└── Hello.spec.js
快速开始
git clone https://github.com/hanan198501/vue-spa-template.git
cd vue-spa-template
cnpm install
npm run dev
命令列表
#开启本地开发服务器,监控项目文件的变化,实时构建并自动刷新浏览器,浏览器访问 http://localhost:8081
npm run dev
#使用生产环境配置构建项目,构建好的文件会输出到 "dist" 目录,
npm run build
#运行构建服务器,可以查看构建的页面
npm run build-server
#运行单元测试
npm run unit
前后端分离
项目基于 spa 方式实现前后端分离,后端将所有 url 都返回到同一个 jsp 页面(由前端提供),此 jsp 页面也是前端的入口页面。然后路由由前端控制(基于vue-router),根据不同的 url 加载相应数据和组件进行渲染。
接口 mock
前后端分离后,开发前需要和后端同学定义好接口信息(请求地址,参数,返回信息等),前端通过 mock 的方式,即可开始编码,无需等待后端接口 ready。项目的本地开发服务器是基于 express 搭建的,通过 express 的中间件机制,我们可以很方便的添加接口 mock 功能:
在 build/dev-server.js 中新增接口 mock 处理:
// mock api requests
var mockDir = path.resolve(__dirname, '../mock');
fs.readdirSync(mockDir).forEach(function (file) {
var mock = require(path.resolve(mockDir, file));
app.use(mock.api, mock.response);
});
其中,mock 目录下可能有个文件内容如下,描述了一个接口的数据信息:
module.exports = {
// 接口地址
api: '/api/hello',
// 返回数据
response: function (req, res) {
res.send(`
<p>hello vue!</p>
`);
}
}
组件化
整个应用通过 vue 组件的方式搭建起来,通过 vue-router 控制相应组件的展现,组件树结构如下:
app.vue 根组件(整个应用只有一个)
├──view1.vue 页面级组件,放在 views 目录里面,有子组件时,可以建立子目录
│ ├──component1.vue 功能组件,公用的放在 components 目录,否则放在 views 子目录
│ ├──component2.vue
│ └──component3.vue
├──view2.vue
│ ├──component1.vue
│ └──component4.vue
└──view3.vue
├──component5.vue
……
单元测试
可以为每个组件编写单元测试,放在 test/unit/specs 目录下面, 单元测试用例的目录结构建议和测试的文件保持一致(相对于src),每个测试用例文件名以 .spec.js 结尾。执行 npm run unit 时会遍历所有的 spec.js 文件,产出测试报告在 test/unit/coverage 目录。
前后端联调
前后端分离后,由于服务端和前端的开发环境处于2台不同的机器上,整个联调过程,入口页面需要引用前端机器的静态资源,又要调用后端机器的异步接口。根据入口页面的位置,我们可以使用不同的联调方案:
1. 入口页面在前端机器:
通过在本地 dev-server 中使用 https://github.com/chimurai/http-proxy-middleware 中间件把接口请求代理到后端机器,vue-cli 生成的 dev-server 中已经自带了这个功能:
// proxy api requests
Object.keys(proxyTable).forEach(function (context) {
var options = proxyTable[context]
if (typeof options === 'string') {
options = { target: options }
}
app.use(proxyMiddleware(context, options))
});
最好通过启动 dev-server 时传入一个参数来控制是否打开代理功能,这样可以避免开发阶段覆盖掉我们的 mock 配置。
2. 入口页面在后端机器:后端工程里面的入口 jsp 中引用的 js 文件地址需要指向前端环境中的地址,联调时才能显示最新的修改。主要有2种实现方式:1) jsp 文件引用一个固定域名(如 debughost)的 js 文件, 后端机器上通过修改此域名的ip指向前端机器,达到引入前端环境 js 的目的。2) jsp 文件通过获取一个 url 参数(如 debughost)的值,这个值为前端机器的 ip 地址,再动态的插入一个 script 标签引入这个 ip 的前端 js 文件。
举个例子,假设前端机器的 ip 为 172.16.36.90,需要加载前端的js文件地址为:http://172.16.36.90:8081/main.js, 那么后端同学的机器中需要在 host 文件加一条记录:
172.16.36.90 debughost
而入口 jsp 页面中则通过以下代码开加载前端js:
var debughost = 'debughost';
location.search.substr(1).split('&').forEach(function (item) {
var arr = item.split('=');
var key = arr[0];
var value = arr[1];
if (key === 'debughost') {
debughost = value;
}
});
var script = document.createElement('script');
script.type = 'text/javascript';
script.src = 'http://' + debughost + ':8081/main.js?' + (new Date()).getTime();
document.head.appendChild(script);
这样,jsp 页面默认会加载 http://debughost:8081/main.js这个文件。此外,如果不想用 debughost 这个域名的 js 文件,访问 jsp 时候还可以通过 url 带入 debughost 参数来指定前端 ip 。
部署方案分离后前后端代码会存放在2个单独的 git 仓库中,构建过程也是分开的。后端构建时,需要依赖前端的构建结果。具体流程如下:
1. 拉取前端项目代码2. 构建前端(构建结果放在dist目录)3. 拉取后端代码4. 将前端的构建结果(dist目录里的文件)复制到后端工程中5. 构建后端
此过程可以借助 jenkins 配置,或者,让运维同学配合修改部署脚本。
最终的项目模板会是这样:annnhan/vue-spa-template
==================
vue交流群:568815621
编辑于 2017-05-08我们项目部也在用,因为就我一个前端,所以框架我自己定的。
公司不大,说出来你们也没听过-_-。
从最初用客户端vue.js,到后面用vue-cli使用的Vue1 + webpack模版,再到后面使用Vue2+webpack,用这个框架做SPA应用也有一年半的时间了。做一些总结吧,想到哪里写哪里(针对Vue2 + webpack)。
一、页面按需加载
这主要是为了加快首屏加载速度。这样做的好处是第一屏所需加载的文件大小变小了,代价是如果用户会走完整个SPA的话,实际的总代码下载量是变多了的。按需加载页面主要就是修改/src/router/index.js文件,示例代码如下:
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
const Home = resolve => require(['@/views/Home.vue'], resolve)
const MyRoot = resolve => require(['@/views/my/Root.vue'], resolve)
const MyIndex = resolve => require(['@/views/my/Index.vue'], resolve)
const MyLogs = resolve => require(['@/views/my/Logs.vue'], resolve)
const MyBrokerages = resolve => require(['@/views/my/Brokerages.vue'], resolve)
export default new Router({
routes: [
{
path: '/home',
name: 'Home',
component: Home,
meta: { needLogin: false }
},
{
path: '/my',
name: 'MyRoot',
component: MyRoot,
meta: { needLogin: true },
children: [
{
path: '',
redirect: 'index'
},
{
path: 'index',
name: 'MyIndex',
component: MyIndex,
meta: { needLogin: true }
},
{
path: 'logs',
name: 'MyLogs',
component: MyLogs,
meta: { needLogin: true }
},
{
path: 'brokerages',
name: 'MyBrokerages',
component: MyBrokerages,
meta: { needLogin: true }
}
]
},
// must be placed at the bottom
{ path: '*', redirect: '/home' }
]
})
因为觉得上面那样引入组件的代码重复的地方很多,本着DRY(Don't Repeat Yourself,中文意思是“懒” -_-)的原则,试过用下面这种方式引入组件:
function generateComponentFunction (path) {
return resolve => require([`@/views${path}`], resolve)
}
const Home = generateComponentFunction('/Home.vue')
const MyRoot = generateComponentFunction('/my/Root.vue')
const MyIndex = generateComponentFunction('/my/Index.vue')
const MyLogs = generateComponentFunction('/my/Logs.vue')
const MyBrokerages = generateComponentFunction('/my/Brokerages.vue')
是不是感觉这样写的话,引入组件的时候能少敲很多代码呢?然而实践发现这样子npm run build后各个页面被打包成了一个js,貌似是把所有这些都当成一个页面了。
二、通过script标签引入第三方js库
这种方式或许并非比较好的实践,个人保留意见,只是告知一下其实还有这种操作方法。这样能减少本地打包后的vendor.js文件的大小,同时也利用了CDN,但是也增加了very first time的首屏加载时间,后面这些第三方js文件被缓存了,首屏加载时间是会快一点的。假设说,现在你的package.json文件中定义的依赖包有:
...
"dependencies": {
"fastclick": "^1.0.6",
"mockjs": "^1.0.1-beta3",
"vue": "^2.2.1",
"vue-router": "^2.2.0",
"vuex": "^2.2.1"
},
...
然后,在这些依赖包(第三方js文件),其实都是可以通过script标签引入的。打开/index.html文件,示例如下(mockjs未处理,一样一样的):
<body ontouchstart="">
<div id="app"></div>
<!-- <script src="http://cdn.bootcss.com/vue/2.2.1/vue.js"></script> -->
<script src="http://cdn.bootcss.com/vue/2.2.1/vue.min.js"></script>
<script src="http://cdn.bootcss.com/vue-router/2.2.0/vue-router.min.js"></script>
<script src="http://cdn.bootcss.com/vuex/2.2.1/vuex.min.js"></script>
<script src="http://cdn.bootcss.com/jquery/3.2.1/jquery.min.js"></script>
<script src="http://cdn.bootcss.com/fastclick/1.0.6/fastclick.min.js"></script>
<script src="http://res.wx.qq.com/open/js/jweixin-1.2.0.js"></script>
<!-- built files will be auto injected -->
</body>
当然,事情没有这么简单,还需要修改/build/webpack.base.conf.js文件的externals部分(不记得脚手架生成的初始文件里有没有externals部分,如果没有的话自己加-_-):
entry: {
app: './src/main.js'
},
output: {
path: config.build.assetsRoot,
filename: '[name].js',
publicPath: process.env.NODE_ENV === 'production'
? config.build.assetsPublicPath
: config.dev.assetsPublicPath
},
externals: {
'jquery': 'window.$',
'vue': 'window.Vue',
'vue-router': 'window.VueRouter',
'vuex': 'window.Vuex',
'fastclick': 'window.FastClick'
},
resolve: {
extensions: ['.js', '.vue', '.json'],
alias: {
'vue$': 'vue/dist/vue.esm.js',
'@': resolve('src'),
}
},
然后vue、vue-router、vuex、jquery、fastclick这些还是平常怎么用就怎么用,比如:
// /src/main.js
import FastClick from 'fastclick'
FastClick.attach(window.document.body)
// /src/router/index.js
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
但是vuex的使用是个例外,需要注释掉Vue.use(Vuex)这句代码,因为通过使用script标签加载window.Vuex时,Vuex会被自动安装,无需手动安装:
// import Vue from 'vue'
import Vuex from 'vuex'
// 用于使用script标签加载window.Vuex,Vuex会被自动安装,无需手动安装
// Vue.use(Vuex)
三、定义一个合并对象属性的merge方法,方便操作vuex store中的数据
/src/scripts/store.js:
/**
* Created by Yakima Teng on 2017/3/8.
*/
// import Vue from 'vue'
import Vuex from 'vuex'
import { merge } from './utils'
import { config } from './mode'
import mock from '../mock'
// 用于使用script标签加载window.Vuex,Vuex会被自动安装,无需手动安装
// Vue.use(Vuex)
const state = {
config,
mock,
states: {
isLoading: true,
isAlerting: false,
alertingText: '',
alertingOkCallback () {},
alertingCancelCallback () {},
isRoaring: false,
roaringText: '',
roaringCallback () {},
isWaiting: false,
presenting: ''
},
user: {
openid: '',
phone: '',
id: '',
logined: false,
// 登录页的验证码
verificationCode: '',
// 登录页,用户是否勾选了“同意车保赢用户服务协议”
agree: true,
// 获取验证码的按钮上的文本
smsBtnText: '获取验证码',
agentName: '',
agentType: '',
agentId: ''
},
...
}
const getters = {
config: state => state.config,
mock: state => state.mock,
states: state => state.states,
user: state => state.user,
...
}
const mutations = {
setStates (state, options) { merge(state.states, options) },
setUser (state, options) { merge(state.user, options) },
...
}
const actions = {
load: ({ commit }, bool) => commit('setStates', {
isLoading: bool
}),
roar: ({ commit }, { text, callback }) => commit('setStates', {
isRoaring: true,
roaringText: text,
roaringCallback: callback || null
}),
alert: ({ commit }, { text, callback }) => commit('setStates', {
isAlerting: true,
alertingText: text,
alertingOkCallback () {
callback && callback()
commit('setStates', { isAlerting: false })
},
alertingCancelCallback: null
}),
confirm: ({ commit }, { text, okCallback, cancelCallback }) => commit('setStates', {
isAlerting: true,
alertingText: text,
alertingOkCallback () {
okCallback && okCallback()
commit('setStates', { isAlerting: false })
},
alertingCancelCallback () {
cancelCallback && cancelCallback()
commit('setStates', { isAlerting: false })
}
}),
wait: ({ commit }, bool) => commit('setStates', {
isWaiting: bool
}),
present: ({ commit }, val) => commit('setStates', {
presenting: val
})
}
export default new Vuex.Store({
state,
getters,
actions,
mutations
})
merge方法的定义:
// typeOf, return 'array', 'object', 'function', 'null', 'undefined', 'string', 'number'
const typeOf = input => {
return ({}).toString.call(input).slice(8, -1).toLowerCase()
}
// 合并对象属性(在原始对象上进行修改)
const merge = (obj, options) => {
if (obj && options) {
for (let p in options) {
if (typeOf(obj[p]) === 'object' && typeOf(options[p]) === 'object') {
merge(obj[p], options[p])
} else {
obj[p] = options[p]
}
}
}
return obj
}
注意这个merge方法是假设如果你赋的值有数组的话那些数组都是临时创建的数组对象,而不是从其他地方引用来的数组对象,比如这样:
merge({
a: 1,
b: []
}, {
b: arrA.map(item => {
return {
id: item.id,
value: item.content
}
})
})
如果你赋的数组值是引用的其他地方的数组对象,建议修改下merge方法,对每个数组元素(包括数组里的数组元素)都用a = [].concat(arrB)或者a = arrB.slice(0)这种方式赋一个全新的数组对象。但是就我自己的项目而言,这种情况几乎没有,多加这样的代码除了影响性能外没啥好处。
四、延迟加载不需要立即加载的js文件
主要是延迟加载一些日期插件之类的js文件,这样首屏加载速度能快一点。示例代码如下(写在/index.html文件中):
<script>
function downloadJSAfterOnload (fileUrl) {
var elem = window.document.createElement('script')
elem.src = fileUrl
window.document.body.appendChild(elem)
}
function downloadJsFiles () {
downloadJSAfterOnload('./static/a.js')
downloadJSAfterOnload('http://example.com/b.js')
}
if (window.addEventListener) {
window.addEventListener('load', downloadJsFiles, false)
} else if (window.attachEvent) {
window.attachEvent('onload', downloadJsFiles)
} else {
// window.onload = downloadJsFiles
}
</script>
五、移除打包后图片文件文件名中的hash
如果你跟我一样,项目中频繁修改的是css和js代码,图片很少有改动,要改也是增删,很少有更新图片的操作的话,建议移除打包后图片文件名中的hash,这样可以将图片文件更好的缓存起来。要修改的文件为/build/webpack.base.conf.js:
...
{
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
loader: 'url-loader',
query: {
limit: 10000,
// name: utils.assetsPath('img/[name].[hash:7].[ext]')
name: utils.assetsPath('img/[name].[ext]')
}
},
...
或者,也可以将[hash:7]改成[chunkhash],根据webpack官方文档https://webpack.js.org/guides/caching/,这个是根据单个文件的内容产生的hash,只要这个文件内容不变,chunkhash的值也不会变。而[hash]是根据当前编译环境来生成的,只要当前编译的文件中有一个文件发生了变化,这个[hash]产生的hash值就会变化。
六、图片压缩
可以在https://tinypng.com/这个网站上事先对你的图片文件进行压缩,效果感人。
七、确保CSS值不被改写
有段时间发现本地npm run dev的时候样式好好的,npm run build后在手机里样式就变了,后面发现是一个插件搞的鬼,修改/build/webpack.prod.conf.js文件:
...
new OptimizeCSSPlugin({
cssProcessorOptions: {
safe: true
}
}),
...
重点就是这个cssProcessorOptions: { safe: true }的配置,有些版本的vue2+webpack模板里没有这行配置,会导致比如你的z-index值被优化(坑)到你没脾气。所以如果你现在的文件里有这样配置就最好了,没有的话需要手动添加一下。
八、使用minxin避免过大的重复代码,提高可维护性
Vue提供了minxin这种在组件内插入组件属性的方法,个人建议这货能少用就少用,但是有个场景则非常建议使用minxin:当某段代码重复出现在多个组件中,并且这个重复的代码块很大的时候,将其作为一个minxin常常能给后期的维护带来很大的方便。
比如说,有个post请求,传参字段有二三十个,后端回参也有几十个字段,整个一个请求下来要写的代码量都好几十行了,这个就不叫适合作为一个minxin来用了。minxin怎么用就不说了,vue官网上都有的。