Subscribe on changes!

server-renderer replacement for renderStyles/renderScripts

avatar
Aug 18th 2021

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?

avatar
Aug 19th 2021

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?

avatar
Aug 20th 2021

@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

avatar
Aug 30th 2021

@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...

avatar
Aug 30th 2021

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

avatar
Aug 30th 2021

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.

avatar
Aug 30th 2021

@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.

avatar
Mar 9th 2022

@weikinhuang I think you can get CSS by using this: 3rd party plugin for SSR

avatar
Mar 9th 2022

@Froxz that plugin requires that you use it's internal CSS framework

avatar
Mar 29th 2022

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.

avatar
Mar 29th 2022

No, I haven't found any workarounds so far.

avatar
Apr 9th 2022

@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?

avatar
Apr 18th 2022

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

https://www.npmjs.com/package/vue-ssr-assets-plugin