Open ustbhuangyi opened 6 years ago
不知道是不是safari
更新的问题,现在的safari
里面是内置setImmediate
方法的,所以safari
会优先使用setImmediate
而不是MessageChannel
吧
请教一下,我看了 issue #4521.有个问题很困扰,js虽然是先捕获再冒泡,但是事件的回调不是默认在冒泡阶段再触发吗?为什么这个issue里面是外层div先捕获了点击事件呢
Vue 2.6.x 版本已经修复此 bug
我的按照这种方法改了,ios上还是不能自动播放,有什么解决方法吗
Vue 2.6.x 版本已经修复此 bug
我的版本是2.6.10,ios上还是无法播放,老师有什么解决方法吗
关于1000ms之后不能播放的问题,我进行了实测并有初步结论。 结论是移动端对音频的延迟播放进行了限制,用户点击到1000ms以内可以play()成功。根据同步JS的执行时间可能略有差异, 如998ms或者999ms。 超过1000ms则会报错:用户没有播放权限。推测为js申请的【硬盘/内存】的【读取/执行】权限为1000ms以内,超出则权限被计算机收回。
Unhandled Promise Rejection: NotAllowedError: The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission. 测试的方式是用模拟器链接safari控制台。见 https://zhuanlan.zhihu.com/p/258911669 所查到的讨论及参考文档如下:
本文并不是什么高深的技术文章,只是记录我最近遇到一个因为 Vue 升级导致我的一个项目踩坑以及我解决问题的过程。文章虽长但不水,写下来的目的是想和大家分享一下我遇到问题时候一个思考的方法和态度。
定位问题
先看现象:同学们写的代码在 iOS 微信浏览器下不能播放,PC 是可以的;我线上的代码是都可以。了解现象后我开始排查问题:
同学们的代码写的有问题? 虽然会有这种可能性,但从 2 个维度被我否决了:1. 同学们也都对比过我的源码的,而且出问题的同学也不是个别现象;2. 如果是代码问题,那么大多可能性是 PC 和移动端都不能播放。
找不同? 这个问题是最新才出现的,同学们开始学习编写课程代码都也是通过 vue-cli 脚手架先初始化代码。接着我大概看了一下新版的脚手架初始化的代码,果然是大不同,webpack 升级到 3+,配置发生了很大的变化。不过依据我的经验,构建工具的升级是不会影响业务代码的,一定还有别的原因。
Vue.js 升级? 除了 webpack 配置的不同,最新脚手架初始化的代码用的 Vue.js 版本是 2.5+,而我线上代码的 Vue.js 版本是 2.3+,难道是 Vue.js 导致的问题吗?带着这个疑问我去翻阅了 Vue.js 的 release log,发现 Vue.js 大大小小版本发布了十几次。如果每个都仔细查看也会很耗时,于是我采用了一个经典的 2 分法的思路去定位,我先把 Vue.js 升级到 2.4.0,发现竟然安装不了(这是 Vue.js 刚升到 2.4 npm 发布的 bug),于是又升级到 2.4.1,然后拿我的手机试了一下,还是可以播放的。接着我把 Vue.js 升级到 2.5.0,手机一试果然不能播放了,(擦。。)我心里默念一句,总算找到问题所在了。
问题的本质
以上定位到问题大概花了我半小时时间,但是我并没有找到问题的根本原因,于是我翻阅了 Vue.js 2.5 的 release log,由于很长就不列了。Vue.js 每次升级主要分成 2 大类,Features & Improvements 和 Bug Fixes。我从上往下依次扫了一遍,把一些关于它核心的改动都点进去看了一下代码的修改,最终锁定了这一条:
接着我点进去看了一下改动,我滴天,改动很大呀,nextTick 的核心实现变了,MutationObserver 不见了,改成了 MessageChannel 的实现。等等,有些同学看到这里,可能会懵,这都是些啥呀。不急,我先简单解释一下 Vue 的 nextTick。
nextTick
介绍 Vue 的 nextTick 之前,我先简单介绍一下 JS 的运行机制:JS 执行是单线程的,它是基于事件循环的。对于事件循环的理解,阮老师有一篇文章写的很清楚,大致分为以下几个步骤:
(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
(2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
(3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
(4)主线程不断重复上面的第三步。
主线程的执行过程就是一个 tick,而所有的异步结果都是通过 “任务队列” 来调度被调度。 消息队列中存放的是一个个的任务(task)。 规范中规定 task 分为两大类,分别是 macro task 和 micro task,并且每个 macro task 结束后,都要清空所有的 micro task。
关于 macro task 和 micro task 的概念,这里不会细讲,简单通过一段代码演示他们的执行顺序:
在浏览器环境中,常见的 macro task 有 setTimeout、MessageChannel、postMessage、setImmediate;常见的 micro task 有 MutationObsever 和 Promise.then。对于它们更多的了解,感兴趣的同学可以看这篇文章。
回到 Vue 的 nextTick,nextTick 顾名思义,就是下一个 tick,Vue 内部实现了 nextTick,并把它作为一个全局 API 暴露出来,它支持传入一个回调函数,保证回调函数的执行时机是在下一个 tick。官网文档介绍了 Vue.nextTick 的使用场景:
在 Vue.js 里是数据驱动视图变化,由于 JS 执行是单线程的,在一个 tick 的过程中,它可能会多次修改数据,但 Vue.js 并不会傻到每修改一次数据就去驱动一次视图变化,它会把这些数据的修改全部 push 到一个队列里,然后内部调用 一次 nextTick 去更新视图,所以数据到 DOM 视图的变化是需要在下一个 tick 才能完成。
接下来,我们来看一下 Vue 的 nextTick 的实现,在 Vue.js 2.5+ 的版本,抽出来一个单独的 next-tick.js 文件去实现它,
我们在有之前的知识背景,再理解 nextTick 的实现就不难了,这里有一段很关键的注释:在 Vue 2.4 之前的版本,nextTick 几乎都是基于 micro task 实现的,但由于 micro task 的执行优先级非常高,在某些场景下它甚至要比事件冒泡还要快,就会导致一些诡异的问题,如 issue #4521、#6690、#6566;但是如果全部都改成 macro task,对一些有重绘和动画的场景也会有性能影响,如 issue #6813。所以最终 nextTick 采取的策略是默认走 micro task,对于一些 DOM 交互事件,如 v-on 绑定的事件回调函数的处理,会强制走 macro task。
这个强制是怎么做的呢,原来在 Vue.js 在绑定 DOM 事件的时候,默认会给回调的
handler
函数调用withMacroTask
方法做一层包装,它保证整个回调函数执行过程中,遇到数据状态的改变,这些改变都会被推到 macro task 中。对于 macro task 的执行,Vue.js 优先检测是否支持原生
setImmediate
,这是一个高版本 IE 和 Edge 才支持的特性,不支持的话再去检测是否支持原生的MessageChannel
,如果也不支持的话就会降级为setTimeout 0
。nextTick 对 audio 播放的影响
回到我们的问题,iOS 微信浏览器不能播放歌曲和 nextTick 有什么关系呢?先来看一下我们的歌曲播放这个功能的实现方法。
我们的代码会有一个播放器组件 player.vue,在这个组件中我们会持有一个 html5 的 audio 标签。由于可调用播放的地方很多,比如在歌曲列表组件、榜单组件、搜索结果组件等等,因此我们用 vuex 对播放相关的数据进行管理。我们把正在播放的列表
playlist
和当前播放索引currentIndex
用 state 维护,当前播放的歌曲currentSong
通过它们计算而来:然后我们在 player.vue 组件里 watch
currentSong
的变化去播放歌曲:这样我们就可以在任何组件中提交对
playlist
和currentIndex
的修改来达到播放不同歌曲的目的。那么这么写和 nextTick 有什么关系呢?因为在 Vue.js 中,watcher 的回调函数执行默认是异步的,当我们提交对
playlist
或者currenIndex
的修改,都会触发currentSong
的变化,但是由于是异步,并不会立刻执行 watcher 的回调函数,而会在 nextTick 后执行。所以当我们点击歌曲列表中的歌曲后,在 click 的事件回调函数中会提交对playlist
和currentIndex
的修改, 经过一系列同步的逻辑执行,最终是在 nextTick 后才会执行 wathcer 的回调,也就是调用 audio 的 play。所以本质上,就是用户点击到 audio 的 play 并不是在一个 tick 中完成,并且前面提到 Vue.js 中对 v-on 绑定事件执行的 nextTick 过程会强制使用 macro task。那么到底是不是由于 nextTick 影响了 audio 在 iOS 微信浏览器中的播放呢, 我们就来把化繁为简,写一个简单 demo 来验证这个问题,用的 Vue.js 版本是 2.5+ 的。
这段代码的逻辑非常简单,我们会添加一个 watcher 监听
url
变化,当点击按钮的时候,会调用changeUrl
方法,修改url
,然后 watcher 的回调函数执行,并调用 audio 的 play 方法。这段代码在 PC 浏览器是可以正常播放歌曲的,但是在 iOS 微信浏览器里却不能播放,这就证实了我们之前的猜想——在用户点击事件的回调函数到 audio 的播放如果经历了 nextTick 在 iOS 微信浏览器下不能播放。macro task 的锅?
有些同学可能会认为,当用户点击了按钮到播放的过程在 iOS 微信浏览器或者是 iOS safari 浏览器应该需要在同一个 tick 才能执行,果真需要这样吗?我们把上述代码做一个简单的修改:
我们现在不利用 Vue.js 的 nextTick 了,直接来模拟 nextTick 的过程,发现使用
setTimeout 0
是可以在 iOS 微信浏览器器、包括 iOS safari 下播放的,然而实际上我们只要在 1000ms 内的延时时间播放都是可以的,但是超过 1000ms,比如setTimeout 1001
又不能播放了,感兴趣的同学可以试试,这个现象的理论依据我还没找到,如果知道理论的同学也非常欢迎留言告诉我。所以通过上述的实验,我们发现并不一定要在同一个 tick 执行播放,那么为啥 Vue.js 的 nextTick 是不可以的呢?回到 nextTick 的 macro task 的实现,它优先
setImmediate
、然后MessageChannel
,最后才是setTimeout 0
。我们知道,除了高版本 IE 和 Edge,setImmediate
是没有原生支持的,除非一些工具对它进行了重新改写。而MessageChannel
的浏览器支持程度还是非常高的,那么我把这段 demo 的异步过程改成用MessageChannel
实现。这段代码在 PC 浏览器是可以播放的,而在 iOS 微信浏览器又不能播放了,调试后发现
this.$refs.audio.play()
的逻辑也是可以执行到的,但是歌曲并不能播放,应该是浏览器对 audio 播放在使用 MessageChannel 做异步的一种限制。前面提到实现 macro task 还有一种方法是利用 postMessage,它的浏览器支持程度也很好,我们来把 demo 改成利用它来实现。
这段代码在 PC 浏览器和 iOS 微信浏览器以及 iOS safari 都可以播放的,说明并不是 macro task 的锅,而是 MessageChannel 的锅。其实 macro task 还有很多实现方式,感兴趣的同学可以看看 core-js 中对于 macro task 的几种实现方式。
如何解决?
现在我们定位到问题的本质是因为 Vue.js 的 nextTick 中优先使用了 MessageChannel,它会影响 iOS 微信浏览器的播放,那么我们如何用最小成本来解决这个问题呢?
Vue.js 的版本降级
如果是真实运行在生产环境中的项目,毫无疑问这肯定是优先解决问题的首选,因为确实也是因为 Vue.js 的升级才造成这个 bug 的。在我们的实际项目中,我们都是锁死某个 Vue.js 的版本的,除非我们想使用某个 Vue.js 新版的 feature 或者是当前版本遇到了一个严重 bug 而新版已经修复的情况,我们才会考虑升级 Vue.js,并且每次升级都需要经过完整的功能测试。
为何把 Vue.js 降级到 2.4+ 就没问题呢,因为 Vue.js 2.5 之前的 nextTick 都是优先使用 microtask 的,那么 audio 播放的时机实际上还是在当前 tick,所以当然不会有问题。
说到版本问题,其实这也是 Vue.js 的一点瑕疵吧,升版本的时候有时候改动过于激进了,比如这次关于 nextTick 的升级,它其实是 Vue.js 一个非常核心的功能,但是它只有单元测试,并没有大量的功能测试 case 覆盖,也只能通过社区帮助反馈问题做改进了。
同步的 watcher
Vue.js 的 watcher 默认是异步的,当然它也提供了同步的 watcher,这样 watcher 的回调函数执行就不需要经历了 nextTick,这样确实可以修复这个 bug,但又会引起别的问题。因为我们的音乐播放器有一个 feature 是可以在播放的过程中切换播放模式,我们支持顺序播放、随机播放、单曲循环三种播放模式,当我们从顺序播放切到随机播放模式的时候,实际上是对播放列表
playlist
做了修改,同时也修改了currentIndex
,这样可以保证我们在切换模式的时候并不会修改当前歌曲。那么问题来了,由于currentSong
是由playlist
和currentIndex
计算而来的,对它们任何一个修改,都会触发currentSong
的变化,由于我们现在改成同步的 watcher,那么 currentSong 的回调会执行 2 次,这样第一次的修改导致计算出来的歌曲就变成了另外一首了,这个显然也不是我们期望的。所以同步 watcher 也是不可行的。其它方式
其实还有很多方式都能“修复”这个问题,比如我们不通过 watcher,改成每次点击通过 event bus 去通知;比如仍然使用同步 watcher,但 currentSong 不通过计算,直接用 state 保留;比如每次点击事件不通过 v-on 绑定,我们直接在 mounted 的钩子函数里利用原生的 addEventListener 去绑定 click 事件。
当然,上述几个方式都是可行的,但是我并不推荐这么去改,因为这样对业务代码的改动实在太大了,如果我们本身的写法如果是合理的,却要强行改成这些方式,就好像是:我知道了框架的某一个坑,我用一些奇技淫巧绕过了这些坑,这样做也是不合理的。
框架产生的意义是什么:制定一种友好的开发规范,提升开发效率,让开发人员更加专注业务逻辑的开发。所以优秀的框架不应该限制开发人员对于一些场景下功能的实现方式,仅仅是因为这种实现方式虽然本身合理但可能会触发框架某个坑。
临时的 hack 方法
由于不想动业务代码,所以我就想了一些比较 hack 的办法,因为是 MessageChannel 的锅,所以我就在 Vue.js 的初始化前,引入了一段 hack.js
这样的话 Vue.js 在初始化 nextTick 的时候,发现全局的
setImmediate
和MessageChannel
被改写了,就自动降级为setTimeout 0
的实现,这样就可以规避掉我们的问题了。当然,这种 hack 方式算是没有办法的办法了,我并不推荐。给 Vue.js 提 issue
所以这种情况最合理的就是给 Vue.js 提 issue,我确实也是这么做了,去 Github 上提了一个 issue,第一次给 Vue.js 提 issue,发现 Vue 官方这块做的还是蛮人性化的,直接给一个提 issue 的链接,通过填写一些表单来描述这个 issue,并且推荐了一个很好的复现问题的工具 CodeSandbox 。这个 issue 当天就收到了尤大的回复,他表示 Vue.js 的 nextTick 确实会造成这个问题,但是我应该在同一个 tick 完成歌曲的播放,而不应该使用 watcher,接着就 close 了 issue。因为我提 issue 为了更直观的演示核心问题,用的就是上面提到的非常简单的 demo,所以在这种场景下,他说的也没问题,确实没有必要使用 watcher,于是我赶紧又回复了 issue,说明了一下我的真实使用场景,并表明希望从 Vue.js 内核去修复这个问题。可惜的是,尤大目前也并没有再回复这个 issue。
总结
通过记录我这一次发现问题——定位问题——解决问题的过程,我想给同学带来的思考不仅仅是这个问题本身,还有我们遇到问题后的一些态度。发现问题并不难,很多人在写代码中都会发现问题,那么发现问题后你的第一反应是尝试自己解决,还是去求助,我相信前者肯定更好。那么在解决之前需要定位问题,这里我要提到一个词,叫“面向巧合编程”,很多人遇到问题后会不断尝试这种办法,很可能某个办法就从表象上“解决”了这个问题,却不知道为什么,这种解决问题的方式是很不靠谱的,你可能并没有根本上解决问题,又可能解决了这个问题却又引发另一个问题。所以定位问题的本质就非常关键了,其实这是一个能力,一个好的工程师不仅会写代码,也要会查问题,能快速定位到问题的本质,是一个优秀的工程师的必要条件,这一点不容易,需要平时不断地的积累。在定位到问题的本质后,就要解决问题了,一道题往往有多解,但每种解法是否合理,这也是一个需要思考的过程,多和一些比你厉害的人交流,多积攒一些这方面的经验,这也是一个积累的过程。如果以后你再遇到问题,也用这样的态度去面对的问题,那么你也会很快的成长。
很多同学学习我的音乐课程后,会问:“黄老师,你什么时候再出新视频呀?”,其实我想说这门课程你真的学完了吗?因为它的定位是一门 Vue.js 的进阶课程,不仅仅是因为课程的项目本身比较复杂,而且项目中很多知识点都可以做延伸的学习,另外项目难免会有一些小 bug 和一些由于接口改动引发的功能不可用的情况,遇到这些问题除了给我提 issue,尝试自己去解决然后给我提 pull request 的方式是不是对自己的提升更大呢?所以这门课程还是值得多去挖掘的,如果真正榨干了这门课的价值再来问我也不迟,当然我也会给你们带来更多干货的课程。
最后也来小小安利我的这门 Vue.js 进阶课程吧(慕课网地址),感兴趣的同学可以点进去看看课程介绍。课程的项目是托管在我的 Github 私服的,并不开源,所以外面的一切和这个课程相关的代码都是盗版的。这个源码我是一直维护的,包括最近 Vue.js 的脚手架的升级,以及依赖方接口的一些改造造成的功能不可用问题,都已经得到了解决。简单地截几张截图:
这一张是对 issue 的处理,我们在课程推出来后解决了几十个 issue,如果有同学在学习过程中遇到问题建议去翻阅 issue 寻找答案。有一些版本的升级的 issue 我不会关,为了让同学们可以更方便的找到。
这一张是代码提交记录,可以看到除了我还是有一些很不错的同学在一起维护这个项目,这其中有一个同学学习非常主动,自驱力很强,常与我探讨技术问题,最近他也加入了滴滴,在我们部门做了很多的产出。
更直观的感受这个项目,可以扫描下方的二维码,体验一下接近原生 App 的感觉:
我们有一个官方的课程交流群,如果购买了这门课程,欢迎与其它同学一起交流学习,也可以加我的 qq 和微信,交流技术问问题都可以,不过我一般白天很忙,晚上才有时间。
当然,想关注我的一些动态,也欢迎 follow 我的 Github。
希望同学们一起来支持正版,抵制盗版,我会为大家带来更多优质的课程以及其它的一些形式的技术方向的分享。
本文参考的一些值得延伸学习的文章:
JavaScript 运行机制详解:再谈Event Loop
Tasks, microtasks, queues and schedules