Como eu tornei o desenvolvimento no Vue.js conveniente com a renderização no servidor

Olá pessoal!

Vou começar com um pouco de experiência.

Decidi experimentar meu novo projeto no Vue.js. Eu precisava de renderização do lado do servidor (SSR), módulos CSS, divisão de código e outras vantagens. Obviamente, era necessária uma reinicialização a quente (HMR) para aumentar a produtividade do desenvolvimento.

Eu não queria usar soluções prontas, como o Nuxt.js, porque quando o projeto cresce, é importante poder personalizar. E, em regra, qualquer solução de alto nível não permite, ou dá, mas com grandes esforços (houve uma experiência semelhante usando o Next.js. para o React).

O principal problema do desenvolvimento local ao usar a renderização do servidor e uma reinicialização a quente era que não era suficiente executar um servidor webpack-dev- . Também devemos fazer algo com as fontes que o Node.js inicia, caso contrário, na próxima vez que recarregarmos a página, obteremos código que não foi atualizado no servidor, mas no cliente.

Tendo mergulhado na documentação e na Internet, infelizmente, não encontrei exemplos e modelos prontos a funcionar adequadamente. Portanto, eu criei o meu.



Eu determinei em que meu modelo deve consistir para que você possa liderar um desenvolvimento confortável:

  • Vuejs
  • SSR
  • Vuex
  • Módulos CSS
  • Divisão de código
  • ESLint, Prettier

Com o desenvolvimento local, tudo isso deve ser atualizado no navegador rapidamente e o código do servidor também deve ser atualizado.

No modo de produção, os pacotes configuráveis ​​devem ser minificados, um hash deve ser adicionado para a estática de cache, os caminhos para os pacotes configuráveis ​​devem ser definidos automaticamente no modelo html.

Tudo isso é implementado no repositório do GitHub , fornecerei o código e descreverei as soluções.

Vale a pena notar que o Vue.js possui uma documentação bastante abrangente para configurar a renderização do servidor, por isso faz sentido procurar lá.

Lado do servidor


Portanto, usaremos o Express como servidor do Node.js, também precisamos do vue-server-renderer . Este pacote nos permite renderizar o código em uma string html, com base no pacote do servidor, no modelo html e no manifesto do cliente, no qual os nomes e o caminho para os recursos são indicados.

O arquivo server.js ficará assim:

const path = require('path'); const express = require('express'); const { createBundleRenderer } = require('vue-server-renderer'); const template = require('fs').readFileSync( path.join(__dirname, './templates/index.html'), 'utf-8', ); const serverBundle = require('../dist/vue-ssr-server-bundle.json'); const clientManifest = require('../dist/vue-ssr-client-manifest.json'); const server = express(); const renderer = createBundleRenderer(serverBundle, { //           ,     runInNewContext: false, template, clientManifest, inject: false, }); //         nginx server.use('/dist', express.static(path.join(__dirname, '../dist'))); server.get('*', (req, res) => { const context = { url: req.url }; renderer.renderToString(context, (err, html) => { if (err) { if (+err.message === 404) { res.status(404).end('Page not found'); } else { console.log(err); res.status(500).end('Internal Server Error'); } } res.end(html); }); }); server.listen(process.env.PORT || 3000); 

Como você pode ver, usamos 2 arquivos: vue-ssr-server-bundle.json e vue-ssr-client-manifest.json .

Eles são gerados quando o aplicativo é construído; no primeiro, o código que será executado no servidor, o segundo contém os nomes e caminhos dos recursos.

Além disso, nas opções createBundleRenderer, especificamos o parâmetro inject: false . Isso significa que não haverá geração automática de código html para carregar recursos e outras coisas, porque precisamos de flexibilidade completa. No modelo, marcaremos independentemente os lugares onde queremos exibir esse código.

O próprio modelo terá a seguinte aparência:

 <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> {{{ meta.inject().title.text() }}} {{{ meta.inject().meta.text() }}} {{{ renderResourceHints() }}} {{{ renderStyles() }}} </head> <body> <div id="app"><!--vue-ssr-outlet--></div> {{{ renderState() }}} {{{ renderScripts() }}} </body> </html> 

Vamos considerar em mais detalhes.

  • meta.inject (). title.text () e meta.inject (). meta.text () são necessários para exibir cabeçalhos e meta descrições. O pacote vue-meta é responsável por isso, sobre o qual discutirei abaixo
  • renderResourceHints () - retornará links rel = "preload / prefetch" para os recursos especificados no manifesto do cliente
  • renderStyles () - retornará links para estilos especificados no manifesto do cliente
  • renderState () - retornará o estado padrão na janela .__ INITIAL_STATE__
  • renderScripts () - retornará os scripts necessários para o aplicativo funcionar

Em vez de um comentário, a marcação do nosso aplicativo será substituída. Ele é requerido.

O ponto de entrada para o aplicativo do servidor Vue é o arquivo entry-server.js .

 import { createApp } from './app'; export default context => new Promise((resolve, reject) => { //      Vue const { app, router, store } = createApp(); // $meta - ,   vue-meta   Vue const meta = app.$meta(); //      router.push(context.url); //  -  ,      context.meta = meta; router.onReady(() => { context.rendered = () => { //    ,     ,  window.__INITIAL_STATE__ context.state = store.state; }; const matchedComponents = router.getMatchedComponents(); //     if (!matchedComponents.length) { return reject(new Error(404)); } return resolve(app); }, reject); }); 

Parte do cliente


O ponto de entrada do lado do cliente é o arquivo entry-client.js .

 import { createApp } from './app'; const { app, router, store } = createApp(); router.onReady(() => { if (window.__INITIAL_STATE__) { //    ,     store.replaceState(window.__INITIAL_STATE__); } app.$mount('#app'); }); //    HMR  ,  webpack-dev-server     hot if (module.hot) { const api = require('vue-hot-reload-api'); const Vue = require('vue'); api.install(Vue); if (!api.compatible) { throw new Error( 'vue-hot-reload-api is not compatible with the version of Vue you are using.', ); } module.hot.accept(); } 

No app.js , nossa instância do Vue é criada, que é usada no servidor e no cliente.

 import Vue from 'vue'; import { sync } from 'vuex-router-sync'; import { createRouter } from './router'; import { createStore } from './client/store'; import App from './App.vue'; export function createApp() { const router = createRouter(); const store = createStore(); sync(store, router); const app = new Vue({ router, store, render: h => h(App), }); return { app, router, store }; } 

Sempre criamos uma nova instância para evitar uma situação em que várias solicitações usam a mesma instância.

App.vue é o componente raiz, que contém a diretiva <router-view> </router-view> , que substituirá os componentes necessários, dependendo da rota.

O roteador em si se parece com isso

 import Vue from 'vue'; import Router from 'vue-router'; import VueMeta from 'vue-meta'; import routes from './routes'; Vue.use(Router); Vue.use(VueMeta); export function createRouter() { return new Router({ mode: 'history', routes: [ { path: routes.pages.main, component: () => import('./client/components/Main.vue') }, { path: routes.pages.about, component: () => import('./client/components/About.vue') }, ], }); } 

Através do Vue.use , conectamos dois plugins: Router e VueMeta .
Nas rotas, os próprios componentes não são indicados diretamente, mas através de

 () => import('./client/components/About.vue') 

Isto é para divisão de código.

Quanto ao gerenciamento de estado (implementado pela Vuex), sua configuração não é nada de especial. A única coisa é que dividi o lado em módulos e usei constantes com um nome para facilitar a navegação por código.

Agora considere algumas nuances nos componentes do Vue.

A propriedade metaInfo é responsável por renderizar metadados usando o pacote vue-meta . Você pode especificar um grande número de vários parâmetros ( mais ).

 metaInfo: { title: 'Main page', } 

Os componentes têm um método que é executado apenas no lado do servidor.

 serverPrefetch() { console.log('Run only on server'); } 

Além disso, eu queria usar módulos CSS. Gosto da ideia de quando você não precisa se preocupar com o nome das classes para não se sobrepor entre os componentes. Usando módulos CSS, a classe resultante será semelhante a <nome da classe> _ <hash> .

Para fazer isso, você precisa especificar um módulo de estilo no componente.

 <style module> .item { padding: 3px 0; } .controls { margin-top: 12px; } </style> 

E no modelo, especifique o atributo : class

 <div :class="$style.item"></div> 

Além disso, é necessário especificar nas configurações do webpack que usaremos os módulos.

Assembléia


Vamos seguir para as configurações do webpack.

Temos uma configuração básica que é herdada pela configuração para as partes do servidor e do cliente.

 const webpack = require('webpack'); const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); const merge = require('webpack-merge'); const VueLoaderPlugin = require('vue-loader/lib/plugin'); const isProduction = process.env.NODE_ENV === 'production'; let config = { mode: isProduction ? 'production' : 'development', module: { rules: [ { test: /\.vue$/, loader: 'vue-loader', }, { test: /\.js$/, loader: 'babel-loader', exclude: file => /node_modules/.test(file) && !/\.vue\.js/.test(file), }, { test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, use: { loader: 'url-loader', options: { limit: 10000, name: 'images/[name].[hash:8].[ext]', }, }, }, ], }, plugins: [ new VueLoaderPlugin(), new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), }), ], }; if (isProduction) { config = merge(config, { optimization: { minimizer: [new OptimizeCSSAssetsPlugin(), new UglifyJsPlugin()], }, }); } module.exports = config; 

A configuração para a construção do código do servidor não é diferente da da documentação . Exceto para manipulação de CSS.

 const merge = require('webpack-merge'); const nodeExternals = require('webpack-node-externals'); const VueSSRServerPlugin = require('vue-server-renderer/server-plugin'); const baseConfig = require('./webpack.base.js'); module.exports = merge(baseConfig, { entry: './app/entry-server.js', target: 'node', devtool: 'source-map', output: { libraryTarget: 'commonjs2', }, externals: nodeExternals({ whitelist: /\.css$/, }), plugins: [new VueSSRServerPlugin()], module: { rules: [ { test: /\.css$/, loader: 'css-loader', options: { modules: { localIdentName: '[local]_[hash:base64:8]', }, }, }, ], }, }); 

No início, todo o processamento CSS foi movido para a configuração base, porque é necessário no cliente e no servidor. Havia uma minificação para o regime de produção.
No entanto, encontrei um problema que o servidor acabou sendo um documento e, consequentemente, ocorreu um erro. Esse foi um erro de mini-css-extract-plugin que foi corrigido dividindo o processamento CSS para o servidor e o cliente.

O VueSSRServerPlugin gera o arquivo vue-ssr-server-bundle.json , que indica o código que é executado no servidor.

Agora considere a configuração do cliente.

 const webpack = require('webpack'); const merge = require('webpack-merge'); const VueSSRClientPlugin = require('vue-server-renderer/client-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const path = require('path'); const baseConfig = require('./webpack.base.js'); const isProduction = process.env.NODE_ENV === 'production'; let config = merge(baseConfig, { entry: ['./app/entry-client.js'], plugins: [new VueSSRClientPlugin()], output: { path: path.resolve('./dist/'), filename: '[name].[hash:8].js', publicPath: '/dist/', }, module: { rules: [ { test: /\.css$/, use: [ isProduction ? MiniCssExtractPlugin.loader : 'vue-style-loader', { loader: 'css-loader', options: { modules: { localIdentName: '[local]_[hash:base64:8]', }, }, }, ], }, ], }, }); if (!isProduction) { config = merge(config, { output: { filename: '[name].js', publicPath: 'http://localhost:9999/dist/', }, plugins: [new webpack.HotModuleReplacementPlugin()], devtool: 'source-map', devServer: { writeToDisk: true, contentBase: path.resolve(__dirname, 'dist'), publicPath: 'http://localhost:9999/dist/', hot: true, inline: true, historyApiFallback: true, port: 9999, headers: { 'Access-Control-Allow-Origin': '*', }, }, }); } else { config = merge(config, { plugins: [ new MiniCssExtractPlugin({ filename: '[name].[hash:8].css', }), ], }); } module.exports = config; 

Do notável, no desenvolvimento local, especificamos publicPath , referenciando o webpack-dev-server, e geramos o nome do arquivo sem hash. Além disso, para o devServer , especificamos o parâmetro writeToDisk: true .

Uma explicação é necessária aqui.

Por padrão, o webpack-dev-server distribui recursos da RAM sem gravá- los no disco. Nesse caso, enfrentamos o problema de que no manifesto do cliente ( vue-ssr-client-manifest.json ), localizado no disco, serão indicados recursos irrelevantes, porque Não será atualizado. Para contornar isso, pedimos ao servidor de desenvolvimento que grave as alterações no disco. Nesse caso, o manifesto do cliente será atualizado e os recursos necessários serão reforçados.

De fato, no futuro, quero me livrar disso. Uma solução está em donzelas. no server.js para conectar o manifesto não no diretório / dist , mas no URL do servidor. Mas, neste caso, torna-se uma operação assíncrona. Ficarei feliz em ter uma boa solução para o problema nos comentários.

O Nodemon é responsável pelo recarregamento no servidor, que monitora dois arquivos: dist / vue-ssr-server-bundle.json e app / server.js e reinicia o aplicativo quando eles mudam.

Para poder reiniciar o aplicativo ao alterar o server.js , não especificamos esse arquivo como um ponto de entrada no nodemon , mas criamos um arquivo nodemon.js no qual conectamos o server.js . E o arquivo nodemon.js se torna o ponto de entrada.

No modo de produção, app / server.js se torna o ponto de entrada.

Conclusão


No total, temos um repositório com configurações e várias equipes.

Para desenvolvimento local:

 yarn run dev 

No lado do cliente: lança o webpack-dev-server , que monitora alterações nos componentes do Vue e no código simples, gera um manifesto do cliente com caminhos para o servidor dev, salva-o em disco e atualiza o código e os estilos em tempo real no navegador.

No lado do servidor: inicia o webpack no modo de monitoramento, coleta o pacote do servidor ( vue-ssr-server-bundle.json ) e reinicia o aplicativo quando é alterado.

Nesse caso, o código muda constantemente no cliente e no servidor automaticamente.
Na primeira inicialização, pode ocorrer um erro que o pacote configurável do servidor não foi encontrado. Isso é normal. Só precisa reiniciar o comando.

Para produção de montagem:

 yarn run build 

No lado do cliente: coleta e minimiza js e css, adicionando um hash ao nome e gera um manifesto do cliente com caminhos de recursos relativos.

Do lado do servidor: coleta um pacote de servidores.

Além disso, criei outro comando start-node do yarn run , que inicia o server.js ; no entanto, isso é feito apenas como exemplo, em um aplicativo de produção para iniciá-lo, vale a pena usar gerenciadores de processos, por exemplo, PM2.

Espero que a experiência descrita ajude a configurar rapidamente o ecossistema para um trabalho confortável e se concentrar no desenvolvimento da funcionalidade.

Links úteis


Source: https://habr.com/ru/post/pt458396/


All Articles