前端性能优化,首推 SSR(Server Side Rendering,服务端渲染),那它是怎么做到的呢?
先抛出几个问题:
- 什么是 SSR?
- 什么是流式渲染?
- 如何判断页面是否使用 SSR?
- 虚拟 DOM 如何转成 DOM 字符串?
- hydrate 主要做了什么?和 Dom Diff 区别?什么情况下会失败?
- 收益在哪?代价是什么?
什么是 SSR
原理都知道,但我想看看代码是怎么写的?
还是以一个点击+1的按钮举例,为了更真实模拟,页面描述信息“sheng_SSU”通过网络请求获取。
如果是 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);
});
运行结果如下:
<div id="app" data-server-rendered="true"><div></div><button>0</button></div>
眼尖的同学发现还差点意思,<div>Hello sheng_SSU</div>没有出来。
通过日志发现:
- created 开始,发送请求
- 生成html
<div id="app" data-server-rendered="true"><div></div><button>0</button></div> - preFetch { name: 'sheng_SSU' }
- 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);
});
});
运行结果如下:
<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);
});
});
输出如下 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 文件通过浏览器打开,点击按钮可以累加。
写到这,简版 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
测试通过。
什么是流式渲染?
字面意思,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
核心点在于 Http 和 浏览器解析 Html 本身就支持流式。
如何判断页面是否使用 SSR?
这问题就简单了,html 主文档中是否有 DOM 和 网络数据 window.__INITIAL_STATE__={...}
虚拟 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
看一下真实 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: "" }],
},
],
},
},
},
],
},
},
};
简单说,就是 require("vue-server-renderer").createRenderer().renderToString(appVue, context) .then((html) => {}) 将 Vue 对象的 VNode 节点遍历输出 Html 标记字符串。
hydrate 主要做了什么?和 Dom Diff 区别?hydrate 什么情况下会失败?
通过 AI 知道 hydrate 做了 Vue 响应式关联和事件绑定。
具体是做了什么呢?
还是从一个例子说起。
还是 ssr-server.js,先看看 hydrate 做啥了?
再看看 dom diff 做啥了?
相信有朋友敏锐地发现,不对呀,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
即证!
细心的朋友可能发现了,上面示例 SSR 渲染文案是“Hello sheng_SSU in Node”,但 CSR 刷新为“Hello sheng_SSU in Browser”,hydrate 却并没报错,不是说 SSR 和 CSR 不一致就会报错吗?
通过修改 <div id="app">**<div v-html="msg">123</div>**<button @click="add">{{ count }}</button><input-component placeholder="hello"></input-component></div>来触发SSR和CSR不一致控制台打印SSR错误提示。
更多代码详见 /vue-ssr/ssr-server-vhtml-change.v5.js。
收益在哪?代价是什么?
收益是使用 SSR,首屏性能绝对能上一个台阶,一方面是中低端机型占比比较大,一方面弱网也比较普遍。
代价是有机器成本,SSR 相当于把在用户手机上面部分运行的工作量放到了服务端统一处理。还有个小成本就是注意不要有全局变量污染,因为多个用户访问的 SSR 是在同一台机器的。
源码还看到还有专门的 serverPrefetch 生命周期,感兴趣可以试试。