Scope and Closures in JavaScript

The topic is quite extensive and I do not pretend to fully disclose it in this article. If you want to understand it in more detail, then I sincerely recommend you the book: Kyle Simpson “Scope and Closure”. I was both taught and inspired by this book. See all links to resources and the book at the end.

Area of ​​visibility

A scope in JS is any area in code that contains named entities (variables, classes, functions) and determines their accessibility from different parts of the code.

In the following example, there are two scopes — the global scope and the scope inside the function:

// Глобальная область видимости

let number = 213;

function printNumber() {
  // Область видимости функции printNumber
  let color = "#f5f5f5"
  console.log(number + number)
}

console.log(number)
console.log(printNumber())

// Переменна color не доступна в глобальной области видимости
// Она объявлена локально внутри функции printNumber
console.log(color)

Variable number declared in the global scope and therefore accessible within the function printNumber. The function itself printNumber is also declared in the global scope and we can call it freely.

Variable color declared inside a function printNumber and therefore it is not available in the global scope. Attempting to access the variable color out of function printNumber will result in an error.

As a result, when running the script, we see the following picture:

Error running script

Error running script

The example above shows what we got: the value of the variable numberthe result of addition inside the function and undefinedsince the function does not return anything. When trying to access the variable color the console tells us that such a variable is not found. This error is universal. That is, if our code does not have the color variable at all, the error content will not change. In our case, we can say that the variable is not found in the global scope, but it is generally present in the code.

But why then can we refer to the variable number being in another scope, but we cannot access the variable in the same way color? First, let's change the existing code a little and add colors to it.

Graphical division into visibility areas

Graphical division into visibility areas

Code from the picture
// Глобальная область видимости

let number = 213;

function printNumber(operand) {
  // Область видимости функции printNumber
  let color = "#f5f5f5"
  console.log(number + operand)

  function printColor() {
    // Область видимости функции printColor
    console.log(color)
  }

  printColor()
}

console.log(number)
console.log(printNumber(312))

// Переменна color не доступна в глобальной области видимости
console.log(color)

// Функция printColor не доступна в глобальной области видимости
console.log(printColor())

Here I added a parameter to the function printNumberused the parameter value when adding and added a function printColor inside the function printNumber. Each color in the picture represents its own area of ​​visibility. From here, a simple idea can be conveyed: each nested scope has access to all outer scopes. In its turn each outer scope does not have access to nested scopes. It turns out that:

  • Orange has access to pink and green

  • Pink has access to green

  • Green (global) has no outer scope

If you noticed, the scope of a function starts from the moment the parameter is declared. That is, when we pass an argument to the function, in our case printNumber(312)then this argument is assigned to the parameter operand and becomes available only within the function.

Closure

To understand the topic in depth, you need to study a number of concepts that will not be here, but which are in the above-mentioned book by Kyle Simpson. And as before, we will go over the surface.

To begin with, a modest example:

function main() {
  let say = "I'am secondary function"

  function secondary() {
    console.log(say)
  }

  return secondary
}

const hi = main()
hi()

Nothing special happens. We return the function secondary from function main. Function secondary uses variable saywhich was declared in the function scope main. At the end we save the result of the function call main into a variable hi. Since the result of calling the function main – it is a function, then it is a variable hi also becomes a function. We call the function hi and we see the value of the variable in the console say.

Function hi is just a reference to a function secondary. The beauty of it is that before the call happens hi()function main will already complete execution. If you do not go into details, but simply read what is written, then following primitive logic we can say that calling the function hi should result in an error, since the function main has already completed its work and variable say no longer exists. This is where we get into a closure. Given the example, we can formulate a definition.

Closure is the ability of a function to remember its environment and interact with it during code execution, even if the function is called outside the environment in which it was declared.

In our case the function secondary closes on the function environment mainbecause it uses a variable saywhich is declared in the outer environment (functions main) relative to the function secondary.

Even if we don't return the function secondaryand we will immediately call it inside mainand then we'll call her ourselves mainthen it will still be a closure. We don't have to return a closure function.

Example without function return
function main() {
  let say = "I'am secondary function"

  function secondary() {
    console.log(say)
  }

  secondary()
}

main()

Examples of using closures

Closures are a fairly common technique in programming. For example, for me, a self-taught junior, it was a revelation that when I pass props in React and use them in a component, it is a closure.

I will give a few examples that I decided to tie to simple game mechanics.

Encapsulation

Let's imagine that we have a game inventory. We need access to the items in this inventory to be defined using special functions and that it would be impossible to interact with the inventory in any other way. For this, we can write the following function:

function inventory() {
    let items = []
    return {
        pishItem(item) {
            if (items.length === 10) {
                console.log('The number of items in the inventory has reached its maximum')
                return
            }
            items.push(item)
        },
        removeItem(item) {
            if (items.length === 0 || !items.includes(item)) {
                console.log('This item is not in the inventory')
                return
            }
            items.splice(items.indexOf(item), 1);
        },
        getItems() {
            console.log(items)
        }
    }
}

const actionsInventory = inventory()

actionsInventory.pishItem('book')
actionsInventory.pishItem('sword')
actionsInventory.pishItem('apple')
actionsInventory.pishItem('armor')

actionsInventory.removeItem('sword')

actionsInventory.getItems()

It may look complicated at first reading, but basically I modified a popular counter example that encapsulates a variable count:

Simple counter
function counter() {
    let count = 0
    return function choice(action) {
        if (action === '+') {
            count++
        } else {
            count--
        }
        console.log(count)
    }
}

const changeCounter = counter()

changeCounter('+') // 1
changeCounter('-') // 0
changeCounter('+') // 1

In the basic example, we return an object with a set of methods instead of a single function. Each function is closed on an array items and performs one action at a time. As a result, we put 4 items in the inventory, then remove one item and display the entire list of remaining items. In this way, we encapsulated the items array, while creating a limited set of actions for the user.

Callback

A callback function is a function passed to another function as an argument, which is then called within the outer function to perform some action.

Let's imagine that in our unknown game there are three types of chests: gold, silver, bronze. Depending on the type of chest, the player either increases or decreases the chance of getting resources.

function openChest(chestType, callback) {
    console.log(`Открытие ${chestType} сундука...`);
    setTimeout(() => {
        let treasure;
        if (chestType === "золотой") {
            treasure = Math.random() > 0.3 ? "Ресурсы" : "ничего"; // 70% шанс найти ресурсы
        } else if (chestType === "серебряный") {
            treasure = Math.random() > 0.5 ? "Ресурсы" : "ничего"; // 50% шанс найти ресурсы
        } else {
            treasure = Math.random() > 0.7 ? "Ресурсы" : "ничего"; // 30% шанс найти ресурсы
        }

        if (treasure !== "ничего") {
            callback(null, `Вы нашли ${treasure}!`);
        } else {
            callback("Сундук пустой");
        }
    }, 1000);
}

function startGame() {
    openChest("золотой", (empty, result) => {
        if (empty) {
            console.log(empty);
        } else {
            console.log(result);
        }
    });
}

startGame();

Let's start from the moment the function is called startGame. Inside the function startGame functions are called openChestwhose arguments are the chest type and an anonymous function. This anonymous function represents a callback function that is closed on the variable treasure. Inside the function openChest we simulate an asynchronous call. The call occurs at the moment of conditional activation of the chest by the player. In our code setTimeot is responsible for calculating the reward and when the calculation is finished, our callbackwhich reports the result of the calculation.

Understanding this code may take a little longer than the previous example, and that's okay.

Memoization

First, let's find out what it is in the context of development and look at a basic example.

Memoization is the process of checking and caching previously calculated values ​​to prevent re-calculation.

Classic example:

function memoize(fn) {
    const cache = new Map();
    
    return function(...args) {
        const key = JSON.stringify(args);
        if (cache.has(key)) {
            console.log('Кэшированный результат:', cache.get(key))
            return cache.get(key);
        }
        
        const result = fn(...args);
        cache.set(key, result);
        return result;
    };
}

Let's take the example line by line before looking at a practical version.

2 >> Create a collection cash

5 >> Store the array of passed arguments in a variable key

6 >> Check if the function was called before with the same arguments

7, 8 >> If it was called, then we return the cached result

9 >> If the calculation with such arguments has not yet been performed, then we pass the arguments further into the function that performs the calculations.

10 >> Cache the array of arguments and the calculation result

Now let's see how the memoization function works in combat conditions. Let's imagine that we have a rectangular playing field divided into equal squares. There is a character on the field, and coins appear around some parts of the field. To build a route to the coins, we will use a conditional pathfinding algorithm. Our algorithm will be an empty function that returns an array consisting of an array of starting coordinates and an array of coordinates of the selected coin.

// Функция поиска пути
function findPath(start, goal, grid) {
    // Этот код будет включать логику для нахождения пути от start до goal.
    // Для упрощения примера оставим функциию пустой
    
    // ...
    
    // Возвращаем путь (список координат от start до goal)
    return [start, goal];
}

// Функция мемоизации
function memoize(fn) {
    const cache = new Map();
    
    return function(...args) {
        const key = JSON.stringify(args);
        if (cache.has(key)) {
            console.log('Кэшированный результат:', cache.get(key))
            return cache.get(key);
        }
        
        const result = fn(...args);
        cache.set(key, result);
        return result;
    };
}

const memoizedFindPath = memoize(findPath);

const start = [0, 0];
const goal = [5, 5];
const grid = [
    // Представление игровой сетки (например, двумерный массив)
];

const path1 = memoizedFindPath(start, goal, grid);
console.log(path1); // "Путь от [0, 0] до [5, 5]"

// Повторный вызов findPath с теми же параметрами - результат будет взят из кэша
const path2 = memoizedFindPath(start, goal, grid);
console.log(path2); // "Путь от [0, 0] до [5, 5]"

// Вызов findPath с другими параметрами - результат будет вычислен и кэширован
const newStart = [1, 1];
const newGoal = [6, 6];
const path3 = memoizedFindPath(newStart, newGoal, grid);
console.log(path3); // "Путь от [1, 1] до [6, 6]"

There may be a misunderstanding here starting from line 29. The following happens: in the variable memoizedFindPath we save the result of the function execution memoize. Result of the function memoize can be demonstrated as follows:

// ...

const memoizedFindPath = function(...args) {
  const key = JSON.stringify(args)
  if (cache.has(key)) {
      console.log('Кэшированный результат:', cache.get(key))
      return cache.get(key);
  }
        
  const result = fn(...args);
  cache.set(key, result);
  return result;
};

The only difference is that in the original example, the function that returns memoize and which is stored in memoizedFindPath closes on a variable cache and on the function passed as an argument fn. The function that takes memoize (findPath) is a computational function and the result of its computation is saved in the collection cache. The function itself fn closes on the arguments that will be passed to the function we created memoizedFindPath.

That is:

  1. IN memoize we pass an argument as a function to be evaluated and return a function that checks the incoming values ​​as value arguments and prevents re-evaluation. Roughly speaking, memoize is a kind of server for constant access to cache.

  2. Now we have a function memoizedFindPathwhich checks the incoming arguments and, based on the results of the check, follows one of the options:
    a. If a result has already been obtained based on the arguments passed, then memoizedFindPath will return this result without recalculating.
    b. If no calculations were performed based on the arguments passed, then memoizedFindPath will call the computational function, save the result of the calculations in a variable resultwill save result to collection cache as a value and return the variable result.

Results

I'll leave a link to the book Kyle Simpson “Scope and Closure”

I also like the article on dock

Closures and scopes are a fairly large and incomprehensible topic for beginners. You may be told that closures are rarely used now and that this knowledge is generally only needed to pass an interview. I fundamentally disagree with this. Understanding how closure works immerses the developer in the deep foundations of the language, which allows solving specialized narrow-profile problems.

There are a lot of specialists who can write basic (surface) code. But there are significantly fewer people who can solve complex problems.

Thank you for reading, I encourage constructive criticism and discussion in the comments.

Peace for everyone!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *