Привет, Хабр!

В предыдущей статье мы рассматривали общую теории ООП в применении к EcmaScript и популярное заблуждение начинающих разработчиков относительно отличия ООП в JS и классических языках.

Сегодня мы поговорим о двух других не менее важных концепциях EcmaScript, а именно связи сущности с контекстом исполнения (this и есть эта самая связь) и связи сущности с порождающим контекстом(ScopeChain).

Итак, начнём!

this


На собеседованиях в ответ на вопрос: «Расскажите подробнее про this.». Начинающие разработчики, как правило, дают очень туманные ответы: "this – это объект «перед точкой», который использовался для вызова метода", "this — контекст, в котором был вызвана функция" и т.д.…

На самом деле, ситуация с этим центральным для EcmaScript языков понятием обстоит несколько сложнее. Разберёмся по порядку.

Допустим, у нас есть программа на языке JavaScript, в которой есть переменные объявленные глобально; глобальные функции; локальные функции(объявленные внутри других функций), функции, возвращаемые из функций.

const a = 10;
const b = 20;
const x = {
  a: 15,
  b: 25,
}

function foo(){
  return this.a + this.b;
}

function bar () {
  const a = 30;

  return a + b;
}

function fooBaz(){
  function test () {
    return this.a + this.b;
  }

  return test();
}

function fooBar() {
  const a = 40;
  const b = 50;

  return function () {
    return a + b;
  }
}

fooBar()();

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

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

Есть ещё третий тип — EvalCode. В рамках этой статьи мы им пренебрежём.

Логически совокупность контекстов исполнения представляет собой стек, работающий по принципу Last-in-First-out(lifo). Дном стека всегда является глобальный контекст, а вершиной текущий исполняемый. Каждый раз при вызове функции осуществляется вход в её контекст. При завершении функции её контекст завершается. Отработанные контексты удаляются из стека последовательно и в обратном порядке.

Взглянем ещё раз на код выше. У нас есть вызов функции fooBar в глобальном коде. В функции fooBar мы возвращаем анонимную функцию, которую сразу вызываем. Со стеком происходят следующие изменения: в него попадает глобальный контекст — при вызове fooBar её контекст попадает в стек — контекст fooBar завершается, возвращает анонимную функцию и удаляется из стека — происходит вызов анонимной функции, её контекст попадает в стек — анонимная функция отрабатывает, возвращает значение и её контекст удаляется из стека — по завершению скрипта глобальный контекст удаляется из стека.

Контекст исполнения можно условно представить как объект. Одним из свойств этого объекта будет Лексическое окружение(Lexical Environment, LO).

Лексическое окружение содержит в себе:

  • все объявления переменных контекста
  • все декларации функций
  • все формальные параметры функции(если речь идёт о контексте функций)

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

this также является свойством контекста исполнения, но никак не самим контекстом, как отвечают некоторые начинающие разработчики на собеседованиях! this определяется при входе в контекст и остаётся неизменным до конца срока жизни контекста(пока контекст не удалится из стека).

В глобальном контексте исполнения this определяется в зависимости от strict mode: при выключенном strict mode в this находится глобальный объект(в браузере он проксирован на верхний уровень в объект window), при 'use strict' this равен undefined.

this в контексте функций — вопрос гораздо более интересный!
this в функциях определяется вызывающей стороной и зависит от синтаксиса вызова. Например, как мы знаем, есть методы, которые позволяют закрепить this жёстко при вызове(call, apply) и метод, который позволяет создать обёртку с «закреплённым this» (bind). В этих ситуациях мы явным способом указываем this и никаких сомнений в его определении быть не может.

При обычном вызове функции ситуация гораздо более сложная!

Понять как проставляется this в функциях, нам поможет один из встроенных типов EcmaScript — ReferenceType. Это один из внутренних типов, доступных на уровне реализации. Логически он представляет собой объект с двумя свойствами base(ссылка на некий базовый объект для которого возвращается ReferenceType), propertyName(строковое представление идентификатора объекта для которого возвращается ReferenceType).

ReferenceType возвращается для всех объявлений переменных, деклараций функции и обращения к свойству(именно этот случай нас интересует с точки зрения понимания this).

Правило определения this для функций, вызванных обычным способом:
Если слева от скобок активации функции находится ReferenceType, то в this функции проставляется base этого ReferenceType. Если слева от скобок любой другой тип, то в this проставляется глобальный объект или undefined(на самом деле проставляется null, но т.к. null не имеет определённого значения с точки зрения ecmascript, то он приводится к глобальному объекту, ссылка на который может быть равна undefined в зависимости от strict mode).

Разберём пример:

const x = 0;

const obj = {
  x: 10,
  foo: function() {
    return this.x;
  }
}

obj.foo();// вернёт 10 т.к. слева от скобок ReferenceType свойство base которого указывает на объект obj

const test = obj.foo;// присвоим метод объекта в глобальную переменную

test();// вернёт 0 т.к. вызов test() эквивалентен вызову ГО.test(),т.е. свойство base укажет на глобальный объект, а в глобальном объекте х присвоено 0.

Думаю, способ определения проиллюстрирован наглядно. Сейчас рассмотрим несколько менее очевидных случаев.

Функциональные выражения


Вернёмся на секунду к нашему ReferenceType. У этого типа есть встроенный метод GetValue, который возвращает истинный тип получаемого через ReferenceType объекта. В зоне выражения GetValue всегда срабатывает.

Пример:

(function (){
  return this;// this проставляется глобальный объект или undefined в зависимости от strict mode
})()

Это происходит из-за того, что в зоне выражения у нас всегда срабатывает GetValue. GetValue возвращает тип Function и слева от скобок активации получается не ReferenceType. Вспомним наше правило определения this: Если слева от скобок любой другой тип, то в this проставляется глобальный объект или undefined(на самом деле проставляется null, но т.к. null не имеет определённого значения с точки зрения ecmascript, то он приводится к глобальному объекту, ссылка на который может быть равна undefined в зависимости от strict mode).

Зоной выражения считаются: присваивание(=), операторы || или иные логические операторы, тернарный оператор, инициализатор массива, перечисление через запятую.

const x = 0;

const obj = {
  x: 10,
  foo: function() {
    return this.x;
  }
}

obj.foo(); 
//приведём вызов этого метод объекта в зону выражения
//сработают ли скобки?
(obj.foo)(); //не сработают, данный вызов эквивалентен предыдущему, GetValue не отрабатывает

//присваивание сработает?
(obj.foo = obj.foo)(); // с обоих сторон от оператора присваивания срабатывает GetValue, поэтому результатом будет тип Fuction, а не ReferenceType, следовательно вернёт 0 из глобального объекта(вспоминай правило определения this)

// операторы || или иные операторы сравнения, тернарный оператор и т.д.?
(obj.foo || obj.foo)();//вернёт 0 по тем же причинам, что и предыдущий пример

//инициализатор массива
[obj.foo][0]();//вернёт 0 по тем же причинам, что и предыдущий пример
//и т.д.

Идентичная ситуация в именованных функциональных выражениях. Даже при рекурсивном вызове this глобальный объект или undefined

this вложенных функций вызываемых в родительской


Также немаловажная ситуация!

const x = 0;
function foo() {
  function bar(){
   return this.x;
 }
return bar();
}

const obj = {x:10};
obj.test = foo;
obj.test();//вернёт undefined

Это связано с тем, что вызов bar() эквивалентен вызову LE_foo.bar, а объект лексического окружения проставляет undefined в качестве this.

Функции-конструкторы


Как я писал выше:
this в функциях определяется вызывающей стороной и зависит от синтаксиса вызова.

Функции-конструкторы мы активируем с помощью ключевого слова new. Особенность этого способа активации функции в том, что вызывается внутренний метод функции [[construct]], который проводит определённые операции(механизм создания сущностей конструкторами разберём во второй или третьей статье по ООП!) и вызывает внутренний метод [[call]], который проставляет в this созданную инстанции функции-конструктора.

Цепь областей видимости(Scope Chain)


Цепь областей видимости также является свойством контекста исполнения как и this. Она представляет собой список объектов лексических окружений текущего контекста и всех порождающих контекстов. Именно в этой цепи происходит поиск переменных при разрешении имён идентификаторов.

Обратите внимание: this связывает функцию с контекстом исполнения, а ScopeChain с порождающими контекстами.

Спецификация утверждает, что ScopeChain это массив:

  SC = [LO, LO1, LO2,..., LOglobal];

Однако, в некоторых реализациях, например в JS, цепь областей видимости реализована через связанные списки.

Для того чтобы лучше разобраться со ScopeChain, обсудим жизненный цикл функций. Он подразделяется на этап создания и этап выполнения.

В момент создания функции ей присваивается внутреннее свойство [[SCOPE]].
В [[SCOPE]] записывается иерархическая цепь объектов лексических окружений вышестоящих(порождающих) контекстов. Это свойство остаётся неизменным до тех пор пока функция не уничтожена сборщиком мусора.

Обратите внимание! [[SCOPE]] в отличии от ScopeChain являертся свойством самой функции, а не её контекста.

При вызове функции инициализируется и наполняется её контекст исполнения. Контексту проставляется ScopeChain = LO(самой функции) + [[SCOPE]](иерархическая цепь LO пораждающих контекстов).

Разрешение имён идентификаторов — последовательный опрос объектов LO в цепи ScopeChain слева направо. На выходе получается ReferenceType свойство base которого указывает на объект LO, в котором был найден искомый идентификатор, а PropertyName будет являться строковым представлением имени идентификатора.

Именно так под капотом устроено Замыкание! Замыкание это по сути результат поиска в ScopeChain всех переменных, идентификаторы которых присутствуют в функции.

const x = 10;

function foo () {
  return x;
}

(function (){
  const x = 20;
  foo();//вернёт 10 т.к. на этапе создания в <b><i>[[SCOPE]]</i></b> foo был записан объект окружения в котором она была создана
})()

Следующим примером проиллюстрирует цикл жизни [[SCOPE]].

function foo () {
  const x = 10;
  const y = 20;

  return function () {
    return [x,y];
  }
}

const x = 30;

const bar = foo();//присвоили переменной анонимную функцию, контекст функции foo отработал и завершился
bar();//вернёт [10,20] т.к. [[SCOPE]] свойство самой функции foo и существует даже после того как её контекст завершился

Важным исключение является функция-конструктор. Для этого типа функций [[SCOPE]] всегда указывает на глобальный объект.

Также не стоит забывать, что если у какого-то из звеньев в цепи ScopeChain есть прототип, то поиск будет осуществляться и в прототипе тоже.

Заключение


Вынесем ключевые идеи тезисно:

  • this — это связь сущности с контекстом исполнения
  • ScopeChain — это связь сущности со всеми порождающими контекстами
  • this и ScopeChain — это свойства контекста исполнения
  • this функций определяется вызывающей стороной и зависит от синтаксиса вызова
  • ScopeChain — это лексическое окружение текущего контекста + [[Scope]]
  • [[Scope]] — это свойство самой функции, содержит в себе иерархическую цепь лексически окружений порождающих контекстов

Надеюсь, статья была полезной. До будущих статей, друзья!

Комментарии (11)


  1. apapacy
    28.09.2019 12:04

    Важным исключение является функция-конструктор. Для этого типа функций [[SCOPE]] всегда указывает на глобальный объект.

    Немного неоднозначно трактуется выражение. Точнее было бы сказать функция, созданная при помощи конструктора Function


    И во втором примере лишний вызов функции при присваивани?и.


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


    1. Alex_Shcherbackov Автор
      28.09.2019 13:46

      На мой взгляд, целевая аудитория статьи — крепкие джуны, стремящиеся к мидлу. Совсем уж джунам не стоит вникать в тонкости спецификации, им вполне достаточно для прогресса изучать основы языка.


  1. PerlPower
    28.09.2019 14:18

    У вас в первом примере вызывается только последняя функция, зачем тогда остальные если про них не идет речь и они не вызываются?

    И имхо для крепких джунов статья скорее вредна. Знать что this — текущий контекст выполнения на мой взгляд достаточно, и что его можно поменять при помощи bind и apply. Достаточно и для собеседования и для практики. У вас все объяснено настолько сложно, что даже мне было тяжело понять о чем речь, хотя пишу на Javascript уже довольно давно.


    1. Alex_Shcherbackov Автор
      28.09.2019 22:13

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

      Возможно статья не простая… но и люди разные бывают. Чтобы развиваться джуну в любом случае придётся понять эти концепции, иначе не понять как под капотом работают фреймворки и многое другое.


  1. ThisMan
    28.09.2019 14:30

    В примере с ReferenceType есть ошибка


    const x = 0;
    const obj = {
      foo: function() {
        return this.x;
      }
    }
    const test = obj.foo;
    console.log(test());

    вернёт 0 т.к. вызов test() эквивалентен вызову ГО.test(), т.е. свойство base укажет на глобальный объект, а в глобальном объекте х присвоено 0.

    const не создает св-ва в глобальном объекте. Вот если объявить переменную без const, либо с var в глобальном скоупе, тогда да, будет работать


    1. Alex_Shcherbackov Автор
      28.09.2019 22:13

      Да, там должен был быть var.


  1. beduin01
    29.09.2019 00:18

    Пишите лучше на Dart. Там таких проблем нет.


  1. Sayonji
    29.09.2019 03:01

    дают очень туманные ответы: «this – это объект «перед точкой», который использовался для вызова метода»
    Если слева от скобок активации функции находится ReferenceType, то в this функции проставляется base этого ReferenceType.
    ИМХО Если не забыть упомнянуть про apply/bind/call и про логику с глобальным объектом, описание «this» в js как «объект перед точкой» в момент вызова — это самое лучшее объяснение.


    1. Alex_Shcherbackov Автор
      29.09.2019 09:25

      var x = 0;
      
      var obj = {
        x: 10,
        foo: function() {
         return this.x;
       }
      }
      console.log(obj.foo())//10
      
      console.log([obj.foo].map(item => item() ))//0
      


      Опишите работу второго вызова руководствуясь лишь "«this» в js как «объект перед точкой» в момент вызова — это самое лучшее объяснение.". Если получится, то снимаю перед вами шляпу.


      1. Sayonji
        29.09.2019 17:14

        Ну, я в моем комментарии сказал, что нужно не забыть упомянуть про логику с глобальным объектом, а именно что если код выполняется не в strict mode, то false-like this будет заменён на глобальный объект. Это то что происходит у вас во втором случае — в момент вызова перед точкой ничего нет, поэтому this=undefined и заменяется на window:
        console.log([obj.foo].map(item => пустоitem() ))


        1. Alex_Shcherbackov Автор
          29.09.2019 19:19

          Вот для понимания этих нюансов работы и была написана статья.