项目链接: https://github.com/bailicangdu/vue2-happyfri
作者:cangdu
1 项目简述
非常简单的一个vue2 + vuex的项目,整个流程一目了然,麻雀虽小,五脏俱全,适合作为入门练习。
正如作者所说,项目本身所实现的功能并不复杂,事实上,对于在移动端使用jQuery或Zepto的开发者来说也许完全没有必要这么做。有这样的想法并不奇怪,基于jQuery或Zepto来做同样的功能无须进行复杂的配置和模块分割,可能仅需使用一些简单的逻辑判断和DOM操作即可完成,但这样做的同时也提升了后期更新和维护的成本。
本文旨在通过对项目源码的分析研究,学习和入门webpack + vue2 + vuex + vue-router2。
2 运行结果
# 运行环境
# node v8.5.0
# npm v5.5.1
# 按照项目运行说明完成依赖安装后运行
npm run dev
# Bash 出现 "webpack: Compiled successfully." 后,在本地访问
# http://localhost:8088
3 目录结构
.
├── build // webpack配置文件
│ ├── build.js
│ ├── dev-client.js
│ ├── dev-server.js
│ ├── utils.js
│ ├── webpack.base.conf.js
│ ├── webpack.dev.conf.js
│ └── webpack.prod.conf.js
├── config // 项目打包路径
│ └── index.js
├── happyfri // 上线项目文件,放在服务器即可正常访问
│ ├── index.html // 构建之后的html入口文件
│ └── static // 构建之后的静态资源目录,包括css/img(未列出)/js
│ ├── css
│ │ └── app.css
│ ├── img
│ └── js
│ ├── app.js
│ ├── home.974d5d21b74c00c29576.min.js
│ ├── item.7eda274d80fddaaf58dc.min.js
│ ├── manifest.js
│ ├── score.928bc788f34a3d977dab.min.js
│ └── vendor.js
├── index.html // 入口html文件
├── LICENSE
├── package.json // npm包标准配置文件
├── README.md
└── src // 源码目录 ---------- [1]
├── App.vue // 页面入口文件 ---------- [1.3]
├── components // 组件 ---------- [1.3]
│ └── itemcontainer.vue // 核心组件 ---------- [1.4]
├── config // 配置及公共方法
│ ├── ajax.js // ajax的原生实现
│ └── rem.js // 相对单位转换
├── images // 静态图片目录,未列出 ---------- [1.1]
├── main.js // 程序入口文件,加载各种公共组件
├── page // 视图组件 ---------- [1.3]
│ ├── home // 首页
│ │ └── index.vue
│ ├── item // 答题
│ │ └── index.vue
│ └── score // 得分
│ └── index.vue
├── router // 路由目录 ---------- [1.3]
│ └── router.js // 路由配置文件 ---------- [1.3]
├── store // vuex的状态管理 ---------- [1.4]
│ ├── action.js // 配置actions ---------- [1.4]
│ ├── index.js // 引用vuex,创建store ---------- [1.4]
│ └── mutations.js // 配置mutations ---------- [1.4]
└── style // 样式文件目录 ---------- [1.2]
└── common.less // 公共样式文件
18 directories, 53 files
4 源码分析
源码目录包含了Webpack打包的所有资源,包括图片、样式文件、js脚本文件(包括入口的main.js、用于状态管理的vuex目录下的脚本文件和用于路由配置的router目录下的脚本文件)以及vue单文件组件。接下来我们逐个来看:
4.1 静态图片目录
可以看到,实际在浏览器中运行时,只有1-1.jpg、1-2.png等5个图片资源是使用src/images目录中的,其他的图片( 有两个图片是项目源码中没有用到的,但作者也放在了images目录下,分别为1-3.png和ewm.png)都做了base64的编码。
关于前端开发中的图片优化,有些基本的方式我们是很熟悉的,比如图片压缩、图片合并( 所谓的“雪碧图”)、选择合适的图片格式( 如webp)、对图片进行缓存以及图片预加载等等,base64编码图片也是其中的一种图片资源优化技巧。经过base64编码的图片会转成一个字符串url,我们可以将它保存到样式或脚本文件中,与样式或脚本文件一起缓存下来,同时,可以减少对应的http请求。
但是,我们可以看到,作者并没有把所有的图片都做base64编码,通过对比不难发现,通过base64编码的图片大都是一些小图标或者是简单的纯色背景。这是因为base64编码后的大小通常要比原图片要大( 大概要大1/3),会造成样式或脚本文件的体积增大,浏览器加载时的进程阻塞,影响用户体验,同时,大体积的base64编码对编译速度也会有影响。那么作者又是如何做到的呢?
图片base64编码是由Webpack来完成的,对应的加载器是url-loader,代码如下:
# 路径:build/webpack.base.conf.js
# 文档:https://doc.webpack-china.org/loaders/url-loader
# url-loader加载器定义
{
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
loader: 'url',
query: {
limit: 10000,
name: utils.assetsPath('img/[name].[ext]')
}
}
关于这个加载器的具体用法可以参考给出的文档链接,这里简要说明下,limit选项指定了一个限制大小,以byte为单位,即当匹配test选项所指定的文件体积小于10kb时,对应的文件会被base64编码,并返回一个DataURL。这与目录对比得出的结论一致,排除两个项目未用到的图片1-3.png和ewm.png,除1-1.jpg、1-2.png等5个图片体积大于10kb外,其他的图片均小于10kb,并且都进行了base64编码。
4.2 样式文件目录
我们可以看到,项目源码中有一个公共的样式文件,在各个vue单文件组件中也都有各自的样式文件,但是,当我们在开发模式下运行时,模拟器中并没有对样式文件的http请求,所有的样式定义都放在了编译之后的html文件中,或定义在<style></style>
标签内,亦或以内联形式定义在<body></body>
标签内。看一下作者是如何做到。
对样式文件的处理也是由Webpack完成的,对应的加载器包括less/sass/stylus-loader、css-loader、vue-style-loader(记录顺序同Webpack实际的执行顺序),代码如下:
# 路径:build/webpack.dev.conf.js
# 文档:https://doc.webpack-china.org/loaders | https://www.npmjs.com/package/vue-style-loader
# 关于样式文件的加载器定义
[{
test: /\.css$/,
loader: 'vue-style-loader!css-loader'
}, {
test: /\.postcss$/,
loader: 'vue-style-loader!css-loader'
}, {
test: /\.less$/,
loader: 'vue-style-loader!css-loader!less-loader'
}, {
test: /\.sass$/,
loader: 'vue-style-loader!css-loader!sass-loader?indentedSyntax'
}, {
test: /\.scss$/,
loader: 'vue-style-loader!css-loader!sass-loader'
}, {
test: /\.stylus$/,
loader: 'vue-style-loader!css-loader!stylus-loader'
}, {
test: /\.styl$/,
loader: 'vue-style-loader!css-loader!stylus-loader'
}]
同样,关于代码中涉及到的相关加载器的具体用法,本文不进行详述,可以参考给出的文档链接,这里只简单的做下解释。less/sass/stylus-loader加载器的功能类似(less/sass/stylus均为css的预处理器),都是添加Webpack的对应支持,即在Webpack进行下一步处理之前,首先将对应的less/sass/stylus文件编译为css文件;css-loader添加Webpack对@import
和url()
的支持;vue-style-loader处理css文件为我们在项目中看到的形式,即将所有的样式定义都放在了编译之后的html文件中,或定义在<style></style>
标签内,亦或以内联形式定义在<body></body>
标签内,从而减少页面的http请求。
这里做一下延伸,我们可能更熟悉的是style-loader,而不是vue-style-loader,在代码中我给出了vue-style-loader在官网上的链接。
This is a fork based on style-loader. Similar to style-loader, you can chain it after css-loader to dynamically inject CSS into the document as style tags.
上述引用至vue-style-loader的模块介绍,可以看到,vue-style-loader其实是style-loader的一个分支,二者的功能基本相似(至于区别,本项目涉及不到,这里暂时不做研究了)。
除了对样式文件的上述处理,项目中还用postcss对vue单文件组件做了浏览器的兼容处理,代码如下:
# 路径:build/webpack.base.conf.js
# 文档:https://doc.webpack-china.org/loaders/postcss-loader/
# 关于样式文件的浏览器兼容处理
postcss: [
require('autoprefixer')({
browsers: ['last 10 versions']
})
]
4.3 Vue单文件组件和路由
这个部分我会尝试将项目的业务逻辑阐述清楚,由于项目中使用了vue-router,在开始分析项目之前,我想请各位开发者(如果你是的话)先思考一个问题,Vue为什么需要这个东西?
要想回答清楚为什么,我们自然要先知道它是什么,来看GitHub的介绍:
vue-router is the official router for Vue.js. It deeply integrates with Vue.js core to make building Single Page Applications with Vue.js a breeze.
这句话讲的不是很明白,当然,它清楚的表达了我们很需要这个东西,但并没有告诉我们这到底是个什么东西。所谓的official router,和vue-resource、vue-lazyload、vue-i18n、vue-core-image-upload等Vue生态系统中的众多插件一样,它是一个Vue框架的插件。
我很想和你一起探讨“Vue是什么”、“它能做些什么”、“Vue比React、AngularJs好在哪里”等等诸如此类的问题,但请原谅,这些问题将会是我在本文所写的最没有“底气”的部分。我是从今年(2017)的4月份开始接触Vue的,不过因为项目的需要,我们使用的是以Vue为核心的一个衍生框架(lighting),迄今为止,仍然很难完整的说清楚这样一个前端框架(尤其是如此成熟和成功的一个框架),可是,于本文而言,又不得不说。
Vue.js是一套构建用户界面的渐进式框架。
Vue 的核心库只关注视图层,它不仅易于上手,还便于与第三方库或既有项目整合。
这是从Vue的官网教程上截取的一段,我关注了两个关键词,“构建用户界面”和“图层”,虽不能尽述,但这与我这段时间了解的知识基本一致。抛开Vue生态系统中众多的插件库和组件库,Vue的核心库主要用于应用界面的构建和交互的完成,关注的是界面元素的组件化和交互脚本的模块化(其实界面元素的组件化也是一种模块化),Vue核心库中诸多的设计理念都始终围绕着“组件”来展开,其中,单文件组件又是核心库中的核心。而作为前端框架(尤其是基于SPA理念的前端框架),组件间的路由切换自然必不可少,因此,对Vue的核心库而言,vue-router就有了存在的价值,它提供了对Vue核心库的路由支持。
明白了这一点,我们再来看本项目中的业务层结构,我们从源码目录中涉及的所有单文件组件着手。不难找出,源码中的单文件组件如下:
├── App.vue // 页面入口文件
├── components // 公共组件
│ └── itemcontainer.vue
├── page // 视图组件
│ ├── home // 首页
│ │ └── index.vue
│ ├── item // 答题
│ │ └── index.vue
│ └── score // 得分
│ └── index.vue
先不去分析这些组件,我们来看下对应的路由设置:
# 路径:src/router/router.js
# 路由配置
import App from '../App'
export default [{
path: '/',
component: App,
children: [{
path: '',
component: r => require.ensure([], () => r(require('../page/home')), 'home')
}, {
path: '/item',
component: r => require.ensure([], () => r(require('../page/item')), 'item')
}, {
path: '/score',
component: r => require.ensure([], () => r(require('../page/score')), 'score')
}]
}]
简单的做下对比,其实项目的结构就已经很清楚了。作者以App.vue为入口,通过三个子路由“/#/”、“/#/item”和“/#/score”分别跳转至组件“/page/home”、“/page/item”和“/page/score”。组件“/page/home”展示宣传页(首页),组件“/page/item”完成答题,组件“/page/score”展示答题结果(参考图一)。
这里有个技术细节要说一下,作者在路由设置时,App.vue是通过import方式引入的,但三个子路由的组件则是通过require方式异步引入的。关于require.ensure的用法,可以参考《webpack代码分离 ensure 看了还不懂,你打我 》。
4.4 Vue单文件组件和状态管理
这部分的项目分析会深入到组件内部,会尝试去分析典型交互的实现逻辑,我希望能通过这种方式来学习和研究我们应该如何更好的使用Vuex,至于Vuex(它的本质也是个Vuejs插件)是什么,来看官网的介绍:
Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。
我很喜欢去想为什么,可能已经被你发现了,为什么它会这样,为什么它变成了那样……但是碍于篇幅,我还不能在这里写太多(这篇文章到这里已经很长了)。我还是关注了几个关键字,“状态管理”,“集中式存储”,“可预测”。状态管理的状态指的是组件的状态,具体则是组件内部用到的响应式数据;集中式存储指的是vuex进行状态管理的方式,即构建store对象对组件的状态进行统一管理;可预测,这一点很好理解,如果组件内部状态是基于store来统一响应和存储的,那store对象的变化在组件内部自然是可预测的。我觉得不妨这样说,对vuex而言,状态管理是目的,集中式存储是手段,可预测是特点 (这里不再展开讲了,只是我个人的一些浅见,关于vuex的设计理念,官网上已经写了很多了,问题在于我们能理解多少)。
对于Vue单文件组件而言,使用vuex进行状态管理对中大型的SPA项目有一定的意义,如果项目较小,我们有其他的替代方式,比如Event Bus。接下来我们分析一个典型的组件(也是项目的核心组件),来研究作者是如何使用vuex对组件状态进行管理的。
# 路径:src/components/itemcontainer.vue
# 项目核心组件
<template>
<section>
<header class="top_tips">
<span class="num_tip" v-if="fatherComponent == 'home'">{{level}}</span>
<span class="num_tip" v-if="fatherComponent == 'item'">题目{{itemNum}}</span>
</header>
<div v-if="fatherComponent == 'home'" >
<div class="home_logo item_container_style"></div>
<router-link to="item" class="start button_style" ></router-link>
</div>
<div v-if="fatherComponent == 'item'" >
<div class="item_back item_container_style">
<div class="item_list_container" v-if="itemDetail.length > 0">
<header class="item_title">{{itemDetail[itemNum-1].topic_name}}</header>
<ul>
<li v-for="(item, index) in itemDetail[itemNum-1].topic_answer" @click="choosed(index, item.topic_answer_id)" class="item_list">
<span class="option_style" v-bind:class="{'has_choosed':choosedNum==index}">{{chooseType(index)}}</span>
<span class="option_detail">{{item.answer_name}}</span>
</li>
</ul>
</div>
</div>
<span class="next_item button_style" @click="nextItem" v-if="itemNum < itemDetail.length"></span>
<span class="submit_item button_style" v-else @click="submitAnswer"></span>
</div>
</section>
</template>
<script>
import { mapState, mapActions } from 'vuex'
export default {
name: 'itemcontainer',
data() {
return {
itemId: null, //题目ID
choosedNum: null, //选中答案索引
choosedId: null //选中答案id
}
},
props:['fatherComponent'],
computed: mapState([
'itemNum', //第几题
'level', //第几周
'itemDetail', //题目详情
'timer', //计时器
]),
methods: {
...mapActions([
'addNum', 'initializeData'
]),
//点击下一题
nextItem(){
if (this.choosedNum !== null) {
this.choosedNum = null;
//保存答案, 题目索引加一,跳到下一题
this.addNum(this.choosedId)
}else{
alert('您还没有选择答案哦')
}
},
//索引0-3对应答案A-B
chooseType: type => {
switch(type){
case 0: return 'A';
case 1: return 'B';
case 2: return 'C';
case 3: return 'D';
}
},
//选中的答案信息
choosed(type,id){
this.choosedNum = type;
this.choosedId = id;
},
//到达最后一题,交卷,请空定时器,跳转分数页面
submitAnswer(){
if (this.choosedNum !== null) {
this.addNum(this.choosedId)
clearInterval(this.timer)
this.$router.push('score')
}else{
alert('您还没有选择答案哦')
}
},
},
created(){
//初始化信息
this.initializeData();
document.body.style.backgroundImage = 'url(./static/img/1-1.jpg)';
}
}
</script>
<style lang="less">
<!-- 省略组件样式定义 -->
</style>
除了一些共有的部分,在组件的模板中(template标签内),作者通过继承至父组件的fatherComponent参数做了区分,当父组件为home(fatherComponent == ‘home’)时,组件使用level来展示当前周序号;当父组件为item(fatherComponent == ‘item’)时,组件用itemNum展示题目序号,itemDetail展示题目详情;在答题结束,提交答案之后,作者清除了答题计时器timer。上述提到的level、itemNum、itemDetail和timer,作者是在计算属性中通过mapState引入到组件内部,代码如下:
computed: mapState([
'itemNum', //题目序号
'level', //当前周序号
'itemDetail', //题目详情
'timer' //答题计时器
])
当我们每次点击下一题时,对应的题目序号和题目详情都会发生对应变化,我们看下这个交互是如何完成的。之前提到过,itemDetail(题目详情)定义在vuex的state状态中,在组件内部通过mapState引入。
# 题目详情
<div class="item_list_container" v-if="itemDetail.length > 0">
<header class="item_title">{{itemDetail[itemNum-1].topic_name}}</header>
<ul>
<li v-for="(item, index) in itemDetail[itemNum-1].topic_answer" @click="choosed(index, item.topic_answer_id)" class="item_list">
<span class="option_style" v-bind:class="{'has_choosed':choosedNum==index}">{{chooseType(index)}}</span>
<span class="option_detail">{{item.answer_name}}</span>
</li>
</ul>
</div>
分析这段代码,不难发现,作者加载的是对应当前itemNum的itemDetail,也就是说,如果我们想切换itemDetail,我们所要做的只是修改itemNum即可,作者也正是这样做的。当我们每次点击下一题时,nextItem方法会被调用,如果用户做了选择,会触发addNum action,接着触发ADD_ITEMNUM mutatuon修改state中的itemNum,再分发到当前组件切换到下一题,代码如下:
# 点击下一题 nextItem
nextItem(){
if (this.choosedNum !== null) {
this.choosedNum = null;
this.addNum(this.choosedId)
}else{
alert('您还没有选择答案哦')
}
}
# 路径:src/store/action.js
# addNum action
addNum({ commit, state }, id) {
//点击下一题,记录答案id,判断是否是最后一题,如果不是则跳转下一题
commit('REMBER_ANSWER', id);
if (state.itemNum < state.itemDetail.length) {
commit('ADD_ITEMNUM', 1);
}
}
# 路径:src/store/mutatuons.js
# ADD_ITEMNUM mutatuon
const ADD_ITEMNUM = 'ADD_ITEMNUM'
[ADD_ITEMNUM](state, num) {
state.itemNum += num;
}
这个组件还有一些其他值得讨论的地方,比如作者在created生命周期中提交的initializeData action,作者在引用vuex做状态管理的同时保留了组件的局部状态itemId(并未使用)、choosedNum和choosedId等,这里不再展开分析了,我觉得这里更值得做的一件事儿是分析作者使用vuex的目录结构,这里有没有值得我们学习的地方。
# 项目中vuex的目录结构
├── store // vuex的状态管理
│ ├── action.js // 配置actions
│ ├── index.js // 引用vuex,创建store
│ └── mutations.js // 配置mutations
对于vuex的目录结构,其实并没有什么统一的标准,我们可以按照实际项目的情况来组织(vuex官网提供了一个模板,但仅作参考)。在本项目中,作者把state、action和mutation分别放在了单独的脚本文件内,结构较为清晰,对于中小型的SPA项目来说,这样的项目结构完全可以满足需求。
5 总结
到这里,本文就告一段落了,写这篇文章的主要目的是为了学习和入门webpack + vue2 + vuex + vue-router2,对我来说,这个目的基本上达到了,但需要做的还有很多,本文也有很多的东西没用覆盖到,比如es6和webpack。webpack是当前被广大前端开发者认可的一个代码编译框架,无论是基于什么样的目的,我们都需要深入的了解它到底是怎么用的,关于webpack的使用我会重新写一篇文章记录。
回顾一下这篇文章,我首先按照项目的运行说明展示了项目的最终运行结果,然后列出了项目的目录结构,接着对源码从图片处理、样式文件处理、路由处理和状态管理四个方面做了分析。这个项目虽小,也很简单,但有很多值得我们学习的地方,比如作者对各个功能模块的分割,对webpack的合理配置使我们仅需要执行几个简单的代码即可完成项目的编译和导出,非常方便。在源码内部,作者对图片资源、样式文件、模块脚本以及vue单文件组件的组织和架构清晰合理,一目了然。具体到代码层面,作者使用的是ES2015语法,比如箭头函数、解构、对象展开运算符等等,还有合理的异步处理等方法来提升用户体验。如果想要完全掌握项目中所涉及的知识点,恐怕还需要相当长的时间。
人的心力总是有限的,如果有可能,我愿意去细细的考究项目中的每一行代码,这真的挺有趣的,愿每位开发者共勉。