FaceHost - Статьи

Как вернуть значение из события или из функции обратного вызова? Или хотя бы дождаться их окончания

2024-02-17 10:38 Разработка
Пытаюсь делать вот так, но ничего не получается:
var result = "";

someInput.onchange = function() {
  result = someInput.value;
};

$.get("someapi", function (data) {
  result = data.foo;
});

some.api.call(42, function (data) {
  result = data.bar;
});

someDiv.textContent = result;
Почему-то в someDiv ничего не отображается.

Ответ

Проблема в том, что в коде нет операции ожидания. Ни подписка на событие, ни AJAX-вызов, ни даже вызов API не ждут поступления данных - а сразу же передают управление дальше. Поэтому строка someDiv.textContent = result; выполняется ДО того, как переменная result получит значение!
Способов сделать это присваивание после получения значения - несколько.

Способ 0 - переместить присваивание внутрь

Возможно, этот способ выглядит как-то глупо - но он решает задачу и наиболее прост в понимании. Если ваше приложение достаточно простое - то так и надо делать. Смотрите:
someInput.onchange = function() {
  someDiv.textContent = someInput.value;
};

$.get("someapi", function (data) {
  someDiv.textContent = data.foo;
});

some.api.call(42, function (data) {
  someDiv.textContent = data.bar;
});

someDiv.textContent = "";
В данном случае я вообще избавился от переменной result.
Недостаток у данного способа ровно 1 - отсутствует разбиение на слои. Данные обрабатываются там же, где и получаются. Если вы чувствуете, что ваши скрипты становятся при использовании такого способа все менее понятными, или вам приходится писать одно и то же в нескольких местах - надо переходить к другим способам.

Способ 0+ - вынесение присваивания в именованную функцию.

Простейшая модификация прошлого способа, позволяющая избавиться от дублирования кода.
someInput.onchange = function() {
  setResult(someInput.value);
};

$.get("someapi", function (data) {
  setResult(data.foo);
});

some.api.call(42, function (data) {
  setResult(data.bar);
});

setResult("");

function setResult(result) {
  someDiv.textContent = result;
}
Напомню, что в js объявления функций "поднимаются на верх", т.е. объявленной в самом низу функцией setResult можно пользоваться где угодно. Это позволяет начинать скрипт не с объявления 100500 функций - а с того кода, который непосредственно начнет выполняться.
Такой способ неплохо подходит для небольших скриптов, которые не разбиты на модули.

Проблема макаронного кода

Иногда, асинхронный запрос делается в одном модуле или его части, а получить его результат надо в другой. Прямое использование способа 0+ приводит к коду, который называют "макаронным":
// модуль 1
function getResult() {
  $.get("someapi", function (data) {
    setResult(data.foo);
  });
}

// модуль 2
function someFunc() {
  getResult();
}
function setResult(result) {
  someDiv.textContent = result;
}
Обращаю внимание: someFunc вызывает getResult, которая вызывает setResult. В итоге два модуля вызывают друг друга. Это и есть макаронный код.
Для борьбы с таким кодом и предназначены способы ниже.

Способ 1 - обратные вызовы ("колбеки", callbacks)

Добавим той функции, которая делает запрос, параметр callback, куда будем передавать функцию, получающую ответ:
function getResult(callback) {
  $.get("someapi", function (data) {
    callback(data.foo);
  });
}
Теперь такую функцию можно вызвать вот так:
getResult(function(result) {
  someDiv.textContent = result;
})
Или вот так:
getResult(setResult);

function setResult(result) {
  someDiv.textContent = result;
}

Способ 2 - обещания ("промизы", promises)

Обещание в js - это шаблон программирования, обозначающий значение, которого сейчас нет, но предполагается, что оно будет в будущем.
Имеется несколько реализаций обещаний. Основной сейчас являются ES6 Promises, они поддерживаются современными браузерами кроме IE.
Создаются обещания вот так:
function getResult(N) {
  return new Promise(function (resolve, reject) {
    some.api.call(N, function (data) {
      resolve(data.bar);
    });
  });
}
Также в качестве обещания можно использовать JQuery Deferred:
function getResult(N) {
  var d = $.Deferred();

  some.api.call(N, function (data) {
    d.resolve(data.bar);
  });

  return d.promise();
}
Или Angular $q:
function getResult(N) {
  var d = $q.defer();

  some.api.call(N, function (data) {
    d.resolve(data.bar);
  });

  return d.promise;
}
Кстати, Angular $q можно использовать и подобно es6 promise:
function getResult(N) {
  return $q(function (resolve, reject) {
    some.api.call(N, function (data) {
      resolve(data.bar);
    });
  });
}
В любом случае, использование такой функции getResult будет выглядеть одинаково:
getResult(42).then(function (result) {
  someDiv.textContent = result;
});
Обращаю внимание, что здесь я для примера взял именно some.api.call, но не событие или ajax-вызов - и это не случайно!
Дело в том, что обещание может быть выполнено (resolved) только 1 раз, а большинство событий происходят несколько раз. Поэтому использовать обещания для того же onchanged - нельзя.
Что же до ajax-вызова - то надо помнить, что он УЖЕ возвращает обещание! А потому все способы выше в комбинации с ним будут выглядеть смешными. Все делается гораздо проще:
function getResult() {
  return $.get("someapi")
    .then(function (data) {
      return data.foo;
    });
}
Кстати, здесь тоже можно было использовать async/await
На случай если вы запутались в коде выше, вот его "развернутая" версия:
function getResult() {
  var q1 = $.get("someapi");

  var q2 = q1.then(function (data) {
    return data.foo;
  });

  return q2;
}
Тут все просто. Сам по себе вызов $.get возвращает обещание, которое при выполнении будет содержать прищедшие с сервера данные.
Далее мы создаем для него продолжение, которое обработает эти данные (достанет поле foo).
Ну и потом это продолжение (которое тоже является обещанием) мы и возвращаем.

Способ 3 - наблюдаемые значения (observables) в Knockout

Обычно про Knockout вспоминают как про библиотеку для двусторонней привязки данных к виду - но ее возможности могут пригодиться и при решении подобных задач.
Можно сделать так. Для начала, заведем наблюдаемое значение:
var result = ko.observable("");
Это значение можно менять по событию:
someInput.onchange = function() {
  // вызов result с параметром устанавливает значение равным параметруresult(someInput.value);
};
И теперь можно выполнять некоторый блок кода каждый раз когда это значение меняется:
ko.computed(function() {
  // вызов result без параметров возвращает текущее значение
  someDiv.textContent = result();
});
Функция, переданная в ko.computed, будет вызвана каждый раз, когда ее зависимости изменятся.
PS код выше приведен как пример ручной работы с наблюдаемыми значениями. Но имейте в виду, что в Knockout есть более простые способы для работы с содержимым элементов DOM:
var vm = {
  result: ko.observable()
};
ko.applyBindings(vm);
<input data-bind="value: result"></input> <!-- бывший someInput -->

<div data-bind="text: result"></div> <!-- бывший someDiv -->

Способ 3.1 - наблюдаемые значения (observables) в MobX

Тут все почти так же, как и в knockout. В примере ниже я использую синтаксис ES2016 и старше, потому что библиотека подразумевает использование новых средств языка:
import { observable, autorun } from 'mobx';

var result = observable("");

someInput.onchange = () => {
  result.set(someInput.value);
};

autorun(() => someDiv.textContent = result.get());
Однако, обычно в MobX используются классы, а не одиночные obervable:
class ViewModel {
  @observable result = "";
}

var vm = new ViewModel();

someInput.onchange = () => {
  vm.result = someInput.value;
};

autorun(() => someDiv.textContent = vm.result);