Skip to content

Latest commit

 

History

History
378 lines (239 loc) · 20 KB

File metadata and controls

378 lines (239 loc) · 20 KB

You Don't Know JS Yet: Scope & Closures - 2nd Edition

Chapter 4: Global Scope

NOTE:
Work in progress

Chapter 3 mentioned the "global scope" several times, but you may still be wondering why a program's outermost scope is all that important in modern JS? The vast majority of work seems to be done inside of functions and modules rather than globally.

The global scope of a JS program is a rich topic, with much more utility and nuance than you would likely assume. This chapter first explores whether the global scope is (still) useful and relevant to writing JS programs, then looks at differences in how to find the global scope in different JS environments.

Why Global Scope?

We've referenced the "global scope" a number of times already, but we should dig into that topic in more detail.

It's likely no surprise to readers that most applications are composed of multiple (sometimes many!) individual JS files. So how exactly do all those separate files get stitched together in a single run-time context by the JS engine?

With respect to browser-executed applications, there are 3 main ways:

  1. If you're exclusively using ES modules (not transpiling those into some other module-bundle format), then these files are loaded individually by the JS environment. Each module then imports references to whichever other modules it needs to access. The separate module files cooperate with each other exclusively through these shared imports, without needing any scopes.

  2. If you're using a bundler in your build process, all the files are typically concatenated together before delivery to the browser and JS engine, which then only processes one big file. Even with all the pieces of the application being co-located in a single file, some mechanism is necessary for each piece to register a name to be referred to by other pieces, as well as some facility for that access to be made.

    In some approaches, the entire contents of the file are wrapped in a single enclosing scope (such as a wrapper function, UMD-like module, etc), so each piece can register itself for access by other pieces by way of local variables in that shared scope.

    For example:

    (function outerScope(){
        var moduleOne = (function one(){
            // ..
        })();
    
        var moduleTwo = (function two(){
            // ..
    
            function callModuleOne() {
                moduleOne.someMethod();
            }
    
            // ..
        })();
    })();

    As shown, the moduleOne and moduleTwo local variables inside the outerScope() function scope are declared so that these modules can access each other for their cooperation.

    While the scope of outerScope() is a function and not the full environment global scope, it does act as a sort of "application-wide scope", a bucket where all the top-level identifiers can be stored, even if not in the real global scope. So it's kind of like a stand-in for the global scope in that respect.

  3. Whether a bundler is used for an application, or whether the (non-ES module) files are simply loaded in the browser individually (via <script> tags or other dynamic JS loading), if there is no single surrounding scope encompassing all these pieces, the global scope is the only way for them to cooperate with each other.

    A bundled file of this sort often looks something like this:

    var moduleOne = (function one(){
        // ..
    })();
    var moduleTwo = (function two(){
        // ..
    
        function callModuleOne() {
            moduleOne.someMethod();
        }
    
        // ..
    })();

    Here, since there is no surrounding function scope, these moduleOne and moduleTwo declarations are simply processed in the global scope. This is effectively the same as if the file hadn't been concatenated:

    module1.js:

    var moduleOne = (function one(){
        // ..
    })();

    module2.js:

    var moduleTwo = (function two(){
        // ..
    
        function callModuleOne() {
            moduleOne.someMethod();
        }
    
        // ..
    })();

    Again, if these files are loaded as normal standalone .js files in a browser environment, each top-level variable declaration will end up as a global variable, since the global scope is the only shared resource between these two separate files (programs, from the perspective of the JS engine).

In addition to (potentially) accounting for where an application's code resides during run-time, and how each piece is able to access the other pieces to cooperate, the global scope is also where:

  • JS exposes its built-ins:

    • primitives: undefined, null, Infinity, NaN
    • natives: Date(), Object(), String(), etc
    • global functions: eval(), parseInt(), etc
    • namespaces: Math, Atomics, JSON
    • friends of JS: Intl, WebAssembly
  • The environment that is hosting JS exposes its built-ins:

    • console (and its methods)
    • the DOM (window, document, etc)
    • timers (setTimeout(..), etc)
    • web platform APIs: navigator, history, geolocation, WebRTC, etc

    NOTE:
    Node also exposes several elements "globally", but they're technically not in its global scope: require(), __dirname, module, URL, etc.

Most developers agree that the global scope shouldn't just be a dumping ground for every variable in your application. That's a mess of bugs just waiting to happen. But it's also undeniable that the global scope is an important glue for virtually every JS application.

Where Exactly Is This Global Scope?

It might seem obvious that the global scope is located in the outermost portion of a file; that is, not inside any function or other block. But it's not quite as simple as that.

Different JS environments handle the scopes of your programs, in particular the global scope, differently. It's extremely common for JS developers to have misconceptions in this regard.

Browser "Window"

With resepct to treatment of the global scope, the most pure (not completely!) environment JS can be run in is as a standalone .js file loaded in a web page environment in a browser. I don't mean "pure" as in nothing automatically added -- lots may be added! -- but rather in terms of minimal intrusion on the code or interference with its behavior.

Consider this simple .js file:

var studentName = "Kyle";

function hello() {
    console.log(`Hello, ${ studentName }!`);
}

hello();
// Hello, Kyle!

This code may be loaded in a webpage environment using an inline <script> tag, a <script src=..> script tag in the markup, or even a dynamically created <script> DOM element. In all three cases, the studentName and hello identifiers are declared in the global scope.

That means if you access the global object (commonly, window in the browser), you'll find properties of those same names there:

var studentName = "Kyle";

function hello() {
    console.log(`Hello, ${ window.studentName }!`);
}

window.hello();
// Hello, Kyle!

That's the default behavior one would expect from a reading of the JS specification. That's what I mean by pure. That won't always be true of other JS environments, and that's often surprising to JS developers.

Shadowing Globals

Recall the discussion of shadowing from Chapter 3? An unusual consequence of the difference between a global variable and a global property of the same name is that a global object property can be shadowed by a global variable:

window.something = 42;

let something = "Kyle";

console.log(something);
// Kyle

The let declaration adds a something global variable, which shadows the something global object property.

While it's possible to shadow in this manner, it's almost certainly a bad idea to do so. Don't create a divergence between the global object and the global scope.

What's In A (Window) Name?

I asserted that this browser-hosted JS environment has the most pure global scope behavior we'll see. Things are not entirely pure, however.

Consider:

var name = 42;

console.log(typeof name, name);
// string 42

window.name is a pre-defined "global" in a browser context; it's a property on the global object, so it seems like a normal global variable (though it's anything but "normal"). We used var for the declaration, which doesn't shadow the pre-defined name global property. That means, effectively, the var declaration is ignored, since there's already a global scope object property of that name. As we discussed in the previous section, had we use let name, we would have shadowed window.name with a separate global name variable.

But the truly weird behavior is that even though we assigned the number 42 to name, when we then retrieve its value, it's a string "42"! In this case, the weirdness is because window.name is actually a getter/setter on the global object, which insists on a string value. Wow!

With the exception some rare corner cases like window.name, JS running as a standalone file in a browser page has some of the most pure global scope behavior we're likely to encounter.

Web Workers

Web Workers are a web platform extension for typical browser-JS behavior, which allows a JS file to run in a completely separate thread (operating system wise) from the thread that's running the main browser-hosted JS.

Since these web worker programs run on a separate thread, they're restricted in their communications with the main application thread, to avoid/control race conditions and other complications. Web worker code does not have access to the DOM, for example. Some web APIs are however made available to the worker, such as navigator.

Since a web worker is treated as a wholly separate program, it does not share the global scope with the main JS program. However, the browser's JS engine is still running the code, so we can expect similar purity of its global scope behavior. But there is no DOM access, so the window alias for the global scope doesn't exist.

In a web worker, a global object reference is typically made with self:

var studentName = "Kyle";
let studentID = 42;

function hello() {
    console.log(`Hello, ${ self.studentName }!`);
}

self.hello();
// Hello, Kyle!

self.studentID;
// undefined

Just as with main JS programs, var and function declarations create mirrored properties on the global object (aka, self), where other declarations (let, etc) do not.

So again, the global scope behavior we're seeing here is about as pure as it gets for running JS programs.

Developer Tools Console/REPL

Recall from "Get Started" Chapter 1 that Developer Tools don't create a completely authentic JS environment. They do process JS code, but they also bend the UX of the interaction in favor of being friendly to developers (aka, "Developer Experience", DX).

In many cases, favoring DX when entering short JS snippets over the normal strict steps expected for processing a full JS program produces observable differences in behavior of code. For example, certain error conditions applicable to a JS program may be relaxed and not displayed when the code is entered into a developer tool.

With respect to our discussions here about scope, such observable differences in behavior may include the behavior of the global scope, hoisting (discussed later in this chapter), and block-scoping declarators (let / const, see Chapter 4) when used in the outermost scope.

Even though while using the console/REPL it seems like statements entered in the outermost scope are being processed in the real global scope, that's not strictly accurate. The tool emulates that to an extent, but it's emulation, not strict adherence. These tool environments prioritize developer convenience, which means that at times (such as with our current discussions regarding scope), observed behavior may deviate from the JS specification.

The take-away is that Developer Tools, while being very convenient and useful for a variety of developer activities, are not suitable environments to determine or verify some of the explicit and nuanced behaviors of an actual JS program context.

ES Modules (ESM)

ES6 introduced first-class support for the module pattern (which we'll cover more in Chapter 6). One of the most obvious impacts of using ESM is how it changes the behavior of observably top-level scope in a file.

Recall this code snippet from earlier:

var studentName = "Kyle";

function hello() {
    console.log(`Hello, ${ studentName }!`);
}

hello();
// Hello, Kyle!

export hello;

If that code were in a file that was loaded as an ES module, it would still run exactly the same. However, the observable effects, from the overall application perspective, would be different.

Despite being declared at the top-level of the (module) file, the outermost obvious scope, studentName and hello are not global variables. Instead, they are module-wide, or if you prefer, "module-global".

However, in a module there's no implicit "global scope object" (or "module-global object") for these top-level declarations to be added to as properties, as there is when declarations appear in the top-level of non-module JS files. This is not to say that global variables cannot exist or be accessed in such programs. It's just that global variables don't get created by declaring variables in the top-level scope of a module.

The module's top-level scope is descended from the global scope, almost as if the entire contents of the module were wrapped in a function. Thus, all variables that exist in the global scope (whether they're on the global object or not!) are available as lexical identifiers from inside the module's scope.

ESM encourages a minimization of reliance on the global scope, where you import whatever modules you may need for the current module to operate. As such, you less often see usage of the global scope or its global object. However, as noted earlier, there are still plenty of JS and web globals that you will continue to access from the global scope, whether you realize it or not!

Node

One aspect of Node that often catches JS developers off-guard is that Node treats every single .js file that it loads, including the main one you start the Node process with, as a module (ES module or CommonJS module). The practical effect is that the top-level of your Node programs is not actually the global scope, the way it is when loading a non-module file in the browser.

As of time of this writing, Node recently added support for ES modules. But additionally, Node has from the beginning supported a module format referred to as "Common JS", which looks like this:

var studentName = "Kyle";

function hello() {
    console.log(`Hello, ${ studentName }!`);
}

hello();
// Hello, Kyle!

module.exports.hello = hello;

Node essentially wraps such code in a function, so that the var and function declarations are contained in that module's scope, not treated as global variables.

Think of the above code when processed by Node sorta like this (illustrative, not actual):

function Module(module,require,__dirname,...) {
    var studentName = "Kyle";

    function hello() {
        console.log(`Hello, ${ studentName }!`);
    }

    hello();
    // Hello, Kyle!

    module.exports.hello = hello;
}

Node then (again, essentially) invokes the added Module(..) function to run your module. You can clearly see here why studentName and hello identifiers are thus not global, but rather declared in the module scope.

As noted earlier, Node defines a number of "globals" like require(), but they're not actually identifiers in the global scope. They're provided in the available scope to every module, essentially a bit like the parameters listed in the Module(..) declaration function.

So how do you define actual global variables in Node? The only way to do so is to add properties to another of Node's automatically provided "globals", which is unsurprisignly called global. global is ostensibly (if not actually) a reference to the real global scope object, somewhat like using window in a browser JS environment.

Consider:

global.studentName = "Kyle";

function hello() {
    console.log(`Hello, ${ studentName }!`);
}

hello();
// Hello, Kyle!

module.exports.hello = hello;

Here we add studentName as a property on the global object, and then in the console.log(..) statement we're able to access studentName as a normal global variable.

Remember, the identifier global is not defined by JS, it's defined by Node.

Global This

Reviewing where we've been so far, depending on which JS environment our code is running in, a program may or may not be able to:

  • declare a global variable in the top-level scope with var or function declarations -- or let, const, and class.

  • also add global variables declarations as properties of the global scope object if var or function were used for the declaration.

  • refer to the global scope object (for adding or retrieving global variables, as properties) with window, self, or global.

I think it's fair to say that global scope access and behavior is more complicated than most developers assume, as the preceding sections have illustrated. But the complexity is never more obvious than in trying to articulate a broadly applicable reference to the global scope object.

Another "trick" for getting a reliable reference to this global scope object might look like:

const theGlobalScopeObject = (new Function("return this"))();
NOTE:
A function can be dynamically constructed from code stored in a string value with the Function() constructor, similar to eval(..) (see "Cheating: Run-Time Scope Modifications" in Chapter 1). Such a function will automatically be run in non-strict mode (for legacy reasons) when invoked as shown (the normal () function invocation); thus, its this will be the global object. See Book 3 Objects & Classes for more information.

So, we have window, self, global, and this ugly new Function(..) trick. That's a lot of different ways to try to get at this global object.

Why not introduce yet another!?!?

As of ES2020, JS has finally defined a standardized reference to the global scope object, called globalThis. So, subject to the recency of the JS engines your code runs in, you can then use globalThis in place of any of those other approaches.

You might even attempt a cross-environment polyfill approach that's safer across pre-globalThis JS environments, such as:

const theGlobalScopeObject =
    (typeof globalThis !== "undefined") ? globalThis :
    (typeof global !== "undefined") ? global :
    (typeof window !== "undefined") ? window :
    (typeof self !== "undefined") ? self :
    (new Function("return this"))();

Phew! That's certainly not ideal, but it works if you find yourself needing a reliable global scope reference.

NOTE:
The name globalThis was fairly controversial while the feature was being added to JS. Specifically, I and many others felt the "this" reference in the name was fairly misleading, since the reason you access this object is to access to the global scope, never to access some sort of global/default this binding. There were many other names considered, but for a variety of reasons ruled out. Unfortunately, the name chosen ended up as a last resort. If you plan to interact with the global scope object in your programs, to reduce confusion, I recommend you choose a better name, such as (the laughably long but accurate!) theGlobalScopeObject above.

Globally Aware

The global scope is present and relevant to every JS program, even though modern patterns for organizing code into modules de-emphasizes reliance on storing identifiers in that namespace.

With the proliferation of our code far beyond the confines of the browser, it's especially important to have a solid grasp on the differences in how the global scope (and global scope object!) behaves across different JS environments.

With the big picture of global scope now clarified, the next chapter begins dissecting the rules of lexical scope that impact how and when variables can be used.