Olá colegas!
Lembramos que, há pouco tempo, publicamos a 3ª edição do lendário livro
“Expressive JavaScript ” (Eloquent JavaScript) - ele foi impresso em russo pela primeira vez, embora traduções de alta qualidade de edições anteriores
tenham sido encontradas na Internet.

No entanto, nem JavaScript nem o trabalho de pesquisa de Haverbeke, é claro, não param. Continuando o tópico do JavaScript expressivo, oferecemos uma tradução de um artigo sobre o design de extensões (usando o desenvolvimento de um editor de texto como exemplo), publicado no blog do autor no final de agosto de 2019
Atualmente, tornou-se moda estruturar grandes sistemas na forma de muitos pacotes separados. A ideia principal subjacente a essa abordagem é que é melhor não limitar as pessoas a um recurso específico (proposto por você) implementando um recurso, mas fornecê-lo como um pacote separado que uma pessoa pode baixar junto com o pacote básico do sistema.
Para fazer isso, em termos gerais, você precisará ...
- A capacidade de nem carregar recursos desnecessários, o que é especialmente útil ao trabalhar com sistemas do lado do cliente.
- A capacidade de substituir por outra implementação a funcionalidade que não atende às suas necessidades. Desse modo, a pressão também é reduzida nos módulos nucleares, que, de outra forma, teriam que cobrir todos os tipos de casos práticos.
- Verificando as interfaces do kernel em condições reais - implementando recursos básicos na parte superior da interface voltada para o cliente; você precisará tornar a interface poderosa o suficiente para, pelo menos, lidar com o suporte desses recursos - e garantir que elementos funcionalmente semelhantes possam ser montados a partir de código de terceiros.
- Isolamento entre componentes do sistema. Os participantes do projeto precisarão procurar o pacote específico que lhes interessa. Os pacotes podem ser versionados, preteridos ou substituídos por outros, e tudo isso não afetará o kernel.
Essa abordagem envolve certos custos, que se resumem a complexidade adicional. Para que os usuários possam começar, você pode fornecer a eles um pacote wrapper, com tudo incluído, mas em algum momento eles provavelmente terão que removê-lo e resolver a instalação e configuração de pacotes auxiliares por conta própria, e isso acaba sendo mais difícil do que incluir Novo recurso entregue na biblioteca monolítica.
Neste artigo, tentarei explorar várias maneiras de projetar mecanismos de extensibilidade que envolvam “extensibilidade em grande escala” e estabelecer novos pontos imediatamente para expansão futura.
ExtensibilidadeO que precisamos de um sistema extensível? Primeiro, é claro, você precisa da capacidade de criar novos comportamentos sobre o código externo.
No entanto, isso não é suficiente. Deixe-me divagar, falar sobre um problema estúpido que encontrei uma vez. Estou desenvolvendo um editor de texto. Em uma versão anterior do
editor de código, o cliente poderia
especificar a aparência de uma linha específica. Foi excelente - o usuário pode selecionar seletivamente esta ou aquela linha dessa maneira.
Além disso, se você tentar iniciar o layout de uma linha a partir de dois fragmentos de código independentes entre si, eles começarão a pisar um no outro. A segunda extensão, aplicada a uma linha específica, substitui as alterações feitas na primeira. Ou, se em algum momento posterior tentarmos excluir, através do primeiro código, as mudanças no design que fizemos com a ajuda dele, como resultado, substituiremos o design feito no segundo fragmento de código.
A solução foi permitir que o código fosse
adicionado (e
removido ) em vez de instalado, para que duas extensões pudessem interagir com a mesma linha sem interromper o trabalho uma da outra.
Em uma formulação mais geral, você precisa garantir que as extensões possam ser combinadas, mesmo se elas "não souberem" uma da outra - e para que não surjam conflitos durante suas interações.
Para que isso funcione, cada extensão deve ser exposta a qualquer número de agentes de uma só vez. A forma como cada um dos efeitos será processado varia de acordo com o caso específico. Aqui estão algumas estratégias que você pode achar úteis:
- Todas as alterações entram em vigor. Por exemplo, se adicionarmos uma classe CSS a um elemento ou exibirmos um widget em uma determinada posição no texto, essas duas coisas poderão ser feitas imediatamente. Obviamente, será necessário algum tipo de pedido: os widgets devem ser exibidos em uma sequência previsível e bem definida.
- Alterações alinhadas no pipeline. Um exemplo dessa abordagem é um manipulador que pode filtrar as alterações feitas em um documento antes que elas entrem em vigor. Cada manipulador recebe uma alteração feita pelo manipulador anterior, e o manipulador subsequente pode continuar essas modificações. A encomenda aqui não é crucial, mas pode ser significativa.
- A abordagem "primeiro a chegar, primeiro a ser servido". Essa abordagem é aplicável, por exemplo, com manipuladores de eventos. Cada manipulador tem a oportunidade de mexer no evento até que um dos manipuladores anuncie que tudo já está feito e, em seguida, o próximo manipulador, por sua vez, não será perturbado.
- Às vezes, é necessário selecionar apenas um valor, por exemplo, para determinar o valor de um parâmetro de configuração específico. Aqui, seria apropriado usar algum tipo de operador (por exemplo, lógico ou lógico e, mínimo, máximo) para reduzir a entrada potencial para um único valor. Por exemplo, um editor pode alternar para o modo somente leitura, se pelo menos uma extensão exigir, ou o tamanho máximo de um documento pode ser o mínimo de todos os valores fornecidos para esta opção.
Em muitas dessas situações, a ordem é importante. Aqui, quero dizer conformidade com a ordem em que os efeitos são aplicados, essa sequência deve ser controlada e previsível.
Essa é uma daquelas situações em que os sistemas de extensão imperativos geralmente não são muito bons, cuja operação depende de efeitos colaterais. Por exemplo, a operação
addEventListener
de um modelo DOM do
addEventListener
requer que os manipuladores de eventos sejam chamados na ordem em que estão registrados. Isso é normal se todas as chamadas forem controladas por um único sistema ou se a ordem das chamadas não for tão importante. No entanto, se você tiver muitos componentes de software que adicionam manipuladores independentemente um do outro, pode ser muito difícil prever quais serão chamados em primeiro lugar.
Abordagem simplesDeixe-me dar um exemplo concreto: primeiro apliquei uma estratégia tão modular ao desenvolver o ProseMirror, um sistema para editar rich text. O núcleo deste sistema em si é essencialmente inútil: ele se baseia em pacotes adicionais para descrever a estrutura dos documentos, vincular chaves e manter um histórico de cancelamentos. Embora seja um pouco difícil usar esse sistema, ele encontrou aplicativos em programas nos quais você precisa configurar itens que não são suportados pelos editores clássicos.
O mecanismo de extensão ProseMirror é relativamente simples. Ao criar um editor, uma única matriz de objetos conectados é especificada no código do cliente. Cada um desses objetos de plug-in pode afetar vários aspectos do editor e executar ações como adicionar bits de dados de estado ou manipular eventos da interface.
Todos esses aspectos foram projetados para trabalhar com uma matriz ordenada de valores de configuração usando uma das estratégias descritas acima. Por exemplo, quando você precisa especificar muitos dicionários com valores, a prioridade das seguintes instâncias de extensão para ligação de chave depende da ordem em que você especifica essas instâncias. A primeira extensão para ligação de teclas, sabendo o que fazer com esse pressionamento de tecla, é processada.
Geralmente, esse mecanismo acaba sendo bastante poderoso, e eles podem tirar proveito dele. Porém, mais cedo ou mais tarde, o sistema de extensão atinge tanta complexidade que se torna inconveniente usá-lo.
- Se o plug-in tiver muitos efeitos, você poderá apenas esperar que todos precisem da mesma prioridade em relação a outros plug-ins ou precise dividi-los em plug-ins menores para organizar adequadamente o pedido.
- Em geral, a organização de plug-ins se torna muito escrupulosa, porque nem sempre o usuário final sabe quais plug-ins podem interferir em quais outros plug-ins, se tiverem uma prioridade mais alta. Os erros cometidos nesses casos geralmente ocorrem apenas no tempo de execução, quando uma oportunidade específica é usada, portanto é fácil ignorar.
- Se os plug-ins forem criados com base em outros plug-ins, esse fato deve ser documentado e espero que os usuários não se esqueçam de incluir as dependências apropriadas (nessa etapa de pedido, quando necessário).
CodeMirror
versão 6 é uma versão reescrita do
editor de código com o mesmo nome. Neste projeto, tento desenvolver uma abordagem modular. Para fazer isso, preciso de um sistema de extensão mais expressivo. Vamos discutir alguns dos desafios com os quais tivemos que lidar ao projetar esse sistema.
EncomendarÉ fácil projetar um sistema que oferece controle total sobre o pedido de extensões. No entanto, é muito mais difícil projetar um sistema com o qual seja agradável trabalhar e que, ao mesmo tempo, permita combinar o código de várias extensões sem inúmeras intervenções da categoria "agora observe suas mãos".
Quando se trata de pedidos, eu realmente quero recorrer ao trabalho com valores de prioridade. Como exemplo, a propriedade CSS
z-index
indica o número da posição ocupada por este elemento na profundidade da pilha.
Como você pode ver no exemplo de valores ridiculamente grandes
z-index
, que às vezes são encontrados em folhas de estilo, essa maneira de indicar prioridade é problemática. O próprio módulo não sabe quais valores de prioridade outros módulos têm. As opções são apenas pontos no meio de um intervalo numérico não diferenciado. Você pode definir um valor enorme (ou profundamente negativo) para tentar alcançar as margens mais distantes desse espectro, mas o restante do trabalho se resume a adivinhações.
A situação pode ser melhorada um pouco se você definir um conjunto limitado de categorias de prioridade claramente definidas, para que as extensões possam caracterizar o "nível" geral de sua prioridade. Além disso, você precisará de uma certa maneira de quebrar os links dentro das categorias.
Agrupamento e desduplicaçãoComo mencionei acima, assim que você começar a confiar seriamente em extensões, poderá ocorrer uma situação em que algumas extensões usarão outras ao trabalhar. Se você gerenciar dependências manualmente, essa abordagem não será bem dimensionada; portanto, seria bom se você pudesse criar um grupo de extensões ao mesmo tempo.
No entanto, essa abordagem não apenas agrava ainda mais o problema de prioridade, mas também apresenta outro problema: muitas outras extensões podem depender de uma extensão específica e, se as extensões forem apresentadas como valores, pode ser que a mesma extensão seja carregada várias vezes. . Para alguns tipos de extensões, como manipuladores de eventos, isso é normal. Em outros casos, como com um histórico de cancelamentos e uma biblioteca de dicas de ferramentas, essa abordagem será um desperdício e pode até quebrar tudo.
Portanto, se permitirmos o layout das extensões, isso introduzirá alguma complexidade adicional em nosso sistema relacionada ao gerenciamento de dependências. Você deve reconhecer essas extensões que não devem ser duplicadas e fazer o download delas exatamente uma de cada vez.
No entanto, como na maioria dos casos as extensões podem ser configuradas e, portanto, nem todas as instâncias da mesma extensão serão exatamente iguais, não podemos apenas pegar uma instância e trabalhar com ela. Teremos que considerar uma fusão significativa de tais instâncias (ou relatar um erro se a fusão de interesse para nós for impossível).
ProjetoAqui, descreverei em termos gerais o que estamos fazendo no CodeMirror 6. Este é apenas um esboço, não uma Solução com Falha. É possível que esse sistema se desenvolva ainda mais quando a biblioteca estabilizar.
O principal primitivo usado nessa abordagem é chamado comportamento. Comportamentos são apenas os recursos que você pode desenvolver com extensões, especificando valores para elas. Um exemplo é o comportamento de um campo de estado, onde, com a ajuda de extensões, você pode adicionar novos campos, fornecendo uma descrição do campo. Outro exemplo é o comportamento de manipuladores de eventos em um navegador; Nesse caso, com a ajuda de extensões, podemos adicionar nossos próprios manipuladores.
Do ponto de vista do consumidor de comportamentos, os próprios comportamentos, configurados de certa maneira em uma instância específica do editor, fornecem uma sequência ordenada de valores e os valores que vêm antes têm uma prioridade mais alta. Cada comportamento tem um tipo e os valores fornecidos devem corresponder a esse tipo.
O comportamento é representado como um valor usado para declarar uma instância do comportamento e para acessar os valores que o comportamento possui. Existem vários comportamentos internos na biblioteca, mas o código externo pode definir seus próprios comportamentos. Por exemplo, na extensão que define o intervalo entre os números de linha, é possível definir um comportamento que permita que outro código adicione marcadores adicionais nesse intervalo.
Uma extensão é um valor que pode ser usado ao configurar o editor. Uma matriz de tais valores é passada durante a inicialização. Cada extensão é permitida em zero ou mais comportamentos.
Uma extensão tão simples pode ser considerada uma instância de comportamento. Se especificarmos um valor para o comportamento, o código nos retornará o valor da extensão que gera esse comportamento.
Uma sequência de extensões também pode ser agrupada em uma única extensão. Por exemplo, na configuração do editor para trabalhar com uma linguagem de programação específica, você pode acessar várias outras extensões - por exemplo, gramática para analisar e destacar texto, informações sobre o recuo necessário, uma fonte de preenchimento automático que exibirá corretamente as solicitações para concluir linhas nesse idioma. Assim, é possível criar uma extensão de idioma único na qual simplesmente coletamos todas essas extensões correspondentes e as agrupamos, resultando em um único valor.
Criando uma versão simples desse sistema, poderíamos parar com isso simplesmente alinhando todas as extensões aninhadas em uma matriz de extensões de comportamento. Então eles poderiam ser agrupados por tipo de comportamento e, em seguida, criar sequências ordenadas de valores de comportamento.
No entanto, resta lidar com a desduplicação e fornecer um melhor controle sobre os pedidos.
Os valores das extensões relacionadas ao terceiro tipo, extensões exclusivas, apenas ajudam a obter a redução de redundância. Extensões que não devem ser instanciadas duas vezes no mesmo editor são desse tipo. Para definir uma extensão, você precisa especificar o
tipo de especificação , ou seja, o tipo de valor de configuração esperado pelo construtor de extensão e também especificar
uma função de instanciação que obtenha uma matriz desses valores especificados e retorne a extensão.
Extensões exclusivas complicam o processo de resolver um conjunto de extensões em um conjunto de comportamentos. Se houver extensões exclusivas no conjunto alinhado de extensões, o mecanismo de resolução deverá selecionar o tipo de extensão exclusiva, coletar todas as suas instâncias e chamar a função de instanciação correspondente, juntamente com as especificações, e substituí-las todas pelo resultado (em uma única cópia).
(Há outro problema: eles devem ser resolvidos na ordem correta. Se você ativar primeiro a extensão exclusiva X, mas depois obter outro X como resultado da resolução, isso estará errado, pois todas as instâncias de X devem ser reunidas. Desde a função de instanciação Como a expansão é limpa, o sistema lida com essa situação por tentativa e erro, reiniciando o processo e registrando informações sobre o que foi possível estudar, estando nessa situação.)
Por fim, você precisa resolver o problema com as regras a seguir. A abordagem básica permanece a mesma: manter a ordem na qual as extensões foram propostas. As extensões compostas se alinham na mesma ordem no ponto em que ocorrem. O resultado da resolução de uma extensão exclusiva é inserido quando é ligado pela primeira vez.
No entanto, as extensões podem relacionar algumas de suas sub extensões com categorias que têm uma prioridade diferente. O sistema fornece quatro dessas categorias:
fallback (entra em vigor depois que outras coisas acontecem),
padrão (padrão),
extensão (prioridade mais alta que a maioria) e
substituição (provavelmente deve ocorrer primeiro). Na prática, a classificação é feita primeiro por categoria e depois pela posição inicial.
Portanto, uma extensão de ligação de chave, que tem baixa prioridade e um manipulador de eventos com prioridade regular, é baseado neles que está na moda obter uma extensão composta criada a partir do resultado de uma extensão de ligação de chave (não exigindo saber em que comportamento consiste) com um nível de prioridade de fallback e de uma instância com o comportamento do manipulador de eventos.
Essa abordagem, que permite combinar extensões sem pensar no que elas fazem "por dentro", parece ser uma grande conquista. As extensões que modelamos anteriormente neste artigo incluem: dois sistemas de análise que exibem o mesmo comportamento no nível de sintaxe, serviço de realce de sintaxe, serviço de indentação inteligente, histórico de cancelamentos, serviço de espaçamento entre linhas, colchetes de fechamento automático, ligação de teclas e seleção múltipla - todos funciona bem.
Existem vários novos conceitos que um usuário deve aprender para usar este sistema. Além disso, trabalhar com esse sistema é realmente um pouco mais complicado do que com os sistemas imperativos tradicionais adotados na comunidade JavaScript (chamamos um método para adicionar / remover um efeito). No entanto, se as extensões forem organizadas adequadamente, os benefícios disso superam os custos associados.