先發原文做者、地址等信息。我把內容所有搬過來了,也能夠去看原文。內容絕對是滿滿的乾貨,給原做者點贊!(我添加的內容在轉載過來的後面,內容很少)css
做者: ustbhuangyi 連接:http://www.imooc.com/article/18232 來源:慕課網
在咱們平常的移動端項目開發中,處理滾動列表是再常見不過的需求了,以滴滴爲例,能夠是這樣豎向滾動的列表,如圖所示:html
也能夠是橫向滾動的導航欄,如圖所示:vue
能夠打開「微信 —> 錢包—>滴滴出行」體驗效果。ios
咱們在實現這類滾動功能的時候,會用到我寫的第三方庫,better-scroll。git
什麼是 better-scrollgithub
better-scroll 是一個移動端滾動的解決方案,它是基於 iscroll 的重寫,它和 iscroll 的主要區別在這裏。better-scroll 也很強大,不只能夠作普通的滾動列表,還能夠作輪播圖、picker 等等。編程
很多同窗可能用過 better-scroll,我收到反饋最多的問題是:axios
個人 better-scroll 初始化了, 可是無法滾動。小程序
不能滾動是現象,咱們得搞清楚這其中的根本緣由。在這以前,咱們先來看一下瀏覽器的滾動原理:
瀏覽器的滾動條你們都會遇到,當頁面內容的高度超過視口高度的時候,會出現縱向滾動條;當頁面內容的寬度超過視口寬度的時候,會出現橫向滾動條。也就是當咱們的視口展現不下內容的時候,會經過滾動條的方式讓用戶滾動屏幕看到剩餘的內容。promise
那麼對於 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,會擦出怎樣的火花呢?
不少同窗開始接觸使用 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 實現」 這句話從問號變成句號。
如下內容是我在做者基礎上添加了一些交互效果,和做者的放在一塊兒作成一個組件,能夠直接拿去用。爲了更容易看懂個人思路,進行了簡要的註釋。
<template> <div ref="wrapper" class="better-scroll-root"> <!--該節點須要定位,內容以此節點的盒模型爲基礎滾動。另外,該節點的背景色配合上拉加載、下拉刷新的UI,正常狀況下不可做它用。--> <div class="content-bg better-scroll-container"> <!--若是須要調滾動內容的背景色,則改該節點的背景色--> <div> <!--不太須要,待優化--> <div v-if="pulldown" class="pulldown-tip"> <i class="pull-icon indexicon icon-pull-down" :class="[pulldownTip.rotate]"></i> <span class="tip-content">{{pulldownTip.text}}</span> </div> <div v-show="loadingStatus.showIcon || loadingStatus.status" class="loading-pos"> <div v-show="loadingStatus.showIcon" class="loading-container"> <div class="cube"> <div class="side side1"></div> <div class="side side2"></div> <div class="side side3"></div> <div class="side side4"></div> <div class="side side5"></div> <div class="side side6"></div> </div> </div> <span class="loading-connecting">{{loadingStatus.status}}</span> </div> </div> <slot></slot> </div> </div> </template> <script> 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 }, /** * 若是啓用loading交互,傳遞loading的狀態 * isShow: false * showIcon: false, // 是否顯示loading的icon * status: '' // '正在加載...', '刷新成功', '刷新失敗', '' */ loadingStatus: { type: Object, default: function () { return { showIcon: false, status: '' }; } }, /** * 是否啓用下拉刷新的交互 */ pulldownUI: { type: Boolean, default: false }, /** * 是否啓用上拉加載的交互 */ pullupUI: { type: Boolean, default: false } }, data() { return { loadingConnecting: false, pulldownTip: { text: '下拉刷新', // 鬆開當即刷新 rotate: '' // icon-rotate }, }; }, 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 || this.pulldown || this.pullup) { let me = this; this.scroll.on('scroll', (pos) => { if (this.listenScroll) { me.$emit('scroll', pos); } if (this.pulldown) { // 下拉動做 if (pos.y > 50) { this.pulldownTip = { text: '鬆開當即刷新', rotate: 'icon-rotate' } } else { this.pulldownTip = { text: '下拉刷新', // 鬆開當即刷新 rotate: '' // icon-rotate } } } if (this.pullup) { } }) } // 是否派發滾動到底部事件,用於上拉加載 if (this.pullup) { this.scroll.on('scrollEnd', () => { console.log('scrollEnd'); console.log(this.scroll); // 滾動到底部 if (this.scroll.y <= (this.scroll.maxScrollY + 50)) { this.$emit('scrollToEnd'); } }); } // 是否派發頂部下拉事件,用於下拉刷新 if (this.pulldown) { this.scroll.on('touchend', (pos) => { // 下拉動做 if (pos.y > 50) { setTimeout(() => { // 重置提示信息 this.pulldownTip = { text: '下拉刷新', // 鬆開當即刷新 rotate: '' // icon-rotate } },600); 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> <style lang="scss" rel="stylesheet/scss"> $cube-size: 10px; // 項目中用了scss,沒用的話,替換掉樣式中的變量便可 .better-scroll-root { background-color: rgba(7, 17, 27, 0.7); .loading-pos, .pulldown-tip { position: absolute; left: 0; top: 0; width: 100%; height: 35px; color: #fcfcfc; text-align: center; z-index: 2000; } .loading-pos { background-color: rgba(7, 17, 27, 0.7); } .pulldown-tip { top: -50px; height: 50px; line-height: 50px; z-index: 1; } .pull-icon { position: absolute; top: 0; left: 30%; color: #a5a1a1; font-size: 1.5em; transition: all 0.15s ease-in-out; } .pull-icon.icon-rotate { transform:rotate(180deg); } .loading-container { position: absolute; height: $cube-size; width: $cube-size; left: 35%; top: 50%; transform: translate(-50%, -50%); perspective: 40px; } .loading-connecting { line-height: 35px; } .cube{ height:$cube-size; width:$cube-size; transform-origin:50% 50%; transform-style:preserve-3d; animation:rotate 3s infinite ease-in-out; } .side{ position:absolute; height:$cube-size; width:$cube-size; border-radius:50%; } .side1{ background: #4bc393; transform:translateZ($cube-size); } .side2{ background:#FF884D; transform:rotateY(90deg) translateZ($cube-size); } .side3{ background:#32526E; transform:rotateY(180deg) translateZ($cube-size); } .side4{ background: #c53fa3; transform:rotateY(-90deg) translateZ($cube-size); } .side5{ background:#FFCC5C; transform:rotateX(90deg) translateZ($cube-size); } .side6{ background:#FF6B57; transform:rotateX(-90deg) translateZ($cube-size); } @keyframes rotate{ 0%{ transform:rotateX(0deg) rotateY(0deg); } 50%{ transform:rotateX(360deg) rotateY(0deg); } 100%{ transform:rotateX(360deg) rotateY(360deg); } } } </style>
下拉刷新,上拉加載(暫時未作),刷新中等效果以下:
以上內容還不夠精細,等這段時間忙過去了會繼續優化。若有bug,歡迎各位看官批評指正。