第1124期Vue.js 升级踩坑小记

Posted 前端早读课

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了第1124期Vue.js 升级踩坑小记相关的知识,希望对你有一定的参考价值。

前言

经常会遇到一有问题就抛到群里等待其他人的回答的同学,他们往往不会自己思考并寻找问题的答案。今日来看看在音乐App中遇到问题并探寻问题本质的过程。今日早读文章由滴滴@黄轶投稿分享。

正文从这开始~

本文并不是什么高深的技术文章,只是记录我最近遇到一个因为 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。我从上往下依次扫了一遍,把一些关于它核心的改动都点进去看了一下代码的修改,最终锁定了这一条:

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 的概念,这里不会细讲,简单通过一段代码演示他们的执行顺序:

 
   
   
 
  1. for (macroTask of macroTaskQueue) {

  2.    // 1. Handle current MACRO-TASK

  3.    handleMacroTask();


  4.    // 2. Handle all MICRO-TASK

  5.    for (microTask of microTaskQueue) {

  6.        handleMicroTask(microTask);

  7.    }

  8. }

在浏览器环境中,常见的 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 文件去实现它,

 
   
   
 
  1. /* @flow */

  2. /* globals MessageChannel */


  3. import { noop } from 'shared/util'

  4. import { handleError } from './error'

  5. import { isIOS, isNative } from './env'


  6. const callbacks = []

  7. let pending = false


  8. function flushCallbacks () {

  9.  pending = false

  10.  const copies = callbacks.slice(0)

  11.  callbacks.length = 0

  12.  for (let i = 0; i < copies.length; i++) {

  13.    copies[i]()

  14.  }

  15. }


  16. // Here we have async deferring wrappers using both micro and macro tasks.

  17. // In < 2.4 we used micro tasks everywhere, but there are some scenarios where

  18. // micro tasks have too high a priority and fires in between supposedly

  19. // sequential events (e.g. #4521, #6690) or even between bubbling of the same

  20. // event (#6566). However, using macro tasks everywhere also has subtle problems

  21. // when state is changed right before repaint (e.g. #6813, out-in transitions).

  22. // Here we use micro task by default, but expose a way to force macro task when

  23. // needed (e.g. in event handlers attached by v-on).

  24. let microTimerFunc

  25. let macroTimerFunc

  26. let useMacroTask = false


  27. // Determine (macro) Task defer implementation.

  28. // Technically setImmediate should be the ideal choice, but it's only available

  29. // in IE. The only polyfill that consistently queues the callback after all DOM

  30. // events triggered in the same loop is by using MessageChannel.

  31. /* istanbul ignore if */

  32. if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {

  33.  macroTimerFunc = () => {

  34.    setImmediate(flushCallbacks)

  35.  }

  36. } else if (typeof MessageChannel !== 'undefined' && (

  37.  isNative(MessageChannel) ||

  38.  // PhantomJS

  39.  MessageChannel.toString() === '[object MessageChannelConstructor]'

  40. )) {

  41.  const channel = new MessageChannel()

  42.  const port = channel.port2

  43.  channel.port1.onmessage = flushCallbacks

  44.  macroTimerFunc = () => {

  45.    port.postMessage(1)

  46.  }

  47. } else {

  48.  /* istanbul ignore next */

  49.  macroTimerFunc = () => {

  50.    setTimeout(flushCallbacks, 0)

  51.  }

  52. }


  53. // Determine MicroTask defer implementation.

  54. /* istanbul ignore next, $flow-disable-line */

  55. if (typeof Promise !== 'undefined' && isNative(Promise)) {

  56.  const p = Promise.resolve()

  57.  microTimerFunc = () => {

  58.    p.then(flushCallbacks)

  59.    // in problematic UIWebViews, Promise.then doesn't completely break, but

  60.    // it can get stuck in a weird state where callbacks are pushed into the

  61.    // microtask queue but the queue isn't being flushed, until the browser

  62.    // needs to do some other work, e.g. handle a timer. Therefore we can

  63.    // "force" the microtask queue to be flushed by adding an empty timer.

  64.    if (isIOS) setTimeout(noop)

  65.  }

  66. } else {

  67.  // fallback to macro

  68.  microTimerFunc = macroTimerFunc

  69. }


  70. /**

  71. * Wrap a function so that if any code inside triggers state change,

  72. * the changes are queued using a Task instead of a MicroTask.

  73. */

  74. export function withMacroTask (fn: Function): Function {

  75.  return fn._withTask || (fn._withTask = function () {

  76.    useMacroTask = true

  77.    const res = fn.apply(null, arguments)

  78.    useMacroTask = false

  79.    return res

  80.  })

  81. }


  82. export function nextTick (cb?: Function, ctx?: Object) {

  83.  let _resolve

  84.  callbacks.push(() => {

  85.    if (cb) {

  86.      try {

  87.        cb.call(ctx)

  88.      } catch (e) {

  89.        handleError(e, ctx, 'nextTick')

  90.      }

  91.    } else if (_resolve) {

  92.      _resolve(ctx)

  93.    }

  94.  })

  95.  if (!pending) {

  96.    pending = true

  97.    if (useMacroTask) {

  98.      macroTimerFunc()

  99.    } else {

  100.      microTimerFunc()

  101.    }

  102.  }

  103.  // $flow-disable-line

  104.  if (!cb && typeof Promise !== 'undefined') {

  105.    return new Promise(resolve => {

  106.      _resolve = resolve

  107.    })

  108.  }

  109. }

我们在有之前的知识背景,再理解 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 通过它们计算而来:

 
   
   
 
  1. // state.js

  2. const state = {

  3.  playlist: [],

  4.  currentIndex:0

  5. }

  6. // getters.js

  7. export const currentSong = (state) => {

  8.  return state.playlist[state.currentIndex] || {}

  9. }

然后我们在 player.vue 组件里 watch currentSong 的变化去播放歌曲:

 
   
   
 
  1. // player.vue

  2. watch : {

  3.   currentSong(newSong,oldSong) {

  4.      if (!newSong.id || !newSong.url || newSong.id === oldSong.id) {

  5.          return

  6.       }

  7.       this.$refs.audio.src = newSong.url

  8.       this.$refs.audio.play()

  9.   }

  10. }

这样我们就可以在任何组件中提交对 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+ 的。

 
   
   
 
  1. <template>

  2.    <div id="app">

  3.        <audio ref="audio"></audio>

  4.        <button @click="changeUrl">click me</button>

  5.    </div>

  6. </template>


  7. <script>

  8.    const musicList = [

  9.    'http://ws.stream.qqmusic.qq.com/108756223.m4a?fromtag=46',

  10.    'http://ws.stream.qqmusic.qq.com/101787871.m4a?fromtag=46',

  11.    'http://ws.stream.qqmusic.qq.com/718475.m4a?fromtag=46'

  12.  ]


  13.  export default {

  14.    name: 'app',

  15.    data() {

  16.      return {

  17.        index: 0,

  18.        url: ''

  19.      }

  20.    },

  21.    methods: {

  22.      changeUrl() {

  23.        this.index = (this.index + 1) % musicList.length

  24.        this.url = musicList[this.index]

  25.      }

  26.    },

  27.    watch: {

  28.      url(newUrl) {

  29.        this.$refs.audio.src = newUrl

  30.        this.$refs.audio.play()

  31.      }

  32.    }

  33.  }

  34. </script>

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

macro task 的锅?

有些同学可能会认为,当用户点击了按钮到播放的过程在 iOS 微信浏览器或者是 iOS safari 浏览器应该需要在同一个 tick 才能执行,果真需要这样吗?我们把上述代码做一个简单的修改:

 
   
   
 
  1. changeUrl() {

  2.  this.index = (this.index + 1) % musicList.length

  3.  this.url = musicList[this.index]


  4.  setTimeout(()=>{

  5.    this.$refs.audio.src = this.url

  6.    this.$refs.audio.play()

  7.  }, 0)

  8. }

我们现在不利用 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 实现。

 
   
   
 
  1. changeUrl() {

  2.  this.index = (this.index + 1) % musicList.length

  3.  this.url = musicList[this.index]


  4.  let channel = new MessageChannel()

  5.  let port = channel.port2

  6.  channel.port1.onmessage = () => {

  7.    this.$refs.audio.src = this.url

  8.    this.$refs.audio.play()

  9.  }

  10.  port.postMessage(1)

  11. }

这段代码在 PC 浏览器是可以播放的,而在 iOS 微信浏览器又不能播放了,调试后发现 this.$refs.audio.play() 的逻辑也是可以执行到的,但是歌曲并不能播放,应该是浏览器对 audio 播放在使用 MessageChannel 做异步的一种限制。

前面提到实现 macro task 还有一种方法是利用 postMessage,它的浏览器支持程度也很好,我们来把 demo 改成利用它来实现。

 
   
   
 
  1. changeUrl() {

  2.  this.index = (this.index + 1) % musicList.length

  3.  this.url = musicList[this.index]


  4.  addEventListener('message', () => {

  5.    this.$refs.audio.src = this.url

  6.    this.$refs.audio.play()

  7.  }, false);

  8.  postMessage(1, '*')

  9. }

这段代码在 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

 
   
   
 
  1. // hack for global nextTick

  2. function noop() {

  3. }


  4. window.MessageChannel = noop

  5. window.setImmediate = noop

这样的话 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。

总结

通过记录我这一次发现问题——定位问题——解决问题的过程,我想给同学带来的思考不仅仅是这个问题本身,还有我们遇到问题后的一些态度。发现问题并不难,很多人在写代码中都会发现问题,那么发现问题后你的第一反应是尝试自己解决,还是去求助,我相信前者肯定更好。那么在解决之前需要定位问题,这里我要提到一个词,叫“面向巧合编程”,很多人遇到问题后会不断尝试这种办法,很可能某个办法就从表象上“解决”了这个问题,却不知道为什么,这种解决问题的方式是很不靠谱的,你可能并没有根本上解决问题,又可能解决了这个问题却又引发另一个问题。所以定位问题的本质就非常关键了,其实这是一个能力,一个好的工程师不仅会写代码,也要会查问题,能快速定位到问题的本质,是一个优秀的工程师的必要条件,这一点不容易,需要平时不断地的积累。在定位到问题的本质后,就要解决问题了,一道题往往有多解,但每种解法是否合理,这也是一个需要思考的过程,多和一些比你厉害的人交流,多积攒一些这方面的经验,这也是一个积累的过程。如果以后你再遇到问题,也用这样的态度去面对的问题,那么你也会很快的成长。

本文参考的一些值得延伸学习的文章:

  • javascript 运行机制详解:再谈Event Loop

  • Tasks, microtasks, queues and schedules

关于本文

原文:https://juejin.im/post/5a1af88f5188254a701ec230




以上是关于第1124期Vue.js 升级踩坑小记的主要内容,如果未能解决你的问题,请参考以下文章

Vue.js升级小记

async语法升级踩坑小记

第2039期最全的Vue3.0升级指南

第1241期webpack4升级完全指南

Android踩坑小记:ndk版本与Android Gradle Plugin版本兼容

Android踩坑小记:ndk版本与Android Gradle Plugin版本兼容