Técnicas para um frontend dinâmico e com controle de acesso
Minha última experiência tem sido bastante peculiar. Acabei tendo de usar/desenvolver:
Ficou bastante coisa, um pouco complexo e com certeza um caso de over engineering, mas foi a forma que mais fez sentido e a que na minha cabeça ficou da melhor forma.
Vamos por parte explicando o setup, mas se quiser ver o resultado, é só ir lá no meu github.
Como é um projeto em React, utilizaremos o create-react-app
, a forma mais simples de começar um projeto React
Lembrando que Typescript é opcional, então pode omitir a flag --typescript
1
create-react-app react-app-multitenant --typescript
Como esse projeto é um fork da empresa, então o exemplo no git está com eject
, mas isso não é obrigatório e não vai interferir muito para você. Caso queira utilizar o import absoluto por dentro do seu projeto, você pode utilizar essa mesma configuração do github que já está tudo certo. Vale lembrar também que o build desse projeto está gerando os arquivos sem o hash para evitar cachê.
A criação do menu, das rotas e a técnica de code splitting ficaram quase que atreladas ao mesmo objeto. Olhando o menu.ts você irá ver uma property no objeto chamada component
que é uma função (profile: string, tenant?: string) => Promise<any>
. Melhor observar a tipagem dos meus itens do menu:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
/* O menu é constituído de um item principal que pode ou não ter sub itens. A property component é onde iremos fazer o import() para fazer funcionar o code splitting. Nele dizemos o componente e a rota (property path) que iremos renderizar para o usuário */ export type MenuItem = { component?: (profile: string, tenant?: string) => Promise<any>; key: string; path: string; icon: string; title: string; tenants: string[]; subItems: MenuSubItem[]; tenantEnv?: string; allowedProfiles: string[]; }; // Um sub item não pode ter sub itens, então iremos omitir este tipo export type MenuSubItem = Omit<MenuItem, "subItems">;
Bom, mas porque component
é uma função que recebe profile
e tenant
? Esses parâmetros são para que nós possamos construir o path do nosso componente de acordo com o perfil logado e o tenant acessado, assim iremos conseguir acessar nosso componente no diretório referente ao tenant e o arquivo de acordo com o perfil, por exemplo ../modules/users/xpto/admin.page
.
Com isso, temos parte do nosso code splitting funcionando, agora precisamos de um pouco de código para a criação das nossas rotas e o Suspense para o lazy loading dos componentes.
Para definir as rotas de forma dinâmica, dado um tenant e um perfil logado, foi necessário um hook
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
// Apenas um tipo para forçar o array de rotas ter os itens necessários para o react-router export type RouterConstruct = { path: string; component: React.LazyExoticComponent<React.ComponentType<any>>; }; // Aplicar o tenant como um caminho const path = (tenant: string) => `/${tenant}/`; // Verificar se o Item ou SubItem possuem uma property component e retornar o componente + rota const getComponentInfo = (item: MenuSubItem, aliasProfile: string) => { if (!!item.component) { const localImport = item.component(aliasProfile, path(TENANT)); return { path: item.path, component: lazy(() => localImport) }; } return null; }; const getProfileRoutes = (filterRoutes: MenuItem[], aliasProfile: string) => { // Essa é a minha lista com as rotas aplicadas no react-router const localRoutes = [] as RouterConstruct[]; filterRoutes.forEach((route) => { const component = getComponentInfo(route, aliasProfile); if (route.path !== "" && component !== null) { localRoutes.push(component); } route.subItems.forEach((subRoute) => { const subComponent = getComponentInfo(subRoute, aliasProfile); if (subComponent !== null) { localRoutes.push(subComponent); } }); }); return localRoutes; }; const useRoutesByProfile = () => { const filterRoutes = useRouteFilter(); const [routes, setRoutes] = useState([] as RouterConstruct[]); /* Toda vez que o filtro de rotas, dado o perfil e tenant mudar, execute a criação de rotas novamente */ useEffect(() => { const user = getAuthUser(); if (!isEmpty(user)) { setRoutes(getProfileRoutes(MenuItems, user.perfil)); } }, [filterRoutes]); return routes; }; export default useRoutesByProfile;
Quase tudo ok, agora só dizer ao react-router
quais são as nossas rotas, aplicar o suspense
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
const createRoute = (x: RouterConstruct) => ( <Route exact key={x.path} path={x.path} component={x.component} /> ); const AppRouter = () => { // Autenticação requerida em toda a aplicação useAuth(); // Hook para roteamento dinâmico const routes = useRoutesByProfile(); return ( <Router history={History}> {/* Error Boundary para caso hajam erros de importação dinâmicas e outros relacionado ao ciclo de vida do react (mais funcional em prod) */} <ErrorBoundary> {/* Este componente você pode ver no github, mas é basicamente um wrapper para o React.Suspense com um loader customizado */} <SuspenseLoader> <HashRouter> <Switch> {/* Mapeamento das rotas dinâmicas de acordo com os dados do nosso hook */} {routes.map(createRoute)} <Route component={NotFound} /> </Switch> </HashRouter> </SuspenseLoader> </ErrorBoundary> </Router> ); };
Feito isso, nosso controle de rotas está totalmente pronto, com as regras de Tenant e perfil aplicadas. Para fazer o funcionamento correto, você precisa fazer o import()
para o path correto e ter uma estrutura de pastas de acordo com os tenants e perfis disponíveis.
1 2 3 4 5 6 7 8 9 10
./src/modules ├── dashboard │ ├── abcd │ │ └── admin.page.tsx │ ├── xkcb │ │ └── admin.page.tsx │ └── xpto │ └── admin.page.tsx └── users └── index.page.tsx
E como faria caso não tenha perfil ou tenant específico? Apenas troque o import para o path exato do seu arquivo. Como a chamada do componente é uma função de dois parâmetros, você pode escolher por não utilizar e apenas retornar a string exata do seu componente.
Dessa forma, o seu frontend já está apto para trabalhar com code splitting. Deixo abaixo apenas um hack para caso seus assets estejam sendo servidos de um subdomínio diferente do qual o site está sendo acessado
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
const isDev = process.env.NODE_ENV === "development"; /* URL de acesso dos assets do frontend, diferente do qual você acessa. site: https://app.xpto.io/ */ export const URL_ACCESS = `https://xpto-industries.io/${TENANT}/dashboard/${VERSION}/`; export declare let __webpack_public_path__: string; if (!isDev) { /* Define onde o webpack irá fazer o load dos `chunks` com dynamic import */ __webpack_public_path__ = URL_ACCESS; }
Se vamos modularizar a aplicação, a parte do redux também precisa ser modularizada. Nossa store deverá ter métodos de injetar os reducers de acordo com a tela que estão sendo acessados, e substituir o atual estado do reducer pelo novo estado com os novos reducers injetados. Tomei como base a resposta do Dan Abramov no StackOverflow, e fiz algumas adaptações para Typescript e utilizando hooks. Você pode observar a forma de como criar uma store com suporte a injeção de novos reducers
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
type Middleware = StoreEnhancer< { dispatch: unknown; }, {} >; export type AsyncStore = Store<unknown, AnyAction> & { dispatch: unknown; asyncReducers: { [key: string]: Reducer<unknown, any>; }; injectReducer: (key: string, reducer: Reducer<any>) => AsyncStore; }; const redux = (asyncReducers: any = {}) => combineReducers({ // reducer de autenticação [ReducersAlias.AuthReducer]: authReducer, ...asyncReducers, }); /* Inicializar a store com os middlewares necessários sendo passados como parâmetro. Note que o `redux()` é uma função que pode receber ou não os reducers assíncronos. Após inserir-los na store, um .replaceReducer é invocado com os novos reducers e o novo estado já está pronto para ser utilizado */ const initializeStore = (middleware: Middleware) => { const store = createStore(redux(), middleware) as AsyncStore; store.asyncReducers = {}; store.injectReducer = (key: string, reducer: Reducer<unknown, any>) => { store.asyncReducers[key] = reducer; store.replaceReducer(redux(store.asyncReducers)); return store; }; return store; }; // src/page.tsx - Onde iremos criar nosso wraper do estado do Redux const sagasMiddleware = reduxSaga(); const middlewares = applyMiddleware(sagasMiddleware, logger); const reduxStore = store(middlewares); sagasMiddleware.run(sagasActions); ReactDOM.render( <StrictMode> <Provider store={reduxStore}> <AppRouter /> </Provider> </StrictMode>, document.getElementById("root") );
Isso é o primeiro passo para a construção da store com injeção de reducers assíncronos. Agora só nos resta criar uma forma de injetar os nossos reducers logo que nosso componente for carregado e precisar fazer o acesso
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
import { useEffect } from "react"; import { Reducer } from "redux"; import { useStore } from "react-redux"; // Essa é a store cujo o código foi mostrado anteriormente import { AsyncStore } from "@/store"; /* Uma modificação legal a ser feita é mudar o método injectReducer para receber um array `key: reducer` e poder acrescentar mais de um reducer na nossa store */ const useInjectReducer = (key: string, reducer: Reducer<any>) => { const store = useStore() as AsyncStore; useEffect(() => { store.injectReducer(key, reducer); }, []); };
E isso é tudo. Na hora de importar seu reducer, não esqueça de definir o estado inicial para o primeiro render não quebrar, pois nele não existirá o seu reducer ainda. E com isso os reducers serão acrescentados dinâmicamente ao nosso app.
Uma coisa que ainda estou testando é fazer a inserção de actions no sagas dinamicamente, seguindo a mesma lógica de um reducer dinâmico, com isso, removeremos todos os assets estáticos do nosso código, e apenas quando o usuário interagir com um módulo da aplicação ele será "ativado".
Suporte a SSR. Apesar de resolver o problema, essa solução é falha quando se trabalha com SSR pois o React Lazy/Suspense não dão suporte, então será necessário trocar a forma de fazer o code splitting para uma biblioteca que dê tal suporte
Realizar testes de performance na aplicação. Como estou mudando a store em alguns renders, é possível que isso apresente lentidões futuras. Será preciso testar
Apesar de parecer muito código, depois que se entende o que deve fazer, tudo fica bem mais claro. A experiência de desenvolvimento com essa prática tem sido bem aceitável, apenas alguns glitches acontecem quando algum componente é atualizado e a tela fica branca, mas nada que fosse realmente impactar.
Se você precisa de uma aplicação que seja modularizada, algo próximo de um micro frontend, essa é uma prática que pode ajudar. Apesar de tudo o que foi feito aqui ter sido com React, creio que o mindset funcione para qualquer outro framework, apenas suas ferramentas serão diferentes.
O over engineering foi aceito para tal solução, infelizmente ainda não vi uma forma de reduzir a quantidade absurda de voltas e alguns boilerplates que isso requer, mas é um preço a se pagar dado a necessidade.
E é isso galerinha, espero que isso possa ajudar você a dar uma clareada na mente e fique como material de pesquisa. Quaisquer problemas, posta uma issue lá no repositório que a gente troca uma ideia xD