去哪儿网复盘

367 阅读8分钟

环境配置

vue/cli 3.0 以下版本

如果有新的脚手架,先卸载 npm uninstall @vue/cli -g

  1. 安装脚手架 cnpm i vue-cli -g
  2. 创建项目 vue init webpack my-pro
  3. 项目初始化如下:

pz.png 4. 进入并启动项目 cd my-pro and cnpm run dev

项目代码初始化

  • 完善meta:用户不能通过手指放大缩小页面
<meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
  • 在入口文件main.js 引入 reset 和 border 样式表
// 统一不同手机初始化样式
import './assets/styles/reset.css'
// 1px 物理像素实现
import './assets/styles/border.css'

项目中所用技术

CSS预处理器 stylus

stylus语法

  • 安装
// 安装高版本的容易出错,所以:
npm install stylus@0.54.5  --save
npm install stylus-loader@3.0.1  --save
  • 使用
// varibles.styl 项目总色调集合
$bgColor = #00bcd4
$darkTextColor = #333
$headerHeight = .86rem

// mixins.styl 通用样式代码:如超出显示省略号
ellipsis()
  overflow: hidden
  white-space: nowrap
  text-overflow: ellipsis

// 所用页面
<style lang="stylus" scoped>
  @import '~styles/varibles.styl'
  @import '~styles/mixins.styl'
  .header
    display: flex
    line-height: $headerHeight
    background: $bgColor
    color: #fff
    ellipsis()
    .box
      width: 100%
</style>

// 注意:如果要改变插件中样式,需使用 >>> 将其样式透传到该组件中
.box >>> .swiper-container

引入 iconfont 图标库

  1. 阿里巴巴矢量图标库
  2. 资源管理、我的项目、新建项目。
  3. 寻找所需图标、添加到购物车、在购物车中添加至项目。
  4. 图标为 Unicode 类型,下载至本地。
  5. 根路径src、assets,新建 stylues文件夹
  6. 该文件夹下放 reset.css(代码样式重置)、border.css(1px边框问题)、varibles.styl(项目总色调集合)、mixins.styl(通用样式代码:如超出显示省略号)、iconfont.css(iconfont下载的css,注意改变里面src路径)
  7. stylues 文件夹下新建 iconfont,里面放置从iconfont下载的iconfont.ttf iconfont.woff iconfont.woff2
  8. 使用: 8. 1 删除 iconfont.css中定义图标的类。
    8. 2 在main.js中引入 iconfont.css。
    8. 3 在 iconfont 中复制所需图片的代码,粘贴到代码里。
import './assets/styles/iconfont.css'

<div class="iconfont">&#xe624;</div>

简化文件引入路径

// 单页面vue中引入外部 css
@import '../../../assets/styles/varibles.styl'
// 用 @ 前面一定要加 ~
@import '~@/assets/styles/varibles.styl'

一些路径被反复调用时,可在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') // 新增
    }
},
// 简化前
import './assets/styles/reset.css'
import './assets/styles/iconfont.css'
import './assets/styles/varibles.css'

@import '~@/assets/styles/varibles.styl'

// 简化后
import 'styles/reset.css'
import 'styles/iconfont.css'
import 'styles/varibles.css'

@import '~styles/varibles.styl'

1px border 问题

因为之前在 main.js 中引入过 border.css 样式,所以在代码中可直接使用

// border-bottom 该类名已有
<div class="title border-bottom">城市</div>

// 如需改变颜色
.border-bottom
  &:before
    border-color: #ccc

300ms延迟问题

当我们点击页面的时候移动端浏览器并不是立即作出反应,而是会等上一小会儿才会出现点击的效果。

// 在reset.css中加入
html{touch-action: manipulation}

轮播图

vue-awesome-swiper

  • 安装
cnpm install vue-awesome-swiper@2.6.7 --save
  • 全局引用
// main.js 中引用
import VueAwesomeSwiper from 'vue-awesome-swiper'
import 'swiper/css/swiper.css'
Vue.use(VueAwesomeSwiper)
  • 在页面中使用
// 有swiperOptions变量的绑定,需在子组件data里绑定
<div class="wrapper">
    <swiper :options="swiperOptions"> 
        <swiper-slide>Slide 1</swiper-slide>
        <swiper-slide>Slide 2</swiper-slide>
        <div class="swiper-pagination" slot="pagination"></div>
    </swiper>
</div>

data () {
    return {
      swiperOption: {
        pagination: '.swiper-pagination', // 分页器,fraction 以数字形式展示
        loop: true, // 循环轮播
        autoplay: false, // 禁止自动轮播
        observeParents: true, // 监听到父级元素发生变化时,刷新一次
        observer: true
      }
    }
},

抖动问题

原因:在轮播图未加载完时高度为0,下方内容会自动在轮播图位置,而当轮播图加载完后,刚才下方内容会被挤到下方。造成视觉上抖动。

// 在swiper 父级添加该样式可解决抖动问题
.wrapper
    overflow: hidden
    width: 100%
    height: 0
    padding-bottom: 31.25% // 重点 图片宽高比例

默认不是第一张轮播图问题

原因:未加载数据前,list为空,所以出现该问题

// 当数据存在时再显示
<swiper :options="swiperOption"  v-if="list.length"></swiper>

数据请求 axios

  • 安装
cnpm i axios --save
  • 局部使用
import axios from 'axios'

methods: {
    async getHomeInfo () {
      let res = await axios.get('/api/index.json?city=' + this.city)
      ...
    },
mounted () {
    this.getHomeInfo()
}

静态文件放在 static 文件夹下可访问

跨域问题

// config、index.js中
proxyTable: {
  '/api': {
    target: 'http://localhost:8080',
    pathRewrite: {
      '^/api': '/static/mock'
    }
  }
},

Vuex

Vuex: 一个专为 Vue.js 应用程序开发的状态管理模式vuex.png

  • 安装
cnpm i vuex --save
  • 使用
  1. 根路径src、新建 vuex 文件夹,再新建 index.js。
import Vue from 'vue'
import Vuex from 'vuex'
// 因为 vuex 是个插件,vue中使用插件方法 Vue.use
Vue.use(Vuex)

const store = new Vuex({
    state: { // 存放全局公用数据
        city: '上海'
    },
    actions: { // 处理异步操作
        // ctx 上下文,city 页面的传值
        changeCityA (ctx, city) {
            ctx.commit('changeCityM', city)
        }
    },
    mutations: { // 处理同步操作
        changeCityM (state, city) {
            state.city = city
        }
    }
})
export default store
  1. main.js
import store from './store'

new Vue({
  el: '#app',
  router,
  ...
})
  1. 页面中
<div>{{ this.$store.state.city }}</div>

methods: {
    handleCityClick (name) {
      // 异步操作时:需要派发一个名字为 changeCityA 的action,并把名字传给它
      this.$store.dispatch('changeCityA', name)
      // 没有异步操作时:
      this.$store.commit('changeCityM', name)
      // 改变之后跳转到首页,写法1/2
      this.$router.push('/')
      this.$router.push({name: 'Home'})
    },
}

localStorage

注意:当使用 vuex 改变数据后,一旦刷新页面,数据又会变成初始化的数据,所以此时需要结合本地存储 localStorage。
注意:只要使用了 localStorage,就要用 try catch,因为如果用户关闭了本地存储功能或者使用隐身模式,会导致代码运行异常。

import Vue from 'vue'
import Vuex from 'vuex'
// 因为 vuex 是个插件,vue中使用插件方法 Vue.use
Vue.use(Vuex)

let defaultCity = '上海'
try {
    defaultCity = localStorage.city ? localStorage.city : defaultCity
} catch(e){}
const store = new Vuex({
    state: {
        city: localStorage.city || '上海'
    },
    // 类似于vue的computed,当需要对state的数据做一些尝试的时候放到Getter上
    getters: {
       doubleCity (state) {
         return state.city + ' ' + state.city
       }
    },
    mutations: { // 处理同步操作
        changeCityM (state, city) {
            state.city = city
            try {
                localStorage.city = city
            } catch(e) {}
        }
    }
})
export default store

高级用法

<div>{{ city }}</div>

import { mapState, mapMutations } from 'vuex'
computed: {
    // 将vuex中的数据city映射到该组件的计算属性computed中
    ...mapState(['city'])
    // 也可以这样写
    ...mapState({
        currentCity: 'city'
    })
},
methods: {
    handleCityClick (name) {
      this.changeCityM(name)
      this.$router.push({name: 'Home'})
    },
    ...mapMutations(['changeCityM'])
},

keep-alive

每次切换路由的时候,数据都会请求一次。

  • <router-view/> 当前路由地址所对应内容
  • keep-alive 路由中内容被加载一次后,就将其放在内存中。下次再进入该组件时不需要重新渲染该组件
  • exclude 除了该页面,其他页面都缓存
// App.vue
<keep-alive exclude="detail">
  <router-view/>
</keep-alive>

多出的生命周期函数

注意:当该页面需要重新渲染时,

  1. exclude后,页面中 mounteddestroyed...可正常使用。
  2. 使用新增的生命周期函数,对其进行调用。
// 首次进入页面时都会执行
mounted () {
    console.log('--- mounted ---')
    this.lastCity = this.city
    this.getHomeInfo()
},
activated () { // 当选择城市不同时才会执行
    console.log('--- activated ---')
    if (this.lastCity !== this.city) {
      this.lastCity = this.city
      this.getHomeInfo()
    }
}
// 页面被隐藏的时候执行,相当于 destroyed
deactivated () {}

首页开发

注:750px的UI图,则为 iphone6 设计的 2倍图。

将主页面分成组件开发,利于维护

  • 根路径下新建pages文件夹,里面为所有文件的集合。
  • 根路径src、pages、home,新建 Home.vue
  • 根路径src、pages、home,新建 components,里面放置 Header.vue Swiper.vue Icons.vue Recommend.vue Weekend.vue

图标区Icons.vue

注意:轮播图循环的层数,实际为双层数组。

<swiper>
    <swiper-slide v-for="(page, index) of pages" :key="index">
      <div
        class="icon"
        v-for="item of page"
        :key="item.id"
      >
        <div class='icon-img'>
          <img class='icon-img-content' :src='item.imgUrl' />
        </div>
        <p class="icon-desc">{{item.desc}}</p>
      </div>
    </swiper-slide>
</swiper>

// 计算总循环层数,然后将图片分别放在每层里面
computed: {
    pages () {
      const pages = []
      this.list.forEach((item, index) => {
        const page = Math.floor(index / 8)
        if (!pages[page]) {
          pages[page] = []
        }
        pages[page].push(item)
      })
      return pages
    }
}

home主页面

<template>
  <div>
      // 页面头部
      <home-header :city="city"></home-header>
      // 轮播图
      <home-swiper :swiperList="swiperList"></home-swiper>
      // 图标列表
      <home-icons :iconList="iconList"></home-icons>
      // 热销推荐
      <home-recommend :recommendList="recommendList"></home-recommend>
      // 周末去哪
      <home-weekend :weekendList="weekendList"></home-weekend>
  </div>
</template>

<script>
import HomeHeader from './components/Header'
...
import axios from 'axios'
export default {
  name: 'home',
  components: {
    HomeHeader,
    ...
  },
  data () {
    return {
      city: '',
      ...
    }
  },
  methods: {
    async getHomeInfo () {
      let res = await axios.get('/api/index.json')
      if (res.data.ret && res.data.data) {
        let resData = res.data.data
        this.city = resData.city
        ...
      }
    }
  },
  mounted () {
    this.getHomeInfo()
  }
}
</script>

<style scoped></style>

城市页面开发

  • 根路径src、pages、city,新建 City.vue
  • 根路径src、pages、city,新建 components,里面放置 Alphabet.vue Header.vue List.vue Search.vue。 跳转到城市页面
// tap 将默认a元素转为div元素,to 跳转链接
<router-link tap='div' to='/city'>城市</router-link>

Bscroll使用

better-scroll

  • 安装
cnpm i better-scroll@1.15.2 --save
  • 使用
// 注意:需要符合该 DOM 结构
<div class="wrapper" ref='wrapper'>
  <ul class="content">
    <li>...</li>
    <li>...</li>
    ...
  </ul>
</div>

import Bscroll from 'better-scroll'
mounted () {
    this.scroll = new Bscroll(this.$refs.wrapper, {
      click: true, // 让其可点击
    })
}

点击侧边栏字母,让页面滚动到对应位置

this.scroll.scrollToElement(DOM)

// 监听传值到该页面的 letter 是否改变
watch: {
    letter () {
      console.log(this.letter)
      if (this.letter) {
        // 获取该DOM元素
        let element = this.$refs[this.letter][0]
        this.scroll.scrollToElement(element)
      }
    }
},

滑动侧边栏字母,让页面对应滑动

逻辑:获取首字母距离顶部高度,滑动时候手指距离顶部高度,所以可以得到手指与首字母的距离。再除以每个字母高度,就可以知道滑动到哪个字母了。

<ul class="list">
  <li
    v-for="item of letters"
    :key="item"
    @touchstart="handleTouchStart"
    @touchmove="handleTouchMove"
    @touchend="handleTouchEnd"
  >
    {{item}}
  </li>
</ul>

data () {
    return {
      touchStatus: false, // 标识位,touchstart后才会触发
      startY: 0,
      timer: null // 函数节流,因为手指移动的时候触发函数频率很高,不利于性能优化
    }
},
updated () {
    // 初始时 cities 为空,后来重新渲染后有值了
    console.log('--- alphabet updated ---')
    this.startY = this.$refs['A'][0].offsetTop
},
methods: {
    handleTouchStart () {
      this.touchStatus = true
    },
    handleTouchMove (e) {
      if (!this.touchStatus) return
      if (this.timer) {
        clearTimeout(this.timer)
      }
      // 正在做的时候延迟10ms执行,假设在这10ms内又去做手指滚动的话,那么会将上一次的操作给清除掉,重新执行这次要做的事情。
      this.timer = setTimeout(() => {
        // 79 为城市页面头部和搜索框高度,20 为每个字母高度
        const touchY = e.touches[0].clientY - 79
        const index = Math.floor((touchY - this.startY) / 20)
        if (index >= 0 && index <= this.letters.length) {
          // 传值给父页面,父页面再传给子页面
          this.$emit('change', this.letters[index])
        }
      }, 10)
    },
    handleTouchEnd () {
      this.touchStatus = false
    }
}

搜索逻辑实现

逻辑:主要是通过该页面数据循环匹配到搜索出的数据,展示对应内容。
做法为通过 indexOf 与 名字 和 拼音简写 相匹配

  • cities 中单个城市 结构
{
    "id": 1,
    "spell": "beijing",
    "name": "北京"
}
  • 页面代码具体实现
<template>
  <div>
    <div class="search">
        <input v-model="keyword" class="search-input" type="text" placeholder="输入城市名或拼音" />
    </div>
    <div
      class="search-content"
      ref="search"
      v-show="keyword"
    >
      <ul>
        <li
          class="search-item border-bottom"
          v-for="item of list"
          :key="item.id"
        >
          {{item.name}}
        </li>
        <li class="search-item border-bottom" v-show="hasNoData">
          没有找到匹配数据
        </li>
      </ul>
    </div>
  </div>
</template>

<script>
import Bscroll from 'better-scroll'
export default {
  name: 'CitySearch',
  props: {
    cities: Object
  },
  data () {
    return {
      keyword: '', // 有搜索内容时显示列表,没有时隐藏
      list: [], // 搜索结果列表
      timer: null // 函数节流
    }
  },
  computed: { // 页面上最好不要放置代码逻辑实现
    hasNoData () { // 没有搜索到内容时显示:没有找到匹配数据
      return !this.list.length
    }
  },
  watch: {
    keyword () {
      if (this.timer) {
        clearTimeout(this.timer)
      }
      if (!this.keyword) {
        this.list = []
        return
      }
      this.timer = setTimeout(() => {
        const result = []
        for (let i in this.cities) {
          this.cities[i].forEach(val => {
            if (val.spell.indexOf(this.keyword) !== -1 || val.name.indexOf(this.keyword) !== -1) {
              result.push(val)
            }
          })
        }
        this.list = result
      }, 100)
    }
  },
  mounted () { // 搜索内容多的时候让其可滚动
    this.scroll = new Bscroll(this.$refs.search, {
      click: true
    })
  }
}
</script>

城市主页面

<template>
    <div>
      // 页面头部
      <city-header></city-header>
      // 页面搜索
      <city-search :cities="cities"></city-search>
      // 页面列表
      <city-list :cities="cities" :hot="hotCities" :letter="letter" </city-list>
      // 页面侧边栏字母列表
      <city-alphabet :cities="cities" @change="clickLetter" ></city-alphabet>
    </div>
</template>

<script>
import axios from 'axios'
import CityHeader from './components/Header'
...
export default {
  name: 'City',
  components: {
    CityHeader,
    ...
  },
  data () {
    return {
      cities: {},
      hotCities: [],
      letter: ''
    }
  },
  methods: {
    async getCityInfo () {
      let res = await axios.get('/api/city.json')
      if (res.data.ret && res.data.data) {
        const data = res.data.data
        this.cities = data.cities
        ...
      }
    },
    clickLetter (e) {
      this.letter = e
    }
  },
  mounted () {
    this.getCityInfo()
  }
}
</script>
<style>
</style>

详情页面

header 渐隐渐现效果

逻辑:通过对页面滑动高度的判断,分别显示不同的头部效果。

<div v-show="showAbs">内容一</div>
<div v-show="!showAbs" :style="opacityStyle">内容二</div>

data () {
    return {
      showAbs: true,
      opacityStyle: {
        opacity: 0
      }
    }
},
methods: {
    handleScroll () {
      let top = document.documentElement.scrollTop || document.body.scrollTop || window.pageYOffset
      if (top > 60) {
        let opacity = top / 120 > 1 ? 1 : top / 120
        this.opacityStyle.opacity = opacity
        this.showAbs = false
      } else {
        this.showAbs = true
      }
    }
},
mounted () {
    window.addEventListener('scroll', this.handleScroll)
},
// 对全局事件的解绑
destroyed () {
    window.removeEventListener('scroll', this.handleScroll)
}

递归组件实现列表部分

循环调用自己

<div class="item" v-for="(item, key) of list" :key="key">
  <div class="item-title border-bottom">
    <span class="item-title-icon"></span>
    {{ item.title }}
  </div>
  <div v-if="item.children" class="item-chilren">
    <detail-list :list="item.children"></detail-list>
  </div>
</div>

name: 'DetailList', // script中name属性,很大一个作用就是为了使用递归组件

list: [{
    "title": "成人票",
    "children": [{
      "title": "成人三馆联票",
      "children": [{
        "title": "成人三馆联票 - 某一连锁店销售"
      }]
    },{
      "title": "成人五馆联票"
    }]
  }, {
    "title": "学生票"
  },]

详情主页面

<template>
  <div>
    // 图片部分
    <detail-banner :bannerImg="bannerImg" :sightName="sightName" :gallaryImgs="gallaryImgs"></detail-banner>
    // 头部
    <detail-header></detail-header>
    // 内容列表
    <div class="content">
      <detail-list :list="categoryList"></detail-list>
    </div>
  </div>
</template>
<script>
import DetailBanner from './components/Banner.vue'
...
import axios from 'axios'
export default {
  name: 'detail',
  components: {
    DetailBanner,
    ...
  },
  data () {
    return {
      bannerImg: '',
      ...
    }
  },
  methods: {
    async getDetailInfo () {
      let upData = {
        params: {
          id: this.$route.params.id
        }
      }
      let res = await axios.get('/api/detail.json', upData)
      if (res.data.ret && res.data.data) {
        let resData = res.data.data
        this.bannerImg = resData.bannerImg
        ...
      }
    }
  },
  mounted () {
    this.getDetailInfo()
  }
}
</script>

添加动画效果

全局公用组件,可放置在根路径下文件夹中

  • 根路径src、新建文件夹common,再新建文件夹fade,再新建文件FadeAnimation.vue
<template>
  <transition>
    <slot></slot>
  </transition>
</template>

<script>
export default {
  name: 'FadeAnimation'
}
</script>
<style lang="stylus" scoped>
  .v-enter, .v-leave-to
    opacity 0
  .v-enter-active, .v-leave-active
    transition opacity .5s
</style>
  • 所用页面
<fade-animation>
    <div>内容</div>
</fade-animation>

import FadeAnimation from 'common/fade/FadeAnimation'

环境配置

vue/cli 3.0 及以上版本

如果有老的脚手架,先卸载 npm uninstall vue-cli -g 当版本升级后,老项目中很多文件已经没有了。具体可见文章末尾出:项目代码结构。

  1. 安装新的脚手架 cnpm i @vue/cli -g
  2. 创建项目 vue create mypro
  3. 项目初始化如下:

jg.png 4. 进入并启动项目 cd mypro and cnpm run serve

代码迁移

  • package.json 中找到安装的第三方依赖包,放置在新项目中对应位置。
"dependencies": {
    "vue": "^2.5.2",
    "vue-router": "^3.0.1",
    "vuex": "^3.4.0",
    // 以下三个是需要的第三方包
    "axios": "^0.19.2",
    "better-scroll": "^1.15.2",
    "vue-awesome-swiper": "^2.6.7",
    // 不需要安装,脚手架自己带的
    "stylus": "^0.54.7",
    "stylus-loader": "^3.0.2",
},
  • 老项目中 static 文件夹,相当于新项目中 public 文件夹
  • 在文件根目录下新建 vue.config.js
const path = require('path');

module.exports = {
    devServer: {
        proxy: {
            '/api': {
                target: 'http://localhost:8080',
                pathRewrite: {
                    '^/api': '/mock'
                }
            }
        }
    },
    // 配置别名
    chainWebpack: (config) => {
        config.resolve.alias
            .set('styles', path.join(__dirname, './src/assets/styles/'))
            .set('@', path.join(__dirname, './src/'))
            .set('common', path.join(__dirname, './src/common/'))
    }
}
  • 其他代码基本复制粘贴即可

代码地址

去哪儿网简单实现

其他

name 名字作用

  1. 对该页面取消缓存的时候。
  2. 使用递归组件的时候。
  3. vue的开发调试工具 vue devtools中各组件名字
  • 路由就是根据网址的不同,返回不同的内容给用户

页面切换问题

  • 始终回到页面最顶部
routes: [...],
scrollBehavior (to, from, savedPosition) {
    return { x: 0, y: 0 }
}
  • 当设置文字超出隐藏不生效时,可在其父级添加属性min-width: 0

项目代码结构

vue/cli 3.0 及以上版本

dm.png

vue/cli 3.0以下版本

dmjg.png

老版本结构描述

  • README.md:项目说明文件...第三方模块依赖。
  • package:里面有很多依赖包,主要是开发项目时候有第三方模块依赖,都放在这里。
  • package-lock.json:是package的一个锁文件,帮我们确定安装的第三方包具体的版本,保持团队编程的统一。
  • .gitignore:当我们使用git时,希望把代码传到线上去,但是有一些特殊的代码并不希望传到线上去,当往线上git仓库提交时,它将不会被提交到git代码仓库。
  • src:放置的整个项目的源代码。
  1. main.js:整个项目的入口文件。
  2. App.vue:项目的最原始根组件。
  3. router:index.js项目的路由。
  4. components:项目里用的小组件。
  5. assets:项目里用的图片类资源。
  • node_modules:放置的项目依赖的第三方包。
  • config:放置的项目配置文件。
  1. index.js:放置基础信息。
  2. dev.env:放置开发环境配置信息。
  3. prod.env:放置线上环境配置信息。
  • index:项目默认的一个首页模板文件。
  • .postcssrc:是对postcss的一个配置项。
  • .eslintrc:代码规范,可以按照规范检测代码。
  • .eslintignore:提示以上格式代码不会被检测/build/,/config/,/dist/,/*.js
  • .editorconfig:帮助我们配置了编辑器里面的语法,也可以自己加一些配置,来统一编辑器自动化代码的格式化。
  • .babelrc:语法解析器,对语法转换后最终转换成浏览器能够编译执行的代码。
  • static:放置的静态资源,如静态图片,json数据等。
  • build:放置的项目打包的内容webpack配置。
  • LICENSE:是一个开源协议的说明