server-renderer replacement for renderStyles/renderScripts
Version
3.2.4
Reproduction link
https://codesandbox.io/s/optimistic-meninsky-2opkl
Steps to reproduce
Create a SSR app with vite, and instead of returning the html directly, attempt to return each rendered section.
What is expected?
Ability to get the html, css, and script blocks like in vue 2.
What is actually happening?
Only able to get the html section of the rendered content.
I'm migrating an existing vue2 app with ssr built in a special way where instead of the server returning the html directly to the user, we're instead having the node server return a json object with
{
body: ...renderedapphtml...,
assets: {
styles: context.renderStyles(),
scripts: context.renderScripts(),
},
}
However in trying to move to vue3 vite, these properties are missing, and I don't see any replacement for them. Am I missing something, or is this unsupported functionality going forward?
I'm not super familiar with Vue 2 SSR as I never built something worthwhile with it, but I don't think we have context.renderStyles()
... where would you get them from now, and what do they return? The actual code or links to the chunks output by the bundler?
@LinusBorg I recreated the same scenario with Vue2 SSR:
https://codesandbox.io/s/cranky-elgamal-boc76?file=/src/index.js
This example shows how the renderStyles
call is returning a style block by Vue's server-renderer. Here's the documentation for Vue 2 renderStyles
/renderScripts
: https://ssr.vuejs.org/guide/build-config.html#client-config
@yyx990803 I see u have been added a label feat:ssr
. Can you explain how it's even possible no one thought about it during the implementation? This is a base feature of SSR implementation. If someone had more then basic sample code then cannot move to vue3 while this "feature" will not be done. I think this should be some of priority for us... Please someone tell this is some kind of joke...
I think the problem is that the required css/js to be loaded from SSR would be highly coupled with your build system (e.g. webpack). Back in Vue 2, everybody just used webpack and the Vue2 SSR renderer itself required the webpack manifest file so it can return the proper js/css files. Vue 3 seems to be build-system agnostic (to support both webpack/vite) hence this missing feature.
Right now, you can already determine what (non-async) components your rendered page needs yourself.
import { RouteComponent, RouteLocationNormalizedLoaded } from 'vue-router'
export type MatchedComponent = RouteComponent & {
components?: Record<string, MatchedComponent>
__file?: string
}
export function getMatchedComponents(route: RouteLocationNormalizedLoaded): Array<MatchedComponent> {
return route.matched.flatMap((record) => {
const recordComponents = Object.values(record.components) as Array<MatchedComponent>
const childComponents = recordComponents.flatMap((c) => c.components ? Object.values(c.components) : []) as Array<MatchedComponent>
return [
...recordComponents,
...childComponents,
]
})
}
But the problem is that this will only return [MainLayout, Header, Footer, HomePage]
. This doesn't tell you what files you actually need since different build systems have different heretics to chunk js/css files for optimal performance. AFAIK there's no easy way to map components to their final output file.
My Webpack 5 (Hacky) Solution
Webpack config
target: 'web',
entry: {
main: path.resolve('src/entryClient.ts'),
},
module: {
rules: [
{
test: /\.vue$/,
use: [{
loader: 'vue-loader',
options: {
exposeFilename: true, // Important: sets __file property in production builds
},
}],
},
]
},
output: {
filename: createOutputNameFn('js', true),
chunkFilename: createOutputNameFn('js', false),
},
optimization: {
chunkIds: 'named', // Do not mangle names in production
splitChunks: { // Do not merge chunks so we can output each component to named files (e.g. HomePage.vue -> HomePage.js HomePage.css)
chunks: 'all',
minSize: 0,
},
},
plugins: [
new MiniCssExtractPlugin({
filename: createOutputNameFn('css', true),
chunkFilename: createOutputNameFn('css', false),
}),
new WebpackManifestPlugin({
fileName: 'ssr-manifest.json',
}),
]
// createOutputFileNameFn.ts
import { Chunk } from 'webpack'
import { isDev } from './webpack.common'
let chunkNameCounter = 0
const chunkNameMap = new Map<string, number>()
export function createOutputNameFn(ext: string, isInitial: boolean): (pathData: unknown) => string {
const suffix = isDev
? ext
: `[contenthash].${ext}`
return (pathData): string => {
const data = pathData as { chunk: Chunk }
const chunkId = String(data.chunk.id)
// Only emit initial vendors file as 'vendor.js'
if (chunkId.startsWith('vendors') && isInitial) {
return `vendors.${suffix}`
}
if (chunkId.endsWith('_vue')) {
const pathParts = chunkId.split('_').reverse()
const fileName = (pathParts[1] === 'index')
? pathParts[2]
: pathParts[1]
return `${fileName}.${suffix}`
}
let id = isDev
? '[name]'
: chunkNameMap.get(chunkId)
if (id === undefined) {
chunkNameCounter += 1
id = chunkNameCounter
chunkNameMap.set(chunkId, id)
}
return `${id}.${suffix}`
}
}
export class VueSsrRenderer<AppContext extends SSRContext> {
private _manifest: Map<string, string>
constructor(manifestPath: string) {
const rawManifest = JSON.parse(readFileSync(manifestPath).toString('utf-8')) as Record<string, string>
this._manifest = new Map(Object.entries(rawManifest))
}
async render(app: App, appContext: AppContext, routeComponents: Array<MatchedComponent>): Promise<string> {
const { renderToString } = await import('@vue/server-renderer')
const headLinks = this.renderHeadLinks(routeComponents)
const appHtml = await renderToString(app, appContext)
return `
<!DOCTYPE html ${appContext.teleports?.htmlAttrs ?? ''}>
<html lang="en">
<head ${appContext.teleports?.headAttrs ?? ''}>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
${headLinks}
${appContext.teleports?.head ?? ''}
</head>
<body ${appContext.teleports?.bodyAttrs ?? ''}>
${appContext.teleports?.noScript ?? ''}
<div id="app">${appHtml}</div>
${appContext.teleports?.body ?? ''}
${this.renderScript('vendors.js')}
${this.renderScript('main.js')}
</body>
</html>
`
}
private renderHeadLinks(routeComponents: Array<MatchedComponent>): string {
let head = ''
head += this.renderCss('vendors.css')
head += this.renderPreloadLink('vendors.js')
head += this.renderCss('main.css')
head += this.renderPreloadLink('main.js')
// Try to see if any of the matched components in our route exists in the manifest
// If it exists, then insert a preload script for performance and avoid FOUC
for (const c of routeComponents) {
const componentName = c.__file
? getFileName(c.__file)
: c.name
if (!componentName) {
continue
}
head += this.renderPreloadLink(`${componentName}.js`)
head += this.renderCss(`${componentName}.css`)
}
return head
}
private renderPreloadLink(fileName: string): string {
const filePath = this._manifest.get(fileName)
if (filePath?.endsWith('.js')) {
return `<link rel="preload" href="${filePath}" as="script">\n`
} else if (filePath?.endsWith('.css')) {
return `<link rel="preload" href="${filePath}" as="style">\n`
}
return ''
}
private renderCss(fileName: string): string {
const filePath = this._manifest.get(fileName)
if (!filePath) {
return ''
}
return `<link rel="stylesheet" href="${filePath}">\n`
}
private renderScript(fileName: string): string {
const filePath = this._manifest.get(fileName)
if (!filePath) {
return ''
}
return `<script src="${filePath}" defer></script>\n`
}
}
Finally in the Express route handler
const { app, router } = await createApp(ssrContext)
const routeComponents = getMatchedComponents(router.currentRoute.value) // From above example
const renderer = new VueSsrRenderer('path/to/ssr-manifest.json')
const html = await renderer.render(app, appContext, routeComponents)
Limitations
- You are exposing your source code directory structure to your users. This isn't an issue for me since I just write open-source Vue websites for fun
- Your users will be loading a separate css/js file for each component. It's not optimal usage of network connections but parallel connections shouldn't be a big issue with HTTP2
- There's no way to determine what aync components will be loaded (that I know of). As a result, even though they will be rendered on the server, their css/js will not be loaded until after they are resolved on the frontend which will result in a FOUC. Best work around is to avoid using
defineAsyncComponent
- You can only have one entry point in your webpack config. Webpack tries to combine shared vendor code which will result in duplicate output entries in my
createOutputNameFn
An ideal would without these limitations would probably involve overhauling vue-loader
to generate a proper manifest that maps components to their output files instead of hacking webpack's chunking behavior. However, this seems to be a huge undertaking that I don't think will happen for some time...
Alternatively, I suggest waiting for Nuxt 3 next year (?) or see if Quasar 2's SSR build has proper asset resolution
The main issue is I'm trying to move away from webpack to speed up development feedback cycles. In development, it shouldn't need to read the built file, as in client only mode, the application has access to CSS and JS that needs to be rendered, but does not in the SSR context to send back to the client. Unlike the example apps, we want to directly be able to inline the css into the html content instead of pointing a <link>
to a separate file for initial page content, same for js.
In the development context for SSR there isn't a manifest file for node and the application to reference, so there is no way to test that SSR'd css for initial page components is rendered properly onto the page. And for the JS parts, the current lookup will return the script in ESM format, and might require a order of operations check as well.
Our SSR application is already well established custom integration with a lot of components, making this very difficult to work around.
@Trinovantes
Your solutnion is fine, but IMO this is just a work around. This have to be part of CORE SSR in Vue3. Can someone explain how it even possible that in v2 this was a part of core code but in vue 3 noone thought about it? I saw Evan set label feat: ssr
so I conclude from this that noone thought about it: /
Alternatively, I suggest waiting for Nuxt 3 next year (?) or see if Quasar 2's SSR build has proper asset resolution
We are not using Nuxt. We have our own implementation using Webpack and Bundle renderer using Vue 2.
@weikinhuang I think you can get CSS by using this: 3rd party plugin for SSR
Hi @weikinhuang did you find another solution for this? Becuase, we want to upgrade to vue3, but don't want to bundle all stylings to 1 file.
@yyx990803 do you have any workaround for this issue? Or maybe u dont see any problems with that? This was the core solution in V2. How should be move into v3 without the core functionality of SSR?
After digging around webpack's internals, I was able to create a plugin that generates a manifest and runtime code to find the current request's critical assets. It doesn't have any of the limitations from my initial hack and seems to work well for my Vue 3 SSR apps