作者:滴滴公共前端 黃軼
在我們日常的移動端專案開發中,處理滾動列表是再常見不過的需求了。 以滴滴為例,可以是這樣豎向滾動的列表,如圖所示:
也可以是橫向滾動的導航欄,如圖所示:
可以開啟“微信 —> 錢包—>滴滴出行”體驗效果。
我們在實現這類滾動功能的時候,會用到我寫的第三方庫,better-scroll。
什麼是 better-scroll
better-scroll 是一個移動端滾動的解決方案,它是基於 iscroll 的重寫,它和 iscroll 的主要區別在這裡。better-scroll 也很強大,不僅可以做普通的滾動列表,還可以做輪播圖、picker 等等。
better-scroll 的滾動原理
不少同學可能用過 better-scroll,我收到反饋最多的問題是:
我的 better-scroll 初始化了, 但是沒法滾動。
不能滾動是現象,我們得搞清楚這其中的根本原因。在這之前,我們先來看一下瀏覽器的滾動原理:
瀏覽器的滾動條大家都會遇到,當頁面內容的高度超過視口高度的時候,會出現縱向滾動條;當頁面內容的寬度超過視口寬度的時候,會出現橫向滾動條。也就是當我們的視口展示不下內容的時候,會通過滾動條的方式讓使用者滾動螢幕看到剩餘的內容。
那麼對於 better-scroll 也是一樣的道理,我們先來看一下 better-scroll 常見的 html 結構:
<div class="wrapper">
<ul class="content">
<li>...</li>
<li>...</li>
...
</ul>
</div>複製程式碼
為了更加直觀,我們再來看一張圖:
綠色部分為 wrapper,也就是父容器,它會有固定的高度。黃色部分為 content,它是父容器的第一個子元素,它的高度會隨著內容的大小而撐高。那麼,當 content 的高度不超過父容器的高度,是不能滾動的,而它一旦超過了父容器的高度,我們就可以滾動內容區了,這就是 better-scroll 的滾動原理。
那麼,我們怎麼初始化 better-scroll 呢,如果是上述 html 結構,那麼初始化程式碼如下:
import BScroll from 'better-scroll'
let wrapper = document.querySelector('.wrapper')
let scroll = new BScroll(wrapper, {})複製程式碼
better-scroll 對外暴露了一個 BScroll 的類,我們初始化只需要 new 一個類的例項即可。第一個引數就是我們 wrapper 的 DOM 物件,第二個是一些配置引數,具體參考 better-scroll 的文件。
better-scroll 的初始化時機很重要,因為它在初始化的時候,會計算父元素和子元素的高度和寬度,來決定是否可以縱向和橫向滾動。因此,我們在初始化它的時候,必須確保父元素和子元素的內容已經正確渲染了。如果子元素或者父元素 DOM 結構發生改變的時候,必須重新呼叫 scroll.refresh()
方法重新計算來確保滾動效果的正常。所以同學們反饋的 better-scroll 不能滾動的原因多半是初始化 better-scroll 的時機不對,或者是當 DOM 結構傳送變化的時候並沒有重新計算 better-scroll。
better-scroll 遇見 Vue
相信很多同學對 Vue.js 都不陌生,當 better-scroll 遇見 Vue,會擦出怎樣的火花呢?
如何在 Vue 中使用 better-scroll
很多同學開始接觸使用 better-scroll 都是受到了我的一門教學課程——《Vue.js高仿餓了麼外賣App》 的影響。在那門課程中,我們把 better-scroll 和 Vue 做了結合,實現了很多列表滾動的效果。在 Vue 中的使用方法如下:
<template>
<div class="wrapper" ref="wrapper">
<ul class="content">
<li>...</li>
<li>...</li>
...
</ul>
</div>
</template>
<script>
import BScroll from 'better-scroll'
export default {
mounted() {
this.$nextTick(() => {
this.scroll = new Bscroll(this.$refs.wrapper, {})
})
}
}
</script>複製程式碼
Vue.js 提供了我們一個獲取 DOM 物件的介面—— vm.$refs
。在這裡,我們通過了 this.$refs.wrapper
訪問到了這個 DOM 物件,並且我們在 mounted 這個鉤子函式裡,this.$nextTick
的回撥函式中初始化 better-scroll 。因為這個時候,wrapper 的 DOM 已經渲染了,我們可以正確計算它以及它內層 content 的高度,以確保滾動正常。
這裡的 this.$nextTick
是一個非同步函式,為了確保 DOM 已經渲染,感興趣的同學可以瞭解一下它的內部實現細節,底層用到了 MutationObserver 或者是 setTimeout(fn, 0)
。其實我們在這裡把 this.$nextTick
替換成 setTimeout(fn, 20)
也是可以的(20 ms 是一個經驗值,每一個 Tick 約為 17 ms),對使用者體驗而言都是無感知的。
非同步資料的處理
在我們的實際工作中,列表的資料往往都是非同步獲取的,因此我們初始化 better-scroll 的時機需要在資料獲取後,程式碼如下:
<template>
<div class="wrapper" ref="wrapper">
<ul class="content">
<li v-for="item in data">{{item}}</li>
</ul>
</div>
</template>
<script>
import BScroll from 'better-scroll'
export default {
data() {
return {
data: []
}
},
created() {
requestData().then((res) => {
this.data = res.data
this.$nextTick(() => {
this.scroll = new Bscroll(this.$refs.wrapper, {})
})
})
}
}
</script>複製程式碼
這裡的 requestData 是虛擬碼,作用就是發起一個 http 請求從服務端獲取資料,並且這個函式返回的是一個 promise(實際專案中我們可能會用 axios 或者 vue-resource)。我們獲取到資料的後,需要通過非同步的方式再去初始化 better-scroll,因為 Vue 是資料驅動的, Vue 資料發生變化(this.data = res.data
)到頁面重新渲染是一個非同步的過程,我們的初始化時機是要在 DOM 重新渲染後,所以這裡用到了 this.$nextTick
,當然替換成 setTimeout(fn, 20)
也是可以的。
為什麼這裡在 created 這個鉤子函式裡請求資料而不是放到 mounted 的鉤子函式裡?因為 requestData 是傳送一個網路請求,這是一個非同步過程,當拿到響應資料的時候,Vue 的 DOM 早就已經渲染好了,但是資料改變 —> DOM 重新渲染仍然是一個非同步過程,所以即使在我們拿到資料後,也要非同步初始化 better-scroll。
資料的動態更新
我們在實際開發中,除了資料非同步獲取,還有一些場景可以動態更新列表中的資料,比如常見的下拉載入,上拉重新整理等。比如我們用 better-scroll 配合 Vue 實現下拉載入功能,程式碼如下:
<template>
<div class="wrapper" ref="wrapper">
<ul class="content">
<li v-for="item in data">{{item}}</li>
</ul>
<div class="loading-wrapper"></div>
</div>
</template>
<script>
import BScroll from 'better-scroll'
export default {
data() {
return {
data: []
}
},
created() {
this.loadData()
},
methods: {
loadData() {
requestData().then((res) => {
this.data = res.data.concat(this.data)
this.$nextTick(() => {
if (!this.scroll) {
this.scroll = new Bscroll(this.$refs.wrapper, {})
this.scroll.on('touchend', (pos) => {
// 下拉動作
if (pos.y > 50) {
this.loadData()
}
})
} else {
this.scroll.refresh()
}
})
})
}
}
}
</script>複製程式碼
這段程式碼比之前稍微複雜一些, 當我們在滑動列表鬆開手指時候, better-scroll 會對外派發一個 touchend 事件,我們監聽了這個事件,並且判斷了 pos.y > 50(我們把這個行為定義成一次下拉的動作)。如果是下拉的話我們會重新請求資料,並且把新的資料和之前的 data 做一次 concat,也就更新了列表的資料,那麼資料的改變就會對映到 DOM 的變化。需要注意的一點,這裡我們對 this.scroll
做了判斷,如果沒有初始化過我們會通過 new BScroll
初始化,並且繫結一些事件,否則我們會呼叫 this.scroll.refresh
方法重新計算,來確保滾動效果的正常。
這裡,我們就通過 better-scroll 配合 Vue,實現了列表的下拉重新整理功能,上拉載入也是類似的套路,一切看上去都是 ok 的。但是,我們發現這裡寫了大量命令式的程式碼(這一點不是 Vue.js 推薦的),如果有很多類似滾動的元件,我們就需要寫很多類似的命令式且重複性的程式碼,而且我們把資料請求和 better-scroll 也做了強耦合,這些對於一個追求程式設計逼格的人來說,就不 ok 了。
scroll 元件的抽象和封裝
因此,我們有強烈的需求抽象出來一個 scroll 元件,類似小程式的 scroll-view 元件,方便開發者的使用。
首先,我們要考慮的是 scroll 元件本質上就是一個可以滾動的列表元件,至於列表的 DOM 結構,只需要滿足 better-scroll 的 DOM 結構規範即可,具體用什麼標籤,有哪些輔助節點(比如下拉重新整理上拉載入的 loading 層),這些都不是 scroll 元件需要關心的。因此, scroll 元件的 DOM 結構十分簡單,如下所示:
<template>
<div ref="wrapper">
<slot></slot>
</div>
</template>複製程式碼
這裡我們用到了 Vue 的特殊元素—— slot 插槽,它可以滿足我們靈活定製列表 DOM 結構的需求。接下來我們來看看 JS 部分:
<script type="text/ecmascript-6">
import BScroll from 'better-scroll'
export default {
props: {
/**
* 1 滾動的時候會派發scroll事件,會截流。
* 2 滾動的時候實時派發scroll事件,不會截流。
* 3 除了實時派發scroll事件,在swipe的情況下仍然能實時派發scroll事件
*/
probeType: {
type: Number,
default: 1
},
/**
* 點選列表是否派發click事件
*/
click: {
type: Boolean,
default: true
},
/**
* 是否開啟橫向滾動
*/
scrollX: {
type: Boolean,
default: false
},
/**
* 是否派發滾動事件
*/
listenScroll: {
type: Boolean,
default: false
},
/**
* 列表的資料
*/
data: {
type: Array,
default: null
},
/**
* 是否派發滾動到底部的事件,用於上拉載入
*/
pullup: {
type: Boolean,
default: false
},
/**
* 是否派發頂部下拉的事件,用於下拉重新整理
*/
pulldown: {
type: Boolean,
default: false
},
/**
* 是否派發列表滾動開始的事件
*/
beforeScroll: {
type: Boolean,
default: false
},
/**
* 當資料更新後,重新整理scroll的延時。
*/
refreshDelay: {
type: Number,
default: 20
}
},
mounted() {
// 保證在DOM渲染完畢後初始化better-scroll
setTimeout(() => {
this._initScroll()
}, 20)
},
methods: {
_initScroll() {
if (!this.$refs.wrapper) {
return
}
// better-scroll的初始化
this.scroll = new BScroll(this.$refs.wrapper, {
probeType: this.probeType,
click: this.click,
scrollX: this.scrollX
})
// 是否派發滾動事件
if (this.listenScroll) {
let me = this
this.scroll.on('scroll', (pos) => {
me.$emit('scroll', pos)
})
}
// 是否派發滾動到底部事件,用於上拉載入
if (this.pullup) {
this.scroll.on('scrollEnd', () => {
// 滾動到底部
if (this.scroll.y <= (this.scroll.maxScrollY + 50)) {
this.$emit('scrollToEnd')
}
})
}
// 是否派發頂部下拉事件,用於下拉重新整理
if (this.pulldown) {
this.scroll.on('touchend', (pos) => {
// 下拉動作
if (pos.y > 50) {
this.$emit('pulldown')
}
})
}
// 是否派發列表滾動開始的事件
if (this.beforeScroll) {
this.scroll.on('beforeScrollStart', () => {
this.$emit('beforeScroll')
})
}
},
disable() {
// 代理better-scroll的disable方法
this.scroll && this.scroll.disable()
},
enable() {
// 代理better-scroll的enable方法
this.scroll && this.scroll.enable()
},
refresh() {
// 代理better-scroll的refresh方法
this.scroll && this.scroll.refresh()
},
scrollTo() {
// 代理better-scroll的scrollTo方法
this.scroll && this.scroll.scrollTo.apply(this.scroll, arguments)
},
scrollToElement() {
// 代理better-scroll的scrollToElement方法
this.scroll && this.scroll.scrollToElement.apply(this.scroll, arguments)
}
},
watch: {
// 監聽資料的變化,延時refreshDelay時間後呼叫refresh方法重新計算,保證滾動效果正常
data() {
setTimeout(() => {
this.refresh()
}, this.refreshDelay)
}
}
}
</script>複製程式碼
JS 部分實際上就是對 better-scroll 做一層 Vue 的封裝,通過 props 的形式,把一些對 better-scroll 定製化的控制權交給父元件;通過 methods 暴露的一些方法對 better-scroll 的方法做一層代理;通過 watch 傳入的 data,當 data 發生改變的時候,在適當的時機呼叫 refresh 方法重新計算 better-scroll 確保滾動效果正常,這裡之所以要有一個 refreshDelay 的設定是考慮到如果我們對列表操作用到了 transition-group 做動畫效果,那麼 DOM 的渲染完畢時間就是在動畫完成之後。
有了這一層 scroll 元件的封裝,我們來修改剛剛最複雜的程式碼(假設我們已經全域性註冊了 scroll 元件)。
<template>
<scroll class="wrapper"
:data="data"
:pulldown="pulldown"
@pulldown="loadData">
<ul class="content">
<li v-for="item in data">{{item}}</li>
</ul>
<div class="loading-wrapper"></div>
</scroll>
</template>
<script>
import BScroll from 'better-scroll'
export default {
data() {
return {
data: [],
pulldown: true
}
},
created() {
this.loadData()
},
methods: {
loadData() {
requestData().then((res) => {
this.data = res.data.concat(this.data)
})
}
}
}
</script>複製程式碼
可以很明顯的看到我們的 JS 部分精簡了非常多的程式碼,沒有對 better-scroll 再做命令式的操作了,同時把資料請求和 better-scroll 也做了剝離,父元件只需要把資料 data 通過 prop 傳給 scroll 元件,就可以保證 scroll 元件的滾動效果。同時,如果想實現下拉重新整理的功能,只需要通過 prop 把 pulldown 設定為 true,並且監聽 pulldown 的事件去做一些資料獲取並更新的動作即可,整個邏輯也是非常清晰的。
外掛 Vue 化引發的一些思考
這篇文章我不僅僅是要教會大家封裝一個 scroll 元件,還想傳遞一些把第三方外掛(原生 JS 實現)Vue 化的思考過程。很多學習 Vue.js 的同學可能還停留在 “XX 效果如何用 Vue.js 實現” 的程度,其實把外掛 Vue 化有兩點很關鍵,一個是對外掛本身的實現原理很瞭解,另一個是對 Vue.js 的特性很瞭解。對外掛本身的實現原理了解需要的是一個思考和鑽研的過程,這個過程可能困難,但是收穫也是巨大的;而對 Vue.js 的特性的瞭解,是需要大家對 Vue.js 多多使用,學會從平時的專案中積累和總結,也要善於查閱 Vue.js 的官方文件,關注一些 Vue.js 的升級等。
所以,我們拒絕伸手黨,但也不是鼓勵大傢什麼時候都要去造輪子,當我們在使用一些現成外掛的同時,也希望大家能多多思考,去探索一下現象背後的本質,把 “XX 效果如何用 Vue.js 實現” 這句話從問號變成句號。
廣告時間
最近上線了一門 Vue.js 的高階課程,想在 Vue.js 和移動端開發方向進階的同學可以關注一波~