From 31b2b69c6831a0929de2944c5b90db9a5a1fb969 Mon Sep 17 00:00:00 2001 From: Evan You Date: Mon, 4 Feb 2019 11:35:57 -0500 Subject: [PATCH] 2.6 updates (#222) * 2.6: serverCacheKey bail out * 2.6: api additions * docs: use ssrPrefetch in data guide (#214) * 2.6: serverPrefetch updates --- docs/api/README.md | 62 +++++++++++ docs/guide/caching.md | 4 + docs/guide/data.md | 239 ++++++++++++++++++------------------------ 3 files changed, 169 insertions(+), 136 deletions(-) diff --git a/docs/api/README.md b/docs/api/README.md index 6f4bba44..65acb4e7 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -84,6 +84,12 @@ Render the bundle to a [Node.js readable stream](https://nodejs.org/dist/latest- ### template +- **Type:** + - `string` + - `string | (() => string | Promise)` (since 2.6) + +**If using a string template:** + Provide a template for the entire page's HTML. The template should contain a comment `` which serves as the placeholder for rendered app content. The template also supports basic interpolation using the render context: @@ -101,6 +107,8 @@ The template automatically injects appropriate content when certain data is foun In 2.5.0+, the embed script also self-removes in production mode. + In 2.6.0+, if `context.nonce` is present, it will be added as the `nonce` attribute to the embedded script. This allows the inline script to conform to CSP that requires nonce. + In addition, when `clientManifest` is also provided, the template automatically injects the following: - Client-side JavaScript and CSS assets needed by the render (with async chunks automatically inferred); @@ -108,6 +116,34 @@ In addition, when `clientManifest` is also provided, the template automatically You can disable all automatic injections by also passing `inject: false` to the renderer. +**If using a function template:** + +::: warning +Function template is only supported in 2.6+ and when using `renderer.renderToString`. It is NOT supported in `renderer.renderToStream`. +::: + +The `template` option can also be function that returns the rendered HTML or a Promise that resolves to the rendered HTML. This allows you to leverage native JavaScript template strings and potential async operations in the template rendering process. + +The function receives two arguments: + +1. The rendering result of the app component as a string; +2. The rendering context object. + +Example: + +``` js +const renderer = createRenderer({ + template: (result, context) => { + return ` + ${context.head} + ${result} + ` + } +}) +``` + +Note that when using a custom template function, nothing will be automatically injected - you will be in full control of what the eventual HTML includes, but also will be responsible for including everything yourself (e.g. asset links if you are using the bundle renderer). + See also: - [Using a Page Template](../guide/#using-a-page-template) @@ -245,6 +281,32 @@ const renderer = createRenderer({ As an example, check out [`v-show`'s server-side implementation](https://github.com/vuejs/vue/blob/dev/src/platforms/web/server/directives/show.js). +### serializer + +> New in 2.6 + +Provide a custom serializer function for `context.state`. Since the serialized state will be part of your final HTML, it is important to use a function that properly escapes HTML characters for security reasons. The default serializer is [serialize-javascript](https://github.com/yahoo/serialize-javascript) with `{ isJSON: true }`. + +## Server-only Component Options + +### serverCacheKey + +- **Type:** `(props) => any` + + Return the cache key for the component based on incoming props. Does NOT have access to `this`. + + Since 2.6, you can explicitly bail out of caching by returning `false`. + + See more details in [Component-level Caching](../guide/caching.html#component-level-caching). + +### serverPrefetch + +- **Type:** `() => Promise` + + Fetch async data during server side rendering. It should store fetched data into a global store and return a Promise. The server renderer will wait on this hook until the Promise is resolved. This hook has access to the component instance via `this`. + + See more details in [Data Fetching](../guide/data.html). + ## webpack Plugins The webpack plugins are provided as standalone files and should be required directly: diff --git a/docs/guide/caching.md b/docs/guide/caching.md index fe331ab2..5ff34b72 100644 --- a/docs/guide/caching.md +++ b/docs/guide/caching.md @@ -73,6 +73,10 @@ The key returned from `serverCacheKey` should contain sufficient information to Returning a constant will cause the component to always be cached, which is good for purely static components. +::: tip Bailing out from Caching +Since 2.6.0, explicitly returning `false` in `serverCacheKey` will cause the component to bail out of caching and be rendered afresh. +::: + ### When to use component caching If the renderer hits a cache for a component during render, it will directly reuse the cached result for the entire sub tree. This means you should **NOT** cache a component when: diff --git a/docs/guide/data.md b/docs/guide/data.md index 247cfcf1..bee85f62 100644 --- a/docs/guide/data.md +++ b/docs/guide/data.md @@ -2,11 +2,9 @@ ## Data Store -During SSR, we are essentially rendering a "snapshot" of our app, so if the app relies on some asynchronous data, **these data need to be pre-fetched and resolved before we start the rendering process**. +During SSR, we are essentially rendering a "snapshot" of our app. The asynchronous data from our components needs to be available before we mount the client side app - otherwise the client app would render using different state and the hydration would fail. -Another concern is that on the client, the same data needs to be available before we mount the client side app - otherwise the client app would render using different state and the hydration would fail. - -To address this, the fetched data needs to live outside the view components, in a dedicated data store, or a "state container". On the server, we can pre-fetch and fill data into the store before rendering. In addition, we will serialize and inline the state in the HTML. The client-side store can directly pick up the inlined state before we mount the app. +To address this, the fetched data needs to live outside the view components, in a dedicated data store, or a "state container". On the server, we can pre-fetch and fill data into the store while rendering. In addition, we will serialize and inline the state in the HTML after the app has finished rendering. The client-side store can directly pick up the inlined state before we mount the app. We will be using the official state management library [Vuex](https://github.com/vuejs/vuex/) for this purpose. Let's create a `store.js` file, with some mocked logic for fetching an item based on an id: @@ -23,9 +21,12 @@ import { fetchItem } from './api' export function createStore () { return new Vuex.Store({ - state: { + // IMPORTANT: state must be a function so the module can be + // instantiated multiple times + state: () => ({ items: {} - }, + }), + actions: { fetchItem ({ commit }, id) { // return the Promise via `store.dispatch()` so that we know @@ -35,6 +36,7 @@ export function createStore () { }) } }, + mutations: { setItem (state, { id, item }) { Vue.set(state.items, id, item) @@ -44,6 +46,11 @@ export function createStore () { } ``` +::: warning +Most of the time, you should wrap `state` in a function, so that it will not leak into the next server-side runs. +[More info](./structure.md#avoid-stateful-singletons) +::: + And update `app.js`: ``` js @@ -80,34 +87,68 @@ So, where do we place the code that dispatches the data-fetching actions? The data we need to fetch is determined by the route visited - which also determines what components are rendered. In fact, the data needed for a given route is also the data needed by the components rendered at that route. So it would be natural to place the data fetching logic inside route components. -We will expose a custom static function `asyncData` on our route components. Note because this function will be called before the components are instantiated, it doesn't have access to `this`. The store and route information needs to be passed in as arguments: +We will use the `serverPrefetch` option (new in 2.6.0+) in our components. This option is recognized by the server renderer and will pause the rendering until the promise it returns is resolved. This allows us to "wait" on async data during the rendering process. + +::: tip +You can use `serverPrefetch` in any component, not just the route-level components. +::: + +Here is an example `Item.vue` component that is rendered at the `'/item/:id'` route. Since the component instance is already created at this point, it has access to `this`: ``` html ``` -## Server Data Fetching +::: warning +You should check if the component was server-side rendered in the `mounted` hook to avoid executing the logic twice. +::: -In `entry-server.js` we can get the components matched by a route with `router.getMatchedComponents()`, and call `asyncData` if the component exposes it. Then we need to attach resolved state to the render context. +::: tip +You may find the same `fetchItem()` logic repeated multiple times (in `serverPrefetch`, `mounted` and `watch` callbacks) in each component - it is recommended to create your own abstraction (e.g. a mixin or a plugin) to simplify such code. +::: + +## Final State Injection + +Now we know that the rendering process will wait for data fetching in our components, how do we know when it is "done"? In order to do that, we need to attach a `rendered` callback to the render context (also new in 2.6), which the server renderer will call when the entire rendering process is finished. At this moment, the store should have been filled with the final state. We can then inject it on to the context in that callback: ``` js // entry-server.js @@ -120,29 +161,17 @@ export default context => { router.push(context.url) router.onReady(() => { - const matchedComponents = router.getMatchedComponents() - if (!matchedComponents.length) { - return reject({ code: 404 }) - } - - // call `asyncData()` on all matched route components - Promise.all(matchedComponents.map(Component => { - if (Component.asyncData) { - return Component.asyncData({ - store, - route: router.currentRoute - }) - } - })).then(() => { - // After all preFetch hooks are resolved, our store is now - // filled with the state needed to render the app. + // This `rendered` hook is called when the app has finished rendering + context.rendered = () => { + // After the app is rendered, our store is now + // filled with the state from our components. // When we attach the state to the context, and the `template` option // is used for the renderer, the state will automatically be // serialized and injected into the HTML as `window.__INITIAL_STATE__`. context.state = store.state + } - resolve(app) - }).catch(reject) + resolve(app) }, reject) }) } @@ -153,105 +182,14 @@ When using `template`, `context.state` will automatically be embedded in the fin ``` js // entry-client.js -const { app, router, store } = createApp() +const { app, store } = createApp() if (window.__INITIAL_STATE__) { + // We initialize the store state with the data injected from the server store.replaceState(window.__INITIAL_STATE__) } -``` - -## Client Data Fetching - -On the client, there are two different approaches for handling data fetching: - -1. **Resolve data before route navigation:** - - With this strategy, the app will stay on the current view until the data needed by the incoming view has been resolved. The benefit is that the incoming view can directly render the full content when it's ready, but if the data fetching takes a long time, the user will feel "stuck" on the current view. It is therefore recommended to provide a data loading indicator if using this strategy. - - We can implement this strategy on the client by checking matched components and invoking their `asyncData` function inside a global route hook. Note we should register this hook after the initial route is ready so that we don't unnecessarily fetch the server-fetched data again. - - ``` js - // entry-client.js - - // ...omitting unrelated code - - router.onReady(() => { - // Add router hook for handling asyncData. - // Doing it after initial route is resolved so that we don't double-fetch - // the data that we already have. Using `router.beforeResolve()` so that all - // async components are resolved. - router.beforeResolve((to, from, next) => { - const matched = router.getMatchedComponents(to) - const prevMatched = router.getMatchedComponents(from) - - // we only care about non-previously-rendered components, - // so we compare them until the two matched lists differ - let diffed = false - const activated = matched.filter((c, i) => { - return diffed || (diffed = (prevMatched[i] !== c)) - }) - - if (!activated.length) { - return next() - } - - // this is where we should trigger a loading indicator if there is one - Promise.all(activated.map(c => { - if (c.asyncData) { - return c.asyncData({ store, route: to }) - } - })).then(() => { - - // stop loading indicator - - next() - }).catch(next) - }) - - app.$mount('#app') - }) - ``` - -2. **Fetch data after the matched view is rendered:** - - This strategy places the client-side data-fetching logic in a view component's `beforeMount` function. This allows the views to switch instantly when a route navigation is triggered, so the app feels a bit more responsive. However, the incoming view will not have the full data available when it's rendered. It is therefore necessary to have a conditional loading state for each view component that uses this strategy. - - This can be achieved with a client-only global mixin: - - ``` js - Vue.mixin({ - beforeMount () { - const { asyncData } = this.$options - if (asyncData) { - // assign the fetch operation to a promise - // so that in components we can do `this.dataPromise.then(...)` to - // perform other tasks after data is ready - this.dataPromise = asyncData({ - store: this.$store, - route: this.$route - }) - } - } - }) - ``` - -The two strategies are ultimately different UX decisions and should be picked based on the actual scenario of the app you are building. But regardless of which strategy you pick, the `asyncData` function should also be called when a route component is reused (same route, but params or query changed. e.g. from `user/1` to `user/2`). We can also handle this with a client-only global mixin: - -``` js -Vue.mixin({ - beforeRouteUpdate (to, from, next) { - const { asyncData } = this.$options - if (asyncData) { - asyncData({ - store: this.$store, - route: to - }).then(next).catch(next) - } else { - next() - } - } -}) +app.$mount('#app') ``` ## Store Code Splitting @@ -262,14 +200,17 @@ In a large application, our Vuex store will likely be split into multiple module // store/modules/foo.js export default { namespaced: true, + // IMPORTANT: state must be a function so the module can be // instantiated multiple times state: () => ({ count: 0 }), + actions: { inc: ({ commit }) => commit('inc') }, + mutations: { inc: state => state.count++ } @@ -289,9 +230,30 @@ We can use `store.registerModule` to lazy-register this module in a route compon import fooStoreModule from '../store/modules/foo' export default { - asyncData ({ store }) { - store.registerModule('foo', fooStoreModule) - return store.dispatch('foo/inc') + computed: { + fooCount () { + return this.$store.state.foo.count + } + }, + + // Server-side only + serverPrefetch () { + this.registerFoo() + return this.fooInc() + }, + + // Client-side only + mounted () { + // We already incremented 'count' on the server + // We know by checking if the 'foo' state already exists + const alreadyIncremented = !!this.$store.state.foo + + // We register the foo module + this.registerFoo() + + if (!alreadyIncremented) { + this.fooInc() + } }, // IMPORTANT: avoid duplicate module registration on the client @@ -300,9 +262,14 @@ export default { this.$store.unregisterModule('foo') }, - computed: { - fooCount () { - return this.$store.state.foo.count + methods: { + registerFoo () { + // Preserve the previous state if it was injected from the server + this.$store.registerModule('foo', fooStoreModule, { preserveState: true }) + }, + + fooInc () { + return this.$store.dispatch('foo/inc') } } } @@ -311,6 +278,6 @@ export default { Because the module is now a dependency of the route component, it will be moved into the route component's async chunk by webpack. ---- - -Phew, that was a lot of code! This is because universal data-fetching is probably the most complex problem in a server-rendered app and we are laying the groundwork for easier further development. Once the boilerplate is set up, authoring individual components will be actually quite pleasant. +::: warning +Don't forget to use the `preserveState: true` option for `registerModule` so we keep the state injected by the server. +:::