Comment j'ai rendu le développement sur Vue.js pratique avec le rendu côté serveur

Bonjour à tous!

Je vais commencer par un petit historique.

J'ai décidé d'essayer mon nouveau projet sur Vue.js. J'avais besoin de rendu côté serveur (SSR), de modules CSS, de fractionnement de code et d'autres goodies. Bien sûr, un redémarrage à chaud (HMR) était nécessaire pour augmenter la productivité du développement.

Je ne voulais pas utiliser de solutions prêtes à l'emploi, telles que Nuxt.js, car lorsque le projet se développe, il est important de pouvoir le personnaliser. Et toutes les solutions de haut niveau, en règle générale, ne permettent pas de faire cela, ni de donner, mais avec de grands efforts (il y a eu une expérience similaire en utilisant Next.js pour React).

Le principal problème du développement local lors de l'utilisation du rendu de serveur et d'un redémarrage à chaud était qu'il ne suffisait pas d'exécuter un serveur webpack-dev-server . Nous devons également faire quelque chose avec les sources que Node.js lance, sinon la prochaine fois que nous rechargerons la page, nous obtiendrons du code qui n'a pas été mis à jour sur le serveur mais mis à jour sur le client.

Après avoir plongé dans la documentation et Internet, je n'ai malheureusement pas trouvé d'exemples et de modèles prêts à l'emploi. Par conséquent, j'ai créé le mien.



J'ai déterminé la composition de mon modèle afin que vous puissiez mener un développement confortable:

  • Vuejs
  • SSR
  • Vuex
  • Modules CSS
  • Fractionnement de code
  • ESLint, Prettier

Avec le développement local, tout cela devrait être mis à jour dans le navigateur à la volée, et le code du serveur devrait également être mis à jour.

En mode production, les bundles doivent être minifiés, un hachage doit être ajouté pour la mise en cache des statiques, les chemins d'accès aux bundles doivent être automatiquement définis dans le modèle html.

Tout cela est implémenté dans le référentiel sur GitHub , je vais fournir le code et décrire les solutions.

Il convient de noter que Vue.js possède une documentation assez complète pour configurer le rendu du serveur, il est donc logique de regarder là-bas.

Côté serveur


Donc, nous utiliserons Express comme serveur pour Node.js, nous avons également besoin de vue-server-renderer . Ce package nous permettra de rendre le code dans une chaîne html, basée sur le bundle serveur, le modèle html et le manifeste client, dans lequel les noms et le chemin d'accès aux ressources sont indiqués.

Le fichier server.js ressemblera finalement à ceci:

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); 

Comme vous pouvez le voir, nous utilisons 2 fichiers: vue-ssr-server-bundle.json et vue-ssr-client-manifest.json .

Ils sont générés lors de la génération de l'application; dans le premier est le code qui sera exécuté sur le serveur, le second contient les noms et les chemins d'accès aux ressources.

De plus, dans les options createBundleRenderer, nous avons spécifié le paramètre inject : false . Cela signifie qu'il n'y aura pas de génération automatique de code html pour le chargement des ressources et d'autres choses, car nous avons besoin d'une flexibilité totale. Dans le modèle, nous marquerons indépendamment les endroits où nous voulons afficher ce code.

Le modèle lui-même ressemblera à ceci:

 <!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> 

Examinons plus en détail.

  • meta.inject (). title.text () et meta.inject (). meta.text () sont nécessaires pour afficher les en-têtes et les méta descriptions. Le paquet vue-meta en est responsable, dont je parlerai ci-dessous
  • renderResourceHints () - renverra les liens rel = "preload / prefetch" vers les ressources spécifiées dans le manifeste client
  • renderStyles () - renverra des liens vers les styles spécifiés dans le manifeste client
  • renderState () - retournera l'état par défaut dans la fenêtre .__ INITIAL_STATE__
  • renderScripts () - retournera les scripts nécessaires au fonctionnement de l'application

Au lieu d'un commentaire, le balisage de notre application sera remplacé. Il est requis.

Le point d'entrée de notre application côté serveur Vue est le fichier 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); }); 

Partie client


Le point d'entrée côté client est le fichier 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(); } 

Dans app.js , notre instance Vue est créée, qui est ensuite utilisée à la fois sur le serveur et sur le client.

 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 }; } 

Nous créons toujours une nouvelle instance pour éviter une situation où plusieurs requêtes utilisent la même instance.

App.vue est le composant racine, qui contient la directive <router-view> </router-view> , qui remplacera les composants nécessaires, selon l'itinéraire.

Le routeur lui-même ressemble à ceci

 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') }, ], }); } 

Grâce à Vue.use, nous connectons deux plugins: Router et VueMeta .
Dans les itinéraires, les composants eux-mêmes ne sont pas indiqués directement, mais

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

C'est pour le fractionnement de code.

Quant à la gestion des états (implémentée par Vuex), sa configuration n'a rien de spécial. La seule chose est que j'ai divisé le côté en modules et que j'utilise des constantes avec un nom pour faciliter la navigation par code.

Considérez maintenant quelques nuances dans les composants Vue eux-mêmes.

La propriété metaInfo est responsable du rendu des métadonnées à l'aide du package vue-meta . Vous pouvez spécifier un grand nombre de paramètres différents ( plus ).

 metaInfo: { title: 'Main page', } 

Les composants ont une méthode qui s'exécute uniquement côté serveur.

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

Aussi, je voulais utiliser des modules CSS. J'aime l'idée lorsque vous n'avez pas à vous soucier du nom des classes afin de ne pas chevaucher les composants. En utilisant des modules CSS, la classe résultante ressemblera à <nom de classe> _ <hash> .

Pour ce faire, vous devez spécifier un module de style dans le composant.

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

Et dans le modèle, spécifiez l'attribut : class

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

De plus, il est nécessaire de spécifier dans les paramètres du webpack que nous utiliserons les modules.

Assemblage


Passons aux paramètres du webpack eux-mêmes.

Nous avons une configuration de base héritée de la configuration pour les parties serveur et client.

 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; 

La configuration pour la construction du code serveur n'est pas différente de celle de la documentation . Sauf pour la gestion 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]', }, }, }, ], }, }); 

Au début, tout le traitement CSS a été déplacé vers la configuration de base, car il est nécessaire à la fois sur le client et sur le serveur. Il y a eu une minification du régime de production.
Cependant, j'ai rencontré un problème que le serveur s'est avéré être un document et, en conséquence, une erreur s'est produite. Cela s'est avéré être une erreur de mini-css-extract-plugin qui a été corrigée en divisant le traitement CSS pour le serveur et le client.

VueSSRServerPlugin génère le fichier vue-ssr-server-bundle.json , qui indique le code qui s'exécute sur le serveur.

Considérez maintenant la configuration client.

 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; 

À partir du remarquable, dans le développement local, nous spécifions publicPath , référençant webpack-dev-server et générons le nom de fichier sans hachage. De plus, pour devServer, nous spécifions le paramètre writeToDisk: true .

Une explication est nécessaire ici.

Par défaut, webpack-dev-server distribue les ressources de la RAM sans les écrire sur le disque. Dans ce cas, nous sommes confrontés au problème que dans le manifeste client ( vue-ssr-client-manifest.json ), qui se trouve sur le disque, des ressources non pertinentes seront indiquées, car Il ne sera pas mis à jour. Pour contourner ce problème, nous demandons au serveur de développement d'écrire les modifications sur le disque, auquel cas le manifeste client sera mis à jour et les ressources nécessaires seront resserrées.

En fait, à l'avenir, je veux m'en débarrasser. Une solution est chez les jeunes filles. dans server.js pour connecter le manifeste non pas depuis le répertoire / dist , mais depuis l'url du serveur. Mais dans ce cas, cela devient une opération asynchrone. Je serai heureux d'avoir une bonne solution au problème dans les commentaires.

Nodemon est responsable du rechargement côté serveur, qui surveille deux fichiers: dist / vue-ssr-server-bundle.json et app / server.js et redémarre l'application lorsqu'ils changent.

Pour pouvoir redémarrer l'application lors du changement de server.js , nous ne spécifions pas ce fichier comme point d'entrée dans nodemon , mais créons un fichier nodemon.js auquel nous connectons server.js . Et le fichier nodemon.js devient le point d'entrée.

En mode production, app / server.js devient le point d'entrée.

Conclusion


Au total, nous avons un référentiel avec des paramètres et plusieurs équipes.

Pour le développement local:

 yarn run dev 

Côté client: il lance webpack-dev-server , qui surveille les changements dans les composants Vue et le code simple, génère un manifeste client avec des chemins vers le serveur dev, l'enregistre sur le disque et met à jour le code, les styles à la volée dans le navigateur.

Côté serveur: il démarre le webpack en mode surveillance, collecte le bundle serveur ( vue-ssr-server-bundle.json ) et redémarre l'application lors de sa modification.

Dans ce cas, le code change automatiquement sur le client et le serveur automatiquement.
Au premier démarrage, une erreur peut se produire lorsque le groupe de serveurs est introuvable. C'est normal. Il suffit de redémarrer la commande.

Pour la production d'assemblage:

 yarn run build 

Côté client: collecte et minimise js et css, ajoute un hachage au nom et génère un manifeste client avec des chemins de ressources relatifs.

Côté serveur: collecte un ensemble de serveurs.

En outre, j'ai créé une autre commande start-node de Yarn Run , qui démarre server.js , mais cela ne se fait qu'à titre d'exemple, dans une application de production, il vaut la peine d'utiliser des gestionnaires de processus, par exemple, PM2.

J'espère que l'expérience décrite aidera à mettre en place rapidement l'écosystème pour un travail confortable et à se concentrer sur le développement des fonctionnalités.

Liens utiles


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


All Articles