Ошибки JavaScript и подробная информация о Трассировке Стека
Перевод статьи - JavaScript Errors and Stack Traces in Depth
Автор - Lucas Fernandes da Costa
Источник оригинальной статьи:
http://lucasfcosta.com/2017/02/17/JavaScript-Errors-and-Stack-Traces.html
Всем привет! Через несколько недель, не написав о JavaScript, самое время, чтобы мы говорили об этом снова!
На этот раз мы собираемся говорить об ошибках и трассировках стека и как управлять ими.
Иногда люди не обращают внимания на эти детали, но это знание, безусловно, будет полезно, если вы пишете любую библиотеку, связанную с тестированием или ошибками, конечно. На этой неделе в Chai, например, у нас был этот отличный Pull Request, который значительно улучшил способ обработки стеков стека, чтобы наши пользователи имели больше информации, когда их утверждения терпят неудачу.
Манипуляция трассировкой стека позволяет очистить неиспользуемые данные и сосредоточиться на том, что важно. Кроме того, когда вы понимаете, что именно есть Ошибки и их свойства, вы будете чувствовать себя более уверенно в использовании.
Этот блогпост может казаться слишком очевидным в начале, но когда мы начинаем манипулировать трассировкой стека, он становится довольно сложным, поэтому перед переходом к этому разделу убедитесь, что у вас есть хорошее представление о предыдущем контенте.
Как работает Стек Вызовов
Прежде чем говорить об ошибках, мы должны понять, как работает стек вызовов. Это очень просто, но важно знать это, прежде чем двигаться дальше. Если вы уже знаете это, не стесняйтесь пропустить этот раздел.
Каждый раз, когда есть вызов функции, он выдвинут к вершине стека. После того, как это закончит бежать, это удалено из вершины стека.
Интересная вещь в этой структуре данных заключается в том, что последний элемент, который нужно войти, будет первым, который выйдет первый. Это известно как свойство LIFO (Last-In-First-Out - получен последним - выдан первым).
Это означает, что при вызове функции y
изнутри функции x
, например, у нас будет стек с x
и y
в этом порядке.
Позвольте мне привести еще один пример, допустим, у вас есть этот код:
function c() {
console.log('c');
}
function b() {
console.log('b');
c();
}
function a() {
console.log('a');
b();
}
a();
В приведенном выше примере при запуске он будет добавлен в верхнюю часть нашего стека. Затем, когда b
вызывается изнутри a
, он попадает в верхнюю часть стека. То же самое происходит с c
когда он вызывается из b
.
При запуске c
наша трассировка стека будет содержать a
, b
и c
в этом порядке.
Как только c
заканчивается, он удаляется из верхней части стека, а затем поток управления возвращается к b
. Когда b
заканчивается, он также удаляется из стека, и теперь мы возвращаем элемент управления a
. Наконец, при завершении работы он также удаляется из стека.
Чтобы лучше продемонстрировать это поведение, мы будем использовать console.trace(
, который печатает текущую трассировку стека на консоли. Кроме того, вы должны обычно считывать трассировки стека сверху вниз. Подумайте о каждой строке как о том, что было вызвано изнутри строки под ней.
function c() {
console.log('c');
console.trace();
}
function b() {
console.log('b');
c();
}
function a() {
console.log('a');
b();
}
a();
При запуске на сервере Node REPL это то, что мы получаем:
Trace
at c (repl:3:9)
at b (repl:3:1)
at a (repl:3:1)
at repl:1:1 // <-- For now feel free to ignore anything below this point, these are Node's internals
at realRunInThisContextScript (vm.js:22:35)
at sigintHandlersWrap (vm.js:98:12)
at ContextifyScript.Script.runInThisContext (vm.js:24:12)
at REPLServer.defaultEval (repl.js:313:29)
at bound (domain.js:280:14)
at REPLServer.runBound [as eval] (domain.js:293:12)
Как мы видим здесь, мы имеем a
, b
и c
когда стек печатается изнутри c
.
Теперь, если мы напечатаем трассировку стека изнутри b
после завершения работы c
мы сможем увидеть, что она уже удалена из верхней части стека, поэтому мы будем иметь только a
и b
.
function c() {
console.log('c');
}
function b() {
console.log('b');
c();
console.trace();
}
function a() {
console.log('a');
b();
}
a();
Как вы можете видеть, у нас больше нет c
в нашем стеке, так как он уже закончил работу и выскочил из него.
Trace
at b (repl:4:9)
at a (repl:3:1)
at repl:1:1 // <-- For now feel free to ignore anything below this point, these are Node's internals
at realRunInThisContextScript (vm.js:22:35)
at sigintHandlersWrap (vm.js:98:12)
at ContextifyScript.Script.runInThisContext (vm.js:24:12)
at REPLServer.defaultEval (repl.js:313:29)
at bound (domain.js:280:14)
at REPLServer.runBound [as eval] (domain.js:293:12)
at REPLServer.onLine (repl.js:513:10)
В двух словах: вы называете вещи, и их подталкивают к вершине стека. Когда они заканчивают работу, они выпрыгивают из нее. Просто как это.
Объект Ошибки и Обработка Ошибок
Когда происходят ошибки, обычно возникает объект Error
. Объекты Error
также могут использоваться в качестве прототипов для пользователей, желающих расширить его, и создать свои собственные ошибки.
Объект Error.prototype
обычно имеет следующие свойства:
constructor
- конструктор, отвечающий за прототип этого экземпляра.message
- сообщение об ошибке.name
- имя ошибки.
Это стандартные свойства, и иногда каждая среда имеет свои специфические свойства. В некоторых средах, таких как Node, Firefox, Chrome, Edge, IE 10+, Opera и Safari 6+, у нас даже есть свойство stack
, которое содержит трассировку стека ошибок. Трассировка стека ошибок содержит все кадры стека до его собственной функции конструктора.
Если вы хотите больше узнать о конкретных свойствах объектов Error
я настоятельно рекомендую вам прочитать эту статью о MDN .
Чтобы выбросить ошибку, вы должны использовать ключевое слово throw
. Чтобы catch
ошибку, вы должны обернуть код, который может вывести ошибку в блок try
за которым следует блок catch
. Catch также принимает аргумент, который вызывает ошибку.
Как это происходит в Java, JavaScript также позволяет вам иметь блок finally
который запускается после блоков try/catch
независимо от того, выбрал ли ваш блок try
ошибку или нет. Хорошо использовать, finally
очистить материал после того, как вы закончите работать с ним, не имеет значения, работали ли ваши операции или нет.
Все до сих пор было совершенно очевидно для большинства людей, поэтому давайте перейдем к некоторым нетривиальным деталям.
Вы можете иметь try
блоки, за которыми не следует catch
, но за ними следует, finally
следовать. Это означает, что мы можем использовать три различные формы заявлений try
:
try...catch
try...finally
try...catch...finally
Инструкции Try могут быть вложены в другие инструкции try
, например:
try {
try {
throw new Error('Nested error.'); // The error thrown here will be caught by its own `catch` clause
} catch (nestedErr) {
console.log('Nested catch'); // This runs
}
} catch (err) {
console.log('This will not run.');
}
Вы также можете вставлять утверждения try
в catch
и, finally
блокировать:
try {
throw new Error('First error');
} catch (err) {
console.log('First catch running');
try {
throw new Error('Second error');
} catch (nestedErr) {
console.log('Second catch running.');
}
}
try {
console.log('The try block is running...');
} finally {
try {
throw new Error('Error inside finally.');
} catch (err) {
console.log('Caught an error inside the finally block.');
}
}
Также важно заметить, что вы также можете передавать значения, которые не являются объектами Error
. Хотя это может показаться классным и разрешительным, на самом деле это не так уж и хорошо, особенно для разработчиков, которые работают с библиотеками, которые имеют дело с кодом других людей, потому что тогда нет стандарта, и вы никогда не знаете, чего ожидать от своих пользователей. Вы не можете доверять им, чтобы бросать объекты Error
просто потому, что они могут не делать этого, а просто бросать строку или число. Это также усложняет работу с трассировками стека и другими значимыми метаданными.
Допустим, у вас есть этот код:
function runWithoutThrowing(func) {
try {
func();
} catch (e) {
console.log('There was an error, but I will not throw it.');
console.log('The error\'s message was: ' + e.message)
}
}
function funcThatThrowsError() {
throw new TypeError('I am a TypeError.');
}
runWithoutThrowing(funcThatThrowsError);
Это будет отлично работать, если ваши пользователи передают функции, которые передают объекты Error
в вашу функцию runWithoutThrowing
. Однако, если они в конечном итоге выбрасывают String
вас могут быть проблемы:
function runWithoutThrowing(func) {
try {
func();
} catch (e) {
console.log('There was an error, but I will not throw it.');
console.log('The error\'s message was: ' + e.message)
}
}
function funcThatThrowsString() {
throw 'I am a String.';
}
runWithoutThrowing(funcThatThrowsString);
Теперь ваш второй console.log
покажет вам, что сообщение об ошибке не undefined
. Теперь это может показаться несущественным, но если вам нужно обеспечить определенные свойства на объекте Error
или использовать свойства, связанные с Error
другим способом (например, утверждение Chai's throws
), вам потребуется намного больше работы, чтобы убедиться, что это сработает правильно.
Кроме того, при металировании значений, которые не являются объектами Error
вас нет доступа к другим важным данным, таким как его stack
, который является свойством объектов Error
в некоторых средах.
Ошибки могут также использоваться как любые другие объекты, вам не обязательно их бросать, поэтому они используются в качестве первого аргумента для функций обратного вызова много раз, как это происходит с функцией fs.readdir
, например.
const fs = require('fs');
fs.readdir('/example/i-do-not-exist', function callback(err, dirs) {
if (err instanceof Error) {
// `readdir` will throw an error because that directory does not exist
// We will now be able to use the error object passed by it in our callback function
console.log('Error Message: ' + err.message);
console.log('See? We can use Errors without using try statements.');
} else {
console.log(dirs);
}
});
Наконец, но не менее Error
объекты также могут использоваться при отказе от обещаний. Это упрощает обработку обещаний:
new Promise(function(resolve, reject) {
reject(new Error('The promise was rejected.'));
}).then(function() {
console.log('I am an error.');
}).catch(function(err) {
if (err instanceof Error) {
console.log('The promise was rejected with an error.');
console.log('Error Message: ' + err.message);
}
});
Манипуляция Трассировками Стека
И теперь часть, которую вы все ждали: как манипулировать трассировкой стека.
Эта глава предназначена специально для сред, поддерживающих Error.captureStackTrace
, таких как NodeJS.
Функция Error.captureStackTrace
принимает object
как первый аргумент и, необязательно, function
как вторую. Какова трассировка стека стека - это захват текущей трассировки стека (очевидно) и создание свойства stack
в целевом объекте для его хранения. Если предоставляется второй аргумент, переданная функция будет считаться конечной точкой стека вызовов, и поэтому трассировка стека будет отображать вызовы, которые произошли до того, как эта функция была вызвана.
Давайте используем некоторые примеры, чтобы сделать это более понятным. Во-первых, мы просто захватим текущую трассировку стека и сохраним ее в общем объекте.
const myObj = {};
function c() {
}
function b() {
// Here we will store the current stack trace into myObj
Error.captureStackTrace(myObj);
c();
}
function a() {
b();
}
// First we will call these functions
a();
// Now let's see what is the stack trace stored into myObj.stack
console.log(myObj.stack);
// This will print the following stack to the console:
// at b (repl:3:7) <-- Since it was called inside B, the B call is the last entry in the stack
// at a (repl:2:1)
// at repl:1:1 <-- Node internals below this line
// at realRunInThisContextScript (vm.js:22:35)
// at sigintHandlersWrap (vm.js:98:12)
// at ContextifyScript.Script.runInThisContext (vm.js:24:12)
// at REPLServer.defaultEval (repl.js:313:29)
// at bound (domain.js:280:14)
// at REPLServer.runBound [as eval] (domain.js:293:12)
// at REPLServer.onLine (repl.js:513:10)
Как вы можете заметить в приведенном выше примере, мы сначала вызвали (который попал в стек), а затем вызвали b
изнутри a
(что подтолкнуло его поверх a
). Затем, внутри b
, мы захватили текущую трассировку стека и сохранили ее в myObj
. Вот почему мы получаем только a
а затем b
в стек, который мы печатали на консоли.
Теперь давайте передадим функцию в качестве второго аргумента функции Error.captureStackTrace
и посмотрим, что произойдет:
const myObj = {};
function d() {
// Here we will store the current stack trace into myObj
// This time we will hide all the frames after `b` and `b` itself
Error.captureStackTrace(myObj, b);
}
function c() {
d();
}
function b() {
c();
}
function a() {
b();
}
// First we will call these functions
a();
// Now let's see what is the stack trace stored into myObj.stack
console.log(myObj.stack);
// This will print the following stack to the console:
// at a (repl:2:1) <-- As you can see here we only get frames before `b` was called
// at repl:1:1 <-- Node internals below this line
// at realRunInThisContextScript (vm.js:22:35)
// at sigintHandlersWrap (vm.js:98:12)
// at ContextifyScript.Script.runInThisContext (vm.js:24:12)
// at REPLServer.defaultEval (repl.js:313:29)
// at bound (domain.js:280:14)
// at REPLServer.runBound [as eval] (domain.js:293:12)
// at REPLServer.onLine (repl.js:513:10)
// at emitOne (events.js:101:20)
Когда мы передали b
в Error.captureStackTraceFunction
он спрятал b
и все фреймы над ним. Вот почему у нас есть только a
в трассировке стека.
Теперь вы можете спросить себя: «Почему это полезно?». Это полезно, потому что вы можете использовать его, чтобы скрыть внутренние детали реализации, которые не имеют отношения к вашим пользователям. Например, в Chai мы используем его, чтобы не показывать нашим пользователям нерелевантные сведения о том, как мы сами реализуем проверки и утверждения.
Манипуляция Трассировкой Стека в реальном мире
Как я уже упоминал в последнем разделе, Chai использует метод манипулирования стеками, чтобы сделать трассировки стека более актуальными для наших пользователей. Вот как мы это делаем.
Во-первых, давайте посмотрим на конструктор AssertionError
созданный при неудачном утверждении:
// `ssfi` stands for "start stack function". It is the reference to the
// starting point for removing irrelevant frames from the stack trace
function AssertionError (message, _props, ssf) {
var extend = exclude('name', 'message', 'stack', 'constructor', 'toJSON')
, props = extend(_props || {});
// Default values
this.message = message || 'Unspecified AssertionError';
this.showDiff = false;
// Copy from properties
for (var key in props) {
this[key] = props[key];
}
// Here is what is relevant for us:
// If a start stack function was provided we capture the current stack trace and pass
// it to the `captureStackTrace` function so we can remove frames that come after it
ssf = ssf || arguments.callee;
if (ssf && Error.captureStackTrace) {
Error.captureStackTrace(this, ssf);
} else {
// If no start stack function was provided we just use the original stack property
try {
throw new Error();
} catch(e) {
this.stack = e.stack;
}
}
}
Как вы можете видеть выше, мы используем Error.captureStackTrace
для захвата трассировки стека и сохранения его в экземпляр AssertionError
мы создаем, и (когда он существует) мы передаем ему функцию стека, чтобы удалить ненужные кадры из трассировка стека, которая отображает только внутренние детали реализации Chai и в конечном итоге делает стек «грязным».
Теперь давайте посмотрим на недавний код, написанный @meeber в этом замечательном PR.
Прежде чем смотреть на код ниже, я должен сказать вам, что делает addChainableMethod
. Он добавляет связанный с ним метод к утверждению, а также сам флажок с помощью метода, который обертывает это утверждение. Это сохраняется с именем ssfi
(который обозначает индикатор функции запуска стека). Это в основном означает, что текущее утверждение будет последним фреймом в стеке, и поэтому мы не будем показывать какие-либо дополнительные внутренние методы из Chai в стеке. Я избегал добавлять весь код для этого, потому что он делает много вещей и является довольно сложным, но если вы хотите его прочитать, здесь идет ссылка на него.
В приведенном ниже фрагменте мы имеем логику для утверждения lengthOf
, которая проверяет, имеет ли объект определенную length
. Мы ожидаем, что наши пользователи будут использовать его следующим образом: expect(['foo', 'bar']).to.have.lengthOf(2)
.
function assertLength (n, msg) {
if (msg) flag(this, 'message', msg);
var obj = flag(this, 'object')
, ssfi = flag(this, 'ssfi');
// Pay close attention to this line
new Assertion(obj, msg, ssfi, true).to.have.property('length');
var len = obj.length;
// This line is also relevant
this.assert(
len == n
, 'expected #{this} to have a length of #{exp} but got #{act}'
, 'expected #{this} to not have a length of #{act}'
, n
, len
);
}
Assertion.addChainableMethod('lengthOf', assertLength, assertLengthChain);
В приведенном выше коде я выделил строки, которые актуальны для нас прямо сейчас. Начнем с вызова this.assert
.
Это код для метода this.assert
:
Assertion.prototype.assert = function (expr, msg, negateMsg, expected, _actual, showDiff) {
var ok = util.test(this, arguments);
if (false !== showDiff) showDiff = true;
if (undefined === expected && undefined === _actual) showDiff = false;
if (true !== config.showDiff) showDiff = false;
if (!ok) {
msg = util.getMessage(this, arguments);
var actual = util.getActual(this, arguments);
// This is the relevant line for us
throw new AssertionError(msg, {
actual: actual
, expected: expected
, showDiff: showDiff
}, (config.includeStack) ? this.assert : flag(this, 'ssfi'));
}
};
В принципе, метод assert
отвечает за проверку того, прошло ли булевое выражение утверждения или нет. Если это не так, мы должны создать экземпляр AssertionError
. Обратите внимание, что при создании нового AssertionError
мы также передаем ему индикатор функции трассировки стека (ssfi
). Если флаг конфигурации includeStack
включен, мы показываем пользователю всю трассировку стека, передавая ему сам this.assert
, что на самом деле является последним фреймом в стеке. Однако, если флаг конфигурации includeStack
повернут, мы должны скрыть больше внутренних деталей реализации из трассировки стека, поэтому мы используем то, что хранится в флагом ssfi
.
Теперь давайте поговорим о другой соответствующей линии для нас:
new Assertion(obj, msg, ssfi, true).to.have.property('length');
Как вы можете видеть здесь, мы передаем контент, который у нас есть, из флага ssfi
при создании нашего вложенного утверждения. Это означает, что при создании нового утверждения он будет использовать эту функцию в качестве отправной точки для удаления неиспользуемых фреймов из трассировки стека. Кстати, это конструктор Assertion
:
function Assertion (obj, msg, ssfi, lockSsfi) {
// This is the line that matters to us
flag(this, 'ssfi', ssfi || Assertion);
flag(this, 'lockSsfi', lockSsfi);
flag(this, 'object', obj);
flag(this, 'message', msg);
return util.proxify(this);
}
Как вы помните из того, что я сказал о addChainableMethod
, он устанавливает флаг ssfi
с собственным методом обертки, что означает, что это самый низкий внутренний фрейм в трассировке стека, поэтому мы можем просто удалить все кадры над ним.
Передавая ssfi
вложенное утверждение, которое проверяет только, имеет ли наш объект длину свойства, мы не возвращаем фрейм, который будем использовать в качестве индикатора начальной точки, а затем имеем предыдущий addChainableMethod
, видимый в стеке.
Это может показаться немного сложным, поэтому давайте рассмотрим, что происходит внутри Chai, мы хотим удалить неиспользуемые фреймы из стека:
- Когда мы запускаем утверждение, мы устанавливаем его собственный метод как ссылку на удаление следующих кадров в стеке
- Утверждение выполняется, и если это не удается, мы удаляем все внутренние кадры после сохранения ссылки
- Если мы вложенное утверждение , мы должны по- прежнему использовать текущий метод утверждения обертки в качестве опорной точки для удаления следующих кадров в стеке, поэтому мы переходим текущий
ssfi
(запуск функции стеки индикатора) на утверждение мы создаем , чтобы он мог сохранить его
Я также настоятельно рекомендую вам прочитать этот комментарий от @meeber, чтобы понять это.
Связаться!
Если у вас есть какие-либо сомнения, мысли или если вы не согласны с тем, что я написал, пожалуйста, поделитесь им со мной в комментариях ниже или связаться со мной по @lfernandescosta в Twitter. Я хотел бы услышать, что вы можете сказать, и внести любые исправления, если я допустил какие-либо ошибки.
Спасибо, что прочитали это!