Vue: [ssr] Add Vue function to render VNode to html string

Created on 16 Dec 2018  路  23Comments  路  Source: vuejs/vue

What problem does this feature solve?

Use case:

I'm developing a Server-Side Renderer for Vue (which works with Express, Koa & etc. Will increase migration to Vue). For the SSR's head management to work, it needs a stable API to render VNodes to text.

The way my Vue SSR package will function:
master.vue

<template>
    <div id="app">
        <slot name="content"></slot>
    </div>
</template>
<script>
export default {
    created: function(){
        if(this.$isServer){
            this.$ssrContext.head = "HEAD HERE" // Something needed like:  renderVNodesToString(this.$slots.head)
        }
    },
}
</script>

home.vue

<template>
    <master>
        <template slot="content">
            Hello World
        </template>
        <template slot="head">
            <script src="https://unpkg.com/vue/dist/vue.js"></script>
            <title>Hello</title>
        </template>
    </master>
</template>
<script>
import master from "layouts/master.vue"

export default {
    components: {
        master
    }
}
</script>

My goal is getting home.vue's head slot rendered into a string and injecting it into the this.$ssrContext so it can be read and injected on the server-side

in master.vue, I can access this.$slots.head with no issue, and it contains the correct VNodes

my question is, how can I render them into a string? a way to basically do:

this.$ssrContext.head = renderVNodesToString(this.$slots.head)

From my research, I have been unable to find an easy way to do this.


To understand how the renderer works

const renderer = createBundleRenderer(bundle.server, {
    runInNewContext: false,
    inject: false,
    template: `<!DOCTYPE html>
        <html>
            <head>
                {{{ head }}}
                {{{ renderResourceHints() }}}
                {{{ renderStyles() }}}
            </head>
            <body>
                <!--vue-ssr-outlet-->
                <script>${ bundle.client }</script>
            </body>
       </html>`
})

This is the code for the serverbundlerenderer

What does the proposed API look like?

/**
* @param {VNode}
* 
* @returns {string} - VNode rendered to a html string
*/
Vue.renderVNode = function(VNode){
    //...
}
feature request

All 23 comments

Technically, you should be able to do this by using a function that returns the vnode and render using what Vue already exports (https://ssr.vuejs.org/guide/#rendering-a-vue-instance).
Not sure of the utility of a feature like this.
FYI you can use https://ssr.vuejs.org/guide/#rendering-a-vue-instance 馃檪

the issue is, the VNode cannot be passed to the server, besides by putting it in the template (but since it is an object it turns into [object Object]). You can't use complex functions in template brackets, as they are single-expression only.

<head>
     {{{ head }}}
     {{{ renderResourceHints() }}}
     {{{ renderStyles() }}}
</head>

Note
It cannot be rendered within the created() function inside the .vue file because rendering is an async operation, which means it cannot assign a variable to this.$ssrContext safely, as created() is synchronous

If I had access to the proposed function, I would be able to pass it to the server easily via the this.$ssrContext variable
master.vue's script

export default {
    created: function(){
        if(this.$isServer){
            var VNodeArray = this.$slots.head
            var html = ""
            for(let i in VNodeArray){
                html += Vue.renderVNode(VNodeArray[i])
            }
            this.$ssrContext.head = html
        }
    }
}

output ($ssrContext)

<script src="https://unpkg.com/vue/dist/vue.js"></script>
<title>Hello</title>

And I understand the use-case may be rare, but this is something that can and will increase server-side adoption and would be even useful for Nuxt.

This would finally allow a head management system to exist in the html/template section of SFCs and would make SSR, SEO and head management much easier

If this is not possible, do you know a way to find a solution to this issue?

@posva if it cannot be added to the core, there is an alternate place it could go:

https://ssr.vuejs.org/guide/build-config.html#client-config scroll to Manual Asset Injection

there should be a context.renderVNode(vnode) / context.renderVNodes(vnodes) in the context API.

This would allow:

createBundleRenderer(serverBundle, {
            inject: false,
            template: `<!DOCTYPE html>
            <html>
                <head>
                    {{{ renderVNodesToString(headVNodes) }}}
                    {{{ renderResourceHints() }}}
                    {{{ renderStyles() }}}
                </head>
                <body>
                    <!--vue-ssr-outlet-->
                    <script>${ clientBundle }</script>
                </body>
            </html>`
        })

where headVNodes is set by this.$ssrContext.headVNodes = this.$slots.head from within a .vue file

I'd be happy to implement an API with tests in vue-server-renderer package for rendering VNodes if given the all-clear.

Proposed API

const { createBundleRenderer, renderVNodesToString } = require("vue-server-renderer")

var renderer = createBundleRenderer(serverBundle, {
            inject: false,
            template: `<!DOCTYPE html>
    <html>
        <head>
            {{{ renderVNodesToString(headVNodes) }}}
        </head>
        <body>
            <!--vue-ssr-outlet-->
        </body>
    </html>`
})

// headVNodes set by this.$ssrContext = this.$slots.head in vue file

renderer.renderToString({
    renderVNodesToString
})

This would allow for a far greater head management system for Vue that's based in the <template>, and would increase server-side adoption in the process. This API should add virtually no over-head to users who decide not to use it. Size increase would be minimal, and limited to the vue-server-renderer package.

My further findings show that if the function is provided, it would need to be either:

  • Fully synchronous, so it could be included in the template of the bundle renderer
  • Or, allowing async code in the {{{ ... }}} syntax in the template so await may be used.

@yyx990803 I would be happy to try implement this.

My implementation would involve making ssr template able to parse awaits (and getting rid of lodash template compiler in the process), and then adding a new exposed API for rendering VNode(s)

Accidental close

@yyx990803 I think that it was accidentally moved from Todo to Done in 2.6 project, once @DominusVilicus accidentally closed the issue. I'm sorry if bothering.

I think I originally misunderstood the request when I added it to 2.6 - after looking at it in more details I think this can be done in userland.

There are a few things that I have concern about landing this in Vue itself:

  • This method cannot be exposed on the Vue runtime, since it's a server only utility (and thus should not be included in the universal runtime)

  • It's better exposed on this.$ssrContext as an injected helper. (This means you can implement it yourself)

  • The renderToString exposed by vue-server-renderer can only be async because there may be async components or async data prefetch functions down the tree. We cannot expose a sync API because it will not work correctly in all cases. In comparison, it's much easier to write a simple VNode -> string render function that only handles predictable <head> content.

The use case also seems niche, so I think we'd be better off to test an implementation in userland first.

@yyx990803

I'd be happy to implement this in userland, but the issue is that there is no exposed API that I could use to render VNodes to a string

The reason you can't use renderToString is because the operation is async, and the bundle renderer won't wait for promises in the {{{ renderVNodes(headVNodes) }}} operation

const { createBundleRenderer} = require("vue-server-renderer")

var renderer = createBundleRenderer(serverBundle, {
            inject: false,
            template: `<!DOCTYPE html>
    <html>
        <head>
            {{{ renderVNodes(headVNodes) }}}
        </head>
        <body>
            <!--vue-ssr-outlet-->
        </body>
    </html>`
})

async function renderVNodes(vnodes){
     // create bundle renderer for the head
     // await renderToString
     // return rendered string
     // error because template can't handle promise values
}

//pass $ssrContext the VNode renderer
renderer.renderToString({
    renderVNodes
})

From studying the internals, it seems the two options are:

  1. making the Lodash template compiler able to handle await (which would probably mean implementing a custom template compiler for the template setting
  2. exposing an API for synchronously rendering VNodes in the vue-server-renderer

The simplest option would be rewriting the template compiler for the vue-server-renderer to allow await expressions, this would also remove the Lodash template compiler dependency

I think this overcomplicates the problem. (Adding API surface for a niche case)

It's quite straightforward if what you need is just sync rendering of head elements (with a very predictable range of element/attributes, so very few edge cases to watch out for). VNodes are just objects in the shape of { tag, data: { attrs: { [key]: value }}}. You are essentially writing a function that serializes a few such objects into HTML strings... it probably is just 30 lines of code without having to patch anything in Vue itself.

You're right, I will probably just do that.

But I still actually like the function-based template.

  • It allows async operations in the template
  • makes the bundle renderer easier to use, and more JS templating (strings & vars) instead of {{{ }}}
  • the documentation could be simplified
  • could potentially remove/deprecate string-based Lodash template dependency (in a major version)

With template function

const renderer = createBundleRenderer(bundle.server, {
            async template(result, context){
                return `
                <!DOCTYPE html>
                    <html${ context.htmlattr ? ' ' + context.htmlattr : '' }>
                    <head>
                        ${ await context.renderVNodes(context.head) }
                        ${ context.renderResourceHints() }
                        ${ context.renderStyles() }
                        ${ context.renderState({ windowKey: '__INITIAL_STATE__', contextKey: "data"}) }
                    </head>
                    <body>
                        ${result}
                        <script>${ bundle.client }</script>
                    </body>
                </html>`
            }
        })

vs string template

const renderer = createBundleRenderer(bundle.server, {
            inject: false,
            template: `<!DOCTYPE html>
            <html{{{ htmlattr ? ' ' + htmlattr : '' }}}>
                <head>
                    {{{ head }}}
                    {{{ renderResourceHints() }}}
                    {{{ renderStyles() }}}
                    {{{ renderState({ windowKey: '__INITIAL_STATE__', contextKey: "data"}) }}}
                </head>
                <body>
                    <!--vue-ssr-outlet-->
                    <script>${ bundle.client }</script>
                </body>
            </html>`
        })

You're right. The usage example makes it much clearer. Let's consider adding this to 2.6.

Awesome! I managed to create my own custom VNode renderer for the specific task of head management (you can see it in #code-review in Vue Land)

I did make a working version with the function based template in #9324 but tests were failing for some reason. It was a non-breaking change too (users could optionally still use the string template)

For me the this.$ssrContext inside a component is undefined. I am using v2.5.22 and the vue-hackernews-2.0 template. Based on what the documentation says, it should be exposed in the component automatically

Finally a working draft:

Vue.mixin({
  created: function () {
    var title = this.$options.title
    if (title) {
      if(typeof document !== 'undefined')
        document.title = title;
      else
        this.$ssrContext.title = title
    }
  }
})

and inside a component:

  export default {
    name: 'home',
    title: 'hello!',
...

Now need to populte this with API data.

Have you had a look at https://github.com/futureaus/servue?

@DominusVilicus I have the following task and am wondering if you are trying to accomplish something similar with this request.

Environment:
Dot Net Core 2.1

Task:

  1. Fetch HTML string from API in serverPrefetch(). _This HTML can contain [PLACEHOLDERS]._
  2. Replace [PLACEHOLDERS] with proper vue component, such as replacing [MAIN_NAV] placeholder with <main-nav :code="navHtml" /> component.
  3. Render updated string of HTML with newly added vue components on the server and deliver to browser with complete functionality for navigation.

Reading up on https://github.com/futureaus/servue now to see if this is a viable solution, but wanted to toss a note out to you for your feedback.

@alucidwolf What are you trying to accomplish? You shouldn't be rendering HTML code from a server, if you need a dynamic menu, send the menu data as the data, and let your .vue files render it

@DominusVilicus I want to create different page layouts from this Html so it is not just data I need for the menu but also the Html structure for the page layout, which can be different.

Was this page helpful?
0 / 5 - 0 ratings