| GetColorings Science Blog |

Printable Coloring Pages

Ошибки 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();

В приведенном выше примере при запуске он будет добавлен в верхнюю часть нашего стека. Затем, когда вызывается изнутри a, он попадает в верхнюю часть стека. То же самое происходит с c когда он вызывается из b.

При запуске c наша трассировка стека будет содержать ab и 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)

Как мы видим здесь, мы имеем ab и 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 обычно имеет следующие свойства:

Это стандартные свойства, и иногда каждая среда имеет свои специфические свойства. В некоторых средах, таких как 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 могут быть вложены в другие инструкции 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, мы хотим удалить неиспользуемые фреймы из стека:

  1. Когда мы запускаем утверждение, мы устанавливаем его собственный метод как ссылку на удаление следующих кадров в стеке
  2. Утверждение выполняется, и если это не удается, мы удаляем все внутренние кадры после сохранения ссылки
  3. Если мы вложенное утверждение , мы должны по- прежнему использовать текущий метод утверждения обертки в качестве опорной точки для удаления следующих кадров в стеке, поэтому мы переходим текущий ssfi (запуск функции стеки индикатора) на утверждение мы создаем , чтобы он мог сохранить его

Я также настоятельно рекомендую вам прочитать этот комментарий от @meeber, чтобы понять это.

 

Связаться!

Если у вас есть какие-либо сомнения, мысли или если вы не согласны с тем, что я написал, пожалуйста, поделитесь им со мной в комментариях ниже или связаться со мной по @lfernandescosta в Twitter. Я хотел бы услышать, что вы можете сказать, и внести любые исправления, если я допустил какие-либо ошибки.

Спасибо, что прочитали это!