vue
vue copied to clipboard
ssr cannot completely inject the resources that a page needs
Version
2.6.14
Reproduction link
https://github.com/tcstory/vue-ssr-issue
Steps to reproduce
- npm run build
- npm run start
- visit http://localhost:3000/page1 with javascript enabled and disabled respectively
What is expected?
with javascript disabled, the corresponding css should be inject to html
What is actually happening?
the corresponding css is missed
What problems does this cause?
Let's consider the following situations
- user visit the page
- after browser finish loading the page, javascript will be excuted and the missed css file will be inject to the page
- the page will update after the css file is loaded
In this process, the user can observe that the page is "jittering" because some css styles are missing when the page is loaded at the beginning, and the css styles are not loaded back until after the js is executed.
what causes this bug?
the following are my own analysis
During server-side rendering, vue generates a string of hash inside the component. By using this string of hash, vue knows what components the page depends on during rendering, and then injects the static resources of these components into the html.
This process usually works without any problems, unless you run into webpack's splitChunks.
Let's assume we have two pages, page1 and page2, and a btn1.vue file that they both depend on
- page1.vue depends on btn1.vue
- page2.vue depends on btn1.vue
- btn1.vue depends on btn1.css
after building, you can get the following files
- page1.js
- page2.js
- page1~page2.js (this file contains the component's js and css code)
However, if you have an additional page3 page, and this page also depends on btn1.css, in this case, after building, you will get the following files
- page1.js
- page2.js
- page3.js
- page1~page2.js (this file only contains the js code of btn1.vue)
- page1~page2 ~ page3.js (this file only contains the code of btn.css)
At this time, when you visit the page1 page, ssr can only inject "page1.js" and "page1 ~ page2.js" files for you, but it doesn't know that you also need page1~page2 ~ page3.js files.
my english is poor, here is a chinese version about the issue: https://github.com/tcstory/blog/issues/13
@tcstory Did you ever find a solution for this?
@tcstory Did you ever find a solution for this?
yes, I had fixed the problem by writing a new webpack plugin based on the original one, i didn't publish the new webpack plugin on npm, i just used it in my project.
the following are the source code, feel free to use it
"use strict";
/* */
const isJS = function (file) {
return /\.js(\?[^.]+)?$/.test(file);
};
const isCSS = function (file) {
return /\.css(\?[^.]+)?$/.test(file);
};
const onEmit = function (compiler, name, hook) {
if (compiler.hooks) {
// Webpack >= 4.0.0
compiler.hooks.emit.tapAsync(name, hook);
} else {
// Webpack < 4.0.0
compiler.plugin("emit", hook);
}
};
const hash = require("hash-sum");
const uniq = require("lodash.uniq");
const VueSSRClientPlugin = function VueSSRClientPlugin(options) {
if (options === void 0) options = {};
this.options = Object.assign(
{
filename: "vue-ssr-client-manifest.json",
},
options
);
};
VueSSRClientPlugin.prototype.apply = function apply(compiler) {
onEmit(compiler, "vue-client-plugin", (compilation, cb) => {
const stats = compilation.getStats().toJson();
const allFiles = uniq(
stats.assets.map(function (a) {
return a.name;
})
);
const initialFiles = uniq(
Object.keys(stats.entrypoints)
.map(function (name) {
return stats.entrypoints[name].assets;
})
.reduce(function (assets, all) {
return all.concat(assets);
}, [])
.filter(function (file) {
return isJS(file) || isCSS(file);
})
);
const asyncFiles = allFiles
.filter(function (file) {
return isJS(file) || isCSS(file);
})
.filter(function (file) {
return initialFiles.indexOf(file) < 0;
});
const manifest = {
publicPath: stats.publicPath,
all: allFiles,
initial: initialFiles,
async: asyncFiles,
modules: {
/* [identifier: string]: Array<index: number> */
},
};
const assetModules = stats.modules.filter(function (m) {
return m.assets.length;
});
const fileToIndex = function (file) {
return manifest.all.indexOf(file);
};
const isSplitChunk = function (chunk) {
return chunk.reason && /split chunk/.test(chunk.reason);
};
const splitChunkMap = {};
for (const chunk of stats.chunks) {
if (isSplitChunk(chunk)) {
splitChunkMap[chunk.id] = chunk.files.map(fileToIndex);
}
}
stats.modules.forEach(function (m) {
// ignore modules duplicated in multiple chunks
if (m.chunks.length === 1) {
const cid = m.chunks[0];
const chunk = stats.chunks.find(function (c) {
return c.id === cid;
});
if (!chunk || !chunk.files) {
return;
}
const id = m.identifier.replace(/\s\w+$/, ""); // remove appended hash
const files = (manifest.modules[hash(id)] =
chunk.files.map(fileToIndex));
if (!isSplitChunk(chunk)) {
for (const sibling of chunk.siblings) {
if (splitChunkMap[sibling]) {
// eslint-disable-next-line
files.push.apply(files, splitChunkMap[sibling]);
}
}
}
// find all asset modules associated with the same chunk
assetModules.forEach(function (m) {
if (
m.chunks.some(function (id) {
return id === cid;
})
) {
// eslint-disable-next-line
files.push.apply(files, m.assets.map(fileToIndex));
}
});
} else {
//
}
});
const json = JSON.stringify(manifest, null, 2);
compilation.assets[this.options.filename] = {
source: function () {
return json;
},
size: function () {
return json.length;
},
};
cb();
});
};
module.exports = VueSSRClientPlugin;