-
Notifications
You must be signed in to change notification settings - Fork 4.7k
Ssr webpack #937
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Ssr webpack #937
Changes from all commits
f38249d
42f211c
3d6ec0d
3d6097d
d543e7d
6cfeac5
5646123
d12048e
a50d49e
59346d4
053eb04
3fde0f8
8c19e79
e20873c
0942c46
e0d5218
32a16fd
dfe68d1
d58ff69
d984a3e
1667317
2ffe6d5
b96d6b4
60d284d
764506a
494fb49
07c87a4
72b37e8
d6be1e2
a615136
de4f557
423f28f
c781688
e383a7d
708a0e3
ea51bdd
6c9b6f6
14963cb
e0b21d5
e3db9c9
645069e
fa95156
886d486
89c0ea8
efb0b97
140c2e4
e5cea72
026d2ca
b7d08c8
8aa3e9a
feebe1b
d395ce1
ad13a9d
fb69cfd
1de777e
24a1646
b306b9e
609a346
4a8c921
2c54e8a
7deaa4c
5668010
32b1b4c
999f518
ff5420e
c0361c4
1fdfa5e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
# Build Configuration | ||
|
||
The webpack config for an SSR project will be similar to a client-only project. If you're not familiar with configuring webpack, you can find more information in the documentation for [Vue CLI](https://cli.vuejs.org/guide/webpack.html#working-with-webpack) or [configuring Vue Loader manually](https://vue-loader.vuejs.org/guide/#manual-setup). | ||
|
||
## Key Differences with Client-Only Builds | ||
|
||
1. We need to create a [webpack manifest](https://webpack.js.org/concepts/manifest/) for our server-side code. This is a JSON file that webpack keeps to track how all the modules map to the output bundles. | ||
|
||
2. We should [externalize application dependencies](https://webpack.js.org/configuration/externals/). This makes the server build much faster and generates a smaller bundle file. When doing this, we have to exclude dependencies that need to be processed by webpack (like `.css`. or `.vue` files). | ||
|
||
3. We need to change webpack [target](https://webpack.js.org/concepts/targets/) to Node.js. This allows webpack to handle dynamic imports in a Node-appropriate fashion, and also tells `vue-loader` to emit server-oriented code when compiling Vue components. | ||
|
||
4. When building a server entry, we would need to define an environment variable to indicate we are working with SSR. It might be helpful to add a few `scripts` to the project's `package.json`: | ||
|
||
```json | ||
"scripts": { | ||
"build:client": "vue-cli-service build --dest dist/client", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe it's worth exploring into the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Follow-up: #969 |
||
"build:server": "SSR=1 vue-cli-service build --dest dist/server", | ||
"build": "npm run build:client && npm run build:server", | ||
} | ||
``` | ||
|
||
## Example Configuration | ||
|
||
Below is a sample `vue.config.js` that adds SSR rendering to a Vue CLI project, but it can be adapted for any webpack build. | ||
|
||
```js | ||
const { WebpackManifestPlugin } = require('webpack-manifest-plugin') | ||
const nodeExternals = require('webpack-node-externals') | ||
const webpack = require('webpack') | ||
|
||
module.exports = { | ||
chainWebpack: webpackConfig => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was thinking whether it is a good idea or not to have 3 configs here? The reasoning behind this is that using There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree with the point but I have a few questions regarding it:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added a follow-up to refactor to CLI agnostic build with a base, server and client configs: #969 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, this is probably not feasible with current vue-cli API, so it should be a part of vue-cli agnostic build in that case. |
||
// We need to disable cache loader, otherwise the client build | ||
// will used cached components from the server build | ||
webpackConfig.module.rule('vue').uses.delete('cache-loader') | ||
webpackConfig.module.rule('js').uses.delete('cache-loader') | ||
webpackConfig.module.rule('ts').uses.delete('cache-loader') | ||
webpackConfig.module.rule('tsx').uses.delete('cache-loader') | ||
|
||
if (!process.env.SSR) { | ||
NataliaTepluhina marked this conversation as resolved.
Show resolved
Hide resolved
|
||
// Point entry to your app's client entry file | ||
webpackConfig | ||
.entry('app') | ||
.clear() | ||
.add('./src/entry-client.js') | ||
return | ||
} | ||
|
||
// Point entry to your app's server entry file | ||
webpackConfig | ||
.entry('app') | ||
.clear() | ||
.add('./src/entry-server.js') | ||
|
||
// This allows webpack to handle dynamic imports in a Node-appropriate | ||
// fashion, and also tells `vue-loader` to emit server-oriented code when | ||
// compiling Vue components. | ||
webpackConfig.target('node') | ||
// This tells the server bundle to use Node-style exports | ||
webpackConfig.output.libraryTarget('commonjs2') | ||
|
||
webpackConfig | ||
.plugin('manifest') | ||
.use(new WebpackManifestPlugin({ fileName: 'ssr-manifest.json' })) | ||
|
||
// https://webpack.js.org/configuration/externals/#function | ||
// https://github.com/liady/webpack-node-externals | ||
// Externalize app dependencies. This makes the server build much faster | ||
// and generates a smaller bundle file. | ||
|
||
// Do not externalize dependencies that need to be processed by webpack. | ||
// You should also whitelist deps that modify `global` (e.g. polyfills) | ||
webpackConfig.externals(nodeExternals({ allowlist: /\.(css|vue)$/ })) | ||
|
||
webpackConfig.optimization.splitChunks(false).minimize(false) | ||
|
||
webpackConfig.plugins.delete('preload') | ||
webpackConfig.plugins.delete('prefetch') | ||
webpackConfig.plugins.delete('progress') | ||
webpackConfig.plugins.delete('friendly-errors') | ||
|
||
webpackConfig.plugin('limit').use( | ||
new webpack.optimize.LimitChunkCountPlugin({ | ||
maxChunks: 1 | ||
}) | ||
) | ||
} | ||
} | ||
``` | ||
|
||
## Externals Caveats | ||
|
||
Notice that in the `externals` option we are whitelisting CSS files. This is because CSS imported from dependencies should still be handled by webpack. If you are importing any other types of files that also rely on webpack (e.g. `*.vue`, `*.sass`), you should add them to the whitelist as well. | ||
|
||
If you are using `runInNewContext: 'once'` or `runInNewContext: true`, then you also need to whitelist polyfills that modify `global`, e.g. `babel-polyfill`. This is because when using the new context mode, **code inside a server bundle has its own `global` object.** Since you don't really need it on the server, it's actually easier to just import it in the client entry. | ||
|
||
## Generating `clientManifest` | ||
|
||
In addition to the server bundle, we can also generate a client build manifest. With the client manifest and the server bundle, the renderer now has information of both the server _and_ client builds. This way it can automatically infer and inject [preload / prefetch directives](https://css-tricks.com/prefetching-preloading-prebrowsing/), `<link>` and `<script>` tags into the rendered HTML. | ||
|
||
The benefits are two-fold: | ||
|
||
1. It can replace `html-webpack-plugin` for injecting the correct asset URLs when there are hashes in your generated filenames. | ||
|
||
2. When rendering a bundle that leverages webpack's on-demand code splitting features, we can ensure the optimal chunks are preloaded / prefetched, and also intelligently inject `<script>` tags for needed async chunks to avoid waterfall requests on the client, thus improving TTI (time-to-interactive). |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -64,6 +64,8 @@ yarn add express | |
``` | ||
|
||
```js | ||
// server.js | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since most of the people who want to migrate from CSR to SSR would be having some kind of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed, it would be a great improvement! Created a follow-up: #970 |
||
|
||
const { createSSRApp } = require('vue') | ||
const { renderToString } = require('@vue/server-renderer') | ||
const server = require('express')() | ||
|
@@ -83,7 +85,7 @@ server.get('*', async (req, res) => { | |
<html> | ||
<body> | ||
<h1>My First Heading</h1> | ||
${appContent} | ||
<div id="app">${appContent}</div> | ||
</body> | ||
</html> | ||
` | ||
|
@@ -94,4 +96,4 @@ server.get('*', async (req, res) => { | |
server.listen(8080) | ||
``` | ||
|
||
Now, when running this Node.js script, we can see a static HTML page on `localhost:8080`. However, this code is not _hydrated_: Vue hasn't yet take over the static HTML sent by the server to turn it into dynamic DOM that can react to client-side data changes. This will be covered in the [Client Side Hydration](#) section. | ||
Now, when running this Node.js script, we can see a static HTML page on `localhost:8080`. However, this code is not _hydrated_: Vue hasn't yet taken over the static HTML sent by the server to turn it into dynamic DOM that can react to client-side data changes. This will be covered in the [Client Side Hydration](hydration.html) section. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
# Client Side Hydration | ||
|
||
Hydration refers to the client-side process during which Vue takes over the static HTML sent by the server and turns it into dynamic DOM that can react to client-side data changes. | ||
|
||
In `entry-client.js`, we are simply mounting the app with this line: | ||
|
||
```js | ||
app.mount('#app') | ||
``` | ||
|
||
Since the server has already rendered the markup, we obviously do not want to throw that away and re-create all the DOM elements. Instead, we want to "hydrate" the static markup and make it interactive. | ||
|
||
Vue provides a `createSSRApp` method for use in client-side code (in this case, in our `entry-client.js`) to tell Vue to hydrate the existing static HTML instead of re-creating all the DOM elements. | ||
|
||
### Hydration Caveats | ||
|
||
Vue will assert the client-side generated virtual DOM tree matches the DOM structure rendered from the server. If there is a mismatch, it will bail hydration, discard existing DOM and render from scratch. There will be a warning in the browser console but your site will still work. | ||
|
||
The first key way to ensure that SSR is working to ensuring your application state is the same on client and server. Take special care not to depend on APIs specific to the browser (like window width, device capability or localStorage) or server (such as Node built-ins), and take care where the same code will give different results when run in different places (such as when using timezones, timestamps, normalizing URLs or generating random numbers). See [Writing Universal Code](./universal.md) for more details. | ||
|
||
A second key thing to be aware of when using SSR + client hydration is that invalid HTML may be altered by the browser. For example, when you write this in a Vue template: | ||
|
||
```html | ||
<table> | ||
<tr> | ||
<td>hi</td> | ||
</tr> | ||
</table> | ||
``` | ||
|
||
The browser will automatically inject `<tbody>` inside `<table>`, however, the virtual DOM generated by Vue does not contain `<tbody>`, so it will cause a mismatch. To ensure correct matching, make sure to write valid HTML in your templates. | ||
NataliaTepluhina marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
You might consider using a HTML validator like [the W3C Markup Validation Service](https://validator.w3.org/) or [HTML-validate](https://html-validate.org/) to check your templates in development. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
# Routing and Code-Splitting | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As an SSR app developer I am also curious how Critical CSS should be implemeneted correctly with this approach. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree! However, this guide is not full and there are a few missing parts here. Here is an issue with 'what's currently missing': #971 |
||
|
||
## Routing with `vue-router` | ||
|
||
You may have noticed that our server code uses a `*` handler which accepts arbitrary URLs. This allows us to pass the visited URL into our Vue app, and reuse the same routing config for both client and server! | ||
|
||
It is recommended to use the official [vue-router](https://github.com/vuejs/vue-router-next) library for this purpose. Let's first create a file where we create the router. Note that similar to application instance, we also need a fresh router instance for each request, so the file exports a `createRouter` function: | ||
|
||
```js | ||
// router.js | ||
import { createRouter, createMemoryHistory, createWebHistory } from 'vue-router' | ||
import MyUser from './components/MyUser.vue' | ||
|
||
const isServer = typeof window === 'undefined' | ||
|
||
const history = isServer ? createMemoryHistory() : createWebHistory() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think a slightly better approach here would be to pass the |
||
|
||
const routes = [{ path: '/user', component: MyUser }] | ||
|
||
export default function() { | ||
return createRouter({ routes, history }) | ||
} | ||
``` | ||
|
||
And update our `app.js`, client and server entries: | ||
|
||
```js | ||
// app.js | ||
import { createSSRApp } from 'vue' | ||
import App from './App.vue' | ||
import createRouter from './router' | ||
|
||
export default function(args) { | ||
const app = createSSRApp(App) | ||
const router = createRouter() | ||
|
||
app.use(router) | ||
|
||
return { | ||
app, | ||
router | ||
} | ||
} | ||
``` | ||
|
||
```js | ||
// entry-client.js | ||
const { app, router } = createApp({ | ||
/*...*/ | ||
}) | ||
``` | ||
|
||
```js | ||
// entry-server.js | ||
const { app, router } = createApp({ | ||
/*...*/ | ||
}) | ||
``` | ||
|
||
## Code-Splitting | ||
|
||
Code-splitting, or lazy-loading part of your app, helps reduce the size of assets that need to be downloaded by the browser for the initial render, and can greatly improve TTI (time-to-interactive) for apps with large bundles. The key is "loading just what is needed" for the initial screen. | ||
|
||
Vue Router provides [lazy-loading support](https://next.router.vuejs.org/guide/advanced/lazy-loading.html), allowing [webpack to code-split at that point](https://webpack.js.org/guides/code-splitting-async/). All you need to do is: | ||
|
||
```js | ||
// change this... | ||
import MyUser from './components/MyUser.vue' | ||
const routes = [{ path: '/user', component: MyUser }] | ||
|
||
// to this: | ||
const routes = [ | ||
{ path: '/user', component: () => import('./components/MyUser.vue') } | ||
] | ||
``` | ||
|
||
On both client and server we need to wait for router to resolve async route components ahead of time in order to properly invoke in-component hooks. For this we will be using [router.isReady](https://next.router.vuejs.org/api/#isready) method Let's update our client entry: | ||
|
||
```js | ||
// entry-client.js | ||
import createApp from './app' | ||
|
||
const { app, router } = createApp({ | ||
/* ... */ | ||
}) | ||
|
||
router.isReady().then(() => { | ||
app.mount('#app') | ||
}) | ||
``` | ||
|
||
We also need to update our `server.js` script: | ||
|
||
```js | ||
// server.js | ||
const path = require('path') | ||
|
||
const appPath = path.join(__dirname, './dist', 'server', manifest['app.js']) | ||
const createApp = require(appPath).default | ||
|
||
server.get('*', async (req, res) => { | ||
const { app, router } = createApp() | ||
|
||
router.push(req.url) | ||
await router.isReady() | ||
|
||
const appContent = await renderToString(app) | ||
|
||
fs.readFile(path.join(__dirname, '/dist/client/index.html'), (err, html) => { | ||
NataliaTepluhina marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if (err) { | ||
throw err | ||
} | ||
|
||
html = html | ||
.toString() | ||
.replace('<div id="app">', `<div id="app">${appContent}`) | ||
res.setHeader('Content-Type', 'text/html') | ||
res.send(html) | ||
}) | ||
}) | ||
``` |
Uh oh!
There was an error while loading. Please reload this page.