(14)城市选择页开发——④ “列表”与“字母表”联动 | Vue.js 项目实战: 移动端“旅游网站”开发

285 阅读5分钟
转载请注明出处,未经同意,不可修改文章内容。

🔥🔥🔥“前端一万小时”两大明星专栏——“从零基础到轻松就业”、“前端面试刷题”,已于本月大改版,合二为一,干货满满,欢迎点击公众号菜单栏各模块了解。


1 需求

实现“列表”与“字母表”之间的联动:

  • 点击“字母表”的任意字母,“列表”会自动显示对应字母区域的内容;
  • 在“字母表”区域滑动时,“列表”自动显示停止滑动时所对应字母区域的内容。

视频01.gif

2 实现“点击”字母,列表显示对应内容的功能

🔗前置知识:
《JavaScript 基础——JS 事件:② 事件对象和事件代理》
《Vue 入门——④ 计算属性、方法与侦听器》
《深入理解 Vue 组件——① 使用组件的细节点》
《深入理解 Vue 组件——② 父子组件间的数据传递》

需求分析:

假设,当点击字母表的“F”时,列表显示对应的以“F”为拼音首字母的城市。这里,就需要在 Alphabet.vueList.vue 这两个“非父子组件”间传值。但,因为他们有共同的父组件 City.vue ,所以,他们之间的通信便属于非常简单的“兄弟组件”间传值。

❓如何实现简单的兄弟组件间传值?

答:在字母表 Alphabet.vue 中,当某个字母被点击时,我们将信息传递给父组件 City.vue ;父组件接收后,再由父组件转发给列表组件 List.vue

1️⃣打开 city 下 components 中的 Alphabet.vue

<template>
  <ul class="list">

    <li 
      class="item" 
      v-for="(item, key) of cities" 
      :key="key" 
      @click="handleLetterClick"
    > <!-- 1️⃣-①:在 li 标签上绑定 click 事件,给每一个循环项都添加一个点击事件,当元素被点击时
		(即字母被点击),执行 handleLetterClick 方法; -->
      
      {{key}}
    </li>
  </ul>
</template>

<script>
export default {
  name: 'CityAlphabet',
  props: {
    cities: Object
  },

  methods: { // 1️⃣-②:在 methods 中定义 handleLetterClick 方法;

    handleLetterClick (e) { // 1️⃣-③:当字母表中的某个元素被点击时,方法接收一个事件对象 e;
    
      this.$emit('change', e.target.innerText) /*
      																				 1️⃣-④:通过 $emit 向外触发一个我们取名
      																				 为 change 的事件,并将被点击元素中的文本内容
                                               传递给父组件(即 A、B、C 等字母);
                                                */
    }
  }
}
</script>

<style lang="stylus" scoped>
@import '~styles/varibles.styl'
.list
  position: absolute
  top: 1.58rem
  right: 0
  bottom: 0
  width: .4rem
  display: flex
  flex-direction: column
  justify-content: center
  .item
    line-height: .4rem
    text-align: center
    color: $bgColor
</style>

每次点击子组件 Alphabet.vue ,都会触发事件。然后,由父组件 City.vue 监听这个事件,并接收它传递过来的数据。

1️⃣-⑤:打开 city 下的 City.vue

<template>
  <div>
    <city-header></city-header>
    <city-search></city-search>
    <city-list :hot="hotCities" :cities="cities"></city-list>

    <!-- 1️⃣-⑥:监听 change 事件,事件触发时执行 handleLetterChange 方法; -->
    <city-alphabet :cities="cities" @change="handleLetterChange"></city-alphabet>
  </div>
</template>

<script>
import axios from 'axios'
import CityHeader from './components/Header'
import CitySearch from './components/Search'
import CityList from './components/List'
import CityAlphabet from './components/Alphabet'

export default {
  name: 'City',
  components: {
    CityHeader,
    CitySearch,
    CityList,
    CityAlphabet
  },
  data () {
    return {
      hotCities: [],
      cities: {}
    }
  },
  methods: {
    getCityInfo () {
      axios.get('/api/city.json')
        .then(this.getCityInfoSucc)
    },
    getCityInfoSucc (res) {
      res = res.data
      if (res.ret && res.data) {
        const data = res.data
        this.hotCities = data.hotCities
        this.cities = data.cities
      }
    },

    handleLetterChange (letter) { /*
    															1️⃣-⑦:handleLetterChange 方法接收 Alphabet.vue 
    															传来的数据 letter;
                                   */

      console.log(letter) // ❗️打印出 letter,查看是否正确接收到数据。
    }
  },
  mounted () {
    this.getCityInfo()
  }
}
</script>

<style>
</style>

保存后,返回页面查看,当点击字母表时,正确输出了被点击的字母(即父组件已接收到数据):

视频02.gif

父组件接收到数据后,需要将数据转发给另一个子组件 List.vue

1️⃣-⑧:返回 city 下的 City.vue

<template>
  <div>
    <city-header></city-header>
    <city-search></city-search>

    <!-- 1️⃣-⑪:通过属性 :letter 将数据 letter 传递给子组件 List.vue; -->
    <city-list :hot="hotCities" :cities="cities" :letter="letter"></city-list>

    <city-alphabet :cities="cities" @change="handleLetterChange"></city-alphabet>
  </div>
</template>

<script>
import axios from 'axios'
import CityHeader from './components/Header'
import CitySearch from './components/Search'
import CityList from './components/List'
import CityAlphabet from './components/Alphabet'

export default {
  name: 'City',
  components: {
    CityHeader,
    CitySearch,
    CityList,
    CityAlphabet
  },
  data () {
    return {
      hotCities: [],
      cities: {},
      letter: '' // 1️⃣-⑨:在 data 中,定义一个变量 letter,默认为空;
    }
  },
  methods: {
    getCityInfo () {
      axios.get('/api/city.json')
        .then(this.getCityInfoSucc)
    },
    getCityInfoSucc (res) {
      res = res.data
      if (res.ret && res.data) {
        const data = res.data
        this.hotCities = data.hotCities
        this.cities = data.cities
      }
    },

    handleLetterChange (letter) {
      this.letter = letter  /*
      											1️⃣-⑩:当接收到 Alphabet.vue 传来的数据时,将传进来的数据赋值
      											给 City.vue 中的数据 letter;
                             */
    }
  },
  mounted () {
    this.getCityInfo()
  }
}
</script>

<style>
</style>

❓当子组件 List.vue 收到“被点击字母”的数据后,如何让它显示对应的城市区域呢?

答:我们可以调用 BetterScroll 提供的 scrollToElement() 方法,让 BetterScroll 的滚动区域自动滚动到某一个元素上。

1️⃣-⑫:返回 city 下 components 中的 List.vue

<template>
  <div class="list" ref="wrapper">
    <div>
      <div class="area">
        <div class="title border-topbottom">当前城市</div>
        <div class="button-list">
          <div class="button-wrapper">
            <div class="button">北京</div>
          </div>
        </div>
      </div>
      <div class="area">
        <div class="title border-topbottom">热门城市</div>
        <div class="button-list">
          <div class="button-wrapper" v-for="item of hot" :key="item.id">
            <div class="button">{{item.name}}</div>
          </div>
        </div>
      </div>

      <div
        class="area"
        v-for="(item, key) of cities"
        :key="key"
        :ref="key"
      > <!-- 1️⃣-⑯:循环上动态绑定 ref 为 key(即字母); -->

        <div class="title border-topbottom">{{key}}</div>
        <ul class="item-list">
          <li
            class="item border-bottom"
            v-for="innerItem of item"
            :key="innerItem.id"
          >
            {{innerItem.name}}
          </li>
        </ul>
      </div>
    </div>
  </div>
</template>

<script>
import BScroll from 'better-scroll'

export default {
  name: 'CityList',
  props: {
    hot: Array,
    cities: Object,

    letter: String // 1️⃣-⑬:子组件通过 props 接收数据,letter 的值类型为字符串;
  },
  mounted () {
    this.scroll = new BScroll(this.$refs.wrapper)
  },

  watch: { // 1️⃣-⑭:借助侦听器,监听 letter 的变化;

    letter () { // 1️⃣-⑮:定义 letter 方法,一旦监听到数据 letter 发生变化,就执行方法;
      
      const element = this.$refs[this.letter][0] /*
      																					 1️⃣-⑰:通过 ref 获取被点击字母对应的 DOM
                                                 元素,并赋值给 element;
      																					 ❗️使用 for 循环输出的 ref,通过
                                                 this.$refs[this.letter] 获取到的是
                                                 一个数组,所以这里获取 DOM 元素需要在后面
                                                 加上下标 0;
                                                  */

      this.scroll.scrollToElement(element) /*
      																		 1️⃣-⑱:scrollToElement 方法接收一个 DOM 元素
                                           (或一个 DOM 选择器),所以方法中传入 element;
                                            */
    }
  }
}
</script>

<style lang="stylus" scoped>
.border-topbottom
  &:before
    border-color: #ccc
  &:after
    border-color: #ccc
.border-bottom
  &:before
    border-color: #ccc
.list
  overflow: hidden
  position: absolute
  top: 1.58rem
  left: 0
  right: 0
  bottom: 0
  .title
    padding-left: .2rem
    line-height: .54rem
    background: #eee
    color: #666
    font-size: .26rem
  .button-list
    overflow: hidden
    padding: .1rem .6rem .1rem .1rem
    .button-wrapper
      float: left
      width: 33.33%
      .button
        margin: .1rem
        padding: .1rem 0
        text-align: center
        border: .02rem solid #ccc
        border-radius: .06rem
  .item-list
    .item
      line-height: .76rem
      padding-left: .2rem
</style>

保存后,返回页面查看,当点击字母后,列表正确显示对应的城市:

视频03.gif

3 实现在字母表“滑动”,列表显示滑动停止时所在字母对应内容

🔗前置知识:《实现一个简单的左右滑动手势库》

需求分析:

假设我们从字母表的“A”滑动到“F”,那么 F 距离顶部的距离减去 A 距离顶部的距离,就能获得手指滑动的距离。再用滑动的距离除以每个字母的高度,就可以知道手指滑动到第几个字母。然后可以触发一个事件,告诉其他组件滑动到了第几个字母。

2️⃣打开 city 下 components 中的 Alphabet.vue ,按照上面分析的思路编写代码:

<template>
  <ul class="list">
    <li
      class="item"
      v-for="item of letters"
      :key="item"
      :ref="item"
      @click="handleLetterClick"
      @touchstart="handleTouchStart"
      @touchmove="handleTouchMove"
      @touchend="handleTouchEnd"
    > 
    <!-- 2️⃣-⑤:li 标签直接对 letters 进行循环,绑定的 key 值和显示的内容也改为循环项 item; -->
    <!-- 2️⃣-⑥:添加一个 ref,名字为循环项 item,方便获取 DOM 元素; -->
      
    <!-- 2️⃣-⑦:添加 touchstart 事件,触发时执行 handleTouchStart 方法;
    2️⃣-⑧:添加 touchmove 事件,触发时执行 handleTouchMove 方法;
    2️⃣-⑨:添加 touchend 事件,触发时执行 handleTouchEnd 方法; -->

      {{item}} <!-- ❗️显示的内容改为为 item。 -->
    </li>
  </ul>
</template>

<script>
export default {
  name: 'CityAlphabet',
  props: {
    cities: Object
  },

  computed: { // 2️⃣-①:在 computed 中定义一个计算属性 letters;
    letters () {
      const letters = [] /*
      									 2️⃣-②:定义一个 letters 变量,它为空数组,用来存储字母,
      									 方便我们利用下标查找字母;
                          */

      for (let i in this.cities) { /*
      														 2️⃣-③:对数据 cities 进行循环,每一项放入 letters 
                                   数组中;
                                    */
        letters.push(i)
      }

      return letters // 2️⃣-④:循环结束,返回 letters;
    }
  },

  data () { // 2️⃣-⑩:定义一个变量 touchStatus 为“滑动”开始和结束的“标识符”,默认为 false;
    return {
      touchStatus: false
    }
  },

  methods: {
    handleLetterClick (e) {
      this.$emit('change', e.target.innerText)
    },

    handleTouchStart () { // 2️⃣-⑪:当滑动开始时,使 touchStatus 变为 true;
      this.touchStatus = true
    },

    handleTouchMove (e) { // 2️⃣-⑬:handleTouchMove 方法接收一个事件 e;
      
      if (this.touchStatus) { /*
      												2️⃣-⑭:只有当 touchStatus 变为 true 时,
        											才进行 touchmove 事件的处理;
                               */
        const startY = this.$refs['A'][0].offsetTop /*
        																						2️⃣-⑮:定义一个 startY 变量,
                                                    它的值为字母 A 与顶部之间的距离;
                                                     */
        const touchY = e.touches[0].clientY /*
        																		2️⃣-⑯:定义一个 touchY 变量,
                                            它的值为手指与可视区域顶部之间的距离;
                                             */

        console.log(startY) // 2️⃣-⑰:分别输出 startY 和 touchY 查看;
        console.log(touchY)
      }
    },

    handleTouchEnd () { // 2️⃣-⑫:当滑动结束时,使 touchStatus 恢复为 false;
      this.touchStatus = false
    }
  }
}
</script>

<style lang="stylus" scoped>
@import '~styles/varibles.styl'
.list
  position: absolute
  top: 1.58rem
  right: 0
  bottom: 0
  width: .4rem
  display: flex
  flex-direction: column
  justify-content: center
  .item
    line-height: .4rem
    text-align: center
    color: $bgColor
</style>

保存后,分别输出 startYtouchY ,可以看到 startY 即 A 与其父元素 .list 顶部之间的距离。 touchY 随”手指“滑动而变化,其中最小的值大概在 170 左右,即 A 距离整个可视区域顶部的距离: 视频04.gif

2️⃣-⑱:startY 是“A”距离搜索框底部的距离没有问题,但目前的 touchY 是手指距离最顶部的高度。所以我们需要先用得到的值再减去搜索框(高 36)和头部(高 43)两个组件的高度之和(和为 79);

<template>
  <ul class="list">
    <li
      class="item"
      v-for="item of letters"
      :key="item"
      :ref="item"
      @click="handleLetterClick"
      @touchstart="handleTouchStart"
      @touchmove="handleTouchMove"
      @touchend="handleTouchEnd"
    >

      {{item}}
    </li>
  </ul>
</template>

<script>
export default {
  name: 'CityAlphabet',
  props: {
    cities: Object
  },
  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

        const touchY = e.touches[0].clientY - 79 /*
        																				 ❗️touchY 的值为 e.touches[0].clientY
                                                 再减去 79。
                                                  */

        const index = Math.floor((touchY - startY) / 20) /*
        																								 2️⃣-⑲:定义一个 index 变量,
                                                         它的值为 touchY 与 startY 的差
                                                         再除以每个字母的高度 20 后得到的
                                                         整数,表示每个字母对应的下标;
                                                          */

        this.$emit('change', this.letters[index]) /*
        																					2️⃣-⑳:最后,依然触发 change 事件,
                                                  并传递被点击的字母;
                                                   */
      }
    },

    handleTouchEnd () {
      this.touchStatus = false
    }
  }
}
</script>

<style lang="stylus" scoped>
@import '~styles/varibles.styl'
.list
  position: absolute
  top: 1.58rem
  right: 0
  bottom: 0
  width: .4rem
  display: flex
  flex-direction: column
  justify-content: center
  .item
    line-height: .4rem
    text-align: center
    color: $bgColor
</style>

保存后,返回页面查看,“点击”和“滑动”的功能都能正常使用: 视频05.gif

4 性能优化

🔗前置知识:
《JavaScript 基础——浏览器提供的对象:③ 定时器》
《Vue 入门——② Vue 实例的生命周期函数》

至此,“列表”与“字母表”联动的功能貌似我们已经完成了。但,实际上还有几个地方需要优化,我们打印出滑动时每次的 index 查看: 视频06.gif

可以“看到”的问题有:

  1. 事件触发过于频繁(用函数节流解决);
  2. 滑动时,当滑动的距离超出字母的范围,依然会触发 change 事件并传递数据,但此时已经没有对应的字母了,所以控制台报错(对 index 值进行处理即可解决);

“隐藏”的问题:每次执行 handleTouchMove 都会对字母 A 距离顶部的这个“固定值”进行一次运算(对 startY 进行处理,让它尽量少地进行运算)。

3️⃣返回 city 下 components 中的 Alphabet.vue

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

<script>
export default {
  name: 'CityAlphabet',
  props: {
    cities: Object
  },
  computed: {
    letters () {
      const letters = []
      for (let i in this.cities) {
        letters.push(i)
      }
      return letters
    }
  },
  data () {
    return {
      touchStatus: false,

      timer: null, // 3️⃣-①:在 data 中定义一个变量 timer,默认值为 null;

      startY: 0 // 3️⃣-⑥:在 data 中定义变量 startY,默认值为 0;
    }
  },

  updated () { // 3️⃣-⑦:在 updated 中,获取字母 A 距离顶部的高度并赋值给 startY;
    this.startY = this.$refs['A'][0].offsetTop
  },

  methods: {
    handleLetterClick (e) {
      this.$emit('change', e.target.innerText)
    },
    handleTouchStart () {
      this.touchStatus = true
    },

    handleTouchMove (e) {
      if (this.touchStatus) { // 3️⃣-②:touchStatus 为 true 时;

        if (this.timer) { // 3️⃣-③:如果 timer 已经存在了,就清除掉 timer;
          clearTimeout(this.timer)
        }

        this.timer = setTimeout(() => { /*
        																3️⃣-④:如果没有 timer,就创建一个 timer,
                                        让它延迟 16 毫秒执行里边的内容;
                                         */

          const touchY = e.touches[0].clientY - 79

          const index = Math.floor((touchY - this.startY) / 20) /*
          																											3️⃣-⑧:startY 更改
          																											为 this.startY。
                                                                 */

          if (index >= 0 && index < this.letters.length) { /*
          																								 3️⃣-⑤:当 index 的值大于或
                                                           等于 0 且小于 letters 的长
                                                           度时,才触发 change 事件并
                                                           传递数据;
                                                            */
            this.$emit('change', this.letters[index])
          }
        }, 16)
      }
    },

    handleTouchEnd () {
      this.touchStatus = false
    }
  }
}
</script>

<style lang="stylus" scoped>
@import '~styles/varibles.styl'
.list
  position: absolute
  top: 1.58rem
  right: 0
  bottom: 0
  width: .4rem
  display: flex
  flex-direction: column
  justify-content: center
  .item
    line-height: .4rem
    text-align: center
    color: $bgColor
</style>

保存后,返回页面查看。从肉眼上来看没有什么变化,但从实际输出的内容就能“实在”地看到性能得到了大大的提升: 视频07.gif

以上,我们实现了城市选择页的“列表 List.vue ”与“字母表 Alphabet.vue ”两个兄弟组件间的联动,并且优化了代码性能。

🏆本篇总结:

  1. 兄弟组件间的传值,可以先由子组件传递给父组件,父组件接收到后,再传递给另一个子组件;
  2. 通过函数节流,限制函数执行的频率来优化代码;
  3. updated 中(当数据发生改变、页面渲染完成后),对元素距离顶部的高度进行运算来优化代码。

祝好,qdywxs ♥ you!