Executando verificação de segurança...
1

Criando uma boa arquitetura Micro Front End

Introdução

Criei uma arquitetura micro front end com javascript vanilla para entender as dificuldades de implementar a arquitetura sem o uso de frameworks de apoio como o single-spa.

Servidor de desenvolvimento

Não implementei HMR (Hot Reload Replacement) devido a necessidade de uma camada extra de desenvolvimento, mas, implementei LR (Live Reload) o que ameniza a necesidade de atualizações manuais para visualizar as alterações realizadas nos componentes.

Utilizei como bundle o ESBUILD que proporciona grande facilidade de configuração das tasks necessárias para empacotar o sofwater tanto para o ambiente de desenvolvimento quanto para produção.

Todas as configurações estão no arquivo esbuild.config.js

Ferramentas auxiliares

Implantei algumas ferramentas para desenvolvedor que auxiliam na qualidade e facilidade de desenvolvimento.

  • EsLint
  • Prettier
  • JS Config
  • VSCode Editor Config
  • PNPM

As configurações estão na raiz do projeto nos seguintes arquivos:

.editorconfig
.eslintrc
.prettierrc
.prettierignore
.eslintrc
jsconfig.json

Abaixo descrevo sobre cada arquivo e sobre suas configurações:

Editor Config

As configurações são auto descritivas e proporcionam aos desenvolvedores regras de escrita homogêneas.

root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
max_line_length = 120

[*.md]
trim_trailing_whitespace = false

ESLint

As configurações são auto descritivas e proporcionam aos desenvolvedores regras de escrita homogêneas. No entanto, o ESLint também aplica um validações de estilo de código assim que o arquivo modificado é salvo e as saídas de validação podem ser acompanhadas no terminal e no próprio VSCode.

Além das configurações presentes no arquivo .eslintrc existem configurações presentes no arquivo package.json que completam as configurações de lint.

{
  "standard": {
    "ignore": [
      "/node_modules",
      "/dist",
      "/android",
      ".idea",
      "esbuild.config.js"
    ]
  },
  "lint-staged": {
    "*.js": [
      "standard",
      "prettier --write"
    ]
  },
  "devDependencies": {
    "esbuild": "^0.15.5",
    "esbuild-copy-files-plugin": "^1.1.0",
    "esbuild-plugin-eslint": "^0.1.1",
    "esbuild-plugin-path-alias": "^1.0.7",
    "esbuild-serve": "^1.0.1",
    "eslint": "^8.29.0",
    "eslint-plugin-fetch": "^0.0.1",
    "eslint-plugin-import": "^2.26.0",
    "lint-staged": "^13.1.0",
    "standard": "^17.0.0",
  }
}

Acima todas as configurações de lint presentes no package.json.

Através dos scripts definidos para inicializar a aplicação o linter é automaticamente aplicado, ou seja, não há necessidade de aplicar ações de lint manualmente.

{
  "scripts": {
    "lint": "standard --fix",
    "dev": "cross-env NODE_ENV=development node ./esbuild.config.js -w liverServer --cors",
    "build": "cross-env NODE_ENV=production node ./esbuild.config.js",
    "start": "pnpm run dev && pnpm run lint",
    "prod": "pnpm run build",
    "api": "json-server --watch api/db.json",
    "test": "echo \"Error: no test specified\" && exit 1"
  }
}

Um destaque importante, o script lint é acionado através do script start que por sua vez inicializa a aplicação.
O script de lint já aplica a correção automática de códigos fora do padrão, pois está sendo executado com a flag --fix.

Prettier

As configurações são auto descritivas e proporcionam aos desenvolvedores regras de escrita homogêneas que trabalham em conjunto com o linter e com as configurações presentes no arquivo .editorconfig.

Com o auxilio das automatizações do prettier, o desenvolvedor não precisa lembrar de aplicar essas validações/formatações manualmente.

JSConfig

O JSConfig foi aplicado com as regras descritas abaixo para simplificar e facilitar a importação de módulos.

{
  "compilerOptions": {
    "baseUrl": "./src",
    "paths": {
      "@/components/*": ["components/*"],
      "@/utils/*": ["utils/*"],
      "@/services/*": ["services/*"],
      "@/assets/*": ["assets/*"],
    }
  }
}

Com essas regras aplicadas podemos importar um componente de qualquer lugar e os caminhos de importação tornam-se muito mais simples e claros.

  import componentModule from '@/components/moduleName/componentName.js'

A notação é a mesma para importar outros tipos de módulos como utilitários, services ou assets.

Essa notação só funciona porque o ESBuild é capaz de entendê-las após configurado de acordo com o trecho abaixo:

import aliasPlugin from 'esbuild-plugin-path-alias'

const plugins = [
  //Código omitido
  aliasPlugin({
    '@/components': path.resolve(__dirname, './src/components'),
    '@/services': path.resolve(__dirname, './src/services'),
    '@/utils': path.resolve(__dirname, './src/utils'),
    '@/assets': path.resolve(__dirname, './src/assets')
  })
  //Código omitido
]

 const { rebuild, stop } = await build({
   plugins
   //Código omitido
 })

Estrutura de componentes/micro aplicativos

As recomendações são para que os APP's carregados através de uma estrutura MFE (Micro Front End) disponham dos métodos Mount, Unmount, Setup.

Nós desenvolvedores de software estamos bem familiarizados com as responsabilidades desses métodos, mas, para garantir total clareza de entendimento descrevo abaixo a responsabilidade de cada método.

  • Mount - Monta todos os recursos do app/component e o renderiza.
  • Unmount - Executa todas as operações pendentes e relacionadas com a remoção/destruição do componente.
  • Setup - Permite configurações opcionais como a passagem de propriedades/parametros para o app/component antes da montagem.

Componentes MVC

Como a intenção é transpor para o angular uma arquitetura de micro front end semelhante, decidi esturutrar os componentes no padrão MVC que é muito próximo do MVVM adotado pelo Angular.

Os componentes são integrados dos arquivos:

  • view.js - A View do componente encapsulando html e estilos css.
  • model.js - O modelo de dados do componente encapsulando o state.
  • controller.js - Os métodos do componente manipulando o state.
  • index.js - Um agregador que exporta um componente.

Abaixo descrevo como se parece cada arquivo de um componente iniciando pelo model.js.

Model

Um model se parece com o trecho em destaque abaixo:

export default (state) => {
  state.merge({})

  const getState = () => state.get()

  return { getState }
}

O método merge do state deve ser utilizado para definir um valor inicial para o state.

Através do método merge podemos definir o título que deve ser apresentado na view por exemplo.

  state.merge({ title: 'Initial View Title' })

Na view, o título seria acessado assim:

  const template ({ html, state }) => html`
    <div class="view-ctx">
      <h1> ${state.title} </h1>
    </div>    
  `

View

Uma View de componente MVC se parece com o trecho em destaque:

  const template = ({ state, html }) => html`
    <div class="view-ctx">
      <h1> View HTML </h1>
    </div>
  `

  const styles = ({ css }) => css`
    .view-ctx { 
      display: flex;
      justify-content: center;
      align-items: center;
      width: 100%;
    }

    .view-ctx > h1 {font-size: 1.2em }
  `

  export default ({ html, css, state }) => ({
    template: () => template({ state, html }),
    styles: () => styles({ css })
  })

Observe que tanto as estilizações CSS quanto a marcação HTML da view estão definidas por funções que retornam o conteúdo HTML e CSS a ser aplicado quando o componente é renderizado.

As tagged functions html e css são úteis para aplicar sintaxe highlight no VSCode e criar os elementos DOM pertinentes.

Tanto as funções html e css quanto o state são providos para o componente pela biblioteca de componentes reativos Terezzu que eu mesmo desenvolvi para criar essa POC.

Controller

Um controller de componente MVC se parece com o trecho em destaque:

export default ({ model }) => {

  const getTitle = () => model.getTitle()

  const setTitle = (payload) => model.setTitle(payload)

  return { getTitle, setTitle }
}

No model teríamos algo como:

export default (state) => {

  const getTitle = () => {
    const { title } = state.get()
    return title
  }

  const setTitle = ({ title }) => {
    const currentState = state.get()
    state.set({  ...currentState, title })
  }

  return { getTitle, setTitle }
}

E na view os métodos getTitle e setTitle do controller poderia ser acionados conforme segue:

  const template = ({ state, controller, html }) => html`
    <div class="view-ctx">
      <h1 onClick=${controller.setTitle({ title: 'Novo Título' })}> 
        ${state.getTitle() || state.title }
      </h1>
    </div>
  `

  export default ({ html, css, state, controller }) => ({
    template: () => template({ state, controller, html }),
  })

Como fica claro, a arquitetura MVC proposta aqui lembra a forma que um componente é utilizado nas principais estruras js/ts da atualidade.

É uma arquitetura bem simples e clara quanto aos conceitos minimalistas e responsabilidades de cada integrante do componente.

Agregador

Um agregador se parece com o trecho abaixo e tem a responsabilidade de definir o componente:

import { createComponent } from 'terezzu'

import model from './model'
import view from './view'
import controller from './controller'

const name = 'appMain'
const app = { name, model, view, controller }
const appMain = createComponent(app)

export { appMain }

Posteriormente o módulo agregador poderia ser utilizado da seguinte maneira em qualquer lugar da aplicação:

import { createApp, render } from 'terezzu'

import { appMain } from '@/components/appMain'

const bootstrapApp = createApp({
  appName: 'bootstrapApp',
  mount: () => {
    render(appMain)
  }
})

export { bootstrapApp }

No trecho acima foi criado um módulo do tipo aplicativo que apresenta o componente appMain como é comum na maioria dos frameworks SPA.

O bootstrapApp representa o módulo inicial que carrega e apresenta os primeiros compoentes a serem exibidos em uma aplicação.

Por fim, o aplicativo poderia ser inicializado através do arquivo index.html do projeto da seguinte forma:

<html>
  <head>
    <title>Terezzu - Hello</title>
  </head>
  <body>
    <div data-component="bootstrapApp"></div>

    <script src="main.js" type="module"></script>

    <script type="module">
      import { bootstrapApp } from '/main.js'
      bootstrapApp.mount()
    </script>

  </body>
</html>

Note que o trecho a seguir é responsável por importar o módulo principal e inicializar a aplicação;

  import { bootstrapApp } from '/main.js'
  bootstrapApp.mount()

Sistema de rotas

O sistema de rotas da aplicação é apenas um Array carregado de objetos javascript que detectam mudanças no URL através de expressões regulares.

definidas previamente as expressões regulares permitem identificar mudanças no path da url e renderizando os componentes pertinentes definidos em cada rota do sistema de rotas.

Exemplo:

import { appNotFound } from '@/components/notFound'

export const routes = [
  {
    regex: /^\/404$/,
    default: '#/404',
    mount: async () => ({
      component: appNotFound,
      children: null
    })
  }

Acima, temos a importação de um componente local da própria aplicação a qual pertence o sistema de rotas. Note a presença da chave default que define a rota '#/404' como padrão para casos de acessos a rotas desconhecidas à aplicação.

Importanto módulos ou aplicações independentes

No exemplo a seguir, dois módulos ou aplicativos independentes são importados utilizando a função import da linguagem javascript. Essa função permite a importação de módulos de forma assíncrona.

A importação dessas aplicações através do sistema de rotas caracteriza a composição de um sistema de Micro Front End (MFE).


export const routes = [
  {
    regex: /^#\/user\/viewer$/,
    start: '#/user/viewer',
    mount: async () => {
      const { appViewer: component } = await import('http://localhost:8083/main.js')
      return { component }
    }
  },
  {
    regex: /^#\/user\/creator$/,
    mount: async () => {
      const { appUser: component } = await import('http://localhost:8082/main.js')
      return { component }
    }
  }
]

No trecho de código destacado acima temos as seguintes propriedades de rota:

  • regex - Um RegExp objeto responsável por detectar a rota acionada.
  • start - Uma string que indica a rota inicial a ser carregada assim que a aplicação inicializa.
  • mount - Uma função assincrona capaz de carregar módulos assíncronamente com lazyloading e promises.

Ainda observando o código acima relembro que o um aplicativo ou componente pode ser montado e desmontado através dos métodos mount e unmount. Por tanto, para iniciar as aplicações (mfe) após a importação de seus módulos basta seguir como no trecho de código abaixo:

//código omitido
  {
    regex: /^#\/user\/creator$/,
    mount: async () => {
      const { appUser: component } = await import('http://localhost:8082/main.js')
      return { component }
    }
  }
//código omitido  

O módulo appUser foi importado e renomeado para component, logo em seguida o componente é retornado e nesse ponto o roteador assume controle sobre o módulo carregado preguiçosamente. Após assumir o controle do módulo componente, o roteador aciona o método mount do módulo.

  component.mount()

É dessa forma que hoje as melhores aplicações MFE estão sendo estruturadas.

Essa arquitetura elimina condições obscuras e duvidosas e torna fácil a composição de aplicações ao mesmo tempo que aprimora o baixo acoplamento entre essas aplicações.

Transposição da arquitetura para Angular

Tudo que é feito em Javascript é 100% compatível com o que é escrito em Typescript, portanto, tudo o que foi demostrado até esse ponto, pode ser fácilmente transcrito para o ambiente Angular com eficiência e segurança.

Um ponto de preocupação muito comum são as dependências comuns entre aplicações e geralemente quando usando webpack com module federation algumas configurações são adotadas para facilitar a diminuição do bundle final.

Ao adotar a arquitetura sugerida acima com esbuild e demais configuraçẽos previamente apresentadas, driblamos essa necessidade de gerenciar as dependências das multiplas aplicações afim de definir um bundle pequeno.

A arquitetura trata a questão levando em consideração que cada aplicação deve gerir independentemente suas dependências e que os módulos dessas aplicações devem ser carregados preguisoçamente com lazyloading em tempo de execução.

A prática descrita no parágrafo acima melhora muito a usabilidade da aplicação construída em MFE, já que reduz drásticamente o tempo de carregamento da aplicação como um todo.

Biblioteca Terezzu:

Repositórios da POC:

Artigos pertinentes

  1. https://microfrontends.info/microfrontends/

  2. https://martinfowler.com/articles/micro-frontends.html

  3. https://www.zenvia.com/blog/developers/micro-frontends/

  4. https://levelup.gitconnected.com/microfrontends-with-single-spa-8370f1396f3a

  5. https://www.linkedin.com/pulse/creating-single-spa-microfrontend-application-using-rany/

  6. https://www.linkedin.com/pulse/understanding-single-spa-roothost-rany-elhousieny-phd%E1%B4%AC%E1%B4%AE%E1%B4%B0/

Carregando publicação patrocinada...