Vue projects are rendered using SSR servers

Posted by MelbourneFL on Sat, 05 Mar 2022 03:29:15 +0100

Note: This is an article used by the author to record his own learning of SSR and sort out the process of server rendering of a project. This article is only the understanding of the reader according to the demo. If you want to learn how to deploy SSR through this article, the author suggests you consult other more authoritative materials for learning; Of course, if you find any inappropriate description in this article, please also point out the correction.

I would like to thank the author for his indispensable help in learning the following articles:
On server side rendering (SSR)
On Bundle in Vue SSR
[VueSSR Series II] interpretation of the processing flow of clientManifest and bundle

1. What is server rendering?

We now have the same project. When accessing port 8000, we are client rendering, and when accessing port 3333, we are server rendering.
http://localhost:8000/footer

http://localhost:3333/footer

From the display of the page, there is no difference, but if we look at the source code, we will find a great difference:
view-source:http://localhost:8000/header , this is the source code of the page we visited without server rendering:

view-source:http://localhost:3333/footer , this is the source code of the page we visit the server rendering:

1.1 server rendering vs client rendering

1.1.1 advantages of server-side rendering (SSR)

From these two source codes, we can know that the source code returned to the browser after server rendering already contains the node information in our page, that is, the web crawler can grab the complete page information. Therefore, server rendering is more conducive to SEO;
The rendering of the first screen is the html string sent from node without relying on js files, which is more conducive to the first screen rendering, which will enable users to see the web content faster. Especially for large single page applications, the packaged file volume is relatively large, the copper breaking client takes a long time to load all the files, and there will be a long white screen time on the home page.

1.1.2 limitations of server-side rendering

  1. Higher server load:
    In the traditional mode, the rendering is completed through the client, and now it is unified to the server node. Especially in the case of high concurrent access, it will occupy a lot of server CPU resources
  2. Restricted development environment:
    In server-side rendering, only the life cycle hooks before componentDidMount will be executed, so the third-party libraries referenced by the project can not use other life cycle hooks, which greatly limits the selection of reference libraries
    Note that this does not mean that we cannot use other lifecycle hook functions, which means that only beforeCreate and created will be called during server-side rendering (SSR). This means that the code in any other lifecycle hook function (such as beforeMount or mounted) will only be executed on the client.
  3. Higher learning costs
    In addition to being familiar with webpack and Vue, you also need to master node, Koa and other related technologies. Compared with client-side rendering, the process of project construction and deployment is more complex. This also means that maintenance costs will increase accordingly.

1.2 Behavior Comparison between server rendering and client rendering

The following two figures are from On server side rendering (SSR)

Server-side rendering is to first request data from the back-end server, and then generate the complete first screen html and return it to the browser; The client-side rendering is to request data rendering after the js code is downloaded, loaded and parsed. The waiting process page has nothing, that is, the white screen seen by the user. That is, the server-side rendering can return to a first screen page with complete data without waiting for the js code to download and request data.
Author: coder_Lucky
Link: https://www.jianshu.com/p/10b6074d772c
Source: Jianshu
The copyright belongs to the author. For commercial reprint, please contact the author for authorization. For non-commercial reprint, please indicate the source.

2. Simple Demo of server rendering

The server-side rendering needs to generate the complete first screen html and return it to the browser, and after the user loads the first screen, the client is still a single page application. Next, we try to understand the construction process of SSR:

  1. We need to add app JS is packaged in different ways. Server Entry is used to package the code Server Bundle required for server rendering, and Client Entry is used to package the code Client Bundle required for client rendering.
  2. Server Bundle is used to build the Bundle Renderer, which generates the first screen Html code according to the user's request;
  3. Client Bundle is used to support single page requirements on browsers. It may be a little difficult to understand here. Hydrate is translated as hydration on Vue's official website. We think that if there is no Client Bundle hydrated into the html of the client, there is no logical js to take over the single page application in the html of the client at this time. If a route is clicked, because there is no js function to process the route, the client will re initiate the request to the server, The server renders the corresponding html return again according to the user's request. Therefore, we also need to load the Client Bundle into html for the logical processing of single page applications (Note: This is the author's personal understanding. If there is any error, I hope to point out the correction).
    Next, we apply the project to the server rendering step by step according to the above figure.

2.1 createApp.js

Here's createapp JS is the app in the figure above js
If we do not perform server-side rendering, the entry file packaged by our Vue project is generally main JS, this file will instantiate a Vue and then be mounted to the browser.
app.js and main JS has similar functions, but we also need to return the router, store and other instances used by Vue instances.

import Vue from 'vue'
import VueRouter from 'vue-router'

import App from './app.vue'
import createRouter from './router'

Vue.use(VueRouter)

export default () => {
  const router = createRouter()
  const app = new Vue({
    router,
    render: h => h(App)
  })
  return { app, router }
}

Note: now this web application only includes the most basic single page routing, without using Vuex, axios, etc

2.2 clientEntry.js

This file is used to package the files rendered by the client. When we do not use the server for rendering, this file is generally main JS, corresponding to the Client entry in the figure above:

import createApp from './createApp'

const { app } = createApp()

app.$mount('#root')

As we can see, this cliententry JS only does one thing, that is, get the Vue instance from createApp, and then mount the Vue instance to the root component of the browser.

2.3 serverEntry.js

serverEntry.js throws a function. We receive a context object. This function returns a promise object. In this promise object, we add some properties for the context.

import createApp from './createApp'

export default context => {
  return new Promise((resolve, reject) => {
    const { app, router } = createApp()

    router.push(context.url)

    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()
      if (!matchedComponents.length) {
        return reject(new Error('no component matched'))
      }
      context.router = router
      resolve(app)
    })
  })
}

When you see here, you may not know serverentry JS does, but we need to know: there is a function for the file generated after packaging that can be called by us. This function adds some attributes to the context, and the function contains the complete app application.

2.4 configuration file of webpack

webpack.client.js is a webpack packaged cliententry JS configuration file, webpack server. JS is a webpack packaged serverentry JS configuration file, the official also recommends that we use a webpack base. JS pulls out the common part of the first two configuration files.

2.4.1 webpack.base.js

webpack.base.js contains some common configurations:

const createVueLoaderOptions = require('./vue-loader.config')
const isDev = process.env.NODE_ENV === 'development'
const config = {
  resolve: {
    extensions: ['.js', '.vue']
  },
  module: {
    rules: [
      {
        test: /\.(vue|js|jsx)$/,
        loader: 'eslint-loader',
        exclude: /node_modules/,
        enforce: 'pre'
      },
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: createVueLoaderOptions(isDev)
      },
      {
        test: /\.jsx$/,
        loader: 'babel-loader'
      },
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/
      },
      {
        test: /\.(gif|jpg|jpeg|png|svg)$/,
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 1024,
              name: 'resources/[path][name].[hash:8].[ext]'
            }
          }
        ]
      }
    ]
  }
}
module.exports = config

2.4.2 webpack.client.js

const path = require('path')
const HTMLPlugin = require('html-webpack-plugin')
const webpack = require('webpack')
const merge = require('webpack-merge')
const baseConfig = require('./webpack.base')
const VueClientPlugin = require('vue-server-renderer/client-plugin')

const isDev = process.env.NODE_ENV === 'development'

const defaultPluins = [
  new webpack.DefinePlugin({
    'process.env': {
      NODE_ENV: isDev ? '"development"' : '"production"'
    }
  }),
  new HTMLPlugin({
    template: path.join(__dirname, 'template.html')
  }),
  new VueClientPlugin()
]

const devServer = {
  port: 7999,
  host: '0.0.0.0',
  overlay: {
    errors: true
  },
  headers: { 'Access-Control-Allow-Origin': '*' },
  historyApiFallback: {
    index: '/public/index.html'
  },
  proxy: {
    '/api': 'http://127.0.0.1:3332',
    '/user': 'http://127.0.0.1:3332'
  },
  hot: true
}

let config

if (isDev) {
  config = merge(baseConfig, {
    target: 'web',
    entry: path.join(__dirname, '../src/clientEntry.js'),
    output: {
      filename: 'bundle.[hash:8].js',
      path: path.join(__dirname, '../public'),
      publicPath: 'http://127.0.0.1:7999/public/'
    },
    devtool: '#cheap-module-eval-source-map',
    module: {
      rules: [
        {
          test: /\.(sc|sa|c)ss/,
          use: [
            'vue-style-loader',
            'css-loader',
            'sass-loader',
            {
              loader: 'postcss-loader',
              options: {
                sourceMap: true
              }
            }
          ]
        }
      ]
    },
    devServer,
    plugins: defaultPluins.concat([
      new webpack.HotModuleReplacementPlugin(),
      new webpack.NoEmitOnErrorsPlugin()
    ])
  })
}

module.exports = config

webpack. client. The only difference between JS and when we do not use server rendering (Note: the [only] here is only for this simple demo) is that we also use Vue server renderer / client plugin, which is used to generate a file called Vue SSR client manifest JSON file. This file will be used when we do server-side rendering.

2.4.3 webpack.server.js

const path = require('path')
const ExtractPlugin = require('extract-text-webpack-plugin')
const webpack = require('webpack')
const merge = require('webpack-merge')
const baseConfig = require('./webpack.base')
const VueServerPlugin = require('vue-server-renderer/server-plugin')

let config

const isDev = process.env.NODE_ENV === 'development'

const plugins = [
  new ExtractPlugin('styles.[contentHash:8].css'),
  new webpack.DefinePlugin({
    'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
    'process.env.VUE_ENV': '"server"'
  })
]

if (isDev) {
  plugins.push(new VueServerPlugin())
}

config = merge(baseConfig, {
  target: 'node',
  entry: path.join(__dirname, '../src/serverEntry.js'),
  devtool: 'source-map',
  output: {
    libraryTarget: 'commonjs2',
    filename: 'server-entry.js',
    path: path.join(__dirname, '../server-build')
  },
  externals: Object.keys(require('../package.json').dependencies),
  module: {
    rules: [
      {
        test: /\.(sc|sa|c)ss/,
        use: ExtractPlugin.extract({
          fallback: 'vue-style-loader',
          use: [
            'css-loader',
            'sass-loader',
            {
              loader: 'postcss-loader',
              options: {
                sourceMap: true
              }
            }
          ]
        })
      }
    ]
  },
  plugins
})
module.exports = config

In order to facilitate readers to contact the context, all webbacks are directly posted here server. JS, but the most noteworthy is the configuration of this section:

...
target: 'node',
entry: path.join(__dirname, '../src/serverEntry.js'),
devtool: 'source-map',
output: {
  libraryTarget: 'commonjs2',
  filename: 'server-entry.js',
  path: path.join(__dirname, '../server-build')
},
 ...

Because the packaged file needs to run on the node side, we need to change the target and libraryTarget.
Similarly, a Vue server renderer / server plugin similar to Vue server renderer / client plugin is used to package serverEntry. This plugin is used to generate a Vue SSR server bundle JSON file.

3. vue-ssr-client-manifest.json and Vue SSR server bundle json

These two files will be used in our server rendering later. In order to understand ssr better later, let's take a look at the two files:

3.1 vue-ssr-client-manifest.json

vue-ssr-client-manifest.json is the file generated by Vue server renderer / client plugin when we package the client rendering.
Contents of the document:

From this json file, we can clearly see that the files used by the application are classified with the help of client plugin. publicPath is the public path, all is all files, initial is the js and css that the entry file depends on, and async is the asynchronous js that is not required for the first screen. Therefore, we can use Vue SSR client manifest What does json do? Its most important function is that we can get the js code rendered by the client according to initial.

3.2 vue-ssr-server-bundle.json

vue-ssr-server-bundle.json is our packaged serverentry JS is generated through Vue server renderer / server plugin.
The file content (vue-ssr-server-bundle.json file is very large. In order to facilitate observation, I deleted the corresponding value of each key):

Entry is the file of the service payment entry, files is the list of files that the server depends on, and maps is the list of sourcemaps files.
Here, we mainly observe the contents of files. If we expand files, we will see a pile of file names: value. Let's take a look at the value value in the following figure:

Yes, you're right. It's all js code. These js codes are the codes we need to use to generate complete html according to user requests on the server.

3. node server

We need to do the following actions on the node side:
Create a server, receive the user's request, generate a complete HTML interface according to the user's request, and put the js file required by the client rendering into the HTML file.
We use koa to handle the work of the server.

3.1 ssr-router.js

Since we need to generate the corresponding html file according to the user's request, we continue to build an SSR router similar to the front-end routing function JS is used for route matching in server rendering. In fact, it is not appropriate to say that it is a match, but the key is to generate a complete html according to the user's request.

const Router = require('koa-router')
const axios = require('axios')
const path = require('path')
const fs = require('fs')
const MemoryFS = require('memory-fs')
const webpack = require('webpack')
const VueServerRenderer = require('vue-server-renderer')

const serverRender = require('./server-render')
const serverConfig = require('../../build/webpack.server')

const serverCompiler = webpack(serverConfig)
const mfs = new MemoryFS()
serverCompiler.outputFileSystem = mfs

let bundle
//Use to configure webpack server. JS to call webpack for packaging
//In fact, here we can also package externally like the packaging client, but this is more convenient for our development.
serverCompiler.watch({}, (err, stats) => {
  //When the supervisor heard the file change, we repackaged it
  if (err) throw err
  stats = stats.toJson()
  stats.errors.forEach(err => console.log(err))
  stats.warnings.forEach(warn => console.warn(err))

  const bundlePath = path.join(
    serverConfig.output.path,
    'vue-ssr-server-bundle.json'
  )
  bundle = JSON.parse(mfs.readFileSync(bundlePath, 'utf-8'))
  console.log('new bundle generated')
})

// ctx contains the user's request path information
const handleSSR = async (ctx) => {
  //When the server packaging has not been completed, if the user makes a request at this time, return directly
  if (!bundle) {
    ctx.body = 'Wait a minute. Don't worry......'
    return
  }
  
  //Get the Vue SSR client manifest package generated by clientEntry json
  const clientManifestResp = await axios.get(
    'http://127.0.0.1:7999/public/vue-ssr-client-manifest.json'
  )
  const clientManifest = clientManifestResp.data
  
  //Get the template to fill in the web page content
  const template = fs.readFileSync(
    path.join(__dirname, '../server.template.ejs'),
    'utf-8'
  )

  //Build a renderer. How this renderer works will be described in more detail later
  const renderer = VueServerRenderer
    .createBundleRenderer(bundle, {
      inject: false,
      clientManifest 
    })

  //Call the rendering method. In this step, a complete html information will be added to ctx
  await serverRender(ctx, renderer, template)
}

const router = new Router()
//The get method of KOA router passes ctx into handleSSR when calling it, and returns ctx after handleSSR execution to the user
router.get('*', handleSSR)

module.exports = router

3.2 server-render.js

This function can actually be written in SSR router JS, because it actually completes the SSR router JS.
But let's extract it into a separate js file here.

const ejs = require('ejs')

module.exports = async (ctx, renderer, template) => {
  ctx.headers['Content-Type'] = 'text/html'
  const context = { url: ctx.path }
  try {
    const appString = await renderer.renderToString(context)
    if (context.router.currentRoute.fullPath !== ctx.path) {
      return ctx.redirect(context.router.currentRoute.fullPath)
    }

    const html = ejs.render(template, {
      appString,
      style: context.renderStyles(),
      scripts: context.renderScripts()
    })

    ctx.body = html	//Assign the complete html to ctx
  } catch (err) {
    console.log('render error', err)
    throw err
  }
}

Let's now combine 3.1 and 3.2 to illustrate how this complete html is generated.
In SSR router JS, we created VueServerRenderer as follows:

  	const renderer = VueServerRenderer
	    .createBundleRenderer(bundle, {
	      inject: false,
	      clientManifest 
	    })
    
    await serverRender(ctx, renderer, template)

In server render JS, we call renderer renderToString(context):

	const appString = await renderer.renderToString(context)

If we can read the createbundlerender part of the source code of KOA router, we can know how the incoming bundle generates html according to ctx. This is the flow chart of the key steps in the bundle processing process:

In the renderToString() phase, the runner(context) will be executed:
We analyzed the contents of the bundle (Vue SSR server bundle. JSON) before. There is an entry in the bundle. When createBundleRunner() is executed, compileModule() will be executed internally to generate a function evaluate that handles the compiled source code. The evaluate function wraps the source code of the compiled file into a module object, and then returns module exports. Defualt is a function that encapsulates the file source code. Executing this function is equivalent to executing the file source code. When this file is an entry file, the encapsulated function of the source code of the entry file is returned, that is, runner. Then execute runner(context) to about executing entry server JS exported function. We can return to 2.2 serverentry again JS to deepen our understanding of a function returned by the client rendering entry file.

run = context => {
  return new Promise((resolve, reject) => {
    const { app, router } = createApp()
    router.push(context.url)
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()
      if (!matchedComponents.length) {
        return reject(new Error('no component matched'))
      }
      context.router = router
      resolve(app)
    })
  })
}

When executing the runner(context), because const context = {URL: CTX. Path}, we can pass the router according to the user's request path Push (context. URL) to get the corresponding routing instance, and then route Onready () means that we have loaded all synchronous / asynchronous components under this route and added a callback function to it. In this function, we add the loaded route instance to the context object: context Router = router. At this time, the context has got all the data for rendering a complete html
Then, we introduce the data of context into the template to get an html:

const html = ejs.render(template, {
      appString,
      style: context.renderStyles(),
      scripts: context.renderScripts()
    })

And because we finally returned ctx to the browser, so:

ctx.body = html	//Assign the complete html to ctx

In the renderToString() stage, after playing the runner(context), we will also execute render(app). The app here is actually the vue instance we get after executing the runner(context). At this time, it is time for clientManifest to play its role:
The resource loading information is recorded in the clientManifest, which is obtained from the context object by running the app_ registedComponents get moduleIds, and then get usedAsyncFiles (files that the component depends on). Its union with preloadFiles (initial file array in clientManifest) is the preloaded resource list for initial rendering, and its difference with prefetchFiles (async file array in clientManifest) is the prefetched resource list. That is, at this time, the js file required to take over the single page application is added to the scripts of context.

4. Create a server:

server.js: used to start the service

const Koa = require('koa')
const send = require('koa-send')
const path = require('path')
const staticRouter = require('./routers/static')
const app = new Koa()

const isDev = process.env.NODE_ENV === 'development'

app.use(async (ctx, next) => {
  try {
    console.log(`request with path ${ctx.path}`)
    await next()
  } catch (err) {
    console.log(err)
    ctx.status = 500
    if (isDev) {
      ctx.body = err.message
    } else {
      ctx.bosy = 'please try again later'
    }
  }
})

app.use(async (ctx, next) => {
  if (ctx.path === '/favicon.ico') {
    await send(ctx, '/favicon.ico', { root: path.join(__dirname, '../') })
  } else {
    await next()
  }
})

app.use(staticRouter.routes()).use(staticRouter.allowedMethods())

let pageRouter
if (isDev) {
  pageRouter = require('./routers/dev-ssr')
}
app.use(pageRouter.routes()).use(pageRouter.allowedMethods())

const HOST = process.env.HOST || '0.0.0.0'
const PORT = process.env.PORT || 3332

app.listen(PORT, HOST, () => {
  console.log(`server is listening on ${HOST}:${PORT}`)
})

server.js is used to start the server. If necessary, you can also set routing interception in it

5. package.json

Add run script:

"scripts": {
    "dev:client": "cross-env NODE_ENV=development webpack-dev-server --config build/webpack.client.js",
    "dev:server": "nodemon server/server.js",
    "dev": "concurrently \"npm run dev:client\" \"npm run dev:server\""
    }

Then we at the command desk:

npm run dev

Finally, visit localhost:3332 to access the web page rendered by the server.

6. Conclusion:

ssr takes some time to understand better. Here is the author's opinion demo address , you can download it yourself if necessary.

Topics: Javascript Front-end Vue Webpack regex