Принцип асинхронной очереди обновлений Vue от входа до отказа

внешний интерфейс JavaScript Vue.js

Отказ от ответственности: весь исходный код в этой статье взят из Vue в ветке dev версии: 2.5.13, и абсолютная точность представлений в статье не гарантируется. Статьи, составленные из моего внутреннего обмена в группе на этой неделе.

Оригинальный адрес статьи

Наш текущий стек технологий в основном использует Vue, и мы столкнулись в своей работе с ситуацией, что при изменении пропсов, переданных в некоторые компоненты, нам необходимо сбросить жизненный цикл всего компонента (например, изменить тип средства выбора даты в IView, ok Новость в том, что компонент больше не может использовать такой дурацкий способ переключения типа отображения времени). Для этого у нас есть следующий код

<template>
  <button @click="handleClick">btn</button>
  <someComponent  v-if="show" />
</template>

<script>
  {
    data() {
      return { show: true }
    },
    methods: {
      handleClick() {
        this.show = false
        this.show = true
      }
    }
  }
</script>

Не смейтесь, конечно, мы знаем, насколько глуп этот код, и мы уверены, что он неправильный, даже не пытаясь, но из опыта работы с реакцией я, вероятно, знаю, чтоthis.show = trueзаменитьsetTimeout(() => { this.show = true }, 0), вы должны получить желаемый результат и наверняка достаточно, компонент сбрасывает свой жизненный цикл, но все еще немного выключено. Мы нашли через несколько кликов, что компонент всегда вспыхнул. Логично, это легко понять. Это нормально для компонентов, чтобы быть уничтоженным первым, а затем перестроили, но извините, мы нашли другого способа (в конце концов, Google OmnniPotent),setTimeout(() => { this.show = true }, 0)заменитьthis.$nextTick(() => { this.show = true })грядет волшебство, компонент все еще сбрасывает свой жизненный цикл, но компонент вообще не мерцает.

Для того, чтобы вы, мои дорогие, почувствовали мое иллюзорное описание, я приготовил это для вас.demo, вы можете заменить handle1 на handle2 и handle3 по очереди, чтобы испытать удовольствие от мигания компонента и его отсутствия.

Если вы все же решите продолжить чтение после того, как испытали острые ощущения, то я скажу вам, что следующий контент будет длиннее, потому что, чтобы полностью понять это, мы должны углубиться во внутренности Vue и EventLoop Javascript.

Основная причина этой проблемы заключается в том, что Vue по умолчанию использует метод асинхронной очереди обновлений, Мы можем найти следующее описание на официальном сайте

Если вы не заметили, Vue выполняет обновления DOM асинхронно. Как только наблюдается изменение данных, Vue открывает очередь и буферизует все изменения данных, которые происходят в том же цикле событий. Если один и тот же наблюдатель запускается несколько раз, он будет помещен в очередь только один раз. Эта дедупликация во время буферизации важна, чтобы избежать ненужных вычислений и манипуляций с DOM. Затем, в следующем цикле событий «тик», Vue очищает очередь и выполняет фактическую (дедублированную) работу. Vue внутренне пытается использовать собственные Promise.then и MessageChannel для асинхронных очередей.Если среда выполнения не поддерживает это, вместо этого будет использоваться setTimeout(fn, 0).

В этом абзаце кратко описан весь процесс, но он не решает наших сомнений, а значит, пришло время показать настоящую технологию. Следует отметить, что если вы не читали некоторые блоги, которые знакомят с исходным кодом, или читали исходный код, то вы можете запутаться. Но это не имеет значения.В конечном счете нас здесь интересует шаг 4. Вам нужно только приблизительно запомнить его, а затем сопоставить этот процесс с исходным кодом, который мы проанализировали позже.

Основной процесс Vue можно условно разделить на следующие этапы.

  1. Перейдите свойство, чтобы добавить к нему методы get и set.В методе get будут собираться зависимости (dev.subs.push(watcher)), а метод set вызовет метод уведомления dev. Функция этого метода — уведомить всех наблюдателей в сабах и вызвать метод обновления наблюдателя. Мы можем понимать это как публикацию и подписку в режиме разработки

  2. По умолчанию метод обновления вызывается после его вызоваqueueWatcherосновной функцией этой функции является добавление самого экземпляра наблюдателя в очередь (queue.push(watcher)), то звонитеnextTick(flushSchedulerQueue)

  3. flushSchedulerQueueэто функция, целью которой является вызов всех наблюдателей в очередиwatcher.runметод, в то время какrunПосле вызова метода следующей операцией является создание нового реального DOM путем выполнения алгоритма сравнения между новым виртуальным DOM и старым виртуальным DOM.

  4. Только в это время мыflushSchedulerQueueне выполняется, последним шагом второго шага являетсяflushSchedulerQueueИ поместите его в очередь обратных вызовов (callbacks.push(flushSchedulerQueue)),ПотомасинхронныйОбратные вызовы будут пройдены и выполнены (это очередь асинхронного обновления).

  5. Как указано вышеflushSchedulerQueueвызывается после выполненияwatcher.run(), так что вы видите новую страницу

Все вышеперечисленные процессы находятся вvue/src/coreпапка.

Далее мы проанализируем процесс выполнения кода Vue в соответствии с последним случаем в приведенном выше примере.Я опущу некоторые детали.Помните, что в начале нас больше всего беспокоит только четвертый шаг.

Когда вы нажимаете кнопку, привязка к кнопке обратного вызова срабатывания,this.show = falseвыполняется, вызывая функцию set в свойстве.В функции set вызывается метод уведомления dev, вызывающий выполнение метода обновления каждого наблюдателя в его подчиненных (в этом примере есть только один наблюдатель~ в subs), a Давайте посмотрим на конструктор наблюдателя

class Watcher {
  constructor (vm) {
    // 将vue实例绑定在watcher的vm属性上
    this.vm = vm 
  }
  update () {
     // 默认情况下都会进入else的分支,同步则直接调用watcher的run方法
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }
}

посмотри сноваqueueWatcher

/**
 * 将watcher实例推入queue(一个数组)中,
 * 被has对象标记的watcher不会重复被加入到队列
 */
export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  // 判断watcher是否被标记过,has为一个对象,此方案类似数组去重时利用object保存数组值
  if (has[id] == null) {
    // 没被标记过的watcher进入分支后被标记上
    has[id] = true
    if (!flushing) {
      // 推入到队列中
      queue.push(watcher)
    } else {
      // 如果是在flush队列时被加入,则根据其watcher的id将其插入正确的位置
      // 如果不幸该watcher已经错过了被调用的时机则会被立即调用
      // 稍后看flushSchedulerQueue这个函数会理解这两段注释的意思
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    // queue the flush
    if (!waiting) {
      waiting = true
     // 我们关心的重点nextTick函数,其实我们写的this.$nextTick也是调用的此函数
      nextTick(flushSchedulerQueue)
    }
  }
}

После запуска этой функции наш наблюдатель входит в очередь (в этом примере в очередь добавляется только этот наблюдатель), а затем вызываетnextTick(flushSchedulerQueue), здесь мы сначала рассмотримflushSchedulerQueueисходный код функции

/**
 * flush整个队列,调用watcher
 */
function flushSchedulerQueue () {
  // 将flush置为true,请联系上文
  flushing = true
  let watcher, id

  // flush队列前先排序
  // 目的是
  // 1.Vue中的组件的创建与更新有点类似于事件捕获,都是从最外层向内层延伸,所以要先
  // 调用父组件的创建与更新
  // 2. userWatcher比renderWatcher创建要早(抱歉并不能给出我的解释,我没理解)
  // 3. 如果父组件的watcher调用run时将父组件干掉了,那其子组件的watcher也就没必要调用了
  queue.sort((a, b) => a.id - b.id)
  
  // 此处不缓存queue的length,因为在循环过程中queue依然可能被添加watcher导致length长度的改变
  for (index = 0; index < queue.length; index++) {
    // 取出每个watcher
    watcher = queue[index]
    id = watcher.id
    // 清掉标记
    has[id] = null
    // 更新dom走起
    watcher.run()
    // dev环境下,检测是否为死循环
    if (process.env.NODE_ENV !== 'production' && has[id] != null) {
      circular[id] = (circular[id] || 0) + 1
      if (circular[id] > MAX_UPDATE_COUNT) {
        warn(
          'You may have an infinite update loop ' + (
            watcher.user
              ? `in watcher with expression "${watcher.expression}"`
              : `in a component render function.`
          ),
          watcher.vm
        )
        break
      }
    }
  }

Еще нужно помнить, что наш flushSchedulerQueue еще не был выполнен, он просто передается в nextTick в качестве обратного вызова, далее мы поговорим о нашем ключе nextTick в этот раз, предлагаю вам взглянуть на него в целомnextTickИсходный код, хотя я также объясню

Сначала извлекаем из next-tick.jswithMacroTaskЭта функция для иллюстрации, извините, я поставил эту функцию в конец, потому что я хочу, чтобы вы знали, что самые важные вещи всегда в финале. Но из общего процесса, когда мы нажимаем кнопку, первым шагом должен быть вызов этой функции.

/**
 * 包装参数fn,让其使用marcotask
 * 这里的fn为我们在事件上绑定的回调函数
 */
export function withMacroTask (fn: Function): Function {
  return fn._withTask || (fn._withTask = function () {
    useMacroTask = true
    const res = fn.apply(null, arguments)
    useMacroTask = false
    return res
  })
}

Верно, на самом деле функция обратного вызова, которую вы привязываете к onclick, запускается в форме применения внутри этой функции. Пожалуйста, нажмите здесь точку останова, чтобы проверить. Хорошо, теперь я считаю, что вы доказали, что я сказал правду, но это не имеет большого значения, потому что важно то, что мы поставим здесь флаг,useMacroTask = true, это ключевая вещь, с помощью Google переводчика мы можем узнать его конкретное значение,использовать задачу макроса

黑人问号

Хорошо, это начинается со второй части EventLoop, о которой мы упоминали в начале нашей статьи.

На самом деле, я считаю, что эта часть содержания уже была затронута вами, кто видел это здесь.Если вам действительно не ясно, я рекомендую вам поближе познакомиться с работой Учителя Жуань Ии.эта статья, мы сделаем только грубое резюме

  1. Вызов нашей задачи синхронизации формирует структуру стека
  2. Кроме того, у нас также есть очередь задач.Когда асинхронная задача имеет результат, задача будет добавлена ​​в очередь.Каждой задаче соответствует функция обратного вызова.
  3. Когда наша структура стека пуста, очередь задач будет прочитана, и в то же время будет вызвана соответствующая функция обратного вызова.
  4. повторить

В этой сводке в настоящее время для нас отсутствует информация о том, что задачи в очереди фактически делятся на два типа: макрозадачи и микрозадачи. Когда все задачи синхронизации, выполняемые в основном потоке, будут завершены, все микрозадачи будут извлечены из очереди задач для выполнения.Когда микрозадачи также будут выполнены, цикл событий завершится, а затем браузер перерендерится (Имейте это в виду, потому что именно это вызывает проблему, упомянутую в начале статьи.). После этого выньте макрозадачу из очереди, чтобы продолжить следующий раунд событийного цикла.Стоит отметить, что микрозадача может продолжать генерироваться во время выполнения микрозадачи и продолжать выполняться в этом раунде события. петля. Так что, по сути, приоритет микрозадач выше, чем у макрозадач.

Если вы хотите более подробно разобраться в макрозадачах и микрозадачах, рекомендуется прочитатьэта статья, это, пожалуй, лучшая, самая доступная и самая подробная статья на эту тему в Восточном полушарии.

Макрозадачи и микрозадачи генерируются по-разному: в среде браузера setImmediate, MessageChannel и setTimeout будут генерировать макрозадачи, а MutationObserver и Promise — микрозадачи. И это также асинхронный метод, принятый во Vue, Vue будетuseMacroTaskЛогическое значение для определения того, генерировать ли макрозадачи или микрозадачи для асинхронного обновления очереди, мы увидим эту часть позже, а пока вернемся к нашей исходной логике.

Когда fn вызывается в функции withMacroTask, генерируются все шаги, о которых мы упоминали выше. Теперь пришло время действительно увидеть, что делает функция nextTick.

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  // callbacks为一个数组,此处将cb推进数组,本例中此cb为刚才还未执行的flushSchedulerQueue
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  // 标记位,保证之后如果有this.$nextTick之类的操作不会再次执行以下代码
  if (!pending) {
    pending = true
    // 用微任务还是用宏任务,此例中运行到现在为止Vue的选择是用宏任务
    // 其实我们可以理解成所有用v-on绑定事件所直接产生的数据变化都是采用宏任务的方式
    // 因为我们绑定的回调都经过了withMacroTask的包装,withMacroTask中会使useMacroTask为true
    if (useMacroTask) {
      macroTimerFunc()
    } else {
      microTimerFunc()
    }
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

После выполнения приведенного выше кода остается только два результата: вызовmacroTimerFuncилиmicroTimerFunc, до сих пор в этом примере будет вызыватьmacroTimerFunc. Цель этих двух функций на самом деле состоит в том, чтобы проходить функции в обратных вызовах в асинхронной форме, но, как мы сказали выше, они используют разные методы, один для макрозадач для достижения асинхронности, другой для микрозадач Задача асинхронная . Кроме того, хочу напомнить, что причиной всех вышеперечисленных процессов является просто запуск строки кодаthis.show = falseа такжеthis.$nextTick(() => { this.show = true })Он еще не начался, но не отчаивайтесь, уже почти его очередь. ок, вернемся к темеmacroTimerFuncа такжеmicroTimerFuncБар.

/**
 * macroTimerFunc
 */
// 如果当前环境支持setImmediate,就用此来产生宏任务达到异步效果
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else if (typeof MessageChannel !== 'undefined' && (
  // 否则MessageChannel
  isNative(MessageChannel) ||
  // PhantomJS
  MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = flushCallbacks
  macroTimerFunc = () => {
    port.postMessage(1)
  }
} else {
  // 再不行的话就只能setTimeout了
  /* istanbul ignore next */
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}
/**
 * microTimerFunc
 */
// 如果支持Promise则用Promise来产生微任务
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  microTimerFunc = () => {
    p.then(flushCallbacks)
    // 对IOS做兼容性处理,(IOS中存在一些问题,具体可以看尤大大自己的解释)
    if (isIOS) setTimeout(noop)
  }
} else {
  // 降级
  microTimerFunc = macroTimerFunc
}

На данный момент должно быть более четкое понимание.На самом деле конечным эффектом nextTick является использование асинхронных методов для вызоваflushCallbacks, Что касается использования макрозадач или микрозадач, Vue уже обработал это для нас внутри, и нам не нужно решать. Что касаетсяflushCallbacksПросто взглянув на имя, вы знаете, что он только что зациклил обратные вызовы и выполнил их.

function flushCallbacks () {
  pending = false
  // 将callbacks做一次复制
  const copies = callbacks.slice(0)
 // 置空callbacks
  callbacks.length = 0
  // 遍历并执行
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

Обратите внимание, что хотя мы объясняем, что здесь делают flushCallbacks, помните, что он обрабатывается асинхронно, а текущая синхронная задача не была выполнена, поэтому эта функция в это время не вызывается, реальная вещь, которую нужно сделать, это пройти всю синхронизацию задача, которая является нашейthis.$nextTick(() => { this.show = true })Наконец-то позвонили, слава богу. когдаthis.$nextTickПосле того, как его назвали() => { this.show = true }Он также проталкивается в обратные вызовы в качестве параметра.В это время можно понять, что обратные вызовы длинные, как это [flushSchedulerQueue, () => {this.show = true}], а затем вwithMacroTaskВызов fn.apply завершенuseMacroTaskизменяется обратно на false, и вся задача синхронизации завершается.

Вы помните, что мы говорили в eventLoop в этот момент, мы будем искать все микрозадачи из очереди задач, а пока в очереди задач нет микрозадач, поэтому один раунд цикла событий завершен, браузер перерисовывает, Тем не менее, наша структура dom на данный момент совсем не изменилась, поэтому даже если браузер не перерисует ее, это не будет иметь никакого эффекта. Следующим шагом является выполнение задачи макроса в очереди задач, и ее соответствующий обратный вызов — это тот, который мы только что зарегистрировали.flushCallbacks. сначала выполнитьflushSchedulerQueue, в котором наблюдатель вызывается с помощью метода run.Поскольку show в наших данных в это время изменено на false, компоненты, привязанные к v-if="show", удаляются из реального DOM после сравнения между новым и старый виртуальный DOM.

Дело в том, что хоть компонент и был удален из DOM, но он все равно отображается в браузере, потому что наш цикл событий не завершился, и еще остались задачи синхронизации, которые нужно выполнить. начать перекрашивать. (Если у вас есть какие-либо вопросы по этому абзацу, я лично думаю, что вы можете не понимать разницу между dom и отображением в браузере. Под dom можно понимать все узлы в модуле elements в консоли. Содержимое, отображаемое в браузер не всегда согласуется с ним)

Остается только выполнить() => { this.show = true }, а при выполненииthis.show = trueВ то время все процессы, описанные в предыдущей статье, выполнялись заново, и только некоторые детали отличались от того, что было только что.

  1. Эта функция неwithMacroTaskОбертка, она вызывается, когда обратные вызовы сбрасываются, поэтому useMacrotask не был изменен и по-прежнему имеет значение по умолчанию false.

  2. По первой причине, когда мы снова выполняем макрозадачу макрозадачи, генерируется микрозадача микрозадачи для обработки этого flushCallbacks (то есть вызовmicroTimerFunc)

Таким образом, когда эта макрозадача завершается, цикл событий еще не завершен, и у нас все еще остаются микрозадачи для обработки, все еще вызывающиеflushSchedulerQueue,Потомwatcher.run, потому что это шоу уже верно, поэтому сравните старый и новый виртуальный дом, перегенерируйте компонент и сбросьте жизненный цикл. В этот момент текущий цикл обработки событий заканчивается, и браузер выполняет повторную визуализацию. Надеюсь, вы помните, что текущее состояние самого нашего браузера таково, что компонент отображается в видимой области, а после повторного рендеринга компонент все еще отображается, поэтому естественно никакого мерцания компонента не будет.

Теперь я уверен, что вы можете понять, почему в нашем примере используетсяsetTimeoutБудут вспышки, но я скажу вам, почему, и посмотрю, согласны ли вы со мной. потому чтоsetTimeoutТо, что генерируется, является задачей макроса. Когда цикл событий завершен, задача макроса не будет обрабатываться напрямую, а рисунок браузера будет вставлен в середину. После перерисовки браузера отображаемые компоненты будут удалены, так что в области останется пустое место, затем запустится очередной цикл событий, пересоздается макрозадача исполняемого компонента dom, цикл событий завершится, браузер перерисуется и Отображение компонента на видимой области. Итак, на вашем изображении компонент замигает, и процесс завершится.

Наконец, мы сказали все, что хотели сказать, большое спасибо, если вы сможете придерживаться этого. Однако есть еще несколько моментов, которые нам еще предстоит рассмотреть.

  1. Почему Vue использует асинхронные обновления очереди, что, очевидно, очень хлопотно и запутанно

На самом деле документация уже говорит нам

Эта дедупликация во время буферизации важна, чтобы избежать ненужных вычислений и манипуляций с DOM.

давайте предположимflushSchedulerQueueне прошелnextTickВместо этого он называется напрямую, тогда первый способ написанияthis.show = false; this.show = trueБудет запущен метод watcher.run, и в результате такой способ записи также может сбросить жизненный цикл компонента.Вы можете закомментировать это в исходном коде Vue.nextTick(flushSchedulerQueue)использовать вместо этогоflushSchedulerQueue()Точки останова, чтобы более четко ощутить поток. Вы должны знать, что это всего лишь простой пример.В реальной работе мы, возможно, много раз меняли дом напрасно из-за этой проблемы.Все мы знаем, что работа с домом стоит дорого, поэтому Vue помогает нам оптимизировать этот шаг в течение рамки. . Вы можете подумать еще раз прямоflushSchedulerQueue()В этом случае компонент будет мигать, чтобы подкрепить то, что мы только что сказали.

  1. Поскольку микрозадача, используемая nextTick, генерируется Promise.then().resolve(), можем ли мы написать ее непосредственно в функции обратного вызоваthis.show = false; Promise.then().resolve(() => { this.show = true })вместо этого.$nextTick? Очевидно, раз я спрашиваю об этом, это не сработает, просто вам нужно подумать о процессе самостоятельно.

Наконец, спасибо за чтение~~~