Если вы разработчик или энтузиаст JavaScript, то наверняка слышали это тысячу раз: JavaScript в браузере не является многопоточным. Если вы никогда об этом не слышали, ну что ж:

JavaScript в браузере не является многопоточным.

Это имеет несколько последствий, но мы собираемся попытаться решить эту проблему с тем фактом, что функция, выполняющая тяжелую работу, может (и будет!) Полностью заблокировать ваш веб-сайт во время его работы.

Это связано с тем, что JavaScript не только однопоточный, он также разделяет свой поток с остальными механизмами, запускающими вашу веб-страницу: ящиком DOM, интерпретатором CSS ...

Пока ваша большая, причудливая функция JavaScript творит чудеса, ничего другого случиться не может. Продолжайте и попробуйте запустить бесконечный цикл на своем веб-сайте:

while (true) { }

Вы увидите, что весь веб-сайт перестает отвечать: ваши :hover эффекты на ваши элементы никогда не будут применяться; вы не сможете щелкнуть ни по какой ссылке, ни по какой кнопке на странице; Фактически, если вы продолжите нажимать на страницу, скорее всего, ваша ОС покажет вам всплывающее окно «Браузер не отвечает».

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

Проблема, которую нужно решить

Недавно я столкнулся с проблемой, связанной с однопоточным характером работы JavaScript. У нас была эта функция для обработки String, и по техническим причинам она должна была выполняться на стороне клиента. Но String для обработки может быть от нескольких байтов до нескольких мегабайт. В среднем на каждый мегабайт требуется около одной секунды на каждый мегабайт. Конечно, у нас не было возможности выпустить функцию, которая могла бы заморозить наш веб-сайт на несколько секунд!

Решение

Поскольку выполнение на стороне сервера недоступно, мне нужно было найти способ работать на стороне клиента без зависания всего пользовательского интерфейса. Если бы я тем временем смог получить способ действительно получить информацию о ходе выполнения функции, то я мог бы даже превратить эту головную боль в еще лучший UX, чем до возникновения проблемы! И, к счастью, ответ был прямо здесь, в моем babel.rc файле: Promise.

Дополнительное примечание о Service Workers: экспериментальный API под названием Service Worker мог позволить запускать функцию в выделенном потоке. Но поскольку 1) это не предполагаемое использование Service Workers и 2) у меня не было времени ждать, пока это будет реализовано, мне пришлось работать с тем, что было доступно.

Попасть туда

Обратный вызов, асинхронный и многопоточный

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

Это заблуждение происходит из-за того, что веб-API работают в другом потоке и работают с обратными вызовами. Но обратный вызов не обязательно должен быть асинхронным (хотя в противном случае он плохо закодирован), а функция с обратным вызовом не запускается в отдельном потоке, за исключением вызовов веб-API.

Давайте посмотрим на следующий код:

myFunctionWithCallback принимает обратный вызов, поэтому он является асинхронным и не блокирует поток!

Что ж… Нет. Запуск этого кода приведет к следующему результату:

3
Waiting for the result...

Но что случилось? Есть обратный звонок и все!

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

Теперь следующая модификация сделает обратный вызов асинхронным:

Заключение нашей функции в setTimeout (который, кстати, является вызовом веб-API) приведет к асинхронному выполнению нашего кода. Выполнив его, мы получаем следующий ожидаемый результат:

Waiting for the result...
3

Ах, теперь он работает в отдельном потоке, поскольку setTimeout - это вызов веб-API!

К сожалению, не совсем так. Единственная его часть, работающая в отдельном потоке, - это подсчет Timer для setTimeout. Однако содержимое setTimeout будет выполняться в том же потоке, что и остальная часть JavaScript. Его выполнение будет отложено только до тех пор, пока не истечет время ожидания и в потоке не станет доступным свободный слот. Здесь тайм-аут происходит сразу (через 0 миллисекунд), но нам все равно нужно подождать, пока поток не освободится. И это происходит сразу после печати нашего Waiting for the result... сообщения.

До сих пор мы могли перемещаться только по последовательности выполнения, но мы не позволяли остальной части веб-сайта работать бесперебойно, поскольку мы все еще зависаем. Все внутри setTimeout будет работать за один раунд.

Из-за однопоточной природы JavaScript мы никогда не сможем сократить время, необходимое потоку для вычисления нашей функции. Но что мы можем сделать, так это разделить наше выполнение на небольшие части и отложить их выполнение, как мы сделали с setTimeout.

Вернемся к нашему примеру

В нашем примере код для обработки следующий:

const myTreatedString = treatmentFunction(myString)

treatmentFunction имеет одно свойство, которое делает возможным этот метод: для его выполнения не требуется полная строка. Это означает, что следующий код даст точно такой же результат:

const firstSlice = myString.slice(0, myString.length/2)
const secondSlice = myString.slice(myString.length/2)
const myTreatedString = treatmentFunction(firstSlice) +   
                        treatmentFunction(secondSlice)

Теперь, как мы видели выше, нашей целью будет запускать каждый фрагмент в обратном вызове веб-API, чтобы он выполнялся только тогда, когда слот становится доступным в потоке.

Поэтому мы создадим функцию, которая должна делать следующее:

  1. Нарежьте ввод небольшими кусочками
  2. Поместите каждый фрагмент в обратный вызов веб-API
  3. Соберите все нарезанные результаты
  4. Вызов обратного вызова, когда все фрагменты будут выполнены, а нарезанные результаты снова сшиты вместе.

Чтобы быть уверенным, что у нас есть время для запуска других частей веб-сайта, нам нужно зарегистрировать второй фрагмент только после того, как первый фрагмент будет завершен, и так далее. Нам также нужен веб-API, который позволит нам различать выполнение среза, оставляя некоторое время для обновления пользовательского интерфейса.

Наш веб-API: requestAnimationFrame

requestAnimationFrame работает так же, как setTimeout, за исключением того, что мы не даем времени ждать. Вместо этого он помещает контент обратно в цикл событий прямо перед обновлением пользовательского интерфейса. Таким образом, мы получим следующую цепочку событий:

Treat a slice -> Update the UI -> ... -> Treat slice -> Update the UI -> ... -> Treat a slice -> Update the UI...

Мы уверены, что у нас будет достаточно времени для обновления пользовательского интерфейса между каждым срезом. Однако имейте в виду, что requestAnimationFrame выполнит вашу функцию непосредственно перед следующим обновлением пользовательского интерфейса, независимо от того, сколько функций было зарегистрировано. Это означает, что если вы зарегистрируете три фрагмента подряд, все они будут выполнены до следующего обновления пользовательского интерфейса.

Связывание кусочков: обещания

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

Теперь обратный вызов первого среза может запускать следующий, пока мы не дойдем до последнего среза, который вместо этого вызовет последний обратный вызов.

Или мы можем работать с тем, что нам дал ES6: Promise!

Поскольку Promises могут быть объединены в цепочку, убедиться, что наш второй срез выполняется только после первого среза, так же просто, как:

executeSlice1().then(executeSlice2)

Если у нас есть неизвестное количество фрагментов, мы можем просто сделать следующее:

const slices = getSlices()
let promise = Promise.resolve(null) // Head of chain
for(let slice in slices) {
   promise = promise.then(() => executeSlice(slice))
}
promise.then(() => console.log('All slices done')

Теперь все, что нам нужно сделать, это создать нашу executeSlice функцию, чтобы она возвращала обещание, которое разрешает наш обработанный срез:

function executeSlice(slice) {
   return new Promise((resolve) => {
      requestAnimationFrame(() => {
         resolve(treatmentFunction(slice))
      }
   }
}

Запустив этот код, мы фактически обработаем всю строку, позволяя пользовательскому интерфейсу обновляться между каждым фрагментом! Это просто вопрос определения размера фрагмента, достаточно маленького, чтобы пользовательский интерфейс обновлялся плавно, и достаточно большого, чтобы его завершение не заняло вечности.

Получение результата

Хотя приведенный выше код обрабатывает всю строку, у него есть одно (большое!) Предостережение: мы не получаем окончательного результата! У нас есть все эти срезы результатов, которые мы сразу отбрасываем и никогда никуда не сохраняем.

Решением для этого является изменение нашей функции executeSlice, чтобы она принимала накопленный результат в параметре и возвращала обновленное накопление, как при вызове Array.reduce.

function executeSlice(slice, accumulator) {
   return new Promise((resolve) => {
      requestAnimationFrame(() => {
         resolve(accumulator + treatmentFunction(slice))
      }
   }
}

Затем нам нужно немного изменить наш цикл цепочки for, чтобы учесть этот новый параметр:

const slices = getSlices()
let promise = Promise.resolve('') // Head of chain: start our 
                                     accumulator
for(let slice in slices) {
   promise = promise.then(acc => executeSlice(slice, acc))
}
promise.then((result) => console.log('All slices done: ' + result)

Вот и все!

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

Подведение итогов

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

Заключение

Я надеюсь, что эта статья поможет людям, которые борются с зависанием пользовательского интерфейса из-за тяжелого JavaScript, и что она помогает подчеркнуть сильные стороны объекта Promise.

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