美文网首页让前端飞Vuejs前端
Vue 2.5 开发 去哪儿 旅游网站项目记录

Vue 2.5 开发 去哪儿 旅游网站项目记录

作者: passMaker | 来源:发表于2018-09-19 08:35 被阅读76次

    本文详细记录了所有 Vue 2.5 开发过程轨迹。根据每个节点 branch 总结归纳了项目开发过程中需要注意的细节和重点。便于以后查阅,同时进行开源分享。欢迎各位 StarFork

    项目 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.exportsdevproxyTable 代理

    webpack-dev-server 工具会自动将 /api 替换成 /static/mock

    proxyTable: {
      '/api': {
        target: 'http://localhost:8080',
        pathRewrite: {
          '^/api': '/static/mock'
        }
      }
    }
    

    城市页

    router-link

    通过路由实现页面间跳转,在外层添加 router-linkto 后面跟需要跳转的 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 搜索功能逻辑

    templateinput 中做 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 () 中定义 keywordlisttimer
    在侦听器 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.vueHome.vue 没有公用父级组件,这样就无法通过一个公用的父级组件进行数据的中转。这里我们使用 Vuex 数据层框架来实现。
    Vuex官方文档

    安装并配置 Vuex

    npm install vuex --save
    

    创建 store 文件夹,建立 index.jsstate 里放置全局公用数据 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'  //引入 store
    
    new Vue({
      el: '#app',
      router: router,
      store: store,  //传递进入根实例的 store
      components: { App },
      template: '<App/>'
    })
    

    List.vueSearch.vue 组件中包含城市循环输出项的元素标签上定义 @click="handleCityClick(item.name)"

    并在相应的 methods 中执行 Vuexcommit 方法( 数据共享 ) 和 Vue-routerpush 方法( 页面跳转 )

    methods: {
      handleCityClick (city) {
        this.$store.commit('changeCity', city)
        this.$router.push('/')
      }
    }
    

    localStorage

    使用 localStorage 实现城市保存的功能,在 storeindex.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 实现动态路由

    使用 tagrouter-link 标签替换成 li,从而不用修改样式就可以达到之前样式的效果。

    然后按照下图所示进行动态路由的实现。即点击相应的列表选择选择动态跳转页面。


    Banner 布局

    .banner-info 渐变效果

    .banner-info {
      background-image: linear-gradient(top, rgba(0, 0, 0, 0), rgba(0, 0, 0, .8))
    }
    

    全局画廊组件

    新建 common 用来放置全局组件,建立 gallaryGallary.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 继承 .containeroverflow 属性即可。

    Banner.vue 调用全局画廊

    使用 @close="handleGallaryClose" 接收 close 事件,订阅为 handleGallaryClose 事件。并在 banner 上创建 handleBannerClick 事件。实现点击进入画廊,再点击画廊退出的逻辑。

    <common-gallary :imgs="imgs" v-show="showGallary" @close="handleGallaryClose"></common-gallary>
    

    detail 页 header 渐变效果

    模板内容

    逻辑实现

    通过 showAbsv-showopacity 完成该效果的实现。
    利用 activated 钩子监听 scroll 触发 this.handleScroll。并在 methodshandleScroll 中完成渐隐渐现的算法逻辑。(通过 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

    同理 HomeCity 也 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 生命周期钩子。此时会出现Detailheader 头部渐隐渐现的效果不显现了。所以将监听 scroll 的事件写入到 created 中。修复此 bug。

    解决滚动行为 bug

    router 下面的 index.js 下添加。解决滚动行为的 bug。使每次做路由切换时,让新显示的页面回到最顶部。

    animation 简单动画效果

    common 公用组件当中新建 fade 文件夹,并创建 FadeAnimation.vue。用来实现简单的动画效果。



    并在 Banner.vue 组件模板中的 common-gallary 外部加上 fade-animation 标签,相当于内部使用了插槽。从而实现 FadeAnimation.vue 中的动画效果。

    调试相关

    接口联调

    Vue 项目的联调,不需要使用类似于 fiddlercharles 的抓包代理工具。只需要使用 proxyTable 配置项把需要请求的后端服务器地址配置好即可。

    configindex.js 中,设置 dev 当中的 proxyTabletarget 指向后端 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.jsbuild 当中进行 assetsPublicPath 的设置。设置为 github 上预览版的项目名称即可。

    相关文章

      网友评论

      本文标题:Vue 2.5 开发 去哪儿 旅游网站项目记录

      本文链接:https://www.haomeiwen.com/subject/qksbnftx.html