Cadeias de protótipos JavaScript, cadeias de escopo e desempenho: o que você precisa saber

Publicados: 2022-03-11

JavaScript: mais do que aparenta

JavaScript pode parecer uma linguagem muito fácil de aprender no início. Talvez seja por causa de sua sintaxe flexível. Ou talvez seja por causa de sua semelhança com outras linguagens conhecidas como Java. Ou talvez seja porque tem tão poucos tipos de dados em comparação com linguagens como Java, Ruby ou .NET.

Mas, na verdade, o JavaScript é muito menos simplista e mais sutil do que a maioria dos desenvolvedores imagina inicialmente. Mesmo para desenvolvedores com mais experiência, alguns dos recursos mais importantes do JavaScript continuam sendo mal compreendidos e levam à confusão. Um desses recursos é a maneira como as pesquisas de dados (propriedade e variável) são executadas e as ramificações de desempenho do JavaScript a serem observadas.

Em JavaScript, as pesquisas de dados são governadas por duas coisas: herança prototípica e cadeia de escopo . Como desenvolvedor, é essencial entender claramente esses dois mecanismos, pois isso pode melhorar a estrutura e, muitas vezes, o desempenho do seu código.

Pesquisas de propriedades por meio da cadeia de protótipos

Ao acessar uma propriedade em uma linguagem baseada em protótipos como JavaScript, ocorre uma pesquisa dinâmica que envolve diferentes camadas dentro da árvore de protótipos do objeto.

Em JavaScript, cada função é um objeto. Quando uma função é invocada com o operador new , um novo objeto é criado. Por exemplo:

 function Person(firstName, lastName) { this.firstName = firstName; this.lastName = lastName; } var p1 = new Person('John', 'Doe'); var p2 = new Person('Robert', 'Doe');

No exemplo acima, p1 e p2 são dois objetos diferentes, cada um criado usando a função Person como construtor. São instâncias independentes de Person , conforme demonstrado por este trecho de código:

 console.log(p1 instanceof Person); // prints 'true' console.log(p2 instanceof Person); // prints 'true' console.log(p1 === p2); // prints 'false'

Como as funções JavaScript são objetos, elas podem ter propriedades. Uma propriedade particularmente importante que cada função possui é chamada de prototype .

prototype , que é um objeto, herda do protótipo de seu pai, que herda do protótipo de seu pai e assim por diante. Isso geralmente é chamado de cadeia de protótipos . Object.prototype , que está sempre no final da cadeia de protótipos (ou seja, no topo da árvore de herança de protótipos), contém métodos como toString() , hasProperty() , isPrototypeOf( isPrototypeOf() e assim por diante.

A relação entre o protótipo JavaScript e a cadeia de escopo é importante

O protótipo de cada função pode ser estendido para definir seus próprios métodos e propriedades personalizados.

Quando você instancia um objeto (chamando a função usando o operador new ), ele herda todas as propriedades do protótipo dessa função. Tenha em mente, porém, que essas instâncias não terão acesso direto ao objeto prototype , mas apenas às suas propriedades. Por exemplo:

 // Extending the Person prototype from our earlier example to // also include a 'getFullName' method: Person.prototype.getFullName = function() { return this.firstName + ' ' + this.lastName; } // Referencing the p1 object from our earlier example console.log(p1.getFullName()); // prints 'John Doe' // but p1 can't directly access the 'prototype' object... console.log(p1.prototype); // prints 'undefined' console.log(p1.prototype.getFullName()); // generates an error

Há um ponto importante e um tanto sutil aqui: mesmo que p1 tenha sido criado antes do método getFullName ser definido, ele ainda terá acesso a ele porque seu protótipo é o protótipo Person .

(Vale a pena notar que os navegadores também armazenam uma referência ao protótipo de qualquer objeto em uma propriedade __proto__ , mas é uma prática muito ruim acessar diretamente o protótipo através da propriedade __proto__ , já que não faz parte do padrão ECMAScript Language Specification, então não não faça isso! )

Como a instância p1 do objeto Person não tem acesso direto ao objeto prototype , se quisermos sobrescrever getFullName em p1 , faríamos assim:

 // We reference p1.getFullName, *NOT* p1.prototype.getFullName, // since p1.prototype does not exist: p1.getFullName = function(){ return 'I am anonymous'; }

Agora p1 tem sua própria propriedade getFullName . Mas a instância p2 (criada em nosso exemplo anterior) não possui nenhuma propriedade própria. Portanto, invocar p1.getFullName() acessa o método getFullName da própria instância p1 , enquanto invocar p2.getFullName() sobe a cadeia de protótipos até o objeto de protótipo Person para resolver getFullName :

 console.log(p1.getFullName()); // prints 'I am anonymous' console.log(p2.getFullName()); // prints 'Robert Doe' 

Veja como P1 e P2 se relacionam com o protótipo Person neste exemplo de protótipo JavaScript.

Outra coisa importante a ser observada é que também é possível alterar dinamicamente o protótipo de um objeto. Por exemplo:

 function Parent() { this.someVar = 'someValue'; }; // extend Parent's prototype to define a 'sayHello' method Parent.prototype.sayHello = function(){ console.log('Hello'); }; function Child(){ // this makes sure that the parent's constructor is called and that // any state is initialized correctly. Parent.call(this); }; // extend Child's prototype to define an 'otherVar' property... Child.prototype.otherVar = 'otherValue'; // ... but then set the Child's prototype to the Parent prototype // (whose prototype doesn't have any 'otherVar' property defined, // so the Child prototype no longer has 'otherVar' defined!) Child.prototype = Object.create(Parent.prototype); var child = new Child(); child.sayHello(); // prints 'Hello' console.log(child.someVar); // prints 'someValue' console.log(child.otherVar); // prints 'undefined'

Ao usar herança prototípica, lembre-se de definir propriedades no protótipo após ter herdado da classe pai ou especificado um protótipo alternativo.

Este diagrama mostra um exemplo do relacionamento entre protótipos JavaScript em uma cadeia de protótipos.

Para resumir, as pesquisas de propriedade por meio da cadeia de protótipos JavaScript funcionam da seguinte maneira:

  • Se o objeto tiver uma propriedade com o nome fornecido, esse valor será retornado. (O método hasOwnProperty pode ser usado para verificar se um objeto possui uma determinada propriedade nomeada.)
  • Se o objeto não possui a propriedade nomeada, o protótipo do objeto é verificado
  • Como o protótipo também é um objeto, se também não contiver a propriedade, o protótipo de seu pai será verificado.
  • Esse processo continua na cadeia de protótipos até que a propriedade seja encontrada.
  • Se Object.prototype for alcançado e também não possuir a propriedade, a propriedade será considerada undefined .

Compreender como a herança prototípica e as pesquisas de propriedade funcionam é importante em geral para os desenvolvedores, mas também é essencial devido às suas (às vezes significativas) ramificações de desempenho do JavaScript. Conforme mencionado na documentação do V8 (o mecanismo JavaScript de código aberto e alto desempenho do Google), a maioria dos mecanismos JavaScript usa uma estrutura de dados semelhante a um dicionário para armazenar propriedades de objetos. Cada acesso de propriedade, portanto, requer uma pesquisa dinâmica nessa estrutura de dados para resolver a propriedade. Essa abordagem torna o acesso a propriedades em JavaScript geralmente muito mais lento do que o acesso a variáveis ​​de instância em linguagens de programação como Java e Smalltalk.

Pesquisas de variáveis ​​através da cadeia de escopo

Outro mecanismo de pesquisa em JavaScript é baseado no escopo.

Para entender como isso funciona, é necessário introduzir o conceito de contexto de execução.

Em JavaScript, existem dois tipos de contextos de execução:

  • Contexto global, criado quando um processo JavaScript é iniciado
  • Contexto local, criado quando uma função é invocada

Os contextos de execução são organizados em uma pilha. Na parte inferior da pilha, há sempre o contexto global, que é único para cada programa JavaScript. Cada vez que uma função é encontrada, um novo contexto de execução é criado e colocado no topo da pilha. Assim que a função terminar de ser executada, seu contexto é retirado da pilha.

Considere o seguinte código:

 // global context var message = 'Hello World'; var sayHello = function(n){ // local context 1 created and pushed onto context stack var i = 0; var innerSayHello = function() { // local context 2 created and pushed onto context stack console.log((i + 1) + ': ' + message); // local context 2 popped off of context stack } for (i = 0; i < n; i++) { innerSayHello(); } // local context 1 popped off of context stack }; sayHello(3); // Prints: // 1: Hello World // 2: Hello World // 3: Hello World

Dentro de cada contexto de execução há um objeto especial chamado cadeia de escopo que é usado para resolver variáveis. Uma cadeia de escopo é essencialmente uma pilha de escopos atualmente acessíveis, desde o contexto mais imediato até o contexto global. (Para ser um pouco mais preciso, o objeto no topo da pilha é chamado de Objeto de Ativação que contém referências às variáveis ​​locais para a função que está sendo executada, os argumentos da função nomeada e dois objetos “especiais”: this e arguments . ) Por exemplo:

A maneira como a cadeia de escopo se relaciona com os objetos é descrita neste exemplo de JavaScript.

Observe no diagrama acima como this aponta para o objeto window por padrão e também como o contexto global contém exemplos de outros objetos como console e location .

Ao tentar resolver variáveis ​​por meio da cadeia de escopo, o contexto imediato é verificado primeiro quanto a uma variável correspondente. Se nenhuma correspondência for encontrada, o próximo objeto de contexto na cadeia de escopo será verificado e assim por diante, até que uma correspondência seja encontrada. Se nenhuma correspondência for encontrada, um ReferenceError será lançado.

É importante observar também que um novo escopo é adicionado à cadeia de escopo quando um bloco try-catch ou um bloco with é encontrado. Em qualquer um desses casos, um novo objeto é criado e colocado no topo da cadeia de escopo:

 function Person(firstName, lastName) { this.firstName = firstName; this.lastName = lastName; }; function persist(person) { with (person) { // The 'person' object was pushed onto the scope chain when we // entered this "with" block, so we can simply reference // 'firstName' and 'lastName', rather than person.firstName and // person.lastName if (!firstName) { throw new Error('FirstName is mandatory'); } if (!lastName) { throw new Error('LastName is mandatory'); } } try { person.save(); } catch(error) { // A new scope containing the 'error' object is accessible here console.log('Impossible to store ' + person + ', Reason: ' + error); } } var p1 = new Person('John', 'Doe'); persist(p1);

Para entender completamente como as pesquisas de variáveis ​​baseadas em escopo ocorrem, é importante ter em mente que em JavaScript atualmente não há escopos em nível de bloco. Por exemplo:

 for (var i = 0; i < 10; i++) { /* ... */ } // 'i' is still in scope! console.log(i); // prints '10'

Na maioria das outras linguagens, o código acima levaria a um erro porque a “vida” (ou seja, escopo) da variável i estaria restrita ao bloco for. No JavaScript, porém, este não é o caso. Em vez disso, i é adicionado ao objeto de ativação no topo da cadeia de escopo e permanecerá lá até que o objeto seja removido do escopo, o que acontece quando o contexto de execução correspondente é removido da pilha. Esse comportamento é conhecido como elevação variável.

Vale a pena notar, porém, que o suporte para escopos em nível de bloco está chegando ao JavaScript por meio da nova palavra-chave let . A palavra-chave let já está disponível no JavaScript 1.7 e está programada para se tornar uma palavra-chave JavaScript oficialmente suportada a partir do ECMAScript 6.

Ramificações de desempenho do JavaScript

A maneira como as pesquisas de propriedade e variável, usando cadeia de protótipos e cadeia de escopo, respectivamente, funcionam em JavaScript é um dos principais recursos da linguagem, mas é um dos mais complicados e sutis de entender.

As operações de pesquisa que descrevemos neste exemplo, sejam baseadas na cadeia de protótipos ou na cadeia de escopo, são repetidas sempre que uma propriedade ou variável é acessada. Quando essa pesquisa ocorre em loops ou outras operações intensivas, ela pode ter ramificações significativas de desempenho do JavaScript, especialmente devido à natureza de thread único da linguagem que impede que várias operações ocorram simultaneamente.

Considere o seguinte exemplo:

 var start = new Date().getTime(); function Parent() { this.delta = 10; }; function ChildA(){}; ChildA.prototype = new Parent(); function ChildB(){} ChildB.prototype = new ChildA(); function ChildC(){} ChildC.prototype = new ChildB(); function ChildD(){}; ChildD.prototype = new ChildC(); function ChildE(){}; ChildE.prototype = new ChildD(); function nestedFn() { var child = new ChildE(); var counter = 0; for(var i = 0; i < 1000; i++) { for(var j = 0; j < 1000; j++) { for(var k = 0; k < 1000; k++) { counter += child.delta; } } } console.log('Final result: ' + counter); } nestedFn(); var end = new Date().getTime(); var diff = end - start; console.log('Total time: ' + diff + ' milliseconds');

Neste exemplo, temos uma longa árvore de herança e três loops aninhados. Dentro do loop mais profundo, a variável counter é incrementada com o valor de delta . Mas delta está localizado quase no topo da árvore de herança! Isso significa que cada vez que child.delta é acessado, a árvore completa precisa ser navegada de baixo para cima. Isso pode ter um impacto muito negativo no desempenho.

Entendendo isso, podemos facilmente melhorar o desempenho da função nestedFn acima usando uma variável delta local para armazenar em cache o valor em child.delta (e, assim, evitar a necessidade de travessia repetitiva de toda a árvore de herança) da seguinte forma:

 function nestedFn() { var child = new ChildE(); var counter = 0; var delta = child.delta; // cache child.delta value in current scope for(var i = 0; i < 1000; i++) { for(var j = 0; j < 1000; j++) { for(var k = 0; k < 1000; k++) { counter += delta; // no inheritance tree traversal needed! } } } console.log('Final result: ' + counter); } nestedFn(); var end = new Date().getTime(); var diff = end - start; console.log('Total time: ' + diff + ' milliseconds');

É claro que essa técnica em particular só é viável em um cenário em que se sabe que o valor de child.delta não mudará enquanto os loops for estiverem sendo executados; caso contrário, a cópia local precisaria ser atualizada com o valor atual.

OK, vamos executar as duas versões do método nestedFn e ver se há alguma diferença de desempenho apreciável entre os dois.

Começaremos executando o primeiro exemplo em um node.js REPL:

 diego@alkadia:~$ node test.js Final result: 10000000000 Total time: 8270 milliseconds

Então, isso leva cerca de 8 segundos para ser executado. Isso é muito tempo.

Agora vamos ver o que acontece quando executamos a versão otimizada:

 diego@alkadia:~$ node test2.js Final result: 10000000000 Total time: 1143 milliseconds

Desta vez, levou apenas um segundo. Muito mais rapido!

Observe que o uso de variáveis ​​locais para evitar pesquisas caras é uma técnica que pode ser aplicada tanto para pesquisa de propriedades (por meio da cadeia de protótipos) quanto para pesquisas de variáveis ​​(por meio da cadeia de escopo).

Além disso, esse tipo de “cache” de valores (ou seja, em variáveis ​​no escopo local) também pode ser benéfico ao usar algumas das bibliotecas JavaScript mais comuns. Tome jQuery, por exemplo. jQuery suporta a noção de “seletores”, que são basicamente um mecanismo para recuperar um ou mais elementos correspondentes no DOM. A facilidade com que se pode especificar seletores no jQuery pode fazer com que se esqueça o quão caro (do ponto de vista de desempenho) cada pesquisa de seletor pode ser. Assim, armazenar resultados de pesquisa de seletor em uma variável local pode ser extremamente benéfico para o desempenho. Por exemplo:

 // this does the DOM search for $('.container') "n" times for (var i = 0; i < n; i++) { $('.container').append(“Line “+i+”<br />”); } // this accomplishes the same thing... // but only does the DOM search for $('.container') once, // although it does still modify the DOM "n" times var $container = $('.container'); for (var i = 0; i < n; i++) { $container.append("Line "+i+"<br />"); } // or even better yet... // this version only does the DOM search for $('.container') once // AND only modifies the DOM once var $html = ''; for (var i = 0; i < n; i++) { $html += 'Line ' + i + '<br />'; } $('.container').append($html);

Especialmente em uma página da Web com um grande número de elementos, a segunda abordagem no exemplo de código acima pode resultar em um desempenho significativamente melhor do que a primeira.

Embrulhar

A pesquisa de dados em JavaScript é bem diferente da maioria das outras linguagens e é altamente diferenciada. Portanto, é essencial entender de forma completa e adequada esses conceitos para realmente dominar o idioma. A pesquisa de dados e outros erros comuns de JavaScript devem ser evitados sempre que possível. Esse entendimento provavelmente resultará em um código mais limpo e robusto que alcançará um desempenho aprimorado do JavaScript.

Relacionado: Como desenvolvedor JS, isso é o que me mantém acordado à noite / Entendendo a confusão de classe ES6