前言
经常会遇到一有问题就抛到群里等待其他人的回答的同学,他们往往不会自己思考并寻找问题的答案。今日来看看在音乐App中遇到问题并探寻问题本质的过程。今日早读文章由滴滴@黄轶投稿分享。
正文从这开始~
本文并不是什么高深的技术文章,只是记录我最近遇到一个因为 Vue 升级导致我的一个项目踩坑以及我解决问题的过程。文章虽长但不水,写下来的目的是想和大家分享一下我遇到问题时候一个思考的方法和态度。
背景:去年我在慕课网推出了一门 Vue.js 的入门实战课程——Vue.js 高仿饿了么外卖 App
,这门课程收到了非常不错的反响,于是今年又在慕课网上继续推出了 Vue.js 的高级进阶实战课程——Vue.js 音乐 App,同样反馈不错。每天晚上下班回家,我会去问答区看一下学生们的问题,发现近期有不少同学反馈了同样的问题,iOS 微信里点击不能播放歌曲了,PC 可以。通常遇到这种问题我会让学生先去访问我的项目的线上地址,看看我的代码会不会有问题,得到的结论是我的线上代码没问题,但他们自己写就不行,并且说已经完全和我的代码做了对比,这就让我觉得十分诡异。没过多久,有些学生就想出了一个办法,在全局 document 绑定一个同步的 click 事件,在 click 事件的回调函数中同步触发一次 audio 的 play 方法,似乎解决了问题,也得到了一些同学的采纳,但是我看到以后的第一反应是不能用这种太 hack 的方式去解决问题,必须找到问题的本质,于是乎我开始了一段很有意思的找问题的过程。
定位问题
先看现象:同学们写的代码在 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。我从上往下依次扫了一遍,把一些关于它核心的改动都点进去看了一下代码的修改,最终锁定了这一条:
use MessageChannel for nextTick 6e41679, closes #6566 #6690
接着我点进去看了一下改动,我滴天,改动很大呀,nextTick 的核心实现变了,MutationObserver 不见了,改成了 MessageChannel 的实现。等等,有些同学看到这里,可能会懵,这都是些啥呀。不急,我先简单解释一下 Vue 的 nextTick。
nextTick
介绍 Vue 的 nextTick 之前,我先简单介绍一下 JS 的运行机制:JS 执行是单线程的,它是基于事件循环的。对于事件循环的理解,阮老师有一篇文章写的很清楚,大致分为以下几个步骤:
-
所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
-
主线程之外,还存在一个”任务队列”(task queue)。只要异步任务有了运行结果,就在”任务队列”之中放置一个事件。
-
一旦”执行栈”中的所有同步任务执行完毕,系统就会读取”任务队列”,看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
-
主线程不断重复上面的第三步。
主线程的执行过程就是一个 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
。
nextTick 对 audio 播放的影响
回到我们的问题,iOS 微信浏览器不能播放歌曲和 nextTick 有什么关系呢?先来看一下我们的歌曲播放这个功能的实现方法。
我们的代码会有一个播放器组件 player.vue,在这个组件中我们会持有一个 html5 的 audio 标签。由于可调用播放的地方很多,比如在歌曲列表组件、榜单组件、搜索结果组件等等,因此我们用 vuex 对播放相关的数据进行管理。我们把正在播放的列表
playlist
和当前播放索引
currentIndex
用 state 维护,当前播放的歌曲
currentSong
通过它们计算而来:
// state.js