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 poucos tipos de dados em comparação com linguagens como Java, Ruby ou .NET.
Mas, na verdade, JavaScript é muito menos simplista e mais matizado do que a maioria desenvolvedores inicialmente perceber. Mesmo para desenvolvedores com mais experiência , alguns dos recursos mais salientes do JavaScript continuam a ser mal interpretados e geram confusão. Um desses recursos é a maneira como as pesquisas de dados (propriedade e variável) são realizadas e as ramificações de desempenho do JavaScript a serem observadas.
Em JavaScript, as pesquisas de dados são regidas por duas coisas: herança prototípica e corrente de escopo . Como desenvolvedor, entender claramente esses dois mecanismos é essencial, pois isso pode melhorar a estrutura e, muitas vezes, o desempenho de seu código.
Ao acessar uma propriedade em uma linguagem baseada em protótipo como JavaScript, ocorre uma pesquisa dinâmica que envolve diferentes camadas dentro da árvore prototípica do objeto.
Em JavaScript, toda função é um objeto. Quando uma função é chamada com new
operador, 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 Person
funcionar como um construtor. Eles são instâncias independentes de Person
, conforme demonstrado por este snippet 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 é por si só um objeto, herda do protótipo de seu pai, que herda do protótipo de seu pai e assim por diante. Isso geralmente é conhecido como cadeia de protótipo . Object.prototype
, que está sempre no final da cadeia de protótipo (ou seja, no topo da árvore de herança prototípica), contém métodos como toString()
, hasProperty()
, isPrototypeOf()
, e assim por diante.
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 (invocando a função usando o operador new
), ele herda todas as propriedades no protótipo dessa função. Lembre-se, porém, de que essas instâncias não terão acesso direto ao prototype
objeto, mas apenas para 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
foi criado antes de getFullName
foi definido, ele ainda terá acesso a ele porque seu protótipo é o Person
protótipo.
(É importante notar que os navegadores também armazenam uma referência ao protótipo de qualquer objeto em uma propriedade __proto__
, mas é prática muito ruim para acessar diretamente o protótipo por meio do __proto__
propriedade, uma vez que não faz parte do padrão Especificação de linguagem ECMAScript , então não faça isso! )
Desde o p1
instância do Person
objeto não tem acesso direto ao prototype
objeto, se quisermos sobrescrever getFullName
em p1
, faríamos isso da seguinte maneira:
// We reference p1.getFullName, *NOT* p1.prototype.getFullName, // since p1.prototype does not exist: p1.getFullName = function(){ return 'I am anonymous'; }
Agora p1
tem seu próprio getFullName
propriedade. Mas o p2
instância (criada em nosso exemplo anterior) faz não possui qualquer propriedade própria. Portanto, invocando p1.getFullName()
acessa o getFullName
método do p1
própria instância, ao invocar p2.getFullName()
sobe na cadeia de protótipos até o Person
objeto de protótipo para resolver getFullName
:
console.log(p1.getFullName()); // prints 'I am anonymous' console.log(p2.getFullName()); // prints 'Robert Doe'
Outra coisa importante a ter em conta é que também é possível dinamicamente mudar 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 de protótipo, lembre-se de definir propriedades no protótipo depois de tendo herdado da classe pai ou especificado um protótipo alternativo.
Para resumir, as pesquisas de propriedade por meio da cadeia de protótipos de JavaScript funcionam da seguinte maneira:
hasOwnProperty
pode ser usado para verificar se um objeto tem uma propriedade nomeada específica.)Object.prototype
é alcançado e também não possui a propriedade, a propriedade é 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 ramificações de desempenho do JavaScript (às vezes significativas). Conforme mencionado na documentação para V8 (Código aberto do Google, mecanismo JavaScript de alto desempenho), a maioria dos mecanismos JavaScript usa uma estrutura de dados semelhante a um dicionário para armazenar propriedades de objetos. Cada acesso à propriedade, portanto, requer uma pesquisa dinâmica nessa estrutura de dados para resolver a propriedade. Essa abordagem torna o acesso às propriedades em JavaScript normalmente muito mais lento do que o acesso a variáveis de instância em linguagens de programação como Java e Smalltalk.
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:
Os contextos de execução são organizados em uma pilha. Na parte inferior da pilha, há sempre o contexto global, que é exclusivo 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 execução da função termina, 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 Dentro de cada contexto de execução há um objeto especial chamado de corrente de escopo que é usado para resolver variáveis. Uma cadeia de escopos é essencialmente uma pilha de escopos atualmente acessíveis, do contexto mais imediato ao 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 nomeados e dois objetos “especiais”: this
e arguments
.) Por exemplo:

Observe no diagrama acima como this
aponta para window
objeto 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 é primeiro verificado em busca de uma variável correspondente. Se nenhuma correspondência for encontrada, o próximo objeto de contexto na cadeia de escopo é verificado e assim por diante, até que uma correspondência seja encontrada. Se nenhuma correspondência for encontrada, um ReferenceError
é lançado.
É importante observar também que um novo escopo é adicionado à cadeia de escopo quando um try-catch
bloco ou um with
bloco é 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 compreender totalmente como ocorrem as pesquisas de variáveis baseadas em escopo, é importante ter em mente que em JavaScript não há atualmente 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
ficaria restrito ao bloco for. Em JavaScript, porém, esse não é o caso. Em vez disso, i
é adicionado ao objeto de ativação no topo da cadeia do 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 içamento variável.
É importante notar, porém, que o suporte para escopos em nível de bloco está chegando ao JavaScript por meio do novo let
palavra-chave. O let
palavra-chave já está disponível em JavaScript 1.7 e deverá se tornar uma palavra-chave JavaScript oficialmente suportada a partir do ECMAScript 6.
Ramificações de desempenho de JavaScript
A forma como as pesquisas de propriedade e variável, usando cadeia de protótipo e cadeia de escopo, respectivamente, funcionam em JavaScript é um dos principais recursos da linguagem, embora seja um dos mais difíceis e sutis de entender.
As operações de pesquisa que descrevemos neste exemplo, seja com base na cadeia de protótipo ou na cadeia de escopo, são repetidas cada hora em que uma propriedade ou variável é acessada. Quando essa pesquisa ocorre dentro de loops ou outras operações intensivas, ela pode ter ramificações significativas de desempenho do JavaScript, especialmente à luz da 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 do contador é incrementada com o valor de delta
. Mas delta
está localizado quase no topo da árvore de herança! Isso significa que cada vez child.delta
for acessado, a árvore completa precisa ser navegada de baixo para cima. Isso pode ter um impacto muito negativo no desempenho.
Compreendendo isso, podemos melhorar facilmente o desempenho do nestedFn
usando uma função local delta
variável 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');
Obviamente, essa técnica em particular só é viável em um cenário onde se sabe que o valor de child.delta
não mudará enquanto os loops for estiverem em execução; caso contrário, a cópia local precisaria ser atualizada com o valor atual.
OK, vamos executar as duas versões do nestedFn
método e veja se há alguma diferença de desempenho apreciável entre os dois.
Começaremos executando o primeiro exemplo em um node.js REPL :
[email protected] :~$ node test.js Final result: 10000000000 Total time: 8270 milliseconds
Isso leva cerca de 8 segundos para ser executado. Isso é muito tempo.
Agora vamos ver o que acontece quando executamos a versão otimizada:
[email protected] :~$ node test2.js Final result: 10000000000 Total time: 1143 milliseconds
Desta vez, demorou 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ótipo) quanto para pesquisas de variáveis (por meio da cadeia de escopo).
Além disso, este tipo de 'armazenamento em 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. Levar jQuery , por exemplo. jQuery suporta a noção de 'seletores', que são basicamente um mecanismo para recuperar um ou mais elementos correspondentes no JULGAMENTO . A facilidade com que se pode especificar seletores em jQuery pode fazer com que se esqueça o quão caro (do ponto de vista de desempenho) cada pesquisa de seletor pode ser. Consequentemente, armazenar os resultados da pesquisa do 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 ”); } // 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 '); } // 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 '; } $('.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 do que na maioria das outras linguagens e é altamente matizada. Portanto, é essencial compreender plena e apropriadamente esses conceitos para realmente dominar a linguagem. Pesquisa de dados e outros erros comuns de JavaScript deve ser evitado sempre que possível. É provável que esse entendimento produza um código mais limpo e robusto que atinge um desempenho JavaScript aprimorado.
Relacionado: Como um desenvolvedor JS, é isso que me mantém acordado à noite / Fazendo sentido da confusão da classe ES6