问题的本质

以上定位到问题大概花了我半小时时间,但是我并没有找到问题的根本原因,于是我翻阅了 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 执行是单线程的,它是基于事件循环的。对于事件循环的理解,阮老师有一篇文章写的很清楚,大致分为以下几个步骤:(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。

nextTick 对 audio 播放的影响

回到我们的问题,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+ 的。

click me

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()

}

}

}

这段代码的逻辑非常简单,我们会添加一个 watcher 监听 url 变化,当点击按钮的时候,会调用 changeUrl 方法,修改 url,然后 watcher 的回调函数执行,并调用 audio 的 play 方法。这段代码在 PC 浏览器是可以正常播放歌曲的,但是在 iOS 微信浏览器里却不能播放,这就证实了我们之前的猜想——在用户点击事件的回调函数到 audio 的播放如果经历了 nextTick 在 iOS 微信浏览器下不能播放。

macro task 的锅?

有些同学可能会认为,当用户点击了按钮到播放的过程在 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 的版本降级

如果是真实运行在生产环境中的项目,毫无疑问这肯定是优先解决问题的首选,因为确实也是因为 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

// hack for global nextTick

function noop() {

}

window.MessageChannel = noop

window.setImmediate = noop

这样的话 Vue.js 在初始化 nextTick 的时候,发现全局的 setImmediate 和 MessageChannel 被改写了,就自动降级为 setTimeout 0 的实现,这样就可以规避掉我们的问题了。当然,这种 hack 方式算是没有办法的办法了,我并不推荐。

vue在微信里面的兼容问题_Vue在 iOS 微信浏览器下不能播放相关推荐

  1. vue检测是不是360浏览器兼容模式_vue开发之不同浏览器的类型判断

    一.通过navigator.userAgent来进行浏览器类型判断 定义和用法 userAgent 属性是一个只读的字符串,声明了浏览器用于 HTTP 请求的用户代理头的值. 一般来讲,它是在 nav ...

  2. 微信摇一摇插件ios_苹果ios微信摇一摇代码实现

    [实例简介] 该文档是苹果ios微信的摇一摇代码实现,希望有帮助 [实例截图] [核心代码] ShakeYiShakeHa ├── __MACOSX │   └── ShakeYiShakeHa │  ...

  3. vue在微信里面的兼容问题_vue 微信浏览器缓存问题解决方案

    1. 试过js.css打包时添加时间戳,因为打包后每次都是新名字的文件,所以感觉加不加时间戳都没有效果 试了一下果然 原因应该是微信浏览器缓存了index.html, 所以打开缓存的页面根本没有任何请 ...

  4. vue检测是不是360浏览器兼容模式_Vue项目 IE/360浏览器兼容模式下打开空白的问题...

    记一次bug解决 bug发现 项目不复杂,就是一个基于vue-cli3搭建的pc端项目 在开发进入后半段的时候发现在360浏览器兼容模式先显示空白而且没有报错,此类问题之前我写react在低版本的qq ...

  5. vue 微信公众号支付接口_vue页面在微信公众号调用支付接口

    // 点击了点击购买按钮 async handlePurchase() { const vm = this // 获取选中的套餐信息,用的是axios的post方法,因此要包装一下params con ...

  6. 360兼容模式,搜狗等奇葩浏览器下无法正常渲染的问题

    近日,通过朋友接到一个游戏官网的项目,对方需要兼容主流浏览器. 当时并没有多想,结果项目交付后对方提出360兼容模式下样式错乱. 因为最近几年在做移动端项目,偶尔web项目也只需要chrome,ff, ...

  7. 微信开发 url传参数 json 导致 ios 微信分享失败苹果手机sinature签名无效

    将对象转成JSON.stringify(arr) json,然后在用encodeURI()转码就行了,encodeURI(JSON.stringify(arr)) http://www.186886. ...

  8. ios 权限提示语_撩妹神技?iOS 微信偷偷上线“拍一拍”

    微信 概要 / 最近微信新功能不断,安卓和 iOS 前后上线了「修改微信号」功能,给了网友们"重新做人"的机会,这不,又来了一个新功能,iOS 的微信可以"拍一拍&quo ...

  9. Ios王者微信抢先服务器,王者荣耀:iOS微信用户抢先体验!国服出装铭文,点击一键查看!...

    原标题:王者荣耀:iOS微信用户抢先体验!国服出装铭文,点击一键查看! Hello大家好,欢迎来到朕享玩,我是朕哥! 今天不停机更新新增了一个值得点赞的新功能! 那就是[常用英雄自定义攻略],现已在i ...

最新文章

  1. 伸展树算法c语言,数据结构伸展树介绍及C语言的实现方法
  2. js图片轮换显示实例(转载)
  3. [SOJ] 畅通工程续
  4. 二叉树创建及遍历算法(递归及非递归)(转)
  5. linux数据库怎么import,mysqlimport
  6. 某少儿不宜网站图片拍摄位置分析,Python批量读取图片GPS位置!
  7. python __enter____exit__(with)
  8. CentOS x86_64系统手动释放内存
  9. 大数据技术在发展 挑战与机遇并存
  10. Mosets Tree开发笔记
  11. spring 视频教程
  12. java integer最大值_Integer的最大值
  13. 网络安全技术——网络地址转换(NAT)
  14. 原生js实现简易的可旋转伸缩连线效果代码
  15. 【图片无损压缩利器】Image Optimizer
  16. 微信小程序设置背景图片
  17. Kymeta加入美国陆军装甲旅战斗队试点项目
  18. devServer和dev的区别,及vue代理接口的使用
  19. nginx静态资源缓存与压缩
  20. 我的python入门心得

热门文章

  1. 【智慧农业】LORA农业灌溉解决方案
  2. HTML5前端开发实战08-外语培训
  3. 【九】分布式微服务架构体系详解——共识问题
  4. Power Query介绍
  5. c++编译常见错误原因集中
  6. 赛意SMOM和金蝶云星空单据接口对接
  7. Excel学习日记:L21-表格数值格式
  8. js 操作字符串,
  9. 新书上市|一位家长的忠告:长大后不成才的孩子,父母都忽视了这个点!
  10. 在C++中部署python深度学习-学习笔记