项目源自网上一个Vue项目。原项目是基于Vue Cli 2 并且采用了UI组件库。为了练手,我采用原生CSS写样式,然后自己进行了响应式设计。后台应用我是直接拿来用的,后期复习到Node的时候我会自己来做一遍。我做这个项目最重要的目的是把学过的理论知识实践一下,虽然当前还有一些bug还没修复,但是我想先把做过的东西总结一下。
前端项目地址:vue-shop
后端项目地址:vue-shop-server
当前完成的功能展示如下:
当前bug待解决:
浏览器首次打开手机模拟器时,首页swiper以及better-scroll都无法滑动,刷新后就没事了,而我在自己的手机Chrome浏览器测试完全正常。
内置浏览器版本过低,打开失败。
功能待完善:
下面会挑选我觉得项目中比较重要的地方来讲解。
项目启动了半个月后,我发现低版本包传到Github上面后有安全问题,于是我决定将原来的版本更新到 Vue Cli 3,这里采用图形界面的方式创建新项目。
vue ui
环境的搭建我相信大家看过很多文章了,所以我不再赘述。创建项目过程中,会让你选一些工具,它们可以集成到项目中去,我选的是:
Babel
Vuex
Router
CSS Pre-processors
(CSS预处理器)然后下一步选择预处理器类型,代码风格采用哪种模式,是否使用history
模式的路由,最后选择什么时候自动修复错误格式的代码,我的选择如下:
点击创建项目后,稍等片刻就好啦。然后你此次的配置会保存一个预设,你下次再次创建的时候就可以使用这个预设了。我简单介绍一下脚手架的目录。
这款风格是带有分号的,如嫌麻烦可以在刚开始创建项目的时候选择标准模式(standard)。
注意:本文提及的所有模块的已经默认安装好了。
项目启动的时候不同的环境会载入不同的环境变量。官方提供了以下几种方式来载入环境变量:
.env # 在所有的环境中被载入
.env.local # 在所有的环境中被载入,但会被 git 忽略
.env.[mode] # 在特定环境下载入,由mode决定
.env.[mode].local # 在特定环境下载入,由mode决定,但会被 git 忽略
被指定模式的环境变量比没有指定的优先级要高,但如果脚手架工具启动时已经存在一个环境变量,它将用有最高优先级。那他们的环境是如何确定的呢?
脚手架工具中在process.env
中已经定义好了一个环境变量NODE_ENV
。我们可以在package.json
中的script
属性中指定--mode
参数来指定NODE_ENV
的值从而确定一个模式,比如我们指定一个alpha
模式:
"script": {
"alpha": "vue-cli-service build --mode test"
}
脚手架工具已经集成了三种模式:development
、production
、test
,通过运行以下命令来启动不同环境,然后加载.env.*
文件载入环境变量:
development
模式用于 vue-cli-service serveproduction
模式用于 vue-cli-service build 和 vue-cli-service test:e2etest
模式用于 vue-cli-service test:unit下面是我项目中的脚本命令:
这三种环境其实已经能满足我们日常开发需求了。项目根目录下我创建了三个文件来载入不同环境的变量:
环境变量命名的风格也是有规定,它是一种键-值
的结构,而且最好是VUE_APP_
开头,因为如果你在配置文件中使用的话,不是以VUE_APP_
开头的变量就不会载入。
我在development
环境中定义了两个环境变量:
VUE_APP_BASE = "http://localhost:4000/" # 代理服务器地址
VUE_APP_IMAGE = "https://fuss10.elemecdn.com" # 图片地址
大家可以根据自己的实际开发需求来定义,我为了方便就只模拟了development
环境。
Vue cli 3 创建的项目会集成一些常见的webpack配置,但是有时候我们还是要自己去配置一些选项,官方提供了vue.config.js
,它会在项目启动的时候加载。
我主要配置了三个地方,其他的配置大家可以参考官方文档。
stylus
样式alias
)proxy
)const path = require('path')
function resolve(dir) {
return path.join(__dirname, dir)
}
// 全局mixins.styl样式
function addStyleResource(rule) {
rule
.use('style-resource')
.loader('style-resources-loader')
.options({
patterns: [resolve('src/assets/styles/stylus/index.styl')]
})
}
module.exports = {
lintOnSave: process.env.NODE_ENV !== 'production', // 开发环境保存时开启代码检查
productionSourceMap: false, // 生产环境下关闭SourceMap
chainWebpack: config => {
const types = ['vue-modules', 'vue', 'normal-modules', 'normal']
types.forEach(type =>
addStyleResource(config.module.rule('stylus').oneOf(type))
)
config.resolve.alias
.set('styles', resolve('src/assets/styles'))
.set('images', resolve('src/assets/images'))
.set('components', resolve('src/components'))
},
devServer: {
proxy: {
'/': {
target: process.env.VUE_APP_BASE, // 代理地址
changeOrigin: true // 是否支持跨域
}
}
}
}
不同的团队有不同的规范,这里是我自己个人比较喜欢的代码风格,以后同团队协作开发的时候还是要向团队看齐的。
有两种方式来创建eslint
配置:
.eslintrc.*
文件或在package.json
中的eslintConfig
属性中配置我选择的是后者。
"eslintConfig": {
"root": true, // 将ESLint限制在当前项目,不再向上查找配置
"env": {
"node": true // 指定node环境,使用该环境下全局变量的预设
},
"extends": [
"plugin:vue/essential", // 使用vue插件
"@vue/prettier", // prttttier 预设
"eslint:recommended" //使用eslint推荐的规则
],
"rules": { // 拓展或者覆盖默认规则
"prettier/prettier": [
"error", //被prettier标记的地方抛出错误信息。
{
"tabWidth": 2, // 几个缩进空格
"useTabs": false, // 是否使用tab缩进
"semi": false, // 句尾是否有分号
"singleQuote": true, // 是否是用单引号
"bracketSpacing": true,
"jsxBracketSameLine": false,
"space-before-function-paren": true
}
]
},
"parserOptions": {
"parser": "babel-eslint" // 解析器,支持检测ES6+语法
},
"overrides": [
{
"files": [
"**/__tests__/*.{j,t}s?(x)",
"**/tests/unit/**/*.spec.{j,t}s?(x)"
],
"env": {
"jest": true
}
}
]
}
在我的项目中组件分为两种:路由组件和UI组件。组件命名规则如下:
index.vue
存放父组件。BASE
开头。components
存放父组件下的其他组件,必须是耦合命名,比如Home
父组件下的HomeShoplist
。响应式指的是不同的终端设备下都能很好的显示网页,通常一套代码就可以搞定。
我采用的方案是viewport + vw + rem + media query + %
的方式来实现。
<meta name="viewport" content="width=device-width,initial-scale=1.0">
它的作用如下:
width=device-width
)这个标签只对移动端页面初始渲染的时候起作用,只不过它还不是一个W3C规范。
除了上面提到的百分比单位%
,还有类似有vw,vh,rem,em
等常见单位,另外配上媒体查询(media query
)和JS来辅助,我们的响应式设计就基本上完美了。
我看了很多文章,下面是我个人比较喜欢的两种方案:
实现思路如下:
rem 是相对于html的font-size大小,默认16px(不同浏览器有差异),也就是说1rem = 16px
将布局视口分成100份,动态获取初始布局视口的1%的宽度,将值设置给font-size,这样就得到了不同设备下的单位rem值
算出某个元素和画布(设计稿)的占比,得出rem的单位系数。
下面说说两种方案怎么个整法。
(1)动态获取布局视口宽度,将1%的值赋值给html的font-size。
(function(){
var html = document.documentElement;
window.addEventListener('resize', function() {
html.style.fontSize = html.clientWidth / 100 + 'px';
})
}())
(2)算出某个元素与设计图的占比,然后把%换成rem即可。这里计算过于繁琐,可以在stylus的中定义一个函数,将设计图某个元素的px转化成rem单位,此后我们只需要在项目中的CSS使用这个函数即可。
// $px 就是设计图某个元素的px
$ueWidth = 375
px2rem($px)
(($px * 100 / $ueWidth))rem
这个方案有个缺点,就是JS和CSS有一定的耦合性。如果不考虑这个小缺点,这个方案还是相当不错的。另外说一点就是,font-size是具有继承性的,那整个页面的字体就变得很诡异了。除此之外,字体使用rem单位后,字体并不会线性变化,所以我们需要配菜
上场了。
(3)通过媒体查询初始化设备字体大小。
这里分割点大家可以参考设计师或者网上给的设计方案,我这里参考了浏览器手机模拟器上的分割点。
/* 分割点 */
/* iphone 5 */
@media screen and (min-width: 320px) {
body {
font-size: 14px;
}
}
/* iphoneX */
@media screen and (min-width: 375px) {
body {
font-size: 16px;
}
}
/* iphone6 7 8 plus */
@media screen and (min-width: 414px) {
body {
font-size: 18px;
}
}
/* ipad */
@media screen and (min-width: 768px) {
body {
font-size: 20px;
}
}
/* ipad pro */
@media screen and (min-width: 1024px) {
body {
font-size: 24px;
}
}
然后所有的子元素的字体使用em
相对单位来设置,注意这个单位是相对父级元素的字体大小,当然如果你不想计算,你也可以在stylus定义相关函数。总体来说,虽然有一点点瑕疵,但是这种方案对移动端兼容性很好,应用的人数也很多。
vw
是CSS3中出现的,它是视口单位,桌面端指的是浏览器的可视区域;移动端指的就是布局视口。1vw代表布局视口的1%,如果不考虑兼容性,我们给html的font-size为1vw不可以替换上面的那种方案了吗。我们只需要把JS代码去掉就可以了。
html {
font-size: 1vw;
}
以上就是我实践的响应式设计,虽然到目前为止还有许多瑕疵,但是我会慢慢完善下去的。
下面是Axios的简单配置,根据项目需求合理地去添加配置,具体可以参考官方文档。
在src
目录下建立一个utils
文件夹,主要是放所有的工具模块,比如正则验证等,然后在此目录下创建request.js
。
引入相关依赖模块
import Axios from 'axios' // 引入axios库
import router from '@/router' // 引入路由
import store from '@/store'
定义公共变量和函数
const SECONDS = 12 // 请求超时秒数
/**
* 跳转登录页
* 携带当前页面路由,在登录页面完成登录后返回当前页面
*/
const toLogin = () => {
router.replace({
path: '/login',
query: {
redirect: router.currentRoute.fullPath
}
})
}
/**
* 错误统一处理
* @param {Number} status 状态码
*/
const errorHandle = (status, other) => {
// 状态码判断
switch (status) {
// 401: 未登录状态,跳转登录页
case 401:
toLogin()
break
// 404请求不存在
case 404:
alert('请求的资源不存在')
break
case 500:
alert('服务器错误')
break
default:
alert(other)
break
}
}
注意:这里的错误提示完全可以使用UI组件来替代,我比较懒就写了个alert,其次是状态码,这个需要和后台协商确定。
创建Axios实例
// 创建axios实例
const instance = axios.create({
baseURL: '', // 之前已经在环境变量中配置好了,这个必须为空,否则会报严重的错误
timeout: 1000 * SECONDS
})
默认配置
instance.default.headers.post['Content-Type'] = 'application/x-www-form-urlencoded'
这样配置后,post传递数据默认格式就是类似这样的name=laocao&age=22
了,当然还有其他默认配置,根据PM给的需求来定,这是我自己的项目,所以…需求我自己定。
拦截器
/**
* 请求拦截器
* 每次请求前,如果存在token则在请求头中携带token
*/
instance.interceptors.request.use(
config => {
// 登录流程控制中,根据本地是否存在token判断用户的登录情况
// 但是即使token存在,也有可能token是过期的,所以在每次的请求头中携带token
// 后台根据携带的token判断用户的登录情况,并返回给我们对应的状态码
// 而后我们可以在响应拦截器中,根据状态码进行一些统一的操作
const token = store.state.token
token && (config.headers['Authorization'] = token)
return config
},
error => Promise.error(error)
)
// 响应拦截器
instance.interceptors.response.use(
// 状态码判断
res => (res.status === 200 ? Promise.resolve(res) : Promise.reject(res)),
// 请求失败错误处理
error => {
const { response } = error
if (response) {
// 请求已发出,但是不在2xx的范围
errorHandle(response.status, response.data.message)
return Promise.reject(response)
} else {
// 处理断网的情况
// eg:请求超时或断网时,更新state的network状态
// network状态在app.vue中控制着一个全局的断网提示组件的显示隐藏
// 关于断网组件中的刷新重新获取数据,会在断网组件中说明
if (!window.navigator.onLine) {
store.commit('changeNetwork', false)
} else {
return Promise.reject(error)
}
}
}
)
响应拦截器中的token过期处理和断网这两种情况我没有考虑到项目中。
断网处理具体做法是dispatch
一个action或者直接commit
一个mutation来改变组件的状态,可以引入相关断网组件提示用户,我这里犯懒就没写了。
导出实例
export default instance
到这里,简单的配置我们算是做完了,实际中肯定不是那么简单的,我也是刚学习,不求快,快则囫囵吞枣会噎着。
我们需要在src
目录下创建一个api文件夹,然后在该目录下创建一个index.js
来统一管理api。
引入axios实例
import axios from '@/utils/request'
统一管理url
// 统一url管理
const url = {
getLocationUrl: '/position',
getShoplistsByLocationUrl: '/shops',
...
}
请求函数
请求我只考虑了get和post请求,通过实例来调用axios中的get
和post
方法就可以实现请求,并且它们都会返回一个Promise
对象。为了减少篇幅,就列举三种比较有代表性的请求函数。
/**
* 获取食品分类列表
*
* @returns {Promise}
*/
function getShopCategoryApi() {
return axios.get(url.getShopCategoryUrl)
}
/**
* 请求手机验证码
*
* @param {Number} phone
* @returns {Promise}
*/
function getMessageCodeApi(phone) {
return axios.get(url.getMessageCodeUrl, {
params: {
phone
}
})
}
/**
* 用户使用密码登陆
*
* @param {Object} { name, pwd, captcha }
* @returns {Promise}
*/
function postUserLoginByPasswordApi({ name, pwd, captcha }) {
return axios.post(url.postUserLoginByPasswordUrl, {
name,
pwd,
captcha
})
}
注意的是,get请求如果需要传参需要在get方法的第二个参数传入一个对象,该对象有一个params
属性,它也是一个对象,参数写在里面即可。post方法和get不同的是,参数直接就在方法的第二个参数写就行。
导出api
最后,我们统一把这些api导出去。
export default {
getLocationApi,
getShoplistsByLocationApi,
getShopCategoryApi,
...
}
添加链接描述
引入
import api from '@/api'
挂载
Vue.prototype.$axios = api // 将所有api请求挂在Vue原型中
使用
以后我们就可以通过以下的方式来访问某个api啦:
this.$api.getLocationApi()
项目采用了Vuex
来作状态管理,来存储组件的公共状态。我这个项目不是特别大,所以我就没有采用Module
方式来进行模块化管理,否则会变得很复杂。但是项目特别大的话,推荐采用模块化管理Vuex的状态。
我们都知道Vuex有一些核心概念:state
、action
、mutation
、getter
,它们构成了Vuex整个结构。简单介绍一下,在组件中我们想改变它的状态我们首先需要分发action
,它可以是异步的也可以是同步的,然后通过commit
触发相关的mutation
,从而进行状态更新,最后在组件中通过计算属性响应式来数据然后驱动更新视图。
上面的mutation
官方把它近似地看作了事件,我觉得这十分合理。事件具有两个很重要的特点:事件类型和回调函数。所以我们可以专门定义一个mutation-types
来管理事件类型。下面我们来说说如何请求数据和管理数据吧。
我就拿一个例子来说吧,比如现在有一个需求:请求首页食物分类信息数据并展示在页面上。
定义好一个categories
数组,存放分类信息数据:
export default {
categories: [], // 食品分类
}
在需要相关数据的组件中来引入状态:
import { mapState } from 'vuex'
// 通过计算属性来响应数据变化
computed: {
...mapState(['categories'])
}
当然还有其他方式来引入状态,不过还是要推荐上面那种方式,因为当组件依赖的状态越来越多时,很显然上面的那种方式更好。
接下来,我们要定义一个事件类型来标识事件,从而触发响应类型的状态变更。
export const RECEIVE_CATEGORIES = 'receive_categories' // 接收食物分类信息
有了事件类型,我们可以根据类型来注册对应的mutation
。/我们可以使用 ES6 风格的计算属性命名功能来命名一个事件:
// 引入事件类型
import { RECEIVE_CATEGORIES } from './mutation-type'
// 注册事件,回调
export default {
// 当RECEIVE_CATEGORIES事件触发时,执行回调改变状态
[RECEIVE_CATEGORIES](state, { categories }) {
state.categories = categories
}
}
上述回调中,有两个参数:state
和payload
。它们都是对象,第二个参数来在action
提交上来的数据,可以通过解构赋值直接拿到数据。
action
都是请求数据变更的操作,通常都是异步操作,我们可以借助async和await
来拿到数据,并且把数据提交给响应的mutation
。这里会有一个小问题就是,我们之前把api
挂载到了Vue原型上面,但在Vuex里面的this
指向的是store对象,所以我们在index.js
需要重新挂载一下。
// 引入事件类型
import { RECEIVE_CATEGORIES } from './mutation-type'
// 请求食物分类信息
async getCategories({ commit }) {
const {
data: { data }
} = await this.$axios.getShopCategoryApi()
commit({
type: RECEIVE_CATEGORIES,
categories: data
})
}
我们只需要在组件中通过dispatch
就可以触发action了:
mounted() {
this.$store.dispatch('getCategories')
}
当然也完全可以采用mapAction
的方式:
// 引入mapAction
import { mapActions } from 'vuex'
// 注册函数
methods: {
...mapActions(['getCategories'])
}
结构写完后,我们需要在index里面把store
对象暴露给全局,这样所有组件才能访问到Vuex。getters
是计算属性,和state
差不多,所以就没说明了。
import Vue from 'vue'
import Vuex from 'vuex'
import state from './state'
import mutations from './mutations'
import actions from './actions'
import getters from './getters'
import api from '@/api'
Vuex.Store.prototype.$axios = api // 将所有api请求挂在Vuex原型中
Vue.use(Vuex)
export default new Vuex.Store({
state,
mutations,
actions,
getters
})
就这样,我们的Vuex设计就结束了,其余的请求无非就是照搬这个模式来做了。
首先我们需要在根目录下创建mock
文件夹,然后在此目录下创建一个data
目录,存储不同接口的数据,然后在index
中讲mock接口导出,最后在main.js中加载即可。当然,我这个过于简单了,可以通过模块化管理不同的mock数据接口。
`
下面是index中的代码:
import Mock from 'mockjs'
import { goods, ratings, info } from './data/shop.json'
// 请求mock接口
Mock.mock('/foods', { code: 0, data: goods })
Mock.mock('/commends', { code: 0, data: ratings })
Mock.mock('/info', { code: 0, data: info })
分析
目前有一个iconList
数组,这个数组存储了食物分类的信息,但是每一页只能显示8个icon,当数组长度超过了8我们就需要放到下一页显示。那么很显然我们需要一个二维数组,一维数组存储每一页,二维数组存储每一页的icon。我们可以遍历这个数组,通过下标来判断是否需要分页,当下标为8时,说明需要放到第2页显示。
代码实现
// 定义一个计算属性
computed: {
page() {
let pages = []
// 遍历iconList
this.iconList.forEach((item, index) => {
// 得到当前页码
let page = Math.floor(index / 8)
// 创建二维数组
if (!pages[page]) {
pages[page] = []
}
pages[page].push(item)
})
// 返回该二维数组
return pages
}
}
下面的步骤就是在组件遍历数组了,这个比较简单,就不写代码了。
我希望滑动右边列表然后左边列表切换到对应的分类,并且样式也同步变化,同样的,我点击左边的分类右边也需要滑动到对应的分类位置。
分析
在渲染左边分类列表的时候,每一个分类项都会有一个下标index,当滑动右边分类列表的时候,我们希望动态计算出当前分类的currentIndex是否和左边的index相等,如果相等的话,就给左边当前分类添加样式就可以了。现在问题就是我们该如何动态计算右边当前分类的下标呢?思路是这样的:
而点击左边分类项滑动右边的分类就比较简单了:
scrollTo
方法让右边滑动到对应下标的分类最顶端实现
定义初始距离数据,实时滑动距离
data() {
return {
scrollY: 0,
detailTops: []
}
}
获取初始分类高度和实时滑动距离:
methods: {
// 初始化Bscroll对象
_initDetailScroll() {
this.detailScroll = new BScroll('.foods-detail', {
probeType: 3, // 滑动类型,必须指定,否则无法滑动
click: true // 是否允许原生的click事件
})
// 监听滑动事件,实时获取滑动距离
this.detailScroll.on('scroll', ({ y }) => {
this.scrollY = Math.abs(y)
})
this.detailTops = this._getClientHeight('detail')
},
_getClientHeight(ele) {
const eleLists = [...this.$refs[ele].children]
let tempArr = []
let top = 0
tempArr.push(top) // 将第一个li元素的距离放入数组
eleLists.forEach(item => {
top += item.clientHeight
tempArr.push(top)
})
return tempArr
},
}
定义计算属性currentIndex
:
computed: {
currentIndex() {
let index = 0
index = this.detailTops.findIndex((ele, index, args) => {
return this.scrollY >= ele && this.scrollY < args[index + 1]
})
return index
}
}
而点击左边分类来让右边滑动到对应的位置就很简单了,只需要得出左边分类当前index
,然后通过BSroll对象相关API就可以让右边滑动了:
// 点击左边分类,右边滑到目标位置
this.detailScroll.scrollTo(0, -this.detailTops[index], 300)
这里我们需要注意Bscroll对象创建的时机,必须是数据请求到了之后才能创建这个对象,我们可以在进行异步请求后执行回调去创建Bscroll对象。但是创建Bscroll对象又涉及到了更新DOM,这又是一个异步的过程,所以我们需要使用this.$nextTick
方法。
mounted() {
// 请求数据后才初始化Bscroll
this.$store.dispatch('getShopLists', () => {
this.$nextTick(() => {
this._initCategoryScroll()
this._initDetailScroll()
})
})
},
然后我们在对应的action中进行判断,如果回调存在就执行,否则不执行。
async getShopLists({ commit }, callback) {
const { data } = await this.$axios.getShopFoodListsApi()
if (data.code === 0) {
commit({
type: RECEIVE_SHOP_FOODS,
foodLists: data['data']
})
callback && callback()
}
},
官方推荐使用Vue.set
方法来解决这个问题:
Vue.set(object, property, value)
这篇文章写完花了2天多,我写下来发现有很多多余的东西,浪费了不该浪费的时间,写文章真的是一件很有技巧的事情,可能我学的还不够精细。另外,还有很多问题需要解决,我才发现前端这条路上的坑真的够多,没办法既然已经入坑,那就继续玩里头深挖吧。
【1】 Vue.js
【2】Rem布局的原理解析
【3】vue中Axios的封装和API接口的管理
【4】视口简介