谈谈十年大前端对 Vue SSR 的理解

744 阅读9分钟

前端性能优化,首推 SSR(Server Side Rendering,服务端渲染),那它是怎么做到的呢?

先抛出几个问题:

  1. 什么是 SSR?
  2. 什么是流式渲染?
  3. 如何判断页面是否使用 SSR?
  4. 虚拟 DOM 如何转成 DOM 字符串?
  5. hydrate 主要做了什么?和 Dom Diff 区别?什么情况下会失败?
  6. 收益在哪?代价是什么?

什么是 SSR

原理都知道,但我想看看代码是怎么写的?

还是以一个点击+1的按钮举例,为了更真实模拟,页面描述信息“sheng_SSU”通过网络请求获取。

image.png

如果是 CSR(Client Side Rendering,客户端渲染),非常简单。

从 CSR 代码可知,初始 DOM 只有一个 <div id="app"></div>,文本和按钮是通过运行 Vue 的 js 脚本生成目标 DOM。

那 SSR 是什么呢?

就是初始 DOM 就包含用户首次看到的 DOM 结构 <div id="app"><div>Hello sheng_SSU</div><button>0</button></div>,这样就省去了执行脚本生存页面 DOM 时间。

这块依赖于 js 脚本代码,如何生成呢?

vue-server-renderer包提供了该功能。

// 运行命令:node ./ssr-local.v0.js 

// node 环境通过 require 导入依赖
// 先需要安装依赖 "dependencies": { "axios": "^1.9.0", "vue": "^2.7.16", "vue-server-renderer": "^2.7.16"}
const Vue = require("vue");
const axios = require("axios");

// 同csr代码部分
const App = {
  name: "app",
  props: {},
  // 内联DOM模板
  template: `<div id="app"><div>{{ msg }}</div><button @click="add">{{ count }}</button></div>`,
  data: function () {
    return {
      count: 0,
      msg: "",
    };
  },
  preFetch: async function name(params) {
    const responseData = await axios.get("https://cdn.jsdelivr.net/gh/shengshuqiang/sheng-vue-playground@main/vue-ssr/data.json");
    console.log('preFetch', responseData.data);
    return responseData;
  },
  created: async function () {
    console.log('created 开始,发送请求');
    const responseData = await App.preFetch();
    const name = responseData.data.name;
    this.msg = `Hello ${name}`;
    console.log('created 结束,请求返回');
  },
  methods: {
    add: function () {
      this.count++;
    },
  },
};

// 第 1 步:创建一个 vue 实例
const app = new Vue({
  render: (h) => h(App),
});
// 不需要挂载,node环境没有dom,也没法挂载
// app.$mount("#app");

// 第 2 步:创建一个 renderer
const renderer = require("vue-server-renderer").createRenderer();

// 第 3 步:将 Vue 实例渲染为 HTML
renderer
  .renderToString(app)
  .then((html) => {
    console.log('生成html', html);
  })
  .catch((err) => {
    console.error(err);
  });

源码位置 /vue-ssr/ssr-local.v0.js

image.png

运行结果如下:

<div id="app" data-server-rendered="true"><div></div><button>0</button></div>

眼尖的同学发现还差点意思,<div>Hello sheng_SSU</div>没有出来。

通过日志发现:

  1. created 开始,发送请求
  2. 生成html <div id="app" data-server-rendered="true"><div></div><button>0</button></div>
  3. preFetch { name: 'sheng_SSU' }
  4. created 结束,请求返回

虽然在 Vue 的 created 生命周期中 await 网络请求,实际上其他生命周期执行是异步的,并不会等待 created 结束。

解决方案就简单了,先调用发送请求,请求结束后触发 renderToString,需要稍微改造下代码,引入 preFetchData 存储网络数据。

// 运行命令:node ./ssr-local.v1.js

// node 环境通过 require 导入依赖
// 先需要安装依赖 "dependencies": { "axios": "^1.9.0", "vue": "^2.7.16", "vue-server-renderer": "^2.7.16"}
const Vue = require("vue");
const axios = require("axios");

// 同csr代码部分
const preFetchData = {};
const App = {
  name: "app",
  props: {},
  // 内联DOM模板
  template: `<div id="app"><div>{{ msg }}</div><button @click="add">{{ count }}</button></div>`,
  data: function () {
    return {
      count: 0,
      msg: "",
    };
  },
  preFetch: async function name(params) {
    console.log("preFetch 开始,发送请求");
    const responseData = await axios.get("https://cdn.jsdelivr.net/gh/shengshuqiang/sheng-vue-playground@main/vue-ssr/data.json");
    preFetchData.data = responseData.data;
    console.log("preFetch 结束,请求返回", responseData.data);
  },
  created: async function () {
    console.log("created 开始");
    if (!preFetchData.data) {
      await App.preFetch();
    }
    const name = preFetchData.data.name;
    this.msg = `Hello ${name}`;
    console.log(`created 结束, this.msg=${this.msg}`);
  },
  methods: {
    add: function () {
      this.count++;
    },
  },
};

// 第 1 步:创建一个 vue 实例
const app = new Vue({
  render: (h) => h(App),
});
// 不需要挂载,node环境没有dom,也没法挂载
// app.$mount("#app");

// 第 2 步:创建一个 renderer
const renderer = require("vue-server-renderer").createRenderer();

App.preFetch().then(() => {
  // 第 3 步:将 Vue 实例渲染为 HTML
  renderer
    .renderToString(app)
    .then((html) => {
      console.log("生成html", html);
    })
    .catch((err) => {
      console.error(err);
    });
});

源码位置 /vue-ssr/ssr-local.v1.js

image.png

运行结果如下:

<div id="app" data-server-rendered="true"><div>Hello sheng_SSU</div><button>0</button></div>

妥了。但是,接下来怎么做?难道要我把最终html通过字符串拼接出来。

为什么不呢?

这里不逐步推演了,通过模版字符串替换的方式而不是直接拼接,这样代码可读性更高。

为了便于拼接,提供的是 CSR 代码的字符串,而不是 js 代码,通过 new Function(codeStr)() 运行。

最后一步在html中注入依赖包的 cdn 脚本。

// 运行命令:node ./ssr-local.v2.js

// node 环境通过 require 导入依赖
// 先需要安装依赖 "dependencies": { "axios": "^1.9.0", "vue": "^2.7.16", "vue-server-renderer": "^2.7.16"}
const Vue = require("vue");
const axios = require("axios");

// HTML代码模版
const HTML_TEMPLATE = `
<html lang="en">
  <head>
    <title>Hello SSR</title>
  </head>
  <body>
    <div id="app"></div>
    <script id="js"></script>
  </body>
</html>
`;

// csr代码部分,因为要拼接代码,这里使用字符引入,需要的地方加上转义符\
const CSR_CODE = `
const preFetchData = {};
const App = {
  name: "app",
  props: {},
  // 内联DOM模板
  template: \`<div id="app"><div>{{ msg }}</div><button @click="add">{{ count }}</button></div>\`,
  data: function () {
    return {
      count: 0,
      msg: "",
    };
  },
  preFetch: async function name(params) {
    console.log("preFetch 开始,发送请求");
    const responseData = await axios.get("https://cdn.jsdelivr.net/gh/shengshuqiang/sheng-vue-playground@main/vue-ssr/data.json");
    preFetchData.data = responseData.data;
    console.log("preFetch 结束,请求返回", responseData.data);
  },
  created: async function () {
    console.log("created 开始");
    if (!preFetchData.data) {
      await App.preFetch();
    }
    const name = preFetchData.data.name;
    this.msg = \`Hello \${name}\`;
    console.log(\`created 结束, this.msg=\${this.msg}\`);
  },
  methods: {
    add: function () {
      this.count++;
    },
  },
};
`;
const App = new Function('axios', CSR_CODE + "return App;")(axios);
// 第 1 步:创建一个 vue 实例
const app = new Vue({
  render: (h) => h(App),
});
// 不需要挂载,node环境没有dom,也没法挂载
// app.$mount("#app");

// 第 2 步:创建一个 renderer
const renderer = require("vue-server-renderer").createRenderer();
App.preFetch().then(() => {
  // 第 3 步:将 Vue 实例渲染为 HTML
  renderer
    .renderToString(app)
    .then((html) => {
      const allCSRCode = `${CSR_CODE} new Vue({render: (h) => h(App)}).$mount("#app");`;
      const scriptStr = `<script src="https://cdn.jsdelivr.net/gh/shengshuqiang/sheng-vue-playground@main/vue/vue.v2.7.16.js"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.15.3/axios.min.js"></script><script>${allCSRCode}</script>`;
      const targetHtml = HTML_TEMPLATE.replace('<div id="app"></div>', html).replace('<script id="js"></script>', scriptStr);
      console.log("生成html", targetHtml);
    })
    .catch((err) => {
      console.error(err);
    });
});

源码位置 /vue-ssr/ssr-local.v3.js

image.png

输出如下 html 字符串如下:

<html lang="en">
  <head>
    <title>Hello SSR</title>
  </head>
  <body>
    <div id="app" data-server-rendered="true"><div>Hello sheng_SSU</div><button>0</button></div>
    <script src="http://127.0.0.1:8080/vue.v2.7.16.js"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.15.3/axios.min.js"></script><script>
const preFetchData = {};
const App = {
  name: "app",
  props: {},
  // 内联DOM模板
  template: `<div id="app"><div>{{ msg }}</div><button @click="add">{{ count }}</button></div>`,
  data: function () {
    return {
      count: 0,
      msg: "",
    };
  },
  preFetch: async function name(params) {
    console.log("preFetch 开始,发送请求");
    const responseData = await axios.get("http://localhost:8080/data.json");
    preFetchData.data = responseData.data;
    console.log("preFetch 结束,请求返回", responseData.data);
  },
  created: async function () {
    console.log("created 开始");
    if (!preFetchData.data) {
      await App.preFetch();
    }
    const name = preFetchData.data.name;
    this.msg = `Hello ${name}`;
    console.log(`created 结束, this.msg=${this.msg}`);
  },
  methods: {
    add: function () {
      this.count++;
    },
  },
};
 new Vue({render: (h) => h(App)}).$mount("#app");</script>
  </body>
</html>

保存为 html 文件通过浏览器打开,点击按钮可以累加。

image.png

写到这,简版 SSR 出来了,为了更真实一点,使用 express 搭建个服务。

// 本地启动node服务 ~/sheng-vue-playground/vue-ssr  node ./ssr-server.v2.js
// 访问url http://127.0.0.1:8080/app.html

// node 环境通过 require 导入依赖
// 先需要安装依赖 "dependencies": { "axios": "^1.9.0", "vue": "^2.7.16", "vue-server-renderer": "^2.7.16", "express": "^4.21.2",}
const Vue = require("vue");
const axios = require("axios");
const path = require("path");
const server = require("express")();

// HTML代码模版
const HTML_TEMPLATE = `
<html lang="en">
  <head>
    <title>Hello SSR</title>
  </head>
  <body>
    <div id="app"></div>
    <script id="js"></script>
  </body>
</html>
`;

// csr代码部分,因为要拼接代码,这里使用字符引入,需要的地方加上转义符\
const CSR_CODE = `
const preFetchData = {};
const App = {
  name: "app",
  props: {},
  // 内联DOM模板
  template: \`<div id="app"><div>{{ msg }}</div><button @click="add">{{ count }}</button></div>\`,
  data: function () {
    return {
      count: 0,
      msg: "",
    };
  },
  preFetch: async function name(params) {
    console.log("preFetch 开始,发送请求");
    const responseData = await axios.get("https://cdn.jsdelivr.net/gh/shengshuqiang/sheng-vue-playground@main/vue-ssr/data.json");
    preFetchData.data = responseData.data;
    console.log("preFetch 结束,请求返回", responseData.data);
  },
  created: async function () {
    console.log("created 开始");
    if (!preFetchData.data) {
      await App.preFetch();
    }
    const name = preFetchData.data.name;
    this.msg = \`Hello \${name}\`;
    console.log(\`created 结束, this.msg=\${this.msg}\`);
  },
  methods: {
    add: function () {
      this.count++;
    },
  },
};
`;
const {App, preFetchData} = new Function("axios", CSR_CODE + "return {App, preFetchData};")(axios);
// 第 1 步:创建一个 vue 实例
const appVue = new Vue({
  render: (h) => h(App),
});
// 不需要挂载,node环境没有dom,也没法挂载
// app.$mount("#app");

// 第 2 步:创建一个 renderer
const renderer = require("vue-server-renderer").createRenderer();

function serverSideRendering(req, res) {
  const context ={};
  // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML
  context.state = preFetchData;
  // 请求完网络数据再渲染SSR
  App.preFetch().then(() => {
    // 第 3 步:将 Vue 实例渲染为 HTML
    renderer
      .renderToString(appVue, context)
      .then((html) => {
        const allCSRCode = `${CSR_CODE} new Vue({render: (h) => h(App)}).$mount("#app");`;
        const scriptStr = `<script src="https://cdn.jsdelivr.net/gh/shengshuqiang/sheng-vue-playground@main/vue/vue.v2.7.16.js"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.15.3/axios.min.js"></script><script>${allCSRCode}</script>`;
        const targetHtml = HTML_TEMPLATE.replace(
          '<div id="app"></div>',
          html
        ).replace('<script id="js"></script>', scriptStr);
        console.log("生成html", targetHtml);
        res.set("Content-Type", "text/html; charset=utf-8");
        res.end(targetHtml);
      })
      .catch((err) => {
        console.error(err);
      });
  });
}

// 服务端口监听处理
server.get("*", (req, res) => {
  console.log(`server.get`, req.url);
  // CROS
  // 设置允许的来源
  const referer = req.headers.referer || "http://127.0.0.1:8080/";

  res.setHeader(
    "Access-Control-Allow-Origin",
    referer.substring(0, referer.length - 1)
  );
  // 设置允许的请求方法
  res.setHeader(
    "Access-Control-Allow-Methods",
    "GET, POST, PUT, DELETE, OPTIONS"
  );
  // 设置允许的请求头
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
  // 设置预检请求的缓存时间(可选)
  res.setHeader("Access-Control-Max-Age", "3600");
  // if (req.url === "/data.json") {
  //   res.status(200).json({ name: "sheng_SSU" });
  // } else 
  if (req.url === "/app.html") {
    serverSideRendering(req, res);
  } 
  // else if (req.url === "/vue.v2.7.16.js") {
  //   // http://127.0.0.1:8080/vue.v2.7.16.js
  //   res.type("application/javascript"); // 手动设置 Content-Type
  //   console.log(__dirname);
  //   res.sendFile(path.join(__dirname, "public", "../../vue/vue.v2.7.16.js"));
  // }
});

server.listen(8080);

源码位置 /vue-ssr/ssr-server.v2.js

image.png

测试通过。

image.png

什么是流式渲染?

字面意思,SSR 不用等渲染完了才发请求,SSR 渲染一段就通过网络请求发给浏览器,浏览器收到一段就渲染一段。

SSR 侧很容易理解,每生成一段字符串就通过事件通知出来。

Http 传输支持流式,请求头加 "Transfer-Encoding": "chunked",数据发送通过多个res.write()续传和一个res.end()结束,相当于一个长连接。

浏览器收到 Html 片段直接就可以加载渲染,不用等全部文件都返回。

// 本地启动node服务 ~/sheng-vue-playground/vue-ssr  node ./ssr-server.v3.js
// 访问url http://127.0.0.1:8080/app.html

// node 环境通过 require 导入依赖
// 先需要安装依赖 "dependencies": { "axios": "^1.9.0", "vue": "^2.7.16", "vue-server-renderer": "^2.7.16", "express": "^4.21.2",}
const Vue = require("vue");
const axios = require("axios");
const path = require("path");
const server = require("express")();

// HTML代码模版
const HTML_TEMPLATE = `
<html lang="en">
  <head>
    <title>Hello SSR</title>
  </head>
  <body>
    <div id="app"></div>
    <script id="js"></script>
  </body>
</html>
`;

// csr代码部分,因为要拼接代码,这里使用字符引入,需要的地方加上转义符\
const CSR_CODE = `
const preFetchData = {};
const App = {
  name: "app",
  props: {},
  // 内联DOM模板
  template: \`<div id="app"><div>{{ msg }}</div><button @click="add">{{ count }}</button></div>\`,
  data: function () {
    return {
      count: 0,
      msg: "",
    };
  },
  preFetch: async function name(params) {
    console.log("preFetch 开始,发送请求");
    const responseData = await axios.get("https://cdn.jsdelivr.net/gh/shengshuqiang/sheng-vue-playground@main/vue-ssr/data.json");
    preFetchData.data = responseData.data;
    console.log("preFetch 结束,请求返回", responseData.data);
  },
  created: async function () {
    console.log("created 开始");
    if (!preFetchData.data) {
      await App.preFetch();
    }
    const name = preFetchData.data.name;
    this.msg = \`Hello \${name}\`;
    console.log(\`created 结束, this.msg=\${this.msg}\`);
  },
  methods: {
    add: function () {
      this.count++;
    },
  },
};
`;
const { App, preFetchData } = new Function(
  "axios",
  CSR_CODE + "return {App, preFetchData};"
)(axios);
// 第 1 步:创建一个 vue 实例
const appVue = new Vue({
  render: (h) => h(App),
});
// 不需要挂载,node环境没有dom,也没法挂载
// app.$mount("#app");

// 第 2 步:创建一个 renderer
const renderer = require("vue-server-renderer").createRenderer();

function serverSideRendering(req, res) {
  const context = {};
  // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML
  context.state = preFetchData;
  res.writeHead(200, {
    "Transfer-Encoding": "chunked",
    "Access-Control-Allow-Origin": "*",
    "Content-Type": "text/html; charset=utf-8",
  });
  // 等一会
  const waitAMonent = async (time) => {
    await new Promise((resolve) => {
      setTimeout(resolve, time);
    });
  };
  // 请求完网络数据再渲染SSR
  App.preFetch().then(async () => {
    // 第 3 步:将 Vue 实例渲染为 HTML
    const stream = renderer.renderToStream(appVue, context);
    const allCSRCode = `${CSR_CODE} new Vue({render: (h) => h(App)}).$mount("#app");`;
    const scriptStr = `<script src="https://cdn.jsdelivr.net/gh/shengshuqiang/sheng-vue-playground@main/vue/vue.v2.7.16.js"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.15.3/axios.min.js"></script><script>${allCSRCode}</script>`;
    const htmlTemplates = HTML_TEMPLATE.split('<div id="app"></div>');
    res.write(htmlTemplates[0]);

    // 监听数据流事件
    stream.on("data", async (data) => {
      await waitAMonent(3000);
      // http写入流式数据
      res.write(data.toString());
      console.log("stream.on(data)", data.toString());
    });

    // 监听流结束事件
    stream.on("end", async () => {
      await waitAMonent(6000);
      // http结束流式数据
      res.end(
        htmlTemplates[1].replace('<script id="js"></script>', scriptStr)
      );
      console.log("stream.on(end)"); // 渲染完成
    });

    stream.on("error", (err) => {
      console.log("stream.on(error)", err);
      // handle error...
    });
    // .then((html) => {

    // })
    // .catch((err) => {
    //   console.error(err);
    // });
  });
}

// 服务端口监听处理
server.get("*", (req, res) => {
  // console.log(`server.get`, req.url, req.headers.referer, req);
  // CROS
  // 设置允许的来源
  const referer = req.headers.referer || "http://127.0.0.1:8080/";

  res.setHeader(
    "Access-Control-Allow-Origin",
    referer.substring(0, referer.length - 1)
  );
  // 设置允许的请求方法
  res.setHeader(
    "Access-Control-Allow-Methods",
    "GET, POST, PUT, DELETE, OPTIONS"
  );
  // 设置允许的请求头
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
  // 设置预检请求的缓存时间(可选)
  res.setHeader("Access-Control-Max-Age", "3600");
  // if (req.url === "/data.json") {
  //   res.status(200).json({ name: "sheng_SSU" });
  // } else 
  if (req.url === "/app.html") {
    serverSideRendering(req, res);
  }
  //  else if (req.url === "/vue.v2.7.16.js") {
  //   // http://127.0.0.1:8080/vue.v2.7.16.js
  //   res.type("application/javascript"); // 手动设置 Content-Type
  //   console.log(__dirname);
  //   res.sendFile(path.join(__dirname, "public", "../../vue/vue.v2.7.16.js"));
  // }
});

server.listen(8080);

源码位置 /vue-ssr/ssr-server.v3.js

image.png

核心点在于 Http 和 浏览器解析 Html 本身就支持流式。

image.png

如何判断页面是否使用 SSR?

这问题就简单了,html 主文档中是否有 DOM 和 网络数据 window.__INITIAL_STATE__={...}

image.png

虚拟 DOM 如何转成 DOM 字符串?

还是从一个简单示例出发:

// 本地启动node服务 ~/sheng-vue-playground/vue-ssr  node ./ssr-server.v4.js
// 访问url http://127.0.0.1:8080/app.html

// node 环境通过 require 导入依赖
// 先需要安装依赖 "dependencies": { "axios": "^1.9.0", "vue": "^2.7.16", "vue-server-renderer": "^2.7.16", "express": "^4.21.2",}
const Vue = require("vue");
const axios = require("axios");
const path = require("path");
const server = require("express")();

// HTML代码模版
const HTML_TEMPLATE = `
<html lang="en">
  <head>
    <title>Hello SSR</title>
  </head>
  <body>
    <div id="app"></div>
    <script id="js"></script>
  </body>
</html>
`;

// csr代码部分,因为要拼接代码,这里使用字符引入,需要的地方加上转义符\
const CSR_CODE = `
const preFetchData = {};
const InputComponent = {
    name: "input-component",
    props: {
      placeholder: '',
    },
    // 内联DOM模板
    template: "<div><input v-model='value' :placeholder='placeholder' /><div>{{value}}</div></div>",
    data: function () {
      return {
        value: ''
      };
    },
  };
const App = {
  name: "app",
  props: {},
   components: { InputComponent },
  // 内联DOM模板
  template: \`<div id="app"><div>{{ msg }}</div><button @click="add">{{ count }}</button><input-component placeholder="hello"></input-component></div>\`,
  data: function () {
    return {
      count: 0,
      msg: "",
    };
  },
  preFetch: async function name(params) {
    console.log("preFetch 开始,发送请求");
    const responseData = await axios.get("https://cdn.jsdelivr.net/gh/shengshuqiang/sheng-vue-playground@main/vue-ssr/data.json");
    preFetchData.data = responseData.data;
    console.log("preFetch 结束,请求返回", responseData.data);
  },
  created: async function () {
    console.log("created 开始");
    if (!preFetchData.data) {
      await App.preFetch();
    }
    const name = preFetchData.data.name;
    this.msg = \`Hello \${name}\`;
    console.log(\`created 结束, this.msg=\${this.msg}\`);
  },
  methods: {
    add: function () {
      this.count++;
    },
  },
};
`;
const { App, preFetchData } = new Function(
  "axios",
  CSR_CODE + "return {App, preFetchData};"
)(axios);
// 第 1 步:创建一个 vue 实例
const appVue = new Vue({
  render: (h) => h(App),
});
// 不需要挂载,node环境没有dom,也没法挂载
// app.$mount("#app");

// 第 2 步:创建一个 renderer
const renderer = require("vue-server-renderer").createRenderer();

function serverSideRendering(req, res) {
  const context = {};
  // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML
  context.state = preFetchData;
  // 请求完网络数据再渲染SSR
  App.preFetch().then(() => {
    // 第 3 步:将 Vue 实例渲染为 HTML
    renderer
      .renderToString(appVue, context)
      .then((html) => {
        const allCSRCode = `${CSR_CODE} const vue = new Vue({render: (h) => h(App)}).$mount("#app"); console.log('SSU vue', vue);`;
        const scriptStr = `<script src="https://cdn.jsdelivr.net/gh/shengshuqiang/sheng-vue-playground@main/vue/vue.v2.7.16.js"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.15.3/axios.min.js"></script><script>${allCSRCode}</script>`;
        const targetHtml = HTML_TEMPLATE.replace(
          '<div id="app"></div>',
          html
        ).replace('<script id="js"></script>', scriptStr);
        console.log("生成html", targetHtml);
        res.set("Content-Type", "text/html; charset=utf-8");
        res.end(targetHtml);
      })
      .catch((err) => {
        console.error(err);
      });
  });
}

// 服务端口监听处理
server.get("*", (req, res) => {
  console.log(`server.get`, req.url, req.headers.referer, req);
  // CROS
  // 设置允许的来源
  const referer = req.headers.referer || "http://127.0.0.1:8080/";

  res.setHeader(
    "Access-Control-Allow-Origin",
    referer.substring(0, referer.length - 1)
  );
  // 设置允许的请求方法
  res.setHeader(
    "Access-Control-Allow-Methods",
    "GET, POST, PUT, DELETE, OPTIONS"
  );
  // 设置允许的请求头
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
  // 设置预检请求的缓存时间(可选)
  res.setHeader("Access-Control-Max-Age", "3600");
  // if (req.url === "/data.json") {
  //   res.status(200).json({ name: "sheng_SSU" });
  // } else 
  if (req.url === "/app.html") {
    serverSideRendering(req, res);
  } 
  // else if (req.url === "/vue.v2.7.16.js") {
  //   // http://127.0.0.1:8080/vue.v2.7.16.js
  //   res.type("application/javascript"); // 手动设置 Content-Type
  //   console.log(__dirname);
  //   res.sendFile(path.join(__dirname, "public", "../../vue/vue.v2.7.16.js"));
  // }
});

server.listen(8080);

源码位置 /vue-ssr/ssr-server.v4.js

image.png

看一下真实 DOM 节点信息和虚拟 DOM 节点信息,比对一下就知道了。

<html>
  <!-- 自定义组件 input-component -->
  <div>
    <input v-model="value" :placeholder="placeholder" />
    <div>{{value}}</div>
  </div>
  <!-- 主体组件 App -->
  <div id="app">
    <div>{{ msg }}</div>
    <button @click="add">{{ count }}</button>
    <input-component placeholder="hello"></input-component>
  </div>
</html>
const appVNode = {
  componentInstance: {
    _vnode: {
      tag: "div",
      children: [
        {
          tag: "div",
          children: [{ tag: undefined, text: "Hello sheng_SSU" }],
        },
        {
          tag: "button",
          children: [{ tag: undefined, text: "0" }],
        },
        {
          tag: "vue-component-2-input-component",
          componentInstance: {
            _vnode: {
              tag: "div",
              children: [
                {
                  tag: "input",
                  text: "0",
                  data: {
                    attrs: { placeholder: "hello" },
                    domProps: { value: "" },
                  },
                },
                {
                  tag: "div",
                  children: [{ text: "" }],
                },
              ],
            },
          },
        },
      ],
    },
  },
};

image.png

简单说,就是 require("vue-server-renderer").createRenderer().renderToString(appVue, context) .then((html) => {}) 将 Vue 对象的 VNode 节点遍历输出 Html 标记字符串。

hydrate 主要做了什么?和 Dom Diff 区别?hydrate 什么情况下会失败?

通过 AI 知道 hydrate 做了 Vue 响应式关联和事件绑定。

具体是做了什么呢?

还是从一个例子说起。

还是 ssr-server.js,先看看 hydrate 做啥了?

image.png

image.png

再看看 dom diff 做啥了?

image.png

image.png

相信有朋友敏锐地发现,不对呀,Vue 不是号称精确更新么,上面看起来也是新旧虚拟 DOM 遍历一遍,这不成 React 了?

问到点上了,其实 Vue 是以自定义组件为粒度进行更新的,更新控制在数据变化的自定义组件里面,通过依赖收集触发对应自定义组件的 updateComponent 方法,重新 render 进行新旧虚拟 DOM 比较。

写个 Demo 试一下,自定义点击组件和输入组件,能通过传入属性设置标题,也能内部点击或者输入改变展示,看看虚拟 DOM 比较?

<!-- CDN引入 -->
<!-- <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script> -->
<!-- 本地文件引入 -->

<!-- 组件模版 -->
<template id="click-template">
  <div>{{title}}<button @click="add">{{ count }}</button></div>
</template>
<!-- 组件JS代码 -->
<script>
  const ClickComponent = {
    name: "click-component",
    props: {
      title: "",
    },
    // 内联DOM模板
    template: "#click-template",
    data: function () {
      return { count: 0 };
    },
    methods: {
      add: function () {
        console.log("SSU add()");
        this.count++;
      },
    },
  };
</script>

<template id="input-template">
  <div>{{title}}<input v-model="value" />{{value}}</div>
</template>
<div>
  <!-- 组件JS代码 -->
  <script>
    const InputComponent = {
      name: "input-component",
      props: {
        title: "",
      },
      // 内联DOM模板
      template: "#input-template",
      data: function () {
        return { value: "" };
      },
    };
  </script>

  <!-- <script src="../vue/vue.v2.7.16.js"></script> -->
  <script src="https://cdn.jsdelivr.net/gh/shengshuqiang/sheng-vue-playground@main/vue/vue.v2.7.16.js"></script>

  <div id="app">
    <click-component
      v-for="title in clickTitles"
      :title="title"
    ></click-component>
    <input-component
      v-for="title in inputTitles"
      :title="title"
    ></input-component>
    <button @click="click">ClickB&InputB</button>
  </div>

  <script>
    var app = new Vue({
      el: "#app",
      components: { ClickComponent, InputComponent },
      data: {
        clickTitles: ["ClickA", "ClickB", "ClickC"],
        inputTitles: ["InputA", "InputB", "InputC"],
      },
      methods: {
        click: function () {
          console.log("SSU click()");
          Vue.set(this.clickTitles, 1, this.clickTitles[1] + "B");
          Vue.set(this.inputTitles, 1, this.inputTitles[1] + "B");
        },
      },
    });
  </script>
</div>

源码位置 /vue-ssr/vue.component-dom-diff-click.html

image.png

image.png

image.png

image.png

image.png

即证!

细心的朋友可能发现了,上面示例 SSR 渲染文案是“Hello sheng_SSU in Node”,但 CSR 刷新为“Hello sheng_SSU in Browser”,hydrate 却并没报错,不是说 SSR 和 CSR 不一致就会报错吗?

image.png

通过修改 <div id="app">**<div v-html="msg">123</div>**<button @click="add">{{ count }}</button><input-component placeholder="hello"></input-component></div>来触发SSR和CSR不一致控制台打印SSR错误提示。

image.png

更多代码详见 /vue-ssr/ssr-server-vhtml-change.v5.js

收益在哪?代价是什么?

收益是使用 SSR,首屏性能绝对能上一个台阶,一方面是中低端机型占比比较大,一方面弱网也比较普遍。

代价是有机器成本,SSR 相当于把在用户手机上面部分运行的工作量放到了服务端统一处理。还有个小成本就是注意不要有全局变量污染,因为多个用户访问的 SSR 是在同一台机器的。

源码还看到还有专门的 serverPrefetch 生命周期,感兴趣可以试试。

image.png