原文地址

最近研究了下 Taro 的源码才发现原来,官网是有出 preload 的方案的,但后面分析自身的场景,发现并不适用,于是记录下我们组在小程序 preload 方面的尝试

Taro 官网 preload

一、使用姿势

  1. Page 层面申明 componentWillPreload 函数并返回一个 Promise,在调用原生 navigateTo 之前帮你发送请求

  2. 在 Page 实例化走 componentWillMount 钩子时将之前的 Promise 赋值给 this.$preloadData,这样就能继续请求之后的事

class Index extends Component {
  componentWillMount() {
    this.$preloadData.then(res => {
      console.log('your data:', res)
    })
  }

  componentWillPreload(params) {
    return fetch(params)
  }
}
  1. 源码实现
  • 从源码中可以看出,传递给 componentWillPreload 的参数是我们调用 navigateTo 时 url 的 query 对象

  • 如果 cacheData 中有 url 对应的 Component,那么 Component 也会 preCreate,然后保存在 cache 中

// https://github.com/NervJS/taro/blob/2.x/packages/taro-weapp/src/native-api.js#L137
taro[key] = (options, ...args) => {
  options = options || {}
  let obj = Object.assign({}, options)

  // 代理原生的 navigateTo 和 redirectTo
  if (key === 'navigateTo' || key === 'redirectTo') {
    let url = obj['url'] ? obj['url'].replace(/^\//, '') : ''

    const Component = cacheDataGet(url)
    if (Component) {
      // 预先生成 Component 实例
      const component = new Component()
      if (component.componentWillPreload) {
        const cacheKey = getUniqueKey()
        const params = queryToJson(obj.url)
        obj.url += '?' + `${preloadPrivateKey}=${cacheKey}`
        // 执行 componentWillPreload 并放入 cache 中
        cacheDataSet(cacheKey, component.componentWillPreload(params))
        cacheDataSet(preloadInitedComponent, component)
      }
    }
  }

  return new Promise((resolve, reject) => {
    // 调用原生 api
    task = wx[key](obj, ...args)
  })
}
  • 存的 component 哪里取呢,当然是在正式创建 Component 实例的时候
// https://github.com/NervJS/taro/blob/2.x/packages/taro-weapp/src/create-component.js#L337
const weappComponentConf = {
  data: initData,
  created(options = {}) {
    // 小程序走原生 created 钩子时先判断是否预警实例化过 Component
    if (isPage && cacheDataHas(preloadInitedComponent)) {
      this.$component = cacheDataGet(preloadInitedComponent, true)
      this.$component.$componentType = 'PAGE'
    } else {
    // 没有则 new
      this.$component = new ComponentClass({}, isPage)
    }
    this.$component._init(this)
    // 挂上 $router 的信息
    Object.assign(this.$component.$router.params, options)
  },
}
  • 还有一点,cacheData 中存的对应 url <-> Component 哪来的呢
// https://github.com/NervJS/taro/blob/2.x/packages/taro-weapp/src/create-component.js#L424
const weappComponentConf = {...}
if (isPage) {
  weappComponentConf.methods = weappComponentConf.methods || {}
  // ...
  // 这里 cacheData 存上 url 和 Component
  __wxRoute && cacheDataSet(__wxRoute, ComponentClass)
} else {}

二、问题分析

  1. 由源码分析可知,componentWillPreload 预请求只能针对于第二次进入界面才有效,因为第一次 Taro 自己都拿不到 ComponentClass。但小程序本身(如微信)是做了第一次进入后的优化的,即第二次进入本身就比第一次快很多(如下图控制台),所以对于 preload 来说,第一次尤为重要,但 Taro 中无法实现

  2. 传参限制:我们已经知道 componentWillPreload params 是来自于 url 的 query,就是说我们要使用的参数必须全部 stringify 到 url 上,那如果预请求的参数需要用数组、对象或是对象数组,又或者参数有 20 个呢,全部写到 query 上?那不现实,也不灵活

  3. 业务上移:使用 componentWillPreload 就意味着,请求接口必须和界面相绑定,预请求的接口必须通过界面来发送,也必须要 componentWillMount 中使用。而实际开发时肯定有这种场景,一个在多个界面复用的 store 或组件,本身承担了发请求的工作,然后供界面或界面级 store 使用,如果你要用 preload,每个界面都单独写一遍发送接口逻辑?高度臃肿,且业务逻辑要上移到 Page 中,可维护性降低

without preload

基于接口的 preload

综合了以上痛点,讨论出基于缓存接口的 preload 方案

一、使用姿势

  1. 维护个全局的 preloadApi 对象,key 为需要 preload 的 page,值为该 page 需要发送的请求

  2. 在调 navigateTo 之前,先发送下个界面的需要的 preload api,将 Promise 存起来

  3. 实例化后 Page 钩子或 store 中正常使用该 api,只是再调用时,会先判断下缓存,有的话则直接返回

// utils/cache.ts
export const cache = {}

// api/index.ts
import { cache } from '@/utils/cache'

export const getUser = (id = '') => {
  // 该 api 的唯一标志
  const url = '/userInfo'
  const api = cache[url]
  // 有缓存则走缓存
  if (api) {
    delete cache[url]
    return api
  }

  const p = fetchUser({ id })
  // 给 Promise 绑上 url
  p.url = url
  return p
}

export const preloadApi = {
  // 申明 page 对应的 preload api
  '/pages/preload/index': [
    ({ id }) => getUser(id),
  ],
}

// utils/history.ts 封装原生 api
const history = {
  go(url, params) {
    return navigateTo({ url, ...params })
  },
  replace(url, params) {
    return redirectTo({ url, ...params })
  },
  // ...
}

['go', 'replace'].forEach(type => {
  const fn = history[type]
  history[type] = (url, params) => {
    const { query = {}, ...restParams } = params
    // 根据 url 获取对应 apis
    const apis = preloadApi[url]

    if (Array.isArray(apis)) {
      apis.forEach(api => {
        // 给 api 传参,可以就不限于 query
        const p = api({ ...query, ...restParams })
        // 根据挂上的 url 缓存 Promise
        cache[p.url] = p
      })
    }
    // call original
    fn.apply(history, [`${url}?${qs.stringify(query)}`, restParams])
  }
})
  1. page 中使用,业务对 preload 无感知
import { getUser } from '@/api'

export default class PreloadPage extends Component {
  state = {
    loading: true,
    user: {},
  }

  componentWillMount() {
    this.request()
  }

  request = async () => {
    // 实际调用时可以不传参
    const user = await getUser()
    this.setState({ user, loading: false })
  }

  render() {
    const { loading, user } = this.state
    console.log('render, loading:', loading)
    return (
      <View>
        {loading ? (
          <View>loading...</View>
        ) : (
          <View>user: {user}</View>
        )}
      </View>
    )
  }
}
  1. Promise.all 的场景,肯定有一个界面发多个请求的
// api/index.ts
export const getUser = (id = '') => {...}

export const getPermission = () => {
  const url = '/userPermission'
  const api = cache[url]
  if (api) {
    delete cache[url]
    return api
  }

  const p = fetchPermissions()
  p.url = url
  return p
}

export const preloadApi = {
  '/pages/preload/index': [({ id }) => getUser(id)],
  // 记录两个即可
  '/pages/preloadAll/index': [({ id }) => getUser(id), () => getPermission()],
}

// pages/preloadAll/index.tsx
import { getUser, getPermission } from '@/api'

export default class PreloadAllPage extends Component {
  state = {
    loading: true,
    user: {},
    perm: {},
  }

  componentWillMount() {
    this.request()
  }

  request = async () => {
    // 直接使用就行了
    const [user, perm] = await Promise.all([getUser(), getPermission()])
    this.setState({ user, perm, loading: false })
  }

  render() {...}
}

二、优缺点

  1. 对第一次加载界面也可做 preload

  2. 封装了原生 api,可针对需求传参,不局限于 query,更加灵活

  3. 业务代码无感知,正常使用 api。子组件,子 store 等均可调用

  4. 调用时机提前,由于不和 Page 绑定,所以不用像 Taro 一样在实例化后,通过原生的 created 钩子才给 this.$preloadData 赋值,现在在 constructor 也可调用(当然 Taro 中会走两次)

  5. 良好的可维护性,如果需要增减 prelod 的 api,修改全局的 preloadApi 就行,Page 只需要更改相应的取值

  6. 小缺点就是,在 preloadApi 中申明了一次,在实际业务代码中调用时也要写一次,有点难受,但还没想到很好的解决办法

  7. 而且,实际业务中的 api 是不用传值的(传了也没用,走的 cache),这让其他人来看代码就比较懵逼,当然其实是推荐你完整传参的,这样 preload api 更改时也不至于多改代码

极致的优化

如果你仔细思考,就会发现上面提到的其实还有可优化的点,就是小程序第一次进界面都会很慢,而很多时候接口都已经返回了,可以当做同步数据来处理

但我们还是用 Promise.then 来延后处理,这导致界面会多走一次 loading 为 true 的 render,这样会快速显示 loading 的逻辑,然后 then 后闪为 loading 为 false 的 render,如第一幅图所示

一、同步的 Promise

  1. 于是我有个大胆的想法,如果 preload api 已经返回了,能否变为同步的 Promise 呢,但修改后的 “Promise” 仍可以调用原来的 then,但却会同步的执行

  2. 说白了就是手撸个 Promise 嘛,Promise 原理很简单,内部维护 fulfilledCallback,rejectedCallback 两个队列,每次 then 会 push 入栈,当调用 resolve 或 reject 时再 forEach 执行,当然具体会用到 setTimeout 和递归

  3. 我参考了 es6-promise 这个库,喜欢的自行了解吧。同步 Promise 实现你可以理解为删除了 setTimeout 和 队列

class ThenSync<R> implements Thenable<R> {
  private data: any = null
  status: 'pending' | 'resolved' | 'rejected' = 'pending'

  constructor(executor) {
    // resolve 回调
    const resolve = (value?: R | Thenable<R>) => {
      if (this.status === 'pending') {
        this.status = 'resolved'
        this.data = value
      }
    }
    // reject 回调
    const reject = (err?: any) => {
      if (this.status === 'pending') {
        this.status = 'rejected'
        this.data = err
      }
    }
    executor(resolve, reject)
  }

  then<U>(onFulfilled,onRejected): ThenSync<U> {
    const that = this
    let t2: any = null
    let x: any

    if (that.status === 'resolved') {
      t2 = new ThenSync((resolve, reject) => {
        try {
          x = onFulfilled!(that.data)
          resolvePromise(t2, x, resolve, reject)
        } catch (err) {
          reject(err)
        }
      })
    } else {
      // rejected
      t2 = new ThenSync((resolve, reject) => {
        try {
          x = onRejected!(that.data)
          resolvePromise(t2, x, resolve, reject)
        } catch (err) {
          reject(err)
        }
      })
    }
    // new thenable
    return t2
  }

  catch<U>(onRejected): ThenSync<U> {
    return this.then(null as any, onRejected)
  }
}
  1. 使用
const aa = new ThenSync(resolve => {
  console.log(1);
  resolve(100);
});

const res = aa
  .then(d => {
    // 正常使用
    console.log(2, d);
    return d;
  })
  .then(d => {
    console.log(3, d);
    // 嵌套使用
    return new ThenSync(r => r(d + 2));
  })
  .then(d => {
    // throw err
    console.log(4, d);
    throw new Error("err");
  })
  .catch(err => {
    console.log(5, err.message);
  });
console.log("sync code");


// 会打印
1
2 100
3 100
4 102
5 "err"
"sync code"
  1. syncify,那么如果将异步的 Promise 转换为同步的呢,首先我们不想改变 cache[url] = p 存的 Promise,为啥,因为懒。那反正都要调用 then 和 catch 的,就改这两个方法吧
function syncify<R>(obj: any, res: R) {
  if (!isPromise(obj)) return obj
  const hack = new ThenSync(r => r(res))
  // 注意这里只是改的 Promise 实例的方法
  obj.then = hack.then.bind(hack)
  obj.catch = hack.catch.bind(hack)
  // 挂上已同步的标志
  obj[THEN_SYNC] = hack
  return obj
}
  1. 那么在 api resolved 后的 then 中 syncify。但要注意如果 Page 业务已经 api 了,但 api 还没 resolved 这时就不要 syncify 了
function syncPromise<T>(promise: Promise<T>) {
  const p: any = promise.then(res => {
    // 如果正式使用时接口还没回来,则直接返回
    if (p[THEN_SYNC] === false) return res
    syncify(p, res)
    return res
  })
  return p as Promise<T>
}

// utils/history.ts
['go', 'replace'].forEach(type => {
  history[type] = (url, params) => {
    // ...
    if (Array.isArray(apis)) {
      apis.forEach(api => {
        const p = api(query)
        // 挂上同步 Promise
        cache[p.url] = syncPromise(p)
      })
    }
    // ...
  }
})

// api/index.ts
export const getUser = (id = '') => {
  const url = '/userInfo'
  const api = cache[url]
  if (api) {
    // 业务正式调用 api,如果没有 THEN_SYNC 标志,说明接口还没返回,则不用 syncify
    if (!api[THEN_SYNC]) {
      api[THEN_SYNC] = false
    }
    delete cache[url]
    return api
  }
  // ...
  1. 业务代码完全无感知,你给我异步还是同步的 Promise,我都是调用 then,效果图如下。另外可以看到第一次 preload 调用 api 和界面正式调用 api 足足相差了 943 ms,正常的话接口已经回来了,这也反过来证实 sync Promise 的必要性

preload

二、Promise.all 的难点

  1. 那么现在遇到了难点就是如何处理 Promise.all 呢,为啥,因为 Promise.all 是内部自己 new 了个异步的 Promise 啊
// 单个请求
request = async () => {
  const user = await getUser()
  this.setState({ user, loading: false })
}

// 等同于
request = () => {
  // 可以从 cache 中取 Promise,同时这个 Promise 可能已经被 syncify
  return getUser().then(user => {
    this.setState({ user, loading: false })
  })
}

// 而 Promise.all
request = async () => {
  const [user, perm] = await Promise.all([getUser(), getPermission()])
  this.setState({ user, perm, loading: false })
}

// 等同于
request = () => {
  return Promise.all([getUser(), getPermission()])
    // 注意就是这!这个由 Promise.all 产生的 Promise 无法与之前缓存的 getUser() 的 Promise 关联,自然也无法 syncify
    .then(([user, perm]) => {
      this.setState({ user, perm, loading: false })
    })
}
  1. 如何让 Promise.all 产生的 Promise 与 cache 的 Promise 产生关联呢,你又不能改 Promise 源码,改 Promise.all。那怎么办呢,实际上我们想左右异步函数的执行,根据我们的需求返回相应的 Promise 是吧。那么 Generator 它来了!

  2. 自己手撸个异步函数执行器,通过监听 yield 的返回值,去定制化返回,期间加上我们自己的逻辑。那么怎么少的了 tj 大神的 co 库

export function co<R>(gen,...args): Promise<R> {
  const that = this
  // 记录是否已同步
  let isAsync = true
  let finalRes
  const p: any = new Promise((resolve, reject) => {
    onFulfilled()

    function onFulfilled(res: any = undefined) {
      let ret
      try {
        ret = gen.next(res)
      } catch (err) {
        return reject(err)
      }
      next(ret)
      return null
    }

    function onRejected(err: any) {...}

    // next 递归执行
    function next(ret) {
      const { value, done } = ret
      if (done) {
        finalRes = value
        return resolve(value)
      }
      const finalValue = toPromiseWithPreload.call(that, value)

      if (isPromise(finalValue)) {
        // 如果请求已经返回则返回同步的 Promise
        finalValue[THEN_SYNC] && (isAsync = false)
        return finalValue.then(onFulfilled, onRejected)
      }
      return ...
    }
  })
  // 根据标志返回相应 Promise
  return isAsync ? p : syncify(p, finalRes)
}

function toPromiseWithPreload(this: any, thing: any) {
  if (isPromise(thing)) return thing
  // yield [Thenable, Thenable]
  if (Array.isArray(thing)) {
    // 查看任意一个 api 是否含有 all 的 key
    const key = thing[0][THEN_ALL]
    // 未缓存时直接返回
    if (!key) return Promise.all(thing)

    const allApi = cache[key]
    if (!allApi[THEN_SYNC]) {
      allApi[THEN_SYNC] = false
    }
    delete cache[key]
    // 定制化返回我们之前存的 allApi
    return allApi
  }
  return thing
}
  1. 那么原生封装稍微改下
// utils/history.ts
['go', 'replace'].forEach(type => {
  history[type] = (url, params) => {
    // ...
    if (Array.isArray(apis)) {
      // 是否为 Promise.all 的预请求
      const isAll = apis.length > 1
      const promises = apis.reduce((arr, api) => {
        const p = api({...query, ...restParams})
        // 单个 Promise 跟 Promise.all 产生关联
        isAll && (p[THEN_ALL] = `${url}all`)
        arr.push(p)
        cache[p.url] = isAll ? p : syncPromise(p)
        return arr
      }, [])
      // 多存一个 all 的 api,正对于 Promise.all syncify
      isAll && (cache[`${url}all`] = syncPromise(Promise.all(promises)))
    }
    // ...
  }
})
  1. 使用如下,也是亲代码性的修改
import { co } from '@/utils/thenSync'

class PreloadAllPage extends Component {
  state = {...}

  componentWillMount() {
    co(this.request)
  }

  *request() {
    // 不用再写 Promise.all
    const [user, perm] = yield [getUser(), getPermission()]
    this.setState({ user, perm, loading: false })
  }

  render() {...}
}
  1. 效果图如下

preload all

最后

  1. 源码获取:taro mini demo

  2. 喜欢的小伙伴,记得留下你的小 ❤️ 哦~

参考资料