【又硬又细】移动端Web开发踩坑指北

4,444 阅读22分钟

文中1.1和1.3关于position: fixed的问题是由于对css层叠上下文理解不透彻才可能会写出来的Bug。具体内容可以查看张鑫旭大神的《CSS世界》第七章或者参考W3官网链接

下面这两条是层叠领域的黄金准则。当元素发生层叠的时候,其覆盖关系遵循下面两条准则:
(1)谁大谁上:当具有明显的层叠水平标识的时候,如生效的 z-index 属性值,在同一个层叠上下文领域,层叠水平值大的那一个覆盖小的那一个。
(2)后来居上:当元素的层叠水平一致、层叠顺序相同的时候,在 DOM 流中处于后面的元素会覆盖前面的元素。

本篇主要记录和总结了近年来在开发移动端Web项目中遇到的一些最典型、最常见的踩坑集锦。其中不乏有设备系统层面的差异性问题、CSS知识储备不到位的技术型问题以及一些热门前端框架的隐藏比较深的Bug

一、 你可能不知道的fixed属性

CSS是一门很深的学问,它没有语法却暗藏语法,它刻画细节却又要统筹全局,很难有系统的学习方法去学习CSS,很多时候都是在边做边学。遇到了问题咱们就解决问题。在CSS中最晦涩的可能就是position属性,而fixed又是其中最恶心的那一个:

1.1 设置position:fixed的元素参考系失效

通常,我们在对一个元素设置position:fixed时,它的参考系是视口viewport(视觉视口),即你设置的toprightbottomleft都是参照viewport的上、右、下、左边框来进行偏移定位的。

但是,在某些场景下,你会发现它的参考系不再是viewport了,而成为了别的元素。有以下html结构和css样式:

<!-- html -->
<section class="wrap">
   <div class="top-wrap"></div>
   <div class="modal-wrap">
      <div class="modal">
      </div>
   </div>
</section>

// stylus
.wrap
  width 100%
  height 100%
  background-color #red
  .top-wrap
    background red
    height 200px
  .modal-wrap
    height 300px
    transform: translate(0px, 0px) scale(1) translateZ(0px)
    .modal
      background-color rgba(0,0,0, 0.3)
      position fixed
      left 0
      top 0
      width 100%
      bottom 0

效果图如下:

其中灰色区域就是modal元素,我们对它设置了position: fixed; left: 0; top: 0; bottom: 0;,理论上来说它的参考坐标系应该是针对的屏幕(viewport),但是真实场景却是modal-wrap元素。

细心的你一定发现了,modal-wrap元素设置了transform: xxx的样式属性,而它就是bug产生的根本原因。解决方案也很简单,去掉这个属性就一切正常了:

产生这个Bug的原因,我查了很多资料也没有找到实质性的解释,大概就是position: fixedtransform: xxx一起使用时会有图层上的冲突,从而导致了这个bug。如果哪位大佬知道,请一定要在评论区指点下迷津。

1.2 设置position:fixed的全屏弹框,滚动事件会击穿

在移动端Web开发中,弹框应该是最常见的一种交互方式,而通常和弹框一起出现的还有它底部的一个半透明的灰色遮罩层。要想实现这样的弹框是很容易的,我们可以很快的写出如下代码:

<!-- html -->
<div class="modal">
   <div class="box"></div>
</div>

// stylus
.modal
  position fixed
  left 0
  top 0
  width 100%
  height 100%
  display flex
  justify-content center
  align-items center
  background-color rgba(0,0,0,0.3)
  .box
    width 300px
    height 300px
    background-color #ffffff
    border-radius 16px

效果也很好,完美符合我们的预期:

但是,当你页面内容(弹框底层)超过一屏时,你会发现你在滚动滚轮或者触摸滑动的时候,事件被击穿了:

如果你的UI(或产品或老板)对交互要求比较严格,这肯定是不合格的,那么我们应该入个解决这个问题呢?

对于这个问题,社区内也有很多解决方案,比较我推荐滚动穿透的6种解决方案

这里,我也会给出一种比较完善并且副作用比较小的解决方案,你需要做以下几件事:

  1. 在全局作用域下定义如下css片段:
.dialog-open {
   position: fixed;
   width: 100%;
}
  
  1. 定义两个工具方法:
// 获取当前页的据顶部滚动距离
const getScrollTop = () => {
   // pageYOffset:属于window对象,IE9+ 、firefox、chrome,opera均支持该方式获取页面滚动高度值,并且会忽略Doctype定义规则;
   // scrollY: 属于window对象,firefox、chrome,opera支持,IE不支持,忽略Doctype规则。
   // 页面如果未定义doctype文档头,所有的浏览器都支持document.body.scrollTop属性获取滚动高度
   // 如果页面定义了doctype文档头,那么html元素上的scrollTop属相在IE、firefox,Opera(presto内核)下都可以获取都可以获取滚动高度值,而在chrome和safari下其值也为0.
   return window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop
}

// 页面滚动到指定位置
scrollTo (xpos = 0, ypos = 0) {
   document.body.scrollLeft = xpos
   document.documentElement.scrollLeft = xpos
   // Chrome
   document.body.scrollTop = ypos
   // Firefox
   document.documentElement.scrollTop = ypos
}
  1. 定义一个辅助函数:
const modalHelper = ((bodyCls) => {
  let scrollTopVal
  return {
    afterOpen: () => {
      scrollTopVal = getScrollTop()
      // 给body添加fixed定位,但是会丢失滚动条位置,因此需要在fixed之前获取当前页面滚动元素的【滚动距离】
      document.body.classList.add(bodyCls)
      // 使用top设置负的【滚动距离】值,模拟的滚动位置
      document.body.style.top = -scrollTopVal + 'px'
    },
    beforeClose: () => {
      // 移除fixed定位,初始化其top值为0,同时滚动视口置【滚动距离】处就可以还原其原来的滚动位置
      document.body.classList.remove(bodyCls)
      document.body.style.top = 0
      scrollHelper.scrollTo(0, scrollTopVal)
    }
  }
})('dialog-open')

其中的实现细节都已经体现在代码的注释上了,其中主要做了对不用版本浏览器的兼容处理。

最后,你只需要要在你需要展示弹框的场景中,在弹出弹框后调用afterOpen,想要关闭弹框时先调用beforeClose即可。

该方法有一定的副作用,如果你开发的是单页面应用,那么当你在某页面打开一个弹框后,想要离开当前页面时一定要记得调用beforeClose(),否则下一个页面会被锁定

1.3 设置position:fixed的元素z-index值失效

还是position: fixed,到这儿你会深深体会到对它的深恶痛疾。

其实这个bug能产生的场景在日常开发工作中应该是相当常见的,即:你开发了一个组件,这个组件的正常文档流里有一个设置了position: fixed的元素。

如果,你曾开发过类似内容的组件,那么你很可能遇到这个bug,如果你没遇到过,要么是你运气好,要么是你技术好~ 我们来看如下代码:

// 你的组件 Com.vue
<template>
	<div class="content">
		<div class="info">
		</div>
	</div>
</template>
<script>

export default {
  name: 'TestCom'
}
</script>
<style lang="stylus">
.content
    position fixed
    left 0
    top 0
    width 100%
    height 500px
    background-color black
    .info
      position fixed
      left 100px
      top 100px
      z-index 999
      width 100px
      height 100px
      background-color blue
</style>

// 使用到 Com组件的一个组件Test.vue
<template>
	<section class="wrap">
		<Com />
		<div class="content">
		</div>
	</section>
</template>
<script>
import Com from './com'
export default {
  name: 'TestCROS',
  components: {
    Com
  }
}
</script>
<style lang="stylus">
.wrap
  .content
    position fixed
    left 150px
    top 150px
    width 100px
    height 100px
    background-color red
    z-index 2
</style>

Test.vue的展示效果如下:

出问题了!我们Com组件内的info元素(蓝色方块)被红色方块给遮住了,你可能会发现一点猫腻:Com组件内的content也设置了position: fixed,如果不设置会怎么样呢?

如果不设置就没问题了,但是你没办法保证Test.vue的外层元素不再有设置了position: fixed的元素了,如果有,那还是会出问题。

而且,如果你把Com组件的样式修改为:

.com-content
    overflow auto
    width 100%
    height 500px
    background-color black
    .info
      ...

我们把position: fixed相关代码去掉了,换成了overflow: autoscroll也一样)在IOS设备中打开,你会发现又出问题了:

可恶吧,其实这是一个隐藏bug,只是它只会出现在IOS设备中,安卓设备中是正常的。

经过我一下午的调试和整理,终于得出了以下结论(可能会有些片面,但是都真实调试过的规律):

假设要判断设置了position: fixed的元素Fz-index值的生效情况,则需要如下判断:

  1. F所处层级及外层不存在多个设置了position: fixed的元素有嵌套时,那么一切正常(即z-index的权重生效 值越大展示在z轴的更高层)
  2. 如果有嵌套,进一步判断F的所有直系父辈元素中是否有一个靠它最远的设置了position: fixed的元素P1
    1. 没有,那么在P1的兄弟元素和它"所有直系父辈元素的兄弟元素中"找到那个靠它最远的、设置了position: fixed的元素P2
      • 找得到这样一个元素P2,那么假设Fz-indexZ1P2z-indexZ2,比较Z1Z2的大小关系即可
      • 找不到这样的元素,那么则一切正常
    2. 有,那么在P1的兄弟元素和它"所有直系父辈元素的兄弟元素中"找到那个靠它最远的、设置了position: fixed的元素P2
      • 找得到这样一个元素P2,那么假设P1z-indexZ1P2z-indexZ2,比较Z1Z2的大小关系即可
      • 找不到这样的元素,那么则一切正常
    3. 比较Z1Z2的大小关系:
      • Z1 > Z2,则F一定会展示在P2元素及其子孙元素的上面
      • Z1 = Z2,需要进一步判断FP2谁的相对层级靠前(即书写的先后顺序,而不是层级顺序),层级靠前的优先级高,即会展示在上面
      • Z1 < Z2,则F一定会展示在P2元素及其子孙元素的下面

其中,如果Z1Fz-index值,则可以通过调整z-index值达到另外两种效果;而如果Z1P1z-index值,则无论如何改变Fz-index都无济于事,除非你去改变P1z-index值。

  1. 如果是IOS设备,那么优先判断F的"所有的直系父辈元素中"是否有设置了overflow: auto || scroll的元素P1
    1. 没有,则后续判断同上
    2. 有,那么判断在F的"所有直系父辈元素的兄弟元素中"是否有一个设置了position: fixed的元素P2
      • 有,那么Fz-index相对P2z-index会失效;
      • 没有,则一切正常。

看在我辛苦调试和整理一下午的份上,点个赞吧各位~

二、成也安卓,败也安卓

在移动端Web开发工作中,最常见的兼容问题就是安卓和IOS系统不兼容的问题,而其中最难伺候的可能就是IOS系统了,但是安卓系统总是会在不应该出现问题的地方给你“使绊子”,简直是又爱又恨。

IOS系统上安装的浏览器必须要基于Safari的内核来构建,而安卓系统则是要基于Chromium内核所以我们在解决这些系统间差异性bug时,也是在解决SafariChromium两大浏览器阵营间的问题。

2.1 Android设备不支持多选图片

这个问题就像标题一样霸道,不支持就是不支持,毫无hack的办法。一般这种涉及到访问用户数据,对数据安全有较高要求的API调用在IOS中有更严格的功能限制,但是谁知道安卓竟然能在这儿给你摆了一道。

如果你是在开发一个基于微信环境的H5项目,那么恭喜你,你可以使用wx.chooseImage进行图片的多选功能,说到底安卓只支持原生应用多选图片,而Web应用不行。

2.2 页面关闭时,埋点上报请求失败(或被取消)

通常,我们在记录埋点数据时都需要调用对应的服务端接口,把当前的埋点数据记录到缓存或日志中供后端进行数据分析。然而,有些极端场景下我们的埋点请求可能会发送失败或者被取消发送,如记录用户在页面的停留时间,那么就需要在页面载入时记录开始时间,在页面跳转或关闭时计算停留时间并发送埋点上报请求,而此时:

如果你用的是axios,请求很可能会被canceled,而如果你用的是同步XmlHttpRequest请求方式的话,由于它会阻塞用户交互,已经被Chrome强制标记为已过期了。

因此,这两种方式都是不合适的、不保险的。那么有没有更优雅的请求方式呢?

答案是有的,navigator.sendBeacon()方法可用于通过HTTP将少量数据异步传输到Web服务器,MDN上这样描述sendBeacon:“会使用户代理在有机会时异步地向服务器发送数据,同时不会延迟页面的卸载或影响下一导航的载入性能。”。

另外,navigator.sendBeacon()相比于传统的上报方式海域以下优势:

  1. 在Chrome浏览器中,其优先级分别为 sendBeacon:【lowest】,异步xhr:【high】,同步xhr:【highest】,Image方式: 【low】

  1. sendBeacon并不会等待接口返回值,其返回值取决于客户端代理是否成功把请求放入到发送队列中;
  2. sendBeacon没有对请求数据大小的限制。

落实到我们的实现代码上就可以是:

function reportData (data) {
   return new Promise((resolve, reject) => {
      const baseUrl = process.env.NODE_ENV === 'production' ? process.env.VUE_APP_API_ROOT : '/api'
      const url = `${baseUrl}/data/xxx/report`
      const blob = new Blob([JSON.stringify(data)], {
         type: 'application/json'
      })
      const result = navigator.sendBeacon(url, blob)
      resolve(result)
   })
}

在开发环境一切正常,发到真机上,一切...不太正常!你会发现这个方法在IOS设备上可以正常发送请求,而在安卓设备上却会报错,需要request headerCORS-safelisted-request-header,在这里则需要保证 Content-Type 为以下三种之一:

  • application/x-www-form-urlencoded
  • multipart/form-data
  • text/plain

好巧不要,我们设置的application/json刚好不在这里面,因此,解决这个问题最好的办法也就出现了,使用官方推荐的这三个Content-Type中的一个就好了,具体怎么用要看你实际的应用场景和数据结构。

需要注意的是,sendBeacon还是一个很新的API,某些浏览器可能还没有实现该方法,因此你最好给它设置一个足够的兜底方法,如axiosxhr

2.3 IOS设备,返回上一个页面数据不更新

这个bug在社区中经常能看到它的身影,如果你还不知道它的样子,我们先来看看它的现象,有如下Vue代码:

<template>
	<section class="date">
		{{ date }}
		<div class="btn" @click="handleLeave">
			离开
		</div>
	</section>
</template>
<script>
export default {
  name: 'Test',
  data () {
    return {
      date: Date.now()
    }
  },
  methods: {
    handleLeave () {
      location.assign(`/${process.env.VUE_APP_BASE_NAME}/test2`)
    }
  }
}
</script>
<style lang="stylus" scoped>
.date
  font-size 40px
  line-height 56px
  color red
  .btn
    width 100%
    height 80px
    font-size 28px
    line-height 80px
    text-align center
    background #eeeeee
</style>

正常情况下(在非IOS系统的设备上),当你点击了离开按钮后,会导航到新的页面,之后你再返回到该页面时,该页面上展示的时间戳会变为当前的时间戳,如图所示:

但是,如果在IOS设备中打开,你会发现如下现象:

这是IOS设备的bug,但是说它是bug又着实委屈它了,因为它的本意是为了提升用户的体验度而做的一个IOS系统特有的交互效果。

要想解决这个bug,我们就需要搞清楚它产生的几个必要条件:

  1. IOS设备;
  2. 跳转到新的页面;
  3. 返回时,页面不刷新。

仔细研究你会发现,其中的跳转到新页面指的是location发生了变化,也就是说页面跳转后的document不等于跳转前的那个document返回时页面不刷新对应到Vue代码实现的话就是在返回到当前页面Test.vue时没用调用当前这个页面组件的生命周期(或者说没有调用data(){})重新拉取数据。

因此,我们可以针对这两点进行hack处理:

  1. 记住页面跳转时机:
import { HREF_TO_OTHER_PAGE } from 'src/constant' 
sessionStorage.setItem(HREF_TO_OTHER_PAGE, '1')
location.href = 'xxx'
  1. 监听页面载入事件,特殊处理IOS设备
const browserType = getBrowserType()
if (browserType.ios) {
   window.addEventListener('pageshow', function (e) {
      // 是否是通过点击导航栏的前进后退按钮访问的该页面
      const isBack = window.performance && window.performance.navigation.type === 2)
      // 只读属性persisted代表一个页面是否从缓存中加载的
      if ((sessionStorage.getItem(HREF_TO_OTHER_PAGE) === '1') && (e.persisted || isBack) {
         sessionStorage.removeItem(HREF_TO_OTHER_PAGE)
         window.location.reload()
         return false
      }
   }, false)
}

其中,window.performance.navigation.type === 2表示当前页面是通过用户点击导航栏上面的前进后退按钮访问的,而e.persisted代表一个页面是否从缓存中加载的。有兴趣的同学可以参考PerformanceNavigationPageTransitionEvent.persisted

这个bug并不是在IOS设备在前进和后退时都会发生的,其实它只会在IOS设备在进行跨Document的页面跳转时才会发生,即location.assign()location.replace()location.href = xx

2.4 IOS设备,页面最底部元素使用margin-bottom撑开页面留白无效

中场小甜点型的bug,其实这个bug大家都有办法解决,我这边也只是拿出来作为抛砖引玉,大家有好的解决办法可以留言在评论区中,至于我的解决方案就很粗暴,直接使用padding-bottom就好了。

2.5 IOS设备微信7.0软键盘收起时页面不回滚到原位置的问题

这个问题是在微信刚刚发布7.0大版本更新时出现的最大的一个bug,但是现在已经修复了,具体不知道是在哪个小版本修复的。

不过,bug的现象就是:页面内的文本输入类型的表单元素在输入内容时会将IOS的唤起软键盘并将当前的文本输入框“推”到合适的视口内(其实是把页面整体推了起来),在输入完成后,正常情况下文本输入框会滚回原位置,但是该bug会导致页面卡死在“推”到的位置。

其实解决这个bug的方案也很简单,就是监听文本输入框的blur事件,调用scrollIntoViewdocument.body滚回页面底部对齐的位置即可:

document.addEventListener('blur', (e) => {
   // 这里加了个类型判断,因为a等元素也会触发blur事件
   ['input', 'textarea'].includes(e.target.localName) && document.body.scrollIntoView(false)
}, true)

2.6 IOS设备的微信,单页面应用不会改变url

目前这个问题已经被修复了,应该是ios14.0大版本更新之后修复的,或者更前面的版本,因为IOS一直有这个问题,所以一直以为它不会修复了,没想到最近在整理项目的过程中突然发现它已经被修复了。但是稳妥起见,还是把它当做一个bug继续处理的好。

开发过微信H5单页面应用的同学对这个问题应该都印象深刻,尤其是当有需要唤起微信支付的功能时,由于在唤起微信支付时微信的API要求传入已经注册在微信管理后台的当前页面的url,但是由于这个bug的存在,再IOS设备中我们只能自己hack想办法拿到当前页面的url。

当前社区上也有各位大神给出的解决方案。我这里拿出来同样是为了把问题拿出来和大家分享一下我自己的解决防范,也是为了抛砖引玉,大家如果有更好的解决方案欢迎拍砖讨论:

// 应用的路由实例
import router from 'src/router'
// 支付路由名称列表
const PAY_ROUTER_LIST = ['route1', 'route2', ...]

// 当前跳转是否是要跳转至支付页面
const is2PayRouter = (toName, fromName) => {
  if (!PAY_ROUTER_LIST.includes(toName)) {
    return false
  }
  if (!(getBrowserType().ios)) {
    return false
  }
  return !!fromName
}

router.beforeEach(async (to, from, next) => {
   ...
   if (filter2PayRouter(to.name, from.name)) {
      const query = to.query
      let action = 'assign'
      let queryStr = ''
      let queryItemList = []
      if (query && typeof query === 'object') {
      if (query.replace) {
         action = 'replace'
         delete query.replace
      }
      Object.keys(query).forEach(key => {
         queryItemList.push(`${key}=${query[key]}`)
      })
      queryStr = queryItemList.join('&')
      }
      // 兼容ios多文档之间跳转时浏览器后退会访问缓存的问题
      if (action === 'assign') {
      sessionStorage.setItem(HREF_TO_OTHER_PAGE, '1')
      }
      window.location[action](`/${process.env.VUE_APP_BASE_NAME}${queryStr ? `${to.path}?${queryStr}` : `${to.path}`}`)
      // 阻断当前路由跳转的动作
      next(false)
      return
   }
   next()
   ...
})  

在需要跳转到支付页面时,写法也很简单,即:

this.$router.replace({
   name: 'route1',
   params: {
      id: 1
   },
   query: {
      replace: 1
   }
})

三、Vue的Bug?

Vue全家桶,每一个都是优秀的项目,也通过了无数前端打工人的实践和检验。但是,如果它们如果有一些功能不能满足大部分人的需求或者设计的功能不符合大家的需求,而且它还又不去改的时候,那么它可能就要被叫做bug了。

3.1 Vue中最难用的功能:KeepAlive

Vue的官方文档中这样介绍keep-alive: “<keep-alive> 包裹动态组件时,会缓存不活动的组件实例,而不是销毁它们”。

但是,如果你按照它文档上介绍的用法去使用的时候,大部分情况下是没有问题的,但是如果用在如下场景中,就会产生bug:

  1. ABC三个页面,B页面含有表单填写内容;
  2. A跳转至B页面,填写表单,然后访问C页面,再从C页面返回时,B页面需要保留上一次填写的数据内容;
  3. A-> B -> C,然后 C -> B -> A,再次 A -> B,此时B页面需要展示其本身默认的内容,而不是缓存的上一次填写的数据内容

此时B页面还缓存着上一次填写的数据内容,这显然与正常的用于交互场景是冲突的,因为用户已经从新回到了A页面,再次从A页面进入B页面时理应该是全新的空页面。

那么该如何解决这个问题呢? 社区中有很多关于keep-alive的讨论和解决方案,其中比较有参考价值的可以看下这个Vue官方GitHub下的讨论

以下,是我总结和整理了一些网上行之有效,比较稳健的方法而形成的一个最终解决方案,并且已经在线上跑了大半年了,暂时还没有发现什么问题。

  1. 在单页面应用的入口组件内使用如下形式组织router-viewkeep-alive组件:
<div id="app">
   <keep-alive>
      <router-view v-if="$route.meta.keepAlive" />
   </keep-alive>
   <router-view v-if="!$route.meta.keepAlive" />
</div>
  1. 在需要使用keep-alive功能的页面级组件的路由配置的meta上配置上keepAlive属性值为true:
{
   path: '/test/keep-alive',
   name: 'testKeepAlive',
   meta: {
      title: '测试测试',
      keepAlive: true
   },
   component: () => import(/* webpackChunkName: "testKeepAlive" */ 'src/views/testKeepAlive.vue')
}
  1. 配置一个全局常量KEEP_ALIVE_MAP
// 需要缓存的路由
// MAP的key表示需要做缓存处理的路由名称(并需要在路由表配置中配置其meta对象中的keepAlive为true)
// Map的value表示从这些(路由名数组)路由中访问key路由时需要的是key路由缓存过的数据
// *** 具体使用逻辑详见: views/mixins/keepAlive.js
export const KEEP_ALIVE_MAP = {
  page1: [
    'page'
  ],
  page2: [
    'page'
  ]
}
  1. 提取一个公用的keepAlive.js
import { KEEP_ALIVE_MAP } from 'src/constant'
// keepAliveCache是一个全局变量,保证单例模式
import { keepAliveCache } from 'src/var'
export default {
  beforeRouteEnter (to, from, next) {
    // 在访问需要缓存的页面,但是我的上一个页面又不是"需要缓存的页面"指定的下一个应该访问的页面;
    // 此时可能是第一次进到这个页面,也可能是二次访问这个页面
    if (KEEP_ALIVE_MAP[to.name] && !KEEP_ALIVE_MAP[to.name].includes(from.name)) {
      const key = `${from.name}_${to.name}`
      // 如果缓存中存在这个key,则意味着是第二次访问该页面了,那么就需要刷新该页面,否则就记下来,下次再进来的时候就要刷新页面了
      if (keepAliveCache[key]) {
        const query = to.query
        let queryStr = ''
        let queryItemList = []
        if (query) {
          Object.keys(query).forEach(key => {
            queryItemList.push(`${key}=${query[key]}`)
          })
          queryStr = queryItemList.join('&')
        }
        window.location.href = (`/${process.env.VUE_APP_BASE_NAME}${queryStr ? `${to.path}?${queryStr}` : `${to.path}`}`)
      } else {
        keepAliveCache[key] = key
        next()
      }
    } else {
      next()
    }
  },
  beforeRouteLeave (to, from, next) {
    const routers = KEEP_ALIVE_MAP[this.$route.name]
    // 页面离开时,但是要去的页面并不是它应该正常访问的“指定页面”,此时需要强制"销毁"该组件,
    // 使得下次进入该页面时可以重新激活该组件的所有默认行为,即created、watch等生命周期等。
    if (!routers.includes(to.name)) {
      this.$destroy()
    }
    next()
  }
}

3.2 VueRouter中,beforeRouteLeave钩子失效

严格意义上来说,这个问题并不算是bug,这个问题只会发生在document对象首次加载时,和2.3节中的场景类似,都是产生了document对象的变化从而导致了一些事件对Vue-Router来说是在此时是监听不到的,如pushstatepopstate

bug只会在如下场景才会出现:

  1. 在页面级组件使用注册的beforeRouteLeave钩子
  2. 当前页面是首次载入(即经由浏览器第一次访问或重置location.href跳转过来)

现象就是,其他钩子函数一切正常,导航栏上的前进后退按钮也能正常使用,但是就是不触发beforeRouteLeave钩子。

关于这个问题,我这边暂时还没有一个比较好的解决方案,就放在这里希望有巨佬可以不吝赐教吧~

3.3 VueRouter中,scrollBehavior失效

在单页面应用中,我们通常会通过在创建Router实例时传递一个scrollBehavior的函数来定义页面在导航结束时的滚动位置,如:

const router = new VueRouter({
  routes: [...],
  scrollBehavior (to, from, savedPosition) {
    // return 期望滚动到哪个的位置
  }
})

scrollBehavior 方法接收 tofrom 路由对象。第三个参数 savedPosition 当且仅当 popstate 导航 (通过浏览器的 前进/后退 按钮触发) 时才可用

这个方法返回滚动位置的对象信息,长这样:

  • { x: number, y: number }
  • { selector: string, offset? : { x: number, y: number }} (offset 只在 2.6.0+ 支持) 如果返回一个 falsy 的值,或者是一个空对象,那么不会发生滚动。

以上是Vue Router官方文档对scrollBehavior的使用说明,但是在实际使用过程中你可能会发现它并没有完全符合官方给出的期望行为:

假如我们对scrollBehavior的配置如下:

scrollBehavior (to, from, savedPosition) {
  // return 期望滚动到哪个的位置
  return savedPosition || { x: 0, y: 0 }
}

意味着,如果savedPosition中没有值就滚动到页面顶部,否则就滚动到它保存的位置。

但是在实际使用过程中,在某些页面它表现正常,但是在某些页面却什么也不做,通过打印savedPosition参数会发现它的xy是有值的,但是它却不会滚动到该指定的位置。

查看Vue Router的源码你会发现,scrollBehavior定义的函数方法会在queue对列中的所有同步任务执行完成之后才会被插入到microTask队列,而在queue的队列中包含着对异步路由组件resolve阶段。

在此情景下那就一定可以保证scrollBehavior会在目标路由对应的异步路由组件在解析完成后执行,从而正常执行。此时scrollBehavior的行为是完全正确的。

但是,通常情况下我们会在页面级组件中定义一些副作用,如在created声明周期中请求接口fetch对数据进行初始化。此时的fetch回调动作一定是一个异步过程,也就意味着它会产生一个异步的macroTask,但是它的声明位置在页面级组件的created生命周期的调用堆栈中,因此它要比scrollBehavior的调用堆栈要深很多。

此时,问题就出现了,scrollBehavior会先于created里面定义的fetch回调函数先执行,而此时页面上的数据还是空的,并不是我们真正要展示给用户的数据,自然而然问题就出现了。

总结如下:

  1. 如果页面级组件内要展示的数据对异步请求fetch的结果有依赖,那么scrollBehavior的动作一定是不正确的,因为它会在异步请求fetch的结果返回之前执行,而此时页面上的数据并不是完整的,可能会出现滚动位置错误甚至不滚动的现象;
  2. 如果页面级组件内要展示的数据是完全确定的、不依赖于副作用结果的,那么scrollBehavior的行为就是正确的。

该问题的实质也许并不是bug,因为在一定场景(即完全静态、独立的页面级组件)下scrollBehavior的行为是完全符合官方给出的预期的。而该问题是不是bug主要取决于你对浏览器的EventLoop的理解和对Vue以及Vue Router源码的掌握程度。

另外,这里贴出Vue的各生命周期和Vue Routter中各导航守卫的执行顺序,理解它们的执行顺序还是大有裨益的:

beforeEach
beforeEnter
beforeRouteEnter
beforeResolve
afterEach
beforeCreate
created
beforeMount
mounted
beforeRouterEnter callback

四、一些小总结

在做移动端Web项目的这段时间里,也整理了一些比较实用的工具方法,但是这里专门拎出两个跟大家分享一下,其从也是想记录一下自己的实现思路。

4.1 RAF滚动动画

在移动端开发中,经常会用到“滚动到”某模块,“返回顶部”这种交互场景,虽然现代浏览器都有提供scrollTo方法用于滚动视口,并且支持传递ScrollToOptions参数以设置不同的滚动方式,但是在IOS设备中却并不支持该类型参数形式,因此我们必须手动实现。

那么如何才能实现一个比较优雅顺滑的scroll效果呢?W3C给我们提供了一个最直接的API:window.requestAnimationFrame

window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。

并且window.requestAnimationFrame()具有极好的兼容性:

我们可以实现以下工具方法实现指定元素$el以指定的每一帧滚动的距离stepDistance进行滚动,甚至还支持滚动结束的回调callback

const scrollHelper = {
  // 获取当前页的据顶部滚动距离
  getScrollTop () {
    return window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop
  },
  getOffsetTopToRoot ($el, offsetTop = 0) {
    if ($el) {
      offsetTop = $el.offsetTop
      if ($el.offsetParent) {
        offsetTop += this.getOffsetTopToRoot($el.offsetParent, offsetTop)
      }
    }
    return offsetTop
  },
  // 页面滚动到指定位置
  scrollTo (xpos = 0, ypos = 0) {
    document.body.scrollLeft = xpos
    document.documentElement.scrollLeft = xpos
    // Chrome
    document.body.scrollTop = ypos
    // Firefox
    document.documentElement.scrollTop = ypos
  }
}

function rafScrollIntoView ({ $el, stepDistance = 150, callback = () => {} }) {
  // 给scrollTo也打上补丁
  const scrollTo = window.scrollTo || scrollHelper.scrollTo
  // 目标元素的距离页面顶部的距离
  const offsetTop = scrollHelper.getOffsetTopToRoot($el)
  // 当前视口总高度
  const totalHeight = document.documentElement.scrollHeight || document.body.scrollHeight
  // 视觉视口高度
  const windowHeight = window.innerHeight
  // 可滚动的高度
  const scrollHeight = totalHeight - windowHeight
  let rafId = null
  const atEndFunc = () => {
    window.cancelAnimationFrame(rafId)
    callback()
  }
  const scrollFunc = () => {
    // 每次获取当前视口的距离视口顶部的滚动距离
    const windowOffsetY = scrollHelper.getScrollTop()
    // 目标元素已经滚到了视口的上面
    if (offsetTop <= windowOffsetY) {
      // 不足一步的距离直接滚动,并取消动画,否则递归调用递增每步的距离
      if (windowOffsetY - offsetTop < stepDistance) {
        scrollTo(0, offsetTop)
        atEndFunc()
      } else {
        scrollTo(0, windowOffsetY - stepDistance)
        rafId = window.requestAnimationFrame(scrollFunc)
      }
    // 目标元素不在视口顶部(即可能存在视口中或在视口底部)
    } else {
      // 边界处理:当前视口的滚动距离刚好滚动了所有可滚动的高度,那么就要停止动画了(否则死循环、页面抖动)
      // 对应的场景就是当前页面的最后一屏上的元素都不能继续朝上滚动了
      if (windowOffsetY >= scrollHeight) {
        atEndFunc()
      } else {
        if (offsetTop - windowOffsetY < stepDistance) {
          scrollTo(0, offsetTop)
          atEndFunc()
        } else {
          scrollTo(0, windowOffsetY + stepDistance)
          rafId = window.requestAnimationFrame(scrollFunc)
        }
      }
    }
  }
  rafId = window.requestAnimationFrame(scrollFunc)
}

4.2 IOS设备中添加安全区

在iPhoneX发布后,许多厂商相继推出了具有边缘屏幕的手机。

这些手机和普通手机在外观上无外乎做了三个改动:圆角(corners)、刘海(sensor housing)和小黑条(Home Indicator)。为了适配这些手机,安全区域这个概念变诞生了:安全区域就是一个不受上面三个效果的可视窗口范围。

为了适配安全区,我们需要做以下几件事:

  1. 设置页面的meta标签的viewport-fit属性,用来限制网页如何在安全区内展示:
    • contain: 可视窗口完全包含网页内容;
    • cover:网页内容完全覆盖可视窗口。
<meta name="viewport" content="initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no,viewport-fit=cover">
  1. 合理组合使用内置函数envconstant:

函数内部可以是四个常量:

  • safe-area-inset-left:安全区域距离左边边界距离
  • safe-area-inset-right:安全区域距离右边边界距离
  • safe-area-inset-top:安全区域距离顶部边界距离
  • safe-area-inset-bottom:安全区域距离底部边界距离

constantiOS < 11.2的版本中生效,enviOS >= 11.2的版本中生效,这意味着我们往往要同时设置他们,将页面限制在安全区域内:

body {
  padding-bottom: constant(safe-area-inset-bottom);
  padding-bottom: env(safe-area-inset-bottom);
}

我这里抽离出来了两个公共的stylus函数,用于向规定场景下的元素上加入安全区域:

// 设置ios设备的安全区域
fnList = constant env

safe-area-fn(fn, position)
  s("%s(safe-area-inset-%s)", fn, position)

// 对于指定属性 指定适配ios11.2前后两个版本的属性值的函数调用
safe-area-mixin(property, position, important = false)
  for fn in fnList
    {property} safe-area-fn(fn, position) important == true ? !important : unquote("")

// 添加指定方向的常量和指定px值之和作为指定属性的值
add-safe-position(property, position, val)
  for fn in fnList
    {property} s("calc(%s(safe-area-inset-%s)+%s)", fn, position, val)  

// hack处理全面屏ios设备底部安全区
// 使用此mixin的必要条件:
// 0. 主要适用于页面中的吸底元素
// 1. 使用在最外层容器元素上(outerWrap)
// 2. 内容元素使用单独的容器元素(innerWrap)进行包裹,保证最外层容器内有且只有内容元素的容器元素 (innerWrap)
// 3. 其层级结构示意为: div.outerWrap > div.innerWrap (内容元素统一维护在innerWrap中)
// 使用此mixin的副作用:不能使用border-1px方法对outerWrap进行边框设置,可以考虑使用bgLine
hackSafeArea()
  &::after
    content ''
    display block
    safe-area-mixin(height, bottom)
    background-color transparent

另外,我这里还整理出了在微信环境下,Web页面底部导航栏出现的时机:

  1. 首次进入(不管内容是否超过H)一定处于满屏状态(即不展示底部导航栏),而且无论怎么滑动都不会展示;
  2. 切换到别的路由再切回来时:默认不展示导航栏,但是如果内容超过H时向下滚动超过一屏的内容隐藏导航栏,向上则会展示导航栏;
  3. 而其中的H则等于window.innerHeight * window.devicePixelRatio * 1.8 + env(safe-area-inset-bottom)

五、文末总结

至此,移动端Web开发踩坑指北就先告一段落了,文中仅列举了一些比较经典却又最常见的“神坑”,当然还有数不尽的“神坑”发生过或正在等着我们。希望大家可以从中得到一些启发和经验,对一些坑一定要勇敢面对,不要抱着侥幸的心理绕过他或者干脆假装不知道,否则你还会遇到它的。

文中可能会有一些不到之处,希望大家多多指教,也希望借此机会可以相互学习相互进步。

六、参考文章

  1. 滚动穿透的6种解决方案
  2. sendBeacon
  3. 使用sendBeacon进行前端数据上报
  4. CORS-safelisted-request-header
  5. PerformanceNavigation
  6. PageTransitionEvent.persisted
  7. Vue官方GitHub下关于KeepAlive的讨论
  8. window.requestAnimationFrame
  9. 关于移动端适配,你必须要知道的