用户体验新尝试&思考|让“跳转”加速

Posted 恪愚

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了用户体验新尝试&思考|让“跳转”加速相关的知识,希望对你有一定的参考价值。

如果页面有大量需要跳转的链接怎么办?
如果跳转过去的页面需要大量数据怎么办?
如果网速比较慢怎么办?

提前加载

我们必须事先说明一下,这里的“提前加载”Preload可能并不那么“pre”。他是依托于浏览器策略进行一个资源提前获取的方式 —— 在当前页面中,你可以指定可能或很快就需要的资源在其页面生命周期的早期,也就是浏览器的主渲染机制介入前就进行预加载,这可以让对应的资源更早的得到加载并使用,也更不易阻塞页面的初步渲染,进而提升性能。

对于原生html来说,资源的加载依赖link以及script标签。但我们可以将其都放在 link 中。
后来,html引入了preload关键字:preload 作为元素 <link> 的属性 rel 的值,表示用户十分有可能需要在当前浏览中加载目标资源,所以浏览器必须预先获取和缓存对应资源 。

<link as="script" rel="preload" href="xxx.js">

这时候一定要加上as!其属性值就是要预加载的内容类型

这样做的好处就是让在当前页面中可能被访问到的资源提前加载但并不阻塞页面的初步渲染,进而提升性能。
目前为止,可以预加载的资源有很多:

  • audio:音频文件,通常用于 audio 标签
  • document: 旨在由 frame 或嵌入的 HTML 文档
  • embed:要嵌入到 embed 元素中的资源
  • fetch:要通过 fetch 或 XHR 请求访问的资源,例如 ArrayBuffer 或 JSON 文件
  • font: 字体文件
  • image:图像文件
  • script: javascript 文件
  • style: CSS 样式表
  • worker:一个 JavaScript 网络工作者或共享工作者
  • video:视频文件,通常用于 video 标签

除此之外,还有preconnect关键字:preconnect 是提示浏览器用户可能需要来自目标域名的资源,因此浏览器可以通过抢先启动与该域名的连接来改善用户体验。

<link rel="preconnect" href="https://h5.weidian.com/m/player/index.html?spider_token=3391#/">

简单来说就是提前告诉浏览器,在后面的 js 代码中可能会去请求这个域名下对应的资源,你可以先去把网络连接建立好,到时候发送对应请求时也就更加快速。

但最让笔者心动的就是这个了 —— prefetch关键字:prefetch 作为元素 的属性 rel 的值,是为了提示浏览器,用户未来的浏览有可能需要加载目标资源,所以浏览器会事先获取和缓存对应资源,优化用户体验。

<link rel="prefetch" href="//assets.geilicdn.com/m/xxx/index.json" crossorigin="anonymous" as="fetch">

一般情况下,当你的页面中具有可能跳转到其他页面的路由链接时,就可以使用 prefetch 预请求对应页面的资源了。

那么重点来了:如果一个页面上含有大量链接怎么办?

这里就先不详说我司的脚手架针对此做的优化,但是可以肯定的是,这里面含有不少的跳转链接,尤其在下面的列表项中。而跳转过去的页面相关所有资源这里都是不感知的。

笔者和同事认为:通过监听 link 元素,当其出现到可见区域时动态插入带有prefetch属性值的 <link> 到HTML文档中,从而去预加载对应路由页面的一些资源,这样当用户点击路由链接跳转过去时由于资源已经请求好所以页面加载会特别快。

但是还是那个问题:如果后续业务迭代或者某个新项目中链接非常多呢?
我大胆提出“能不能通过AI分析用户行为、感知用户操作、预测用户下一步最可能的行为,然后去prefetch那一项或几项?”
但这需要各方评估,暂时没有完整方案。(但我深以为可行!)这里只放出简单的监听方案:

// 通过监听 `link` 元素,当其出现到可见区域时动态插入带有`prefetch`属性值的 `<link>` 到HTML文档中

如何监听元素?Intersection Observer API !

IntersectionObserver 是异步的,不随着目标元素的滚动同步触发。

IntersectionObserver 的实现,应该采用requestIdleCallback(),即只有线程空闲下来,才会执行观察器。这意味着,这个观察器的优先级非常低,只在其他任务执行完,浏览器有了空闲才会执行。

这也意味着,使用此API不会对用户操作产生负影响。这为我们提供了一种异步检测目标元素与祖先元素或 viewport 相交情况变化的方法。

Intersection Observer API 会注册一个回调函数,每当被监视的元素进入或者退出另外一个元素时(或者 viewport ),或者两个元素的相交部分大小发生变化时,该回调方法会被触发执行。这样,我们网站的主线程不需要再为了监听元素相交而辛苦劳作,浏览器会自行优化元素相交管理。

对于其参数,

  1. isIntersecting 和 intersectionRatio
    当你想要在 target 在 root 元素中的可见性每超过 25%或者减少 25%的时候都通知一次;你还可以在创建 observer 的时候指定 thresholds 属性值为[0, 0.25, 0.5, 0.75, 1],你可以通过检测在每次交集发生变化的时候的都会传递回调函数的参数"IntersectionObserverEntry.isIntersecting"的属性值来判断 target 元素在 root 元素中的可见性是否发生变化。如果 isIntersecting 是 true,target 元素的至少已经达到 thresholds 属性值当中规定的其中一个阈值,如果是 false,target 元素不在给定的阈值范围内可见。
new IntersectionObserver(
    entries => 
      entries.forEach(entry => 
        if (entry.isIntersecting) 
          // 可见
          console.log(entry)
         else 

      )
    ,
    
      threshold: exposureRate || 0.8, // 可视区域 >= 80% 视为曝光
    
)

我们可以看下它的输出:

  1. rootMargin
    这个参数其实就是检测边界可见的,它对应css中的margin 属性。我们可以用一张图来理解:

  2. root:指定区域(可不传,默认视野区域)

到这里,上面的需求就结束了。总结来说就是通过检测元素进入视野/进入合适位置区间时提前加载下一个页面需要用到的资源,从而提升加载速度。

但笔者有了更大胆的想法:如果网络不稳定呢?如果下个页面需要从后端拿到的数据量特别庞大呢?比如电商营销侧的设置端:

其中每个商品又包含有自己的信息。这可能是非常庞大的数据。这时候我们能不能在上一个页面提前获取请求?存入到本地缓存中?使得进入时不必发送请求?

首先说明:这是有风险的。首先你没法确定网络在什么时间好(这个我们可以加入网络校验API);其次没法确定请求响应的时间(关于这一点有两种方案:

  1. 加入状态,如果跳转时还没有响应,就中断请求(这也是本文的思路)
  2. 让响应延迟到下一个页面(难!)

);最后,“我们不能寄希望于用户的环境是最优的环境”。

为了达到笔者的“crazy ideas”,我把设计了几个环节:

下面的代码已做脱敏处理,故而也去除了一些内部设计和工具。本文以下的代码当然也可以直接使用,不过笔者更建议依托于自己的业务二次设计。毕竟,“能够服务于当前体验的设计才是出色的设计。 —— iPhone”

首先必然是“监听元素”。为了性能,我们不能把所有的链接页面的数据都做请求,而是需要符合一定的条件。简单点的话,「出现在视野中」就是一个条件。
我们可以在比如列表项接口拿到数据后执行:

observeListItem() 
  let observerVideo = new IntersectionObserver(
    (entries, observer) => 
        entries.forEach(entry => 
            // 当移入指定区域内后....
            if(entry.intersectionRatio === 1) 
              cacheIndexs[entry.target.dataset.index].observe = true;
              cacheUtils.setItemObserve(entry.target.dataset.index, 'observe', true);
                // 一些操作
                return
             else 
              if(cacheIndexs[entry.target.dataset.index].observe) 
                cacheIndexs[entry.target.dataset.index].observe = false;
                cacheUtils.setItemObserve(entry.target.dataset.index, 'observe', false);
              
            
            // 停止监听,这一步放在这个页面跳出,并且这时候需要清空缓存。
            // 如果做的精细点,这一步放在跳去某一个缓存项对应页面,只需要将那一项
            // observer.unobserve(entry.target);
          );
        , 
        
          // root: document.getElementById('scrollView'),
          rootMargin: '-16px -16px -16px -24px',
          threshold: 1
        
    );
  document.querySelectorAll('.cl__table-observe').forEach(video =>  observerVideo.observe(video.querySelector('.td--s-ob')) ); // 列表项
,

这里面涉及到一些缓存。也就是“出现在视野中后干什么”:当元素出现在指定区域后,我们将其“被监听的状态”给改变了,并将其存入缓存中。

这里需要介绍下我们的缓存:笔者认为要用到两个缓存。一个是列表项的缓存,Array类型,缓存一些状态变量,比如是否被监听、是否正在请求对应编辑页的接口等等;还有一个是接口数据的缓存,Map类型,而且利用LRU算法维持Map的数量(如果列表项有1w个,总不能都缓存下来吧~)。

// 列表状态缓存
let cacheItemIndex = [];

export function setNewListIndex(val) 
    cacheItemIndex = val;

export function setItemObserve(index, name, val) 
    cacheItemIndex[index][name] = val;

export function matchRequest(index, name) 
    return cacheItemIndex[index][name] || 0;


// 请求数据缓存 
let cacheListMap = new Map();

export function setCacheItem(id, val) 
    cacheListMap.set(id, val);

export function getCacheItem(id) 
    return cacheListMap.has(id) && cacheListMap.get(id);

export function delCacheItem(id) 
    cacheListMap.delete(id);

缓存设置好以后。首先我们在拿到数据时还应该初始化“监听状态”:

// 循环中
cacheIndexs.push(observe: false);

并更新缓存:

// 循环外 - 列表字段设置完成后
cacheUtils.setNewListIndex(cacheIndexs); //更新索引缓存,有删除项记得同步

到这里,缓存和“监听元素是否到页面中”就完成了。

那么,上面有一段说过,我们初始化并设置了当前列表项的链接元素是否展示的状态变量。我们完全可以这么做:当元素出现在视野中/某个区域中,才去监听鼠标是否靠近该元素。而且如果用户靠近该跳转链接并停留了一段时间,就认为该用户可能想要点击跳转。这时候我们就去提前请求对应的接口。

似乎还不错?但是请你“身临其境”想一想,这里面是有问题的~
所以,为什么我们不改成“如果用户鼠标快速划过元素所在区域,则认为用户此时并不想点击跳转,就不去请求此项接口”呢?

你品,你细品!

我们在元素上绑定了一些动态参数,和鼠标监听相关事件:

:data-index="index" :data-acid="item.activityId" @mouseenter="onHandleMouseOn" @mousemove="onHandleMouseMove" @mouseleave="onHandleMouseUp"
onHandleMouseOn(e) 
  nowItemCheck = e;
  // 38-90\\7-33
  dateNow = Date.now();
,

async onHandleMouseMove(e) 
  if(cacheUtils.matchRequest(nowItemCheck.target.dataset.index, 'moveEnd') || !cacheIndexs[nowItemCheck.target.dataset.index].observe) return
    // 下面的思路:直接发请求,如果停留没超过500ms,就不缓存,终止请求
    cacheUtils.setItemObserve(nowItemCheck.target.dataset.index, 'moveEnd', true);
    //发送请求

    // 下面的思路:如果停留超过500ms,就请求并缓存
    // moveInterval = setTimeout(()=> 
    //   console.log('jj')
    //   // 此时需要发送请求了
    //   moveEnd = true;
    //   return
    // , 500)
,

onHandleMouseUp() 
  // 下面的思路:直接发请求,如果停留没超过500ms,就不缓存,终止请求
  if(Date.now() - dateNow < 500) 
    cancelFn('取消了上一次的请求');
  
  // 下面的思路:如果停留超过500ms,就请求并缓存
  // clearTimeout(moveInterval);
  // moveInterval = null;
  // if(!moveEnd && Date.now() - dateNow > 550) 
  //   // 此时要发请求了
  // 
,

我们在鼠标移入时获取到当前date,在鼠标移动时进行去改变状态、发送请求(在发送请求中还要进行请求状态 - 前中后的变化,这个后面代码中有体现)。在鼠标移出时要判断是否超时,如果超时要取消。

请求的发送笔者也考虑到两方面的因素 ——

  • 请求超时
  • 主动取消

对于请求超时,笔者利用Promise的静态race方法营造了一个“竞态”环境。而请求本体也是利用Promise进行包裹,如此,只要我们主动调用reject方法,就能够终止Promise的返回

是的,返回!请求是异步的,而且我们没办法知道此时请求是否已经在路上,而服务器可不会“听从我们的命令”。所以我们“唯一能做的”就是不让返回的结果(无论是正确的还是报错了)影响到后续的流程。

在笔者另两篇研究超时和中断请求的文章中也提到过,著名的axios,其cancelToken就是利用了这种办法实现的。

// 上面onHandleMouseMove方法中“发送请求”部分
Promise.race([
    this.requestPreData(nowItemCheck.target.dataset),
    this.preNetworkTimeout(800, nowItemCheck.target.dataset.index)
  ])
  .then((value)=>
    console.log('拿到数据了?!', value)
    if(value) 
      cacheUtils.setCacheItem(nowItemCheck.target.dataset.acid, value);
    
  )
requestPreData(data) 
  cacheUtils.setItemObserve(data.index, 'request', 1); // request缓存字段为1表示请求进行中 0表示已终止 2表示已完成
  return new Promise((resolve,reject)=>
    this.$$APIS.getActDetail(
      activity_id: data.acid
    ).then(res => 
      cacheUtils.matchRequest(data.index, 'request') < 2 && cacheUtils.setItemObserve(data.index, 'request', 2);
      resolve(res);
    ).catch(err=> 
      reject('请求失败', err);
    )
    // 向外暴露取消函数
    cancelFn=function(msg)
      cacheUtils.setItemObserve(data.index, 'request', 0);
      reject(message:msg)
    
  )
,

preNetworkTimeout(times, index)
  return new Promise((resolve,reject)=>
    setTimeout(()=>
      cacheUtils.matchRequest(index, 'request') < 2 ? cacheUtils.setItemObserve(index, 'request', 0) : console.log('预请求进行中');
      cancelFn('请求超时,暂不缓存当前项');
    ,times)
  )
,

以及,别忘了在跳转前检测状态(上面说了,本文介绍的是如果没法确定请求是否正常,就中断当前请求):

if(cacheUtils.matchRequest(nowItemCheck.target.dataset.index, 'request') == 1) 
    cancelFn('请求取消,不走缓存')

最后,我们就可以在编辑页中“如愿以偿”了:

let res = null;
if(cacheUtils.getCacheItem(this.id)) 
  res = cacheUtils.getCacheItem(this.id);
else 
  res = await getActDetail(
    activity_id: this.id
  );

  // 编辑模式,数据加载结束后展示
  this.isRequestOver = true;

然后,加入 LRU 即可!

最后,想说一些…

当然,对后半部分来说,本文举的例子其实并不那么恰当。在笔者的实践和实际一些项目使用的效果来看,我认为它的使用场景应该更多的在买家端。
而且本文只说了pc端的情况,要知道,H5是没有“鼠标事件”的,这又会带来一个问题:如何更精准的知道“哪些资源需要提前获取?”关于这个问题以及后续的问题我都会及时回到本文中进行更新~

相比之下,在本文这种场景下,笔者更认同“在新建-保存时缓存一份表单项数据,在进入时判断”这种做法 —— 虽然一刷新就无了。

最后,我再一次认为,用户体验是没有尽头的。和「性能优化」一样,我们需要不断的和业务结合,在其中尝试一些“奇思怪想”。

以上是关于用户体验新尝试&思考|让“跳转”加速的主要内容,如果未能解决你的问题,请参考以下文章

用户体验新尝试&思考|让“跳转”加速

【iOS】为你的App增加WIFI认证检测,让用户体验更加丝滑

云计算2.0时代,云巨头如何提升用户体验赢得竞争优势

从产品和用户角度,思考需求和用户体验

加速 iPhone 动画

CDN加速的四大解决方案