H5 頁面列表緩存方案

大家好,我是若川(點這裏加我微信 ruochuan12,長期交流學習)。今天給大家介紹一下關於 h5 頁面的列表緩存方案。感謝屏幕前的你一直關注着我。

前言

在 H5 日常開發中,會經常遇到列表點擊進入詳情頁面然後返回列表的情況,對於電商類平臺尤爲常見,像我們平常用的淘寶、京東等電商平臺都是做了緩存,而且不只是列表,很多地方都用到了緩存。但剛纔說的都是 App,在原生 App 中,頁面是一層層的 View,蓋在 LastPage 上,天然就能夠保存上一個頁面的狀態,而 H5 不同,從詳情返回到列表後,狀態會被清除掉,重新走一遍生命週期,會重新發起請求,會有新的狀態寫入,對於分頁接口,列表很長,當用戶翻了好幾頁後,點擊詳情看看商品詳情後再返回列表,此時頁面回到第一頁,這樣用戶體驗很差,如果在進入詳情的時候將列表數據緩存起來,返回列表的時候用緩存數據,而不是重新請求數據,停留在離開列表頁時的瀏覽位置;或者是能夠像 App 那樣,將頁面一層層堆疊在 LastPage 上,返回的時候展示對應的頁面,這樣用戶體驗會好很多,本文簡單介紹一下在自己在做列表緩存的時候考慮的幾點,後附簡單實現。

思考

狀態丟失的原因

通常在頁面開發中,我們是通過路由去管理不同的頁面,常用的路由庫也有很多,譬如:React-Router (https://react-guide.github.io/react-router-cn/),Dva-router (https://dvajs.com/api/#dva-router)... 當我們切換路由時,沒有被匹配到的 Component 也會被整體替換掉,原有的狀態也丟失了。因此,當用戶從詳情頁退回到列表頁時,會重新加載列表頁面組件,重新走一遍生命週期,獲取的就是第一頁的數據,從而回到了列表頂部,下面是常用的路由匹配代碼段。

function RouterConfig({ history, app }) {
  const routerData = getRouterData(app);
  return (
    <ConnectedRouter history={history}>
      <Route
        path="/"
        render={(props) => <Layouts routerData={routerData} {...props} />}
        redirectPath="/exception/403"
      />
    </ConnectedRouter>
  );
}
// 路由配置說明(你不用加載整個配置,
// 只需加載一個你想要的根路由,
// 也可以延遲加載這個配置)。
React.render((
  <Router>
    <Route path="/" component={App}>
      <Route path="about" component={About}/>
      <Route path="users" component={Users}>
        <Route path="/user/:userId" component={User}/>
      </Route>
      <Route path="*" component={NoMatch}/>
    </Route>
  </Router>
), document.body)

如何解決

原因找到了,那麼我們怎麼去緩存頁面或者數據呢?一般有兩種解決方式:1. 路由切換時自動保存狀態。2. 手動保存狀態。在 Vue 中,可以直接使用 keep-alive 來實現組件緩存,只要使用了 keep-alive 標籤包裹的組件,在頁面切換的時候會自動緩存 失活 的組件,使用起來非常方便,簡單例子如下。

<!-- 失活的組件將會被緩存!-->
<keep-alive>
  <component v-bind:is="currentTabComponent"></component>
</keep-alive>

但是,React 中並沒有 keep-alive 這種類似的標籤或功能,官方認爲這個功能容易造成內存泄漏,暫不考慮支持 (https://github.com/facebook/react/issues/12039)。

所以只能是在路由層做手腳,在路由切換時做對應的緩存操作,之前有開發者提出了一種方案:通過樣式來控制組件的顯示 / 隱藏 (https://github.com/facebook/react/issues/12039),但是這可能會有問題,例如切換組件的時候無法使用動畫,或者使用 ReduxMobx 這樣的數據流管理工具,還有開發者通過 React.createPortal API 實現了 React 版本的 React Keep Alive (https://github.com/Sam618/react-keep-alive),並且使用起來也比較方便。第二種解決方案就是手動保存狀態,即在頁面卸載時手動將頁面的狀態收集存儲起來,在頁面掛載的時候進行數據恢復,個人採用的就是簡單粗暴的後者,實現上比較簡單。緩存緩存,無外乎就是兩件事,存和取,那麼在存、取的過程中需要注意哪些問題呢?

個人認爲需要注意的有以下幾點:

存什麼?何時存?存在哪?何時取?在哪取?

存什麼

首先我們需要關心的是:存什麼?既然要緩存,那麼我們要存的是什麼?是緩存整個 Component、列表數據還是滾動容器的 scrollTop。舉個例子,微信公衆號裏的文章就做了緩存,任意點擊一篇文章瀏覽,瀏覽到一半後關閉退出,再一次打開該文章時會停留在之前的位置,而且大家可以自行測試一下,再次打開的時候文章數據是重新獲取的,在這種場景下,是緩存了文章詳情滾動容器的滾動高度,在離開頁面的時候存起來,再次進入的時候拿到數據後跳轉到之前的高度,除此之外,還有很多別的緩存的方式,可以緩存整個頁面,緩存 state 的數據等等,這些都可以達到我們想要的效果,具體用哪一種要看具體的業務場景。

何時存

其次,我們需要考慮的是什麼時候存,頁面跳轉時會有多種 action 導航操作,比如:POPPUSHREPLACE 等,當我們結合一些比較通用的路由庫時,action 會區分的更加細緻,對於不同的 action 在不同的業務場景下處理的方式也不盡相同。還是拿微信公衆號舉例,文章詳情頁面就是無腦存,無論是 PUSHPOP 都會存高度數據,所以我們無論跳轉多少次頁面,再次打開總能跳轉到之前離開時的位置,對於商品列表的場景時,就不能無腦存了,因爲從 List -> Detail -> List 需要緩存沒問題,但是用戶從 List 返回到其他頁面後再次進入 List 時,是進入一個新的頁面,從邏輯上來說就不應該在用之前緩存的數據,而是重新獲取數據。正確的方式應該是進行 PUSH 操作的時候存,POP 的時候取。

存在哪

  1. 持久化緩存。如果是數據持久化可存到 URL 或 localStorage 中,放到 URL 上有一個很好點在於確定性,易於傳播。但 URL 可以先 pass 掉,因爲在複雜列表的情況下,需要存的數據比較多,全部放到 URL 是不現實的,即使可以,也會讓 URL 顯得極其冗長,顯然不妥。localStorage 是一種方式,提供的 getItemsetItem 等 api 也足夠支持存取操作,最大支持 5M,容量也夠,通過序列化 Serialize 整合也可以滿足需求,另外 IndexDB 也不失爲一種好的方式,WebSQL 已廢棄,就不考慮了,詳細可點擊張鑫旭的這篇文章《HTML5 indexedDB 前端本地存儲數據庫實例教程》(https://www.zhangxinxu.com/wordpress/2017/07/html5-indexeddb-js-example/) 查看對比。

  2. 內存。對於不需要做持久化的列表或數據來說,放內存可能是一個更好的方式,如果進行頻繁的讀寫操作,放內存中操作 I/O 速度快,方便。因此,可以放到 Redux 或 Rematch 等狀態管理工具中,封裝一些通用的存取方法,很方便,對於一般的單頁應用來說,還可以放到全局的 window 中。

何時取

在進入緩存頁面的時候取,取的時候又有幾種情況

  1. 當導航操作爲 POP 時取,因爲每當 PUSH 時,都算是進入一個新的頁面,這種情況是不應該用緩存數據。

  2. 無論哪種導航操作都進行取數據,這種情況需要和何時存一起看待。

  3. 看具體的業務場景,來判斷取的時機。

在哪取

這個問題很簡單,存在哪就從哪裏取。

CacheHoc 的方案

CacheHoc 是一個高階組件,緩存數據統一存到 window 內,通過 CACHE_STORAGE 收斂,外部僅需要傳入 CACHE_NAMEscrollElRefs 即可,CACHE_NAME 相當於緩存數據的 key,而 scrollElRefs 則是一個包含滾動容器的數組,爲啥用數組呢,是考慮到頁面多個滾動容器的情況,在 componentWillUnmount 生命週期函數中記錄對應滾動容器的 scrollTopstate,在 constructor 內初始化 state,在 componentDidMount 中更新 scrollTop

簡單使用

import React from 'react'
import { connect } from 'react-redux'
import cacheHoc from 'utils/cache_hoc'

@connect(mapStateToProps, mapDispatch)
@cacheHoc
export default class extends React.Component {
  constructor (...props) {
    super(...props)
    this.props.withRef(this)
  }

  // 設置 CACHE_NAME
  CACHE_NAME = `customerList${this.props.index}`;
  
  scrollDom = null

  state = {
    orderBy: '2',
    loading: false,
    num: 1,
    dataSource: [],
    keyWord: undefined
  }

  componentDidMount () {
    // 設置滾動容器list
    this.scrollElRefs = [this.scrollDom]
    // 請求數據,更新 state
  }

  render () {
    const { history } = this.props
    const { dataSource, orderBy, loading } = this.state

    return (
      <div className={gcmc('wrapper')}>
        <MeScroll
          className={gcmc('wrapper')}
          getMs={ref =(this.scrollDom = ref)}
          loadMore={this.fetchData}
          refresh={this.refresh}
          up={{
            page: {
              num: 1, // 當前頁碼,默認0,回調之前會加1,即callback(page)會從1開始
              size: 15 // 每頁數據的數量
              // time: null // 加載第一頁數據服務器返回的時間; 防止用戶翻頁時,後臺新增了數據從而導致下一頁數據重複;
            }
          }}
          down={{ auto: false }}
        >
          {loading ? (
            <div className={gcmc('loading-wrapper')}>
              <Loading />
            </div>
          ) : (
            dataSource.map(item =(
              <Card
                key={item.clienteleId}
                data={item}
                {...this.props}
                onClick={() =>
                  history.push('/detail/id')
                }
              />
            ))
          )}
        </MeScroll>
        <div className={styles['sort']}>
          <div className={styles['sort-wrapper']} onClick={this._toSort}>
            <span style={{ marginRight: 3 }}>最近下單時間</span>
            <img
              src={orderBy === '2' ? SORT_UP : SORT_DOWN}
              alt='sort'
              style={{ width: 10, height: 16 }}
            />
          </div>
        </div>
      </div>
    )
  }
}

效果如下:

緩存的數據:

代碼

const storeName = 'CACHE_STORAGE'
window[storeName] = {}

export default Comp ={
  return class CacheWrapper extends Comp {
    constructor (props) {
      super(props)
      // 初始化
      if (!window[storeName][this.CACHE_NAME]) {
        window[storeName][this.CACHE_NAME] = {}
      }
      const { history: { action } = {} } = props
      // 取 state
      if (action === 'POP') {
        const { state = {} } = window[storeName][this.CACHE_NAME]
        this.state = {
          ...state,
        }
      }
    }

    async componentDidMount () {
      if (super.componentDidMount) {
        await super.componentDidMount()
      }
      const { history: { action } = {} } = this.props
      if (action !== 'POP') return
      const { scrollTops = [] } = window[storeName][this.CACHE_NAME]
      const { scrollElRefs = [] } = this
      // 取 scrollTop
      scrollElRefs.forEach((el, index) ={
        if (el && el.scrollTop !== undefined) {
          el.scrollTop = scrollTops[index]
        }
      })
    }

    componentWillUnmount () {
      const { history: { action } = {} } = this.props
      if (super.componentWillUnmount) {
        super.componentWillUnmount()
      }
      if (action === 'PUSH') {
        const scrollTops = []
        const { scrollElRefs = [] } = this
        scrollElRefs.forEach(ref ={
          if (ref && ref.scrollTop !== undefined) {
            scrollTops.push(ref.scrollTop)
          }
        })
        window[storeName][this.CACHE_NAME] = {
          state: {
            ...this.state
          },
          scrollTops
        }
      }
      if (action === 'POP') {
        window[storeName][this.CACHE_NAME] = {}
      }
    }
  }
}

總結

以上的 CacheHoc 只是最簡單的一種實現,還有很多可以改進的地方,譬如:直接存在 window 中有點粗暴,多頁應用下存到 window 會丟失數據,可以考慮存到 IndexDB 或者 localStorage 中,另外這種方案若不配合上 mescroll 需要在 componentDidMount 判斷 state 內的數據,若有值就不初始化數據,這算是一個 bug

緩存方案縱有多種,但需要考慮的問題就以上幾點。另外在講述需要注意的五個點的時候,着重介紹了存什麼和存在哪,其實存在哪不太重要,也不需要太關心,找個合適的地方存着就行,比較重要的是存什麼、何時存,需要結合實際的應用場景,來選擇合適的方式,可能不同的頁面採用的方式都不同,沒有固定的方案,重要的是分析存取的時機和位置。



本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/wwSd9xXt7-aWncoRBGy-9w