Twig, Vue and Server Side Rendering: How It Went Sideways
Cyril Gaunet7 min read
One of our clients at Theodo runs a large marketplace. We launched an e-commerce platform for them back in 2016. To do so, the first frameworks used were:
- Symfony + Twig for templating
- jQuery for dynamic contents
But in 2019, as performance was going down, we started thinking about server side rendering.
For those who don’t know Twig, it is a templating engine that Symfony develops (here is the documentation link)
Simple right? It displays static views generated server side easily. But, in 2017, we decided to add Vue in the project as jQuery was already getting old and Vue was more dev-friendly.
Now we have:
- Symfony + Twig for templating
- Vue for templating and dynamic contents
How Twig and Vue work together?
In a Twig file there is a mix of Twig and Vue:
<div class="main-content">
{{ twigVariableToDisplay }} <!-- These are also Twig delimiters -->
<simple-vue-component
:isDisplayed="true"
label="Hello World!"
/>
</div>
And when the user loads the page, he receives the DOM with a component tag and Vue processes it as in any Vue application
Performance
So what happens now for your performance when you go from pure Twig to a mix between Twig and Vue?
A good metric, the speed index:
The speed index is a metric that, roughly speaking, tells you how much time it takes for most of your page to be fully rendered
Speed index getting bad
That is why the A page has a better score than the B one in the above example. After 1 second it renders almost all the content. In opposite, the B page takes up to 4 seconds to have a big chunk appearing.
Performance issue with Vue
Whereas Symfony renders Twig server side, Vue renders on client side, it means that the user receives the twig rendered first and then the Vue part will appear after it’s been processed.
Therefore, the more Vue code you have in your application, the more it will go from A page to B page.
SSR with Vue
As you may know, e-commerce websites must be fast, a recent study made by unbounce says that
27% of the users will leave after 3s of page loading
This is the reason why we wanted to server-side render the Vue part of the application.
Nuxt
In the first place, we thought about Nuxt. It is a widely spread Vue.js framework that makes server side rendering really easy.
However, Nuxt only works with a 100% Vue.js code and is not compatible with our Twig Vue mixed solution.
To do that, we decided to start from scratch and to use Vue server renderer.
Implementation
Vue server renderer
Vue server renderer is a package that Vue develops to ease server side rendering.
First, create the renderer:
const renderer = require("vue-server-renderer").createRenderer();
Then, create a virtual dom server side thanks to JSDOM: (This helps us to globally access a virtual document)
process.stdin.setEncoding('utf8');
let domInline = '';
process.stdin.on('readable', () => { //We will see later how Symfony communicates on this input
let chunk;
// Use a loop to make sure we read all available data.
while ((chunk = process.stdin.read()) !== null) {
domInline += chunk;
}
});
process.stdin.on('end', () => {
const virtualDom = new JSDOM(domInline.toString());
global.document = virtualDom.window.document;
.... The rest of the following code comes here
}
Next, declare your Vue app as usual:
const app = new Vue({
el: "#app-container",
store,
...
})
Finally, render this app server side:
renderer.renderToString(app, (err, renderedHTML) => {
const appEL = document.getElementById("app-container");
// Insert global __TEMPLATE__ variable to be used by front
const scriptEl = document.getElementById("ssr-script-container"); //This is a script tag you put into your twig
scriptEl.innerHTML = `
var template = "${appEL.outerHTML}";
window.__SSR_CONFIG__ = {
fromServer: true,
template: template,
};
`;
appEL.parentElement.replaceChild(createElementFromHTML(renderedHTML), appEL);
process.stdout.write(document.documentElement.outerHTML);
});
And that’s it for the node part of it!
Symfony side
First of all, on the Symfony side, we need to render the Twig content before passing it to the node process:
$content = $this->render(':homepage:view.html.twig', $twigVariables);
Moreover, we need to communicate this content to the node process. To do so, we use the proc_open method from PHP.
To begin, let’s make a method that creates the pipes we will use:
private function nodeProcOpen(&$pipes)
{
$processInputOutputConfig = array(
0 => array('pipe', 'r'), // stdin is a pipe that the child will read from
1 => array('pipe', 'w'), // stdout is a pipe that the child will write to
);
$scriptFilePath = "$PROJECT_PATH/bin/server-side-renderer.js";
if (!file_exists($scriptFilePath)) return false;
return proc_open(
"node $scriptFilePath",
$processInputOutputConfig,
$pipes,
null, //If null, the process is launched by default where PHP is launched
null //If null, the process got the same env variables has the one of the parent
);
}
In addition to that, let’s create three methods: two to read/write into the pipes and one to wait for the process to finish:
private function readFromPipeAndClose($pipe)
{
$content = stream_get_contents($pipe);
fclose($pipe);
return $content;
}
private function writeIntoPipeAndCloseIt($pipe, $content)
{
fwrite($pipe, $content);
fclose($pipe);
}
private function waitForNodeProgramToExit($nodeProcess)
{
$status = proc_get_status($nodeProcess);
while (1 === $status['running']) {
$status = proc_get_status($nodeProcess);
}
}
All in all, we can render the Vue components:
public function renderVueComponents($originalDom)
{
$nodeProcess = $this->nodeProcOpen($pipes);
$domWithRenderedVueComponents = '';
if (is_resource($nodeProcess)) {
// $pipes now looks like this:
// 0 => writeable handle connected to child stdin
// 1 => readable handle connected to child stdout
// Any error output will be appended to /tmp/error-output.txt
$this->writeIntoPipeAndCloseIt($pipes[0], $originalDom);
$this->waitForNodeProgramToExit($nodeProcess);
$domWithRenderedVueComponents = $this->readFromPipeAndClose($pipes[1]);
proc_close($nodeProcess);
if ('ERROR: Fail to server-side render' === $domWithRenderedVueComponents
|| false === $domWithRenderedVueComponents
) {
$domWithRenderedVueComponents = $originalDom;
}
}
$whitelinesToBeReplaced = '/\>[\s]*\</'; //This is meant to replace useless extra whitelines
$content = preg_replace($whitelinesToBeReplaced, '><', $domWithRenderedVueComponents);
return $content;
}
Congratulations! Your Vue components are now server side rendered! 🎉
Major problems
Mismatch between client and server side rendered DOM
Nevertheless, we encountered some problems as we went through this solution.
For instance, the client side rendered DOM and the server side rendered DOM must match for the Vue application to make the page dynamic again. In fact, this is a requirement I can speak about in another article, let me know if it interests you 😉
Thus, if everything matches, your page is dynamic:
However, if your client renders one extra span or that a link becomes a span, there is a mismatch and the page is static, ouch!
Given that we wanted to render our top menu server side on every page of the website, there was a tremendous amount of conflicts we needed to fix. Yet, most of them were happening in a part of the page the user could not see at first, it conflicted on components the client could render and not the server.
We went to see the source code of vue-server-renderer and noticed one really important information to do this:
// empty element, allow client to pick up and populate children
if (!element.hasChildNodes()) {
createChildren(vnode, children, insertedVnodeQueue)
As a matter of fact, these three lines teach us that the client application can populate an element as long as the server application let that element without any children.
Actually, it means that if you want to render a component only client side, you only need to render its first HTML element and leave the children empty:
The user downloads twice the DOM
In fact, for the client Vue application to make the page dynamic, it needs to know how the DOM was before server side rendering. Thus, we inject this pre-rendered DOM in a template variable directly in a script tag for the Vue application to read it. It causes a performance problem that can’t be solved.
Communication between Symfony and Node.js
At first sight, we were calling the node process using the —html option of Vue server renderer. It was working perfectly fine until the DOM became too big and the HTML option was not following. Hence, we have chosen to use the proc_open solution
Conclusion
On the one hand, we managed to put this solution in production, the top menu was server side rendered on the homepage and we had a significant improvement on our speed index.
On the other hand, we looked at all the pros and cons and finally decided to go with Nuxt, the article about this is coming the next week!
Want to go from Vue to Nuxt on your project? Feel free to contact one of our Vue/Nuxt.js experts!