本文并非什么高深的技术文章,只是记录我最近遇到一个由于 Vue 升级致使个人一个项目踩坑以及我解决问题的过程。文章虽长但不水,写下来的目的是想和你们分享一下我遇到问题时候一个思考的方法和态度。html
背景:去年我在慕课网推出了一门 Vue.js 的入门实战课程——Vue.js 高仿饿了么外卖 App ,这门课程收到了很是不错的反响,因而今年又在慕课网上继续推出了 Vue.js 的高级进阶实战课程——Vue.js 音乐 App,一样反馈不错。天天晚上下班回家,我会去问答区看一下学生们的问题,发现近期有很多同窗反馈了一样的问题,iOS 微信里点击不能播放歌曲了,PC 能够。一般遇到这种问题我会让学生先去访问个人项目的线上地址,看看个人代码会不会有问题,获得的结论是个人线上代码没问题,但他们本身写就不行,而且说已经彻底和个人代码作了对比,这就让我以为十分诡异。没过多久,有些学生就想出了一个办法,在全局 document 绑定一个同步的 click 事件,在 click 事件的回调函数中同步触发一次 audio 的 play 方法,彷佛解决了问题,也获得了一些同窗的采纳,可是我看到之后的第一反应是不能用这种太 hack 的方式去解决问题,必须找到问题的本质,因而乎我开始了一段颇有意思的找问题的过程。vue
先看现象:同窗们写的代码在 iOS 微信浏览器下不能播放,PC 是能够的;我线上的代码是均可以。了解现象后我开始排查问题:html5
同窗们的代码写的有问题?
虽然会有这种可能性,但从 2 个维度被我否决了:1. 同窗们也都对比过个人源码的,并且出问题的同窗也不是个别现象;2. 若是是代码问题,那么大多可能性是 PC 和移动端都不能播放。webpack
找不一样?
这个问题是最新才出现的,同窗们开始学习编写课程代码都也是经过 vue-cli 脚手架先初始化代码。接着我大概看了一下新版的脚手架初始化的代码,果真是大不一样,webpack 升级到 3+,配置发生了很大的变化。不过依据个人经验,构建工具的升级是不会影响业务代码的,必定还有别的缘由。ios
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,手机一试果真不能播放了,(擦。。)我内心默念一句,总算找到问题所在了。git
以上定位到问题大概花了我半小时时间,可是我并无找到问题的根本缘由,因而我翻阅了 Vue.js 2.5 的 release log,因为很长就不列了。Vue.js 每次升级主要分红 2 大类,Features & Improvements 和 Bug Fixes。我从上往下依次扫了一遍,把一些关于它核心的改动都点进去看了一下代码的修改,最终锁定了这一条:github
use MessageChannel for nextTick 6e41679, closes #6566 #6690web
接着我点进去看了一下改动,我滴天,改动很大呀,nextTick 的核心实现变了,MutationObserver 不见了,改为了 MessageChannel 的实现。等等,有些同窗看到这里,可能会懵,这都是些啥呀。不急,我先简单解释一下 Vue 的 nextTick。vuex
介绍 Vue 的 nextTick 以前,我先简单介绍一下 JS 的运行机制:JS 执行是单线程的,它是基于事件循环的。对于事件循环的理解,阮老师有一篇文章写的很清楚,大体分为如下几个步骤:vue-cli
(1)全部同步任务都在主线程上执行,造成一个执行栈(execution context stack)。
(2)主线程以外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
(3)一旦"执行栈"中的全部同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,因而结束等待状态,进入执行栈,开始执行。
(4)主线程不断重复上面的第三步。
主线程的执行过程就是一个 tick,而全部的异步结果都是经过 “任务队列” 来调度被调度。 消息队列中存放的是一个个的任务(task)。 规范中规定 task 分为两大类,分别是 macro task 和 micro task,而且每一个 macro task 结束后,都要清空全部的 micro task。
关于 macro task 和 micro task 的概念,这里不会细讲,简单经过一段代码演示他们的执行顺序:
for (macroTask of macroTaskQueue) { // 1. Handle current MACRO-TASK handleMacroTask(); // 2. Handle all MICRO-TASK for (microTask of microTaskQueue) { handleMicroTask(microTask); } }复制代码
在浏览器环境中,常见的 macro task 有 setTimeout、MessageChannel、postMessage、setImmediate;常见的 micro task 有 MutationObsever 和 Promise.then。对于它们更多的了解,感兴趣的同窗能够看这篇文章。
回到 Vue 的 nextTick,nextTick 顾名思义,就是下一个 tick,Vue 内部实现了 nextTick,并把它做为一个全局 API 暴露出来,它支持传入一个回调函数,保证回调函数的执行时机是在下一个 tick。官网文档介绍了 Vue.nextTick 的使用场景:
Usage: Defer the callback to be executed after the next DOM update cycle. Use it immediately after you’ve changed some data to wait for the DOM update.
使用:在下次 DOM 更新循环结束以后执行延迟回调,在修改数据以后当即使用这个方法,获取更新后的 DOM。
在 Vue.js 里是数据驱动视图变化,因为 JS 执行是单线程的,在一个 tick 的过程当中,它可能会屡次修改数据,但 Vue.js 并不会傻到每修改一次数据就去驱动一次视图变化,它会把这些数据的修改所有 push 到一个队列里,而后内部调用 一次 nextTick 去更新视图,因此数据到 DOM 视图的变化是须要在下一个 tick 才能完成。
接下来,咱们来看一下 Vue 的 nextTick 的实现,在 Vue.js 2.5+ 的版本,抽出来一个单独的 next-tick.js 文件去实现它。
/* @flow */ /* globals MessageChannel */ import { noop } from 'shared/util' import { handleError } from './error' import { isIOS, isNative } from './env' const callbacks = [] let pending = false function flushCallbacks () { pending = false const copies = callbacks.slice(0) callbacks.length = 0 for (let i = 0; i < copies.length; i++) { copies[i]() } } // Here we have async deferring wrappers using both micro and macro tasks. // In < 2.4 we used micro tasks everywhere, but there are some scenarios where // micro tasks have too high a priority and fires in between supposedly // sequential events (e.g. #4521, #6690) or even between bubbling of the same // event (#6566). However, using macro tasks everywhere also has subtle problems // when state is changed right before repaint (e.g. #6813, out-in transitions). // Here we use micro task by default, but expose a way to force macro task when // needed (e.g. in event handlers attached by v-on). let microTimerFunc let macroTimerFunc let useMacroTask = false // Determine (macro) Task defer implementation. // Technically setImmediate should be the ideal choice, but it's only available // in IE. The only polyfill that consistently queues the callback after all DOM // events triggered in the same loop is by using MessageChannel. /* istanbul ignore if */ if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { macroTimerFunc = () => { setImmediate(flushCallbacks) } } else if (typeof MessageChannel !== 'undefined' && ( isNative(MessageChannel) || // PhantomJS MessageChannel.toString() === '[object MessageChannelConstructor]' )) { const channel = new MessageChannel() const port = channel.port2 channel.port1.onmessage = flushCallbacks macroTimerFunc = () => { port.postMessage(1) } } else { /* istanbul ignore next */ macroTimerFunc = () => { setTimeout(flushCallbacks, 0) } } // Determine MicroTask defer implementation. /* istanbul ignore next, $flow-disable-line */ if (typeof Promise !== 'undefined' && isNative(Promise)) { const p = Promise.resolve() microTimerFunc = () => { p.then(flushCallbacks) // in problematic UIWebViews, Promise.then doesn't completely break, but // it can get stuck in a weird state where callbacks are pushed into the // microtask queue but the queue isn't being flushed, until the browser // needs to do some other work, e.g. handle a timer. Therefore we can // "force" the microtask queue to be flushed by adding an empty timer. if (isIOS) setTimeout(noop) } } else { // fallback to macro microTimerFunc = macroTimerFunc } /** * Wrap a function so that if any code inside triggers state change, * the changes are queued using a Task instead of a MicroTask. */ export function withMacroTask (fn: Function): Function { return fn._withTask || (fn._withTask = function () { useMacroTask = true const res = fn.apply(null, arguments) useMacroTask = false return res }) } export function nextTick (cb?: Function, ctx?: Object) { let _resolve callbacks.push(() => { if (cb) { try { cb.call(ctx) } catch (e) { handleError(e, ctx, 'nextTick') } } else if (_resolve) { _resolve(ctx) } }) if (!pending) { pending = true if (useMacroTask) { macroTimerFunc() } else { microTimerFunc() } } // $flow-disable-line if (!cb && typeof Promise !== 'undefined') { return new Promise(resolve => { _resolve = resolve }) } }复制代码
咱们在有以前的知识背景,再理解 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
。
回到咱们的问题,iOS 微信浏览器不能播放歌曲和 nextTick 有什么关系呢?先来看一下咱们的歌曲播放这个功能的实现方法。
咱们的代码会有一个播放器组件 player.vue,在这个组件中咱们会持有一个 html5 的 audio 标签。因为可调用播放的地方不少,好比在歌曲列表组件、榜单组件、搜索结果组件等等,所以咱们用 vuex 对播放相关的数据进行管理。咱们把正在播放的列表 playlist
和当前播放索引 currentIndex
用 state 维护,当前播放的歌曲 currentSong
经过它们计算而来:
// state.js const state = { playlist: [], currentIndex:0 } // getters.js export const currentSong = (state) => { return state.playlist[state.currentIndex] || {} }复制代码
而后咱们在 player.vue 组件里 watch currentSong
的变化去播放歌曲:
// player.vue watch : { currentSong(newSong,oldSong) { if (!newSong.id || !newSong.url || newSong.id === oldSong.id) { return } this.$refs.audio.src = newSong.url this.$refs.audio.play() } }复制代码
这样咱们就能够在任何组件中提交对 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+ 的。
<template> <div id="app"> <audio ref="audio"></audio> <button @click="changeUrl">click me</button> </div> </template> <script> const musicList = [ 'http://ws.stream.qqmusic.qq.com/108756223.m4a?fromtag=46', 'http://ws.stream.qqmusic.qq.com/101787871.m4a?fromtag=46', 'http://ws.stream.qqmusic.qq.com/718475.m4a?fromtag=46' ] export default { name: 'app', data() { return { index: 0, url: '' } }, methods: { changeUrl() { this.index = (this.index + 1) % musicList.length this.url = musicList[this.index] } }, watch: { url(newUrl) { this.$refs.audio.src = newUrl this.$refs.audio.play() } } } </script>复制代码
这段代码的逻辑很是简单,咱们会添加一个 watcher 监听 url
变化,当点击按钮的时候,会调用 changeUrl
方法,修改 url
,而后 watcher 的回调函数执行,并调用 audio 的 play 方法。这段代码在 PC 浏览器是能够正常播放歌曲的,可是在 iOS 微信浏览器里却不能播放,这就证明了咱们以前的猜测——在用户点击事件的回调函数到 audio 的播放若是经历了 nextTick 在 iOS 微信浏览器下不能播放。
有些同窗可能会认为,当用户点击了按钮到播放的过程在 iOS 微信浏览器或者是 iOS safari 浏览器应该须要在同一个 tick 才能执行,果然须要这样吗?咱们把上述代码作一个简单的修改:
changeUrl() { this.index = (this.index + 1) % musicList.length this.url = musicList[this.index] setTimeout(()=>{ this.$refs.audio.src = this.url this.$refs.audio.play() }, 0) }复制代码
咱们如今不利用 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
实现。
changeUrl() { this.index = (this.index + 1) % musicList.length this.url = musicList[this.index] let channel = new MessageChannel() let port = channel.port2 channel.port1.onmessage = () => { this.$refs.audio.src = this.url this.$refs.audio.play() } port.postMessage(1) }复制代码
这段代码在 PC 浏览器是能够播放的,而在 iOS 微信浏览器又不能播放了,调试后发现 this.$refs.audio.play()
的逻辑也是能够执行到的,可是歌曲并不能播放,应该是浏览器对 audio 播放在使用 MessageChannel 作异步的一种限制。
前面提到实现 macro task 还有一种方法是利用 postMessage,它的浏览器支持程度也很好,咱们来把 demo 改为利用它来实现。
changeUrl() { this.index = (this.index + 1) % musicList.length this.url = musicList[this.index] addEventListener('message', () => { this.$refs.audio.src = this.url this.$refs.audio.play() }, false); postMessage(1, '*') }复制代码
这段代码在 PC 浏览器和 iOS 微信浏览器以及 iOS safari 均可以播放的,说明并非 macro task 的锅,而是 MessageChannel 的锅。其实 macro task 还有不少实现方式,感兴趣的同窗能够看看 core-js 中对于 macro task 的几种实现方式。
如今咱们定位到问题的本质是由于 Vue.js 的 nextTick 中优先使用了 MessageChannel,它会影响 iOS 微信浏览器的播放,那么咱们如何用最小成原本解决这个问题呢?
若是是真实运行在生产环境中的项目,毫无疑问这确定是优先解决问题的首选,由于确实也是由于 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 覆盖,也只能经过社区帮助反馈问题作改进了。
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 的办法,由于是 MessageChannel 的锅,因此我就在 Vue.js 的初始化前,引入了一段 hack.js
// hack for global nextTick function noop() { } window.MessageChannel = noop window.setImmediate = noop复制代码
这样的话 Vue.js 在初始化 nextTick 的时候,发现全局的 setImmediate
和 MessageChannel
被改写了,就自动降级为 setTimeout 0
的实现,这样就能够规避掉咱们的问题了。固然,这种 hack 方式算是没有办法的办法了,我并不推荐。
因此这种状况最合理的就是给 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。
但愿同窗们一块儿来支持正版,抵制盗版,我会为你们带来更多优质的课程以及其它的一些形式的技术方向的分享。
本文参考的一些值得延伸学习的文章: