vue icon indicating copy to clipboard operation
vue copied to clipboard

ssr cannot completely inject the resources that a page needs

Open tcstory opened this issue 2 years ago • 2 comments

Version

2.6.14

Reproduction link

https://github.com/tcstory/vue-ssr-issue

Steps to reproduce

  1. npm run build
  2. npm run start
  3. 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

  1. user visit the page
  2. after browser finish loading the page, javascript will be excuted and the missed css file will be inject to the page
  3. 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

  1. page1.vue depends on btn1.vue
  2. page2.vue depends on btn1.vue
  3. btn1.vue depends on btn1.css

after building, you can get the following files

  1. page1.js
  2. page2.js
  3. 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

  1. page1.js
  2. page2.js
  3. page3.js
  4. page1~page2.js (this file only contains the js code of btn1.vue)
  5. 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 avatar Jun 21 '21 11:06 tcstory

@tcstory Did you ever find a solution for this?

christoph-bessei avatar Jul 21 '22 16:07 christoph-bessei

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

tcstory avatar Jul 23 '22 06:07 tcstory