如何通过服务器端渲染方便地在Vue.js上进行开发

大家好!

我将从一些背景开始。

我决定在Vue.js上尝试新项目。 我需要服务器端渲染(SSR),CSS模块,代码拆分和其他功能。 当然,需要热重启(HMR)来提高开发效率。

我不想使用现成的解决方案,例如Nuxt.js,因为 当项目发展时,进行定制非常重要。 通常,任何高级解决方案都不允许这样做或付出任何努力,但要付出很大的努力(使用Next.js进行React也有类似的经验)。

使用服务器渲染和热重启时本地开发的主要问题是仅运行一个webpack-dev-server是不够的。 我们还必须对Node.js启动的源代码进行处理,否则,下次我们重新加载页面时,我们将获得未在服务器上更新但在客户端上更新的代码。

不幸的是,进入文档和Internet之后,我没有找到现成的可以正常工作的示例和模板。 因此,我创建了自己的。



我确定了模板应包含的内容,以便您可以轻松进行开发:

  • Vuejs
  • 固态继电器
  • 威克斯
  • CSS模块
  • 代码拆分
  • ESLint,更漂亮

通过本地开发,所有这些都应在浏览器中即时更新,并且服务器代码也应更新。

在生产模式下,应最小化包,应添加哈希值以缓存静态变量,应在html模板中自动设置包的路径。

所有这些都在GitHub上存储库中实现,我将提供代码并描述解决方案。

值得注意的是,Vue.js具有用于设置服务器渲染的非常全面的文档,因此在此处查找是有意义的。

服务器端


因此,我们将Express用作Node.js的服务器,我们还需要vue-server-renderer 。 这个包允许我们根据服务器包,html模板和客户端清单将代码呈现为html字符串,并在其中指明资源的名称和路径。

server.js文件最终将如下所示:

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

如您所见,我们使用2个文件: vue-ssr-server-bundle.jsonvue-ssr-client-manifest.json

它们是在构建应用程序时生成的。 第一个是将在服务器上执行的代码,第二个包含了资源的名称和路径。

另外,在createBundleRenderer选项中我们指定了inject:false参数。 这意味着将不会自动生成用于加载资源和其他内容的html代码,因为 我们需要完全的灵活性。 在模板中,我们将独立标记要显示此代码的位置。

模板本身将如下所示:

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

让我们更详细地考虑。

  • 需要meta.inject(),title.text()meta.inject()。meta.text()来显示标题和元描述。 vue-meta软件包对此负责,下面将对此进行讨论
  • renderResourceHints() -将返回rel =“ preload / prefetch”链接到客户端清单中指定的资源
  • renderStyles() -将返回指向客户端清单中指定的样式的链接
  • renderState() -将在窗口.__ INITIAL_STATE__中返回默认状态。
  • renderScripts() -将返回应用程序正常工作所需的脚本

除了注释,我们的应用程序的标记将被替换。 他是必需的。

Vue服务器端应用程序的入口点是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); }); 

客户部分


客户端入口点是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(); } 

app.js中 ,创建了我们的Vue实例,然后在服务器和客户端上使用它。

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

我们总是创建一个新实例,以避免多个请求使用同一实例的情况。

App.vue是根组件,它包含<router-view> </ router-view>指令,它将根据路由替换必要的组件。

路由器本身看起来像这样

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

通过Vue.use,我们连接了两个插件: RouterVueMeta
在路线中,组件本身不会直接显示,而是通过

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

这是用于代码拆分。

至于状态管理(由Vuex实现),其配置没什么特别的。 唯一的事情是,我将这一面分为模块,并使用带有名称的常量以使其更易于通过代码进行导航。

现在考虑一下Vue组件本身的一些细微差别。

metaInfo属性负责使用vue-meta包呈现元数据。 您可以指定大量的各种参数( 更多 )。

 metaInfo: { title: 'Main page', } 

组件具有仅在服务器端运行的方法。

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

另外,我想使用CSS模块。 我喜欢这个主意,因为您不必关心类的名称,以免组件之间重叠。 使用CSS模块,生成的类将类似于<class name> _ <hash>

为此,您需要在组件中指定样式模块

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

并在模板中指定属性:class

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

另外,有必要在webpack设置中指定我们将使用这些模块。

组装方式


让我们继续进行webpack设置。

我们有一个基本配置,该配置由服务器和客户端部件的配置继承。

 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; 

用于构建服务器代码的配置与文档中的配置相同 。 除了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]', }, }, }, ], }, }); 

最初,所有CSS处理都移至基本配置,因为 客户端和服务器上都需要它。 生产制度有一个缺点。
但是,我遇到了一个问题,该服务器原来是document ,因此发生了错误。 事实证明这是一个 mini-css-extract-plugin错误,该错误通过拆分服务器和客户端的CSS处理得以解决。

VueSSRServerPlugin生成vue-ssr-server-bundle.json文件 ,该文件指示在服务器上运行的代码。

现在考虑客户端配置。

 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; 

值得注意的是,在本地开发中,我们指定publicPath ,引用webpack-dev-server,并生成不带哈希的文件名。 另外,对于devServer,我们指定writeToDisk:true参数。

这里需要解释。

默认情况下, webpack-dev-server从RAM分配资源而不将其写入磁盘。 在这种情况下,我们面临的问题是,不相关的资源将在位于磁盘上的客户端清单( vue-ssr-client-manifest.json )中指示出来,因为 它不会被更新。 为了解决这个问题,我们告诉开发服务器将所做的更改写入磁盘,在这种情况下,客户端清单将被更新,必要的资源将被收紧。

实际上,将来我想摆脱这种情况。 一种解决方案是处女。 server.js中的 mode 连接清单不是来自/ dist目录,而是来自服务器url。 但是在这种情况下,它变为异步操作。 我很高兴在评论中有一个很好的解决方案。

Nodemon负责服务器端的重新加载 ,它监视两个文件: dist / vue-ssr-server-bundle.jsonapp / server.js,并在更改它们后重新启动应用程序。

为了能够在更改server.js时重新启动应用程序,我们没有将此文件指定为nodemon中的入口点,而是创建了一个连接server.jsnodemon.js文件。 并且nodemon.js文件成为入口点。

在生产模式下, app / server.js成为入口点。

结论


总计,我们有一个包含设置和几个团队的存储库。

对于本地发展:

 yarn run dev 

在客户端:它启动webpack-dev-server ,该服务器监视Vue组件和简单代码中的更改,生成带有开发服务器路径的客户端清单,将其保存到磁盘中,并在浏览器中即时更新代码和样式。

在服务器端:它以监视模式启动webpack ,收集服务器捆绑包( vue-ssr-server-bundle.json ),并在更改应用程序后重新启动。

在这种情况下,代码会自动在客户端和服务器上一致地更改。
在第一次启动时,可能会发生一个错误,即找不到服务器捆绑包。 这很正常。 只需要重启命令即可。

对于组装生产:

 yarn run build 

在客户端:收集并最小化js和css,在名称中添加一个哈希,并生成具有相对资源路径的客户端清单。

从服务器端:收集服务器捆绑包。

另外,我还创建了另一个yarn run start-node命令,该命令启动server.js ,但这只是作为示例,在生产应用程序中启动它是值得使用流程管理器(例如PM2)进行的。

我希望所描述的经验将有助于快速建立生态系统,以实现舒适的工作并专注于功能开发。

有用的链接


Source: https://habr.com/ru/post/zh-CN458396/


All Articles