本文详细记录了所有 Vue 2.5 开发过程轨迹。根据每个节点 branch
总结归纳了项目开发过程中需要注意的细节和重点。便于以后查阅,同时进行开源分享。欢迎各位 Star
和 Fork
。
项目 github 开源地址:https://github.com/evenyao/Travel
mytravel
Vue 2.5 开发移动端旅游网站项目整体流程与记录。
效果预览
扫描二维码:
项目涉及到技术栈:
Vue:Vue、Vue-router、Vuex、Vue-cli
插件:vue-awesome-swiper、better-scroll、axios
CSS的预处理框架:stylus
api:后台数据接口
项目特点
组件化自适应布局
代码,简洁,易维护
兼容大部分浏览器
实现性能优化
项目具体结构
首页部分
iconfont 的引入和使用
图片轮播组件的使用
图标区域轮播组件的使用
axios 获取接口数据
组件间数据传递
城市选择页部分
字母表布局
better-scroll 的使用
函数节流实现列表性能优化
搜索逻辑实现
Vuex 实现数据共享
LocalStorage 实现页面数据存储
keep-alive 优化路由性能
详情页部分
banner 布局
动态路由配置
公用画廊组件拆分
实现 fixed header 渐隐渐显效果
递归组件实现详情列表
transition slot 插槽实现 animation 简单动画效果
项目相关
项目相关 npm 依赖包
fastClick: 用来处理移动端
click
事件 300毫秒延迟stylus: CSS 预处理框架
stylus-loader
vue-awesome-swiper: 轮播插件
axios: 实现
ajax
better-scroll: scroll插件
设置样式变量
通过 variable.styl 设置样式变量,抽离出公用样式。以方便维护
首页
HomeSwiper 组件
使用 vue-awesome-swiper 轮播插件
使用 2.6.7 版本
npm install vue-awesome-swiper@2.6.7 --save
具体参考 vue-awesome-swiper
轮播图当中的 CSS
样式重点
该样式主要是防止网速过慢时页面布局的抖动,其含义是,wrapper
宽度 100%
,高度由宽度的 27%
自动撑开。
.wrapper { overflow: hidden; width: 100%; height: 0; padding-bottom: 27%; }
或者写成
.wrapper { width: 100%; height: 27vw; }
HomeIcons 组件
iconsList 分页
同样使用 swiper
进行分页,并利用以下方式实现自动构建多页切换的功能
computed: { //根据数据项目的不同,自动构建icons多页切换功能 pages () { const pages = [] this.iconsList.forEach((item, index) => { const page = Math.floor(index / 8) if (!pages[page]) { pages[page] = [] } pages[page].push(item) }) return pages } }
ellipsis()样式封装
将 ellipsis
封装在 mixins.styl
文件中
ellipsis() overflow: hidden white-space: nowrap text-overflow: ellipsis
Recommend / Weekend 组件
设置 min-width
是为了让 ellipsis()
生效
.item-info { flex: 1; padding: .1rem; min-width: 0; }
index-ajax
使用 axios
进行 ajax 请求
npm install axios --save
.gitignore 设置
添加 staitc/mock
,防止被推送到仓库
设置 mock数据 开发环境转发代理
设置 config
文件夹下的 index.js
设置 module.exports
下 dev
的 proxyTable
代理
webpack-dev-server 工具会自动将 /api
替换成 /static/mock
proxyTable: { '/api': { target: 'http://localhost:8080', pathRewrite: { '^/api': '/static/mock' } } }
城市页
router-link
通过路由实现页面间跳转,在外层添加 router-link
。to
后面跟需要跳转的 path 。
<router-link to="/city"> <div class="header-right"> {{this.city}} <span class="iconfont icon-jiantou"></span> </div> </router-link>
然后在 router 文件夹的相应 index.js
路由配置文件中进行 path、name 和 component
的声明,并进行 import from
。即完成了路由配置。
import Vue from 'vue'import Router from 'vue-router'import Home from '@/pages/home/Home'import City from '@/pages/city/City'Vue.use(Router)export default new Router({ routes: [{ path: '/', name: 'Home', component: Home }, { path: '/city', name: 'City', component: City }] })
city-List
修改一像素边框 .border-topbottom
的颜色
.border-topbottom &:before border-color: #ccc &:after border-color: #ccc
将页面固定住,后续搭配 better-scroll
插件实现类似于原生 app 的页面上下拖动效果
.list { overflow: hidden; position: absolute; top: 1.58rem; left: 0; right: 0; bottom: 0; }
better-scroll 插件
npm install better-scroll --save
将 HTML DOM 结构调整成文档中规定的结构,在外层取 wrapper
,引用插件之后,在 mounted ()
生命周期钩子里面新建一个这个 DOM 引用的实例。
import Bscroll from 'better-scroll'export default { name: 'CityList', //生命周期函数 挂载之后执行 mounted () { //引用 wrapper DOM this.scroll = new Bscroll(this.$refs.wrapper) } }
具体用法,请查看文档 better-scroll
alphabet
是一个显示在右的 a-z 字母缩略指引
city-ajax
按照 index-ajax
一样的方式进行 axios
数据获取
包括 热门城市、字母表排序城市列表、Alphabet 在内的部分都通过
axios
获取数据
在 v-for
循环输出 cities 的时候,需要注意,cities 是一个 Object
props: { hot: Array, cities: Object }
因此后面用 v-for="(item, key) of cities"
,和 v-for="innerItem of item"
做循环输出
<div class="area" v-for="(item, key) of cities" :key="key"> <div class="title border-topbottom">{{key}}</div> <div class="item-list"> <div class="item border-bottom" v-for="innerItem of item" :key="innerItem.id">{{innerItem.name}}</div> </div></div>
city-components
兄弟组件间联动,这里没有采用 bus
。
而是采用 Alphabet.vue
(子组件) 传递给 City.vue
(父组件) ,然后再通过 City.vue
(父组件) 传递给 List.vue
(子组件)。
在 Alphabet.vue
的 template 的循环展示中绑定 @click
,并在 methods 中使用 $emit
向外( City.vue
父组件 )发送 change
事件。
<template> <ul class="list"> <li class="item" v-for="(item, key) of cities" :key="key" @click="handleLetterClick" > {{key}} </li> </ul></template>
methods: { handleLetterClick (e) { this.$emit('change', e.target.innerText) } }
在 City.vue
的 template 中设置 @change="handleLetterClick"
监听 change 事件。
<city-alphabet :cities="cities" @change="handleLetterClick"></city-alphabet>
在 methods
中定义事件 handleLetterClick
,传递 letter
参数。
methods: { handleLetterClick (letter) { this.letter = letter } }
并在 data
中定义数据 letter
。
data () { return { cities: {}, hotCities: [], letter: '' // Alphabet 通过 change 事件传递过来的数据 } }
并传递给 List.vue
。
<city-list :cities="cities" :hot="hotCities" :letter="letter"></city-list>
然后在 List.vue
子组件 props 接收 letter
props: { hot: Array, cities: Object, letter: String // 接收 letter}
通过侦听器 watch,侦听 letter
的变化。在此之前先用 ref
引用找到相应的 DOM
<div class="area" v-for="(item, key) of cities" :key="key" :ref="key"> <div class="title border-topbottom">{{key}}</div> <div class="item-list"> <div class="item border-bottom" v-for="innerItem of item" :key="innerItem.id">{{innerItem.name}}</div> </div></div>
使用 better-scroll
中的 scrollToElement
方法进行点击跳转效果的实现
watch: { letter () { if (this.letter) { const element = this.$refs[this.letter][0] this.scroll.scrollToElement(element) } } }
alphabet 滑动逻辑
上下滑动时,取字母位置逻辑:
获取 A 字母距离顶部高度
滑动时,取当前位置距离顶部高度
计算差值,得到当前手指位置与 A 字母顶部差值
除以每个字母高度,得出当前字母,触发 change 事件给外部
在 Alphabet.vue
中进行代码的编写
<template> <ul class="list"> <li class="item" v-for="item of letters" :key="item" :ref="item" @touchstart="handleTouchStart" @touchmove="handleTouchMove" @touchend="handleTouchEnd" @click="handleLetterClick" > {{item}} </li> </ul></template><script>export default { name: 'CityAlphabet', props: { cities: Object }, // 计算属性中定义 letters 是一个数组,从 cities 数据中遍历得到数据 computed: { letters () { const letters = [] for (let i in this.cities) { letters.push(i) } return letters } }, data () { return { touchStatus: false // 标识位 } }, methods: { handleLetterClick (e) { this.$emit('change', e.target.innerText) }, handleTouchStart () { this.touchStatus = true }, handleTouchMove (e) { if (this.touchStatus) { const startY = this.$refs['A'][0].offsetTop // A 字母距离 header区域下沿 高度 const touchY = e.touches[0].clientY - 79 // 手指距离 header区域下沿 高高度 const index = Math.floor((touchY - startY) / 20) // 当前字母下标 if (index >= 0 && index < this.letters.length) { this.$emit('change', this.letters[index]) // 也通过 $emit 向外发送事件 } } }, handleTouchEnd () { this.touchStatus = false } } }</script>
实现效果解析图
函数节流优化
使用函数节流优化 handleTouchMove
,提高性能
handleTouchMove (e) { if (this.touchStatus) { // 使用函数节流优化性能 if (this.timer) { clearTimeout(this.timer) } this.timer = setTimeout(() => { const startY = this.startY const touchY = e.touches[0].clientY - 79 const index = Math.floor((touchY - startY) / 20) if (index >= 0 && index < this.letters.length) { this.$emit('change', this.letters[index]) } }, 16) } }
city-search 搜索功能逻辑
在 template
的 input
中做 v-model="keyword"
双向绑定。
<template> <div> <div class="search"> <input v-model="keyword" class="search-input" type="text" placeholder="输入城市名或拼音"> </div> <div class="search-content"> <ul> <li v-for="item of list">{{item.name}}</li> </ul> </div> </div></template>
在 data ()
中定义 keyword
、list
和 timer
。
在侦听器 watch
中侦听 keyword
的改变。
并使用函数节流进行优化。
<script>export default { name: 'CitySearch', props: { cities: Object }, data () { return { keyword: '', list: [], timer: null } }, watch: { keyword () { if (this.timer) { clearTimeout(this.timer) } this.timer = setTimeout(() => { const result = [] for (let i in this.cities) { this.cities[i].forEach((value) => { if (value.spell.indexOf(this.keyword) > -1 || value.name.indexOf(this.keyword) > -1) { result.push(value) } }) } this.list = result }, 100) } } }</script>
输入逻辑优化
清空 input
由于数据是双向绑定的,所以在 watch
当中添加条件判断,当 !this.keyword
时,清空 list
。
if (!this.keyword) { this.list = [] return}
这样就实现了清空 input
搜索栏时,同时清空下面搜索结果的逻辑。
没有找到匹配
添加 li
,其内容为 没有找到匹配
。同时用 v-show
指令,完成在没有匹配时候(!list.length
)。显示该 li
内容,即 没有找到匹配 的功能。
<li class="search-item border-bottom" v-show="!list.length">没有找到匹配</li>
search-content 显示与否
同样的使用 v-show
指令,决定是否显示 class="search-content"
这个 div 元素。决定的值为 keyword
,这容易理解。
<div class="search-content" ref="search" v-show="keyword"> <ul> <li class="search-item border-bottom" v-for="item of list">{{item.name}}</li> <li class="search-item border-bottom" v-show="!list.length">没有找到匹配</li> </ul></div>
给 search-item 添加 better-scroll
给搜索结果页面也添加 better-scroll
使其多结果超出页面显示时,可以进行同样的 better-scroll
插件效果的滑动。
首先引入 better-scroll
import Bscroll from 'better-scroll'
使用 ref
引用 search-content
的元素
<div class="search-content" ref="search"> <ul> <li class="search-item border-bottom" v-for="item of list">{{item.name}}</li> </ul></div>
同样使用 mounted
生命周期钩子,传递的内容是 this.$refs.search
mounted () { this.scroll = new Bscroll(this.$refs.search) }
这样搜索结果页面结果过多超出页面时,也可以拥有 better-scroll
的滑动效果。
使用 Vuex 实现数据共享
需要实现 city 页面的数据传递给 index 首页。由于 City.vue
和 Home.vue
没有公用父级组件,这样就无法通过一个公用的父级组件进行数据的中转。这里我们使用 Vuex
数据层框架来实现。
Vuex官方文档
安装并配置 Vuex
npm install vuex --save
创建 store
文件夹,建立 index.js
,state
里放置全局公用数据 city
。
import Vue from 'vue'import Vuex from 'vuex'Vue.use(Vuex)export default new Vuex.Store({ state: { city: '重庆' }, mutations: { changeCity (state, city) { state.city = city } } })
在 main.js
的根实例下,将 store
传递进去。在其他子组件中使用 this.$store
进行派发。
import store from './store' //引入 storenew Vue({ el: '#app', router: router, store: store, //传递进入根实例的 store components: { App }, template: '<App/>'})
在 List.vue
和 Search.vue
组件中包含城市循环输出项的元素标签上定义 @click="handleCityClick(item.name)"
。
并在相应的 methods
中执行 Vuex
的 commit
方法( 数据共享 ) 和 Vue-router
的 push
方法( 页面跳转 )
methods: { handleCityClick (city) { this.$store.commit('changeCity', city) this.$router.push('/') } }
localStorage
使用 localStorage
实现城市保存的功能,在 store
的 index.js
文件中配置 localStorage
export default new Vuex.Store({ state: { city: localStorage.city || '重庆' }, mutations: { changeCity (state, city) { state.city = city localStorage.city = city } } })
有可能当用户使用隐身模式或禁用 localStorage
,会导致浏览器报错。所以建议使用 try catch
进行优化
let defalutCity = '重庆'try { if (localStorage.city) { defaultCity = localStorage.city } } catch (e) {}export default new Vuex.Store({ state: { city: defaultCity }, mutations: { changeCity (state, city) { state.city = city try { localStorage.city = city } catch (e) {} } } })
keep-alive 优化
当查看 network 时候,可以看到从首页到城市选择页切换过程中每次切换都会发送 ajax
请求。所以我们对此进行优化。
在 App.vue
中给 <router-view/>
外部添加一个 <keep-alive>
标签。其含义是路由的内容被加载过一次之后,就把路由的内容放置到内存中,下一次再使用路由的时候,无需重新加载组件、执行钩子函数。只需要从内存中拿出以前的内容显示就可以了。
activated 生命周期钩子
结合 keep-alive 新增的 activated
生命周期钩子,实现每次点击曾经选中过的城市,不发送 ajax
,城市选择变化的时候再进行 ajax
请求的优化。
详情页
:to 实现动态路由
使用 tag
将 router-link
标签替换成 li
,从而不用修改样式就可以达到之前样式的效果。
然后按照下图所示进行动态路由的实现。即点击相应的列表选择选择动态跳转页面。
Banner 布局
.banner-info 渐变效果
.banner-info { background-image: linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, .8)) }
全局画廊组件
新建 common
用来放置全局组件,建立 gallary
的 Gallary.vue
画廊组件,并在 build/webpack.base.conf.js
中进行路径别名指向的设置
resolve: { extensions: ['.js', '.vue', '.json'], alias: { 'vue$': 'vue/dist/vue.esm.js', '@': resolve('src'), 'styles': resolve('src/assets/styles'), 'common': resolve('src/common'), } }
在 Banner.vue
中引入画廊组件,并在 components
中进行注册
import CommonGallary from 'common/gallary/Gallary'
Gallary.vue
画廊组件内部也使用了 awesome-swiper
,所以同样使用 swiper
标签。swiperOption
设置的几个参数分别是,分页器样式,设置为分数形式的分页;还有解决点击进入画廊之后 swiper
无法进行滑动的 bug 问题。
swiperOption: { pagination: '.swiper-pagination', paginationType: 'fraction', //设置分页器 样式为分式 observeParents: true, //swiper 插件监听到自身或父级元素DOM变化时,自动自我刷新。解决 swiper 刷新宽度计算 bug 的问题 observer: true }
使用 props
接收外部传递过来的 imgs 参数。默认为空。并设置相应点击事件,并使用 $emit
传出。
methods: { handleGallaryClick () { this.$emit('close') } }
其中还需要注意样式相关的问题。在 Gallary.vue
中的分页器会因为 .swiper
标签自带的 overflow: hidden
而隐藏。使用 >>>
让 .swiper-container
继承 .container
的 overflow
属性即可。
Banner.vue 调用全局画廊
使用 @close="handleGallaryClose"
接收 close
事件,订阅为 handleGallaryClose
事件。并在 banner
上创建 handleBannerClick
事件。实现点击进入画廊,再点击画廊退出的逻辑。
<common-gallary :imgs="imgs" v-show="showGallary" @close="handleGallaryClose"></common-gallary>
detail 页 header 渐变效果
模板内容
逻辑实现
通过 showAbs
、 v-show
和 opacity
完成该效果的实现。
利用 activated
钩子监听 scroll
触发 this.handleScroll
。并在 methods
的 handleScroll
中完成渐隐渐现的算法逻辑。(通过 document.documentElement.scrollTop
计算 opacity
属性即可实现该动画效果)
布局相关
.header-fixed
使用 fixed
定位到浏览器最上方。
对全局事件解绑
之前在 activated
中监听 scroll
实际上带来了一些问题。因为如果在一个组件内部模板的某个标签上使用 @click
,不会给其他标签和组件带来任何影响。但如果在组件中使用 window
这个全局对象的属性绑定,就会出现诸多 bug。因为相当于这个事件并不是绑定在该组件之中,而是绑定到了全局的 window
对象上。所以对其他的组件也产生了影响。
这个时候使用 deactivated
这个生命周期钩子(页面即将被隐藏或替换成其他页面时)来解除全局事件的绑定。
递归组件实现详情列表
之所以在组件当中需要一个 name
属性,也是为了方便在组件自身调用自身出现递归的时候便于调用。下面可以看到,在下一个 div
标签中做一个 v-if
判断,如果存在 item.children
。就把 item.children
当做 list
再传递给自身,进行递归调用。
在 Detaile.vue
中写入一些数据,分为三级。传入递归组件(子组件)中。
由于递归会自己调用自己,样式也会随之进行调整,可以看到以下效果。
detail - ajax
同理 Home
与 City
也 aixos 获取。在父组件进行 ajax 获取,再传递给每个子组件。
每个子组件则通过 props
获取到相应的数据。
Detail 页禁用 keepalive
在 App.vue
的根实例中,在 router-view
之外的 keep-alive
包裹上加上 exclude="Detail"
即可。所以这也是 name
属性的又一个用途。
解决 exclude 带来的 bug
由于在 App.vue 中使用了 keep-alive exclude="Detail"
,那么在 Detail
下的 Header.vue
中就不会执行 activated
钩子, 但是会执行 created
生命周期钩子。此时会出现Detail
页 header
头部渐隐渐现的效果不显现了。所以将监听 scroll
的事件写入到 created
中。修复此 bug。
解决滚动行为 bug
在 router
下面的 index.js
下添加。解决滚动行为的 bug。使每次做路由切换时,让新显示的页面回到最顶部。
animation 简单动画效果
在 common
公用组件当中新建 fade
文件夹,并创建 FadeAnimation.vue
。用来实现简单的动画效果。
并在 Banner.vue
组件模板中的 common-gallary
外部加上 fade-animation
标签,相当于内部使用了插槽。从而实现 FadeAnimation.vue
中的动画效果。
调试相关
接口联调
Vue 项目的联调,不需要使用类似于
fiddler
、charles
的抓包代理工具。只需要使用proxyTable
配置项把需要请求的后端服务器地址配置好即可。
在 config
的 index.js
中,设置 dev
当中的 proxyTable
。target
指向后端 api 的地址。并删除 static
下的 mock
文件夹。(然后 npm run dev
重启服务器)
线上调试
在 package.json
中配置 scripts
下的 dev
添加 --host 0.0.0.0
即可在同局域网下通过 IP 地址访问项目页面。
修改完毕之后需 npm run dev
重启服务器,然后通过 IP 地址 + 端口就可以访问项目页面,即可以通过局域网移动端手机或PAD进行真机调试了。
真机调试 bug 修复
在城市选择页面进行最右 Alphabet
字母表选择的时候,拉动字母表会出现整个页面也跟着上下拖动的 bug。
修复这个 bug 的方法是在 Alphabet.vue
中找到 @touchstart
事件,并在这个事件之后加上 .prevent
事件修饰符。阻止 @touchstart
的默认行为,就不会出现页面跟着上下拖动的效果了。
低版本浏览器白屏
可能情况:
手机浏览器不支持 promise
解决方法:安装 babel-polyfill
包。当 babel-polyfill
判断浏览器不支持 promise
,会自动向里面添加 es6
的新特性支持。
npm install babel-polyfill --save
安装 npm
包完毕后,重启服务器。然后在 main.js
中引入。
其他优化
异步组件拆分,按需加载。
访问那一页,就加载那一页的逻辑文件。
当
app.js
文件不大的时候,不建议拆分异步加载。因为多次的http
请求代价可能更大。
项目上线
命令行打开目录,运行命令
npm run build
出现 Build complete.
即编译完成。
编译完成生成的目录的代码就是最终上线的代码。
将这些文件内容放置在后端目录,就完成了项目最基本的上线。
github 线上打包版预览
在进行 github 上传预览打包版代码的时候,由于 github 每个项目都自带一个 path 路径,导致 url 必须带一个 path 路径才可以正常浏览。所以在 npm run build
之前先在 config
下的 index.js
的 build
当中进行 assetsPublicPath
的设置。设置为 github 上预览版的项目名称即可。
作者:evenyao
链接:https://www.jianshu.com/p/9b69ce6d9e9e
共同学习,写下你的评论
评论加载中...
作者其他优质文章