一、前言

在这个项目开发的过程中,因为使用了IScroll.js这个库导致了很多问题,其中最严重的问题就是在ios的真机测试环境下,Dom结构的层次问题。经过了无数次debug后终于得到解决,通过这个问题进一步引发了我对此项目布局方案的思考。

布局遇到的问题

层级出错(overflow: hidden失效)

IMG_0126.PNG
这个bug异常诡异,因为在PC和安卓端都没有遇到这个问题,而在ios端测试的时候,其他页面也并没有问题,可偏偏在歌手列表界面中就出现了overflow: hidden失效的问题。同样的布局方式,同样的代码,偏偏在这个页面出现了问题,使我感到很诧异。
通过搜索网上网友给出的解释:

ios官方认为,网页内容是个整体,需要将所有的都显示出来,所有overflow就不该起作用,这是他们刻意的,不是bug,而且在更高版本的ios7,8,9中也是这样设定的

这样的情况看来确实也见怪不怪了,ios非常喜欢擅作主张。例如在开发此项目的时候也遇到类似的问题就是ios不允许audio进行预加载,导致oncanplay事件无法加载到歌曲资源的总时长。

解决方案

通过无数次的demo测试,发现这种问题基本是配合IScroll.js使用时可能会遇到的问题,不过此问题也并不是无解决方案,不过需要慢慢去调试。
最开始是的DOM结构如下:

  1. <div class="singer">
  2. <div id="wrapper">
  3. <ul class="list-wrapper">
  4. <li>...</li>
  5. <li>...</li>
  6. <li>...</li>
  7. </ul>
  8. </div>
  9. </div>

singer是最外层容器,为fixed定位方式,宽度为100%,高度为视口剩余高度,设置overflow: hidden
wrapper是我们声明的IScroll容器,宽高都是参照父元素的100%
list-wrapper是IScroll中的滚动容器,高度由内容撑开
也就是说除了list-wrapper滚动容器是由内容撑开高度以外,singer和wrapper都是视口的高度,并且超出部分不显示。但是ios上测试后发现,singer设置的overflow: hidden没有效果。
经过一系列debug调整DOM结构后:

  1. <div class="singer">
  2. <!-- 新增一个singer-wrapper容器包裹 -->
  3. <div class="singer-wrapper">
  4. <div id="wrapper">
  5. <ul class="list-wrapper">
  6. <li>...</li>
  7. <li>...</li>
  8. <li>...</li>
  9. </ul>
  10. </div>
  11. </div>
  12. </div>
  13. <style scoped lang="scss">
  14. .singer{
  15. position: fixed;
  16. top: 184px;
  17. bottom: 0;
  18. left: 0;
  19. right: 0;
  20. .singer-wrapper{
  21. width: 100%;
  22. height: 100%;
  23. overflow: hidden;
  24. #wrapper{
  25. width: 100%;
  26. height: 100%;
  27. }
  28. }
  29. }
  30. </style>

再在singer容器内部嵌套了一个singer-wrapper,将singer-wrapper的宽高设置为100%,overflow: hidden设置给singer-wrapper此时又奇迹般的解决了这个bug。
image.png

bug总结

经过无数次测试,正常情况下使用IScroll,其实三层结构就可以了,没有什么问题,就像以下方式即可:

  1. <template>
  2. <div class="singer">
  3. <div id="wrapper" ref="wrapper">
  4. <ul class="list-wrapper">
  5. <li>...</li>
  6. <li>...</li>
  7. <li>...</li>
  8. </ul>
  9. </div>
  10. </div>
  11. </template>
  12. <script>
  13. import IScroll from 'iscroll/build/iscroll-probe'
  14. export default {
  15. name: 'Singer',
  16. created () {
  17. new IScroll(this.$refs.wrapper, {
  18. mouseWheel: true,
  19. scrollbars: false,
  20. probeType: 3,
  21. // 解决拖拽卡顿问题
  22. scrollX: false,
  23. scrollY: true,
  24. disablePointer: true,
  25. disableTouch: false,
  26. disableMouse: true,
  27. // IScroll.js移动端点击事件被阻止的解决方案(巨坑)
  28. preventDefault: false,
  29. onClick: true
  30. })
  31. }
  32. }
  33. </script>
  34. <style scoped lang="scss">
  35. .singer{
  36. position: fixed;
  37. top: 100px;
  38. bottom: 0;
  39. left: 0;
  40. right: 0;
  41. overflow: hidden;
  42. }
  43. #wrapper{
  44. width: 100%;
  45. height: 100%;
  46. }
  47. .list-wrapper{
  48. /* 不用设置高度,由内容撑开 */
  49. }
  50. </style>

但在极个别特殊情况下如果还是出现了overflow: hidden无效的情况,就再套上一层包裹容器,宽高设置为父元素的100%,将overflow: hidden设置给该容器,并且要让其父容器保持fixed定位方式。

二、整体布局方案

通过一系列代码发现,最外层的容器的定位方式都是fixed,这是为什么呢?

1. fixed定位注意点

fixed定位—MDN文档
【注意】:在使用fixed定位之前,再总结一个小结论:

fixed定位不一定是相对视口的

为什么fixed定位不一定相对视口

通过MDN文档中的说明发现,满足下列条件之一的祖先元素,也是它们的包含块(相对于 padding 区域):

  1. transform 的属性值不为 none。
  2. perspective 的属性值不为 none。
  3. will-change 的属性值为 transform 或 perspective;还有在属性值为 filter 的时候,不过仅在 Firefox 浏览器中有效。
  4. filter 属性值不为 none。
  5. contain 的属性值为 paint,即 contain: paint。

也就是说使用fixed定位的元素的祖先元素如果满足以上其中一个条件,fixed就还是基于它的祖先元素定位,不会相对于整个视口定位。所以在布局的时候一定要注意一下。

2. 使用IScroll.js时利用fixed定位技巧

排除掉以上特殊情况之后,可以进一步来说一下fixed定位在相对视口情况时的使用技巧。
在使用IScroll实现网页内容回弹效果进行布局的时候使用fixed定位会更省事,例如下面有这么一个情况,前面的header和tabbar都是正常文档流定位,但是下面要使用到IScroll来对容器中的内容实现回弹效果怎么办?
现在就有两个方案:
方案一:继续使用正常文档流定位,但必须指定IScroll容器的父元素高度为具体的像素高度,否则无法滚动。但这种情况对于固定写死的内容还好,如果我们容器中的内容是动态增加的,我们就无法预知容器的具体高度。
方案二:使用fixed定位,将IScroll容器的父元素高度指定为视口剩余高度。IScroll内部的滚动容器随内容撑开高度后调用IScroll的刷新方法更新滚动高度。
image.png
第二种方案显然要灵活些,而且适配所有尺寸的设备。

如何填充视口剩余宽高?

假如上方headertabbar已经占用了视口100px的高度,此时我们可以这样设置:
将容器设置为fixed定位,top设置为上方已占用的高度,left,right,bottom都设置为0,这样就可以使容器自动计算并铺满视口剩余宽高。

  1. <style>
  2. body,html{
  3. width: 100%;
  4. height: 100%;
  5. overflow: hidden;
  6. /* 使用IScroll的时候加上touch-action: none; */
  7. touch-action: none;
  8. }
  9. *{
  10. margin: 0;
  11. padding: 0;
  12. }
  13. .header{
  14. width: 100%;
  15. height: 50px;
  16. text-align: center;
  17. line-height: 50px;
  18. background: #d43c33;
  19. color: #fff;
  20. }
  21. .tabBar{
  22. width: 100%;
  23. height: 50px;
  24. text-align: center;
  25. line-height: 50px;
  26. background: #999;
  27. color: #fff;
  28. }
  29. .recommend{
  30. position: fixed;
  31. top: 100px;
  32. bottom: 0;
  33. left: 0;
  34. right: 0;
  35. /* 使用IScrolld的时候设置为overflow: hidden; */
  36. overflow: hidden;
  37. color: #fff;
  38. background: #f90;
  39. }
  40. </style>
  41. <div class="header">我是header</div>
  42. <div class="tabBar">我是tabbar</div>
  43. <div class="recommend">
  44. <!--声明IScroll容器-->
  45. </div>

3. 如何设计此项目整体布局(重点)

这个项目参照安卓上的网易云音乐等软件实现了许多花里胡哨的效果,因此布局方式也很特殊。不过值得说的是其中一个特色就是实现了类似安卓原生App上WebView的切换效果。

页面整体结构

  1. App.vue
  2. <template>
  3. <div id="app">
  4. <Header></Header>
  5. <Tabbar></Tabbar>
  6. <!-- 一级路由 -->
  7. <router-view></router-view>
  8. <Player></Player>
  9. </div>
  10. </template>

image.png
image.png
上方的Header组件和Tabbar组件以及Player(播放器)是全局组件,下边设置一个一级路由用于切换不同的栏目,例如推荐、歌手、排行、搜索等页面
image.png
在推荐、歌手、排行、搜索等一级路由页面中又设置了一个二级路由,用于跳转二级详情界面
切换效果如图:
路由跳转.gif
下面来看看Dom结构是如何表现这种层次关系的:
image.png
那么问题来了,既然一级路由的界面是在主页面上利用剩余视口高度开辟出来的,二级路由界面又是嵌套在一级路由界面中的,切换到二级路由界面按照一般的层次关系应该是会在一级路由界面范围之中的,为什么还可以做到类似WebView那种全屏层次的切换呢?表面上看起来似乎不太符合父子元素嵌套的层次关系。因此这里就用到了fixed布局技巧。

通过Demo演示大致项目布局:

  1. <div class="app">
  2. <div class="header">我是header</div>
  3. <div class="tabBar">我是tabbar</div>
  4. <!--
  5. recommend为一级路由的界面
  6. 这里可以为recommend推荐页也可为singer歌手页,根据具体的路由来切换,这里只是做DOM演示
  7. -->
  8. <div class="recommend">
  9. <div class="item"></div>
  10. <div class="item"></div>
  11. <div class="item"></div>
  12. <div class="item"></div>
  13. <div class="item"></div>
  14. <div class="item"></div>
  15. <div class="item"></div>
  16. <div class="item"></div>
  17. <div class="item"></div>
  18. <div class="item"></div>
  19. <div class="item"></div>
  20. <div class="item"></div>
  21. <div class="item"></div>
  22. <div class="item"></div>
  23. <div class="item"></div>
  24. <div class="item"></div>
  25. <!--
  26. detail为二级路由界面,也就是一级路由下的子路由
  27. 默认情况下不显示,push以后才显示
  28. 在vue中是<router-view>组件充当,这里仅作为DOM结构演示,注意别被混淆
  29. -->
  30. <div class="detail">
  31. <div class="item"></div>
  32. <div class="item"></div>
  33. <div class="item"></div>
  34. <div class="item"></div>
  35. <div class="item"></div>
  36. </div>
  37. </div>
  38. </div>

CSS部分:

  1. body,html{
  2. width: 100%;
  3. height: 100%;
  4. overflow: hidden;
  5. touch-action: none;
  6. }
  7. *{
  8. margin: 0;
  9. padding: 0;
  10. }
  11. .header{
  12. width: 100%;
  13. height: 50px;
  14. text-align: center;
  15. line-height: 50px;
  16. background: #d43c33;
  17. color: #fff;
  18. }
  19. .tabBar{
  20. width: 100%;
  21. height: 50px;
  22. text-align: center;
  23. line-height: 50px;
  24. background: #999;
  25. color: #fff;
  26. }
  27. /*一级路由界面*/
  28. .recommend{
  29. position: fixed;
  30. top: 100px;
  31. bottom: 0;
  32. left: 0;
  33. right: 0;
  34. overflow-y: scroll;
  35. color: #fff;
  36. background: #f90;
  37. }
  38. .recommend>.item{
  39. float: left;
  40. width: 100px;
  41. height: 100px;
  42. background: #999;
  43. margin: 10px;
  44. }
  45. /*二级路由界面(默认不显示)*/
  46. .recommend .detail{
  47. display: none;
  48. overflow-y: scroll;
  49. transform: translateX(100%);
  50. transition: transform .3s;
  51. /*
  52. 二级路由界面定位方式一定要设置为fixed,相对整个视口定位,
  53. 这样才不受父元素的overflow:hidden影响
  54. */
  55. position: fixed;
  56. top: 0;
  57. bottom: 0;
  58. left: 0;
  59. right: 0;
  60. background: #fefefe;
  61. }
  62. .recommend .detail .item{
  63. width: 200px;
  64. height: 200px;
  65. margin: 10px;
  66. background: #999;
  67. }

这里将header和tabbar都设置为正常文档流就好,一级路由界面设置为剩余视口的宽高铺满(方法见上边)。【特别注意】二级路由界面虽然是嵌套在一级路由之中的,但一定也要设置为fixed定位,并铺满全屏。(只要fixed定位元素的父元素不满足上述fixed定位注意点中说到的包含块规则,这个元素一定是脱离文档流并且是相对于视口定位的)这样二级路由的宽高和显示范围就不会受一级路由所限制,也不会受到一级路由界面的overflow: hidden影响。
我们可以来通过3D视图看看此时的层级关系:
此时可以看到,app中的header和tabbar是处于正常文档流中
image.png
而一级路由使用了fixed定位设置为剩余视口的宽高,已脱离正常文档流,但超出部分可通过滚动显示
image.png
二级路由界面虽然结构上嵌套在一级路由界面中,但由于同样使用fixed定位也脱离了正常文档流,铺满视口的宽高,成为了一个独立的页面
image.png
完整效果展示:
结构演示.gif

注意:二级路由界面一定要设置为fixed定位相对于视口,否则会受一级路由宽高范围限制。

如果我们把二级路由界面设置为了absolute,就会是以下结果

  1. /*二级路由界面(默认不显示)*/
  2. .recommend .detail{
  3. display: none;
  4. overflow-y: scroll;
  5. transform: translateX(100%);
  6. transition: transform .3s;
  7. position: absolute;
  8. top: 0;
  9. bottom: 0;
  10. left: 0;
  11. right: 0;
  12. background: #fefefe;
  13. }

3D视图:
如果设置为了absolute或者relative,那么二级路由界面就会参照父元素宽高,并且父元素设置了overflow的话,整个显示范围会被父元素宽高所限制。
image.png
错误结构演示.gif

三、总结

关于IScroll.js的使用

通过在项目中对bug Scroll库的使用发现,项目仅仅为了呈现一个内容回弹效果做了巨大牺牲,性能方面都不至于很大开销,关键是在调试bug层面开销巨大,IScroll与WebView效果同时呈现的时候,在ios端的一些特殊机制下,可能会有一些莫名的显示错误。而且由于IScroll不会自动检测容器中的DOM节点动态的变化,在此项目中还进行了二次封装,手动添加了观察者对象(MutationObserver)对容器中的DOM进行动态检测以便于更新可滚动距离,还有一些刷新方法,但在某些情况下仍存在一些的问题。

个人建议:专业的事情还是要专业的技术去做

若不是必须场景,不建议再使用IScroll。至于滚动回弹效果,其实在ios端也自动实现了,安卓端现目前很多APP也没有涉及滚动回弹,大部分是下拉刷新和上拉加载更多。各种仿原生APP效果其实可以交给更专业的框架去做,例如UniAPPReact Nativeflutter等等。web端的移动端开发主要还是更关注项目整体架构和逻辑代码多一些,完成一些通用场景即可。