目录
src/module/topic/views/UTopic.vue
src/module/topic/components/UItem.vue
Stream【node.js中的Stream;可以通过stream渲染html】
build/webpack.client.config.js
SSR(ServerSideRender)服务器端渲染SSR(Server Side Render)
目标
- 同构代码中获取数据
- 性能调优——缓存
- 性能调优——Stream页面渲染(优化内存使用效率)
- 性能调优——Prerendering对页面进行静态化操作
asyncData
src/module/topic/views/UTopic.vue
<template>
<div>
<!-- <u-infinite-list :items="items" :item-height="80" #default="{ sliceItems }"> -->
<u-list :items="items"></u-list>
<!-- </u-infinite-list> -->
<!-- 自定义指令intersect,出现在屏幕中执行handler -->
<div class="x-bottom" v-intersect="{ handler: fetchNext }">
滚动到底部,加载下一页
</div>
</div>
</template>
<script>
import { createNamespacedHelpers } from "vuex";
import UList from "../components/UList.vue";
// import UInfiniteList from "../../../components/UInfiniteList.vue";
// 便捷的映射topic命名空间的一个mapState方法
const { mapState, mapActions } = createNamespacedHelpers("topic");
export default {
name: "u-top",
props: ["type"],
components: {
UList,
// UInfiniteList,
},
computed: {
...mapState({
items: (state) => state[state.activeType].items,
}),
},
// created() {
// this.fetchNext();
// },
// 1.asyncData
// 服务端,客户端都可以使用,
// 服务端可以同步获得数据进行渲染
// 客户端也可以进行同样的操作
// 约定的列表函数
asyncData({ store, route }) {
return store
.dispatch("topic/FETCH_LIST_DATA", {
type: route.name,
})
.catch((e) => console.error(e));
},
watch: {
type(type) {
this.fetchData({ type });
},
},
methods: {
...mapActions({
fetchData: "FETCH_LIST_DATA",
}),
fetchNext() {
const { type } = this;
this.fetchData({ type });
},
},
};
</script>
<style scoped>
.x-bottom {
width: 100%;
height: 1px;
}
</style>
src/entry-server.js
view-source:http://localhost:9090/top【访问源代码】
// 【服务端】入口文件:
import { createApp } from "./app";
const isDev = process.env.NODE_ENV !== "production";
import Vue from "vue";
import ULink from "./components/ULink.server.vue";
Vue.component("u-link", ULink);
export default (context) => { // nodejs 或 web server服务,返回一些对象
return new Promise((resolve, reject) => {
const { app, router, store } = createApp(); // 解构,获得app,router,store
const s = isDev && Date.now();
// url in router ? 判断一下当前请求的url是否在路由列表中
const { url } = context;
// 解构出route.fullPath
const { fullPath } = router.resolve(url).route;
if (fullPath !== url) {
return reject({ // 报错
url: fullPath,
});
}
// 路由跳转
router.push(url);
router.onReady(() => { // 路由触发后
//=======================================================
// 异步数据-1. 根据路由表信息获得路由组件信息
const matchedComponents = router.getMatchedComponents();
if (!matchedComponents.length) { // 路由不合法
return reject({ code: 404 }); // 报错404
}
// /a/b/c // matchedComponents多级数组。
// /a => A asyncData
// /b => B asyncData
// /c => C asyncData
Promise.all(
matchedComponents.map(
({ asyncData }) =>// 数组对应的asyncData解构出来
asyncData && asyncData({ store, route: router.currentRoute })
)
)
.then(() => {
isDev && console.log(`data-fetched in: ${Date.now() - s}ms`);
// 👇提供给页面进行vuex的初始化
context.state = store.state;
resolve(app);
})
.catch(reject);
// 在服务端进行数据请求的操作
//=======================================================
});
});
};
src/entry-client.js
// 客户端【浏览器端】入口文件:
// 1.只需创建应用程序,
// 2.还要挂载到dom当中,
// 3.还要做客户端激活操作,服务端数据和客户端后续要进行的操作有机结合起来
import { createApp } from "./app";
import Vue from "vue";
const { app, router, store } = createApp();
import ULink from "./components/ULink.client.vue";
Vue.component("u-link", ULink);
//=====================================================================
// 【服务端调用约定asyncData】context.state = store.state;生成的
if (window.__INITIAL_STATE__) {
// vuex.store同步操作,vuex在客户端激活的操作
store.replaceState(window.__INITIAL_STATE__);
}
Vue.mixin({
// 监听路由localhost:9090/top/:id
// 扩展
beforeRouteUpdate(to, from, next) {
// 解构
const { asyncData } = this.$options;
if (asyncData) {// 存在
asyncData({ // 进行异步请求
store: this.$store,
route: to,
})
.then(() => next)
.catch(next);
} else {
next();
}
},
});
// 同步asyncData数据处理完成了
//=====================================================================
// 1. 路由加载完成执行app.$mount操作;
router.onReady(() => {
app.$mount("#app");// 3.app.$mount("#app", true)强制客户端激活操作
});
// 保证在服务端和客户端渲染完全一致,这样才可以激活,否则会有客户端激活失败的情况
// <div></div>
// <table> <tbody><tr><td></td></tr></tbody> </table>
缓存相关处理
微缓存(Microcache)
我们把一个缓存的有效性设置一个很小的时间,比如说1s,在这1s之内当页面只会进行一次渲染,其他时候我们都用缓存生成给用户
- 页面级别缓存
- 可以在node.js的路由层面进行缓存
- 适用于页面内容基本不变的页面(运维推荐的页面)
- 组件级别缓存
- vue-server-renderer内置支持组件级别缓存,可以在创建renderer时传入LRU cache
- 适用于千人千面的用户推荐页面(每个用户看到的是不同界面)
server.js
// 使用微缓存,要先安装个工具 npm i lru-cache
const fs = require("fs");
const path = require("path");
const express = require("express");
// 缓存:使用微缓存,要先安装个工具 npm i lru-cache
const LRU = require("lru-cache");
const setUpDevServer = require("./build/setup-dev-server");
const isProd = process.env.NODE_ENV === "production";
// 4.修改一下页面模板地址
const HTML_FILE = path.join(__dirname, "./src/index.template.html");
// 1. 安装个插件 npm i vue-server-renderer
const { createBundleRenderer } = require("vue-server-renderer");
const app = express();
// 缓存:页面级微缓存
const microCache = new LRU({
max: 100,
maxAge: 1000 * 60,
});
// ---------------------------------------------------------------------
// 2. 定义一个函数
// bundle :webpack打包输出的一个bundle
// options 参数传递
const createRenderer = (bundle, options) =>
createBundleRenderer(
bundle,
Object.assign(options, {// 对options参数加强,加入一些其它参数,如:自定义指令,对服务端版本的处理
// 基于组件微缓存
// cache: LRU({
// max: 100, // 最大缓存个数100
// maxAge: 60 * 1000, // 淘汰时间1分钟
// }),
shouldPrefetch: (file, type) => false,
})
);
let renderer;
// ---------------------------------------------------------------------
const resolve = (file) => path.resolve(__dirname, file);
const serve = (path, cache) =>
express.static(resolve(path), {
maxAge: cache && isProd ? 1000 * 60 * 60 * 24 * 30 : 0,
});
app.use("/dist", serve("./dist", true));
app.use("/public", serve("./public", true));
// ---------------------------------------------------------------------
// 3. app:node server 的app信息
// templatePath: 页面模板
// cb: 回调函数
const serverReady = setUpDevServer(app, HTML_FILE, (bundle, options) => {
// cb:回调函数里,给renderer通过createRenderer赋一下值
renderer = createRenderer(bundle, options);
});
// ---------------------------------------------------------------------
const json = require("./mock.json");
app.get("/api/lists", function (req, res) {
res.send(json);
});
// 5.---------------------------------------------------------------------
app.get("*", (req, res) => {
serverReady.then((clientCompiler) => {
const s = Date.now();
// clientCompiler.outputFileSystem.readFile(HTML_FILE, (err, result) => {
// if (err) {
// return next(err);
// }
// res.set("content-type", "text/html");
// res.send(result);
// res.end();
// });
// 获取缓存(基于页面的微缓存)
// const hit = microCache.get(req.url);
// if (hit) {
// if (!isProd) {
// console.log(`whole request in: ${Date.now() - s}ms`);
// }
// // 使用缓存(基于页面的微缓存,完成)
// return res.end(hit);
// }
// 5. 生成一个字符串模板
renderer.renderToString(
{
url: req.url, // 访问的页面
},
(err, html) => {
if (err) { // 报错
res.status(404).send("404 | Not Found");
} else { // 正常
// 缓存:key:req.url,value:html (基于页面微缓存)
// microCache.set(req.url, html);
res.send(html); // send html模板
if (!isProd) { //缓存: 不是线上生产环境,完成整个请求***ms
console.log(`whole request in: ${Date.now() - s}ms`);
}
}
}
);
// const stream = renderer.renderToStream({
// url: req.url,
// });
// let html = "";
// stream.on("data", (chunk) => {
// html += chunk.toString();
// res.write(chunk.toString());
// });
// stream.on("end", (chunk) => {
// microCache.set(req.url, html);
// res.end();
// if (!isProd) {
// console.log(`whole request in: ${Date.now() - s}ms`);
// }
// });
// stream.on("error", (err) => {
// if (err) {
// res.status(404).send("404 | Not Found");
// }
// });
});
});
let port = process.env.PORT || 9090;
app.listen(port, () => {
console.log(`server started at localhost:${port}`);
});
src/module/topic/components/UItem.vue
<template>
<a
class="item"
:href="article.originalUrl"
:style="{ borderColor: theme.primary }"
>
<div class="title">{{ article.title }}</div>
<div class="tags" v-if="hasTag">
#
<span class="tag" v-for="tag in node.item_info.tags" :key="tag.id">{{
tag.tag_name
}}</span>
</div>
</a>
</template>
<script>
export default {
name: "u-item", // 基于组件微缓存,需要有一个name属性
// 基于组件微缓存,服务端缓存key,根据key缓存组件模板,当用到同样组件id的时候就在缓存取出来
serverCacheKey: (props) => props.node.id,
inject: ["theme"],
props: {
node: {
required: true,
},
},
computed: {
article() {
return this.node.item_info.article_info;
},
hasTag() {
return this.node.item_info.tags && this.node.item_info.tags.length > 0;
},
},
};
</script>
<style scoped>
.item {
display: block;
background: #fff;
color: #333;
padding: 20px 0px 20px;
border-bottom: 1px solid #eee;
position: relative;
}
.title {
font-size: 16px;
margin-bottom: 5px;
}
.tags {
font-size: 12px;
color: #999;
}
.tag {
margin-right: 3px;
}
</style>
Stream【node.js中的Stream;可以通过stream渲染html】
为什么要用streams
- 内存效率
- 相比较一次性加载一大块内容,stream将内容切分为小块chunks,分批在内存中处理,
- 避免高并发下,内存占用过大的情况发生。
- 时间效率
- 当处理完第一个chunk,即返回用户可视内容,能够减少用户访问页面的等待时间。
server.js
const fs = require("fs");
const path = require("path");
const express = require("express");
// 缓存:使用微缓存,要先安装个工具 npm i lru-cache
const LRU = require("lru-cache");
const setUpDevServer = require("./build/setup-dev-server");
const isProd = process.env.NODE_ENV === "production";
// 4.修改一下页面模板地址
const HTML_FILE = path.join(__dirname, "./src/index.template.html");
// 1. 安装个插件 npm i vue-server-renderer
const { createBundleRenderer } = require("vue-server-renderer");
const app = express();
// 缓存:页面级微缓存
const microCache = new LRU({
max: 100,
maxAge: 1000 * 60,
});
// ---------------------------------------------------------------------
// 2. 定义一个函数
// bundle :webpack打包输出的一个bundle
// options 参数传递
const createRenderer = (bundle, options) =>
createBundleRenderer(
bundle,
Object.assign(options, {// 对options参数加强,加入一些其它参数,如:自定义指令,对服务端版本的处理
// 基于组件微缓存
// cache: LRU({
// max: 100, // 最大缓存个数100
// maxAge: 60 * 1000, // 淘汰时间1分钟
// }),
shouldPrefetch: (file, type) => false,
})
);
let renderer;
// ---------------------------------------------------------------------
const resolve = (file) => path.resolve(__dirname, file);
const serve = (path, cache) =>
express.static(resolve(path), {
maxAge: cache && isProd ? 1000 * 60 * 60 * 24 * 30 : 0,
});
app.use("/dist", serve("./dist", true));
app.use("/public", serve("./public", true));
// ---------------------------------------------------------------------
// 3. app:node server 的app信息
// templatePath: 页面模板
// cb: 回调函数
const serverReady = setUpDevServer(app, HTML_FILE, (bundle, options) => {
// cb:回调函数里,给renderer通过createRenderer赋一下值
renderer = createRenderer(bundle, options);
});
// ---------------------------------------------------------------------
const json = require("./mock.json");
app.get("/api/lists", function (req, res) {
res.send(json);
});
// 5.---------------------------------------------------------------------
app.get("*", (req, res) => {
serverReady.then((clientCompiler) => {
const s = Date.now();
// clientCompiler.outputFileSystem.readFile(HTML_FILE, (err, result) => {
// if (err) {
// return next(err);
// }
// res.set("content-type", "text/html");
// res.send(result);
// res.end();
// });
// 获取缓存(基于页面的微缓存)
// const hit = microCache.get(req.url);
// if (hit) {
// if (!isProd) {
// console.log(`whole request in: ${Date.now() - s}ms`);
// }
// // 使用缓存(基于页面的微缓存,完成)
// return res.end(hit);
// }
// 5. 生成一个字符串模板,整体页面渲染
// renderer.renderToString(
// {
// url: req.url, // 访问的页面
// },
// (err, html) => {
// if (err) { // 报错
// res.status(404).send("404 | Not Found");
// } else { // 正常
// // 缓存:key:req.url,value:html (基于页面微缓存)
// // microCache.set(req.url, html);
// res.send(html); // send html模板
// if (!isProd) { //缓存: 不是线上生产环境,完成整个请求***ms
// console.log(`whole request in: ${Date.now() - s}ms`);
// }
// }
// }
// );
//================================================================================
// stream 分块 渲染
// stream 优化内存管理,第一时间触达我们的页面
const stream = renderer.renderToStream({ // 定义一个stream流
url: req.url,
});
// 缓存+stream结合使用
let html = "";
stream.on("data", (chunk) => { // 监听数据chunk信息
// 拼接页面字符串
html += chunk.toString();
res.write(chunk.toString()); // 返回写入页面
});
stream.on("end", (chunk) => { // 表示页面都传输完毕了
// 微缓存页面
microCache.set(req.url, html);
res.end();
if (!isProd) { // 时间打点
console.log(`whole request in: ${Date.now() - s}ms`);
}
});
stream.on("error", (err) => { // 监听一下异常情况
if (err) {
res.status(404).send("404 | Not Found");
}
});
});
});
let port = process.env.PORT || 9090;
app.listen(port, () => {
console.log(`server started at localhost:${port}`);
});
Prerendering
如果只是要把工程中几个帮助页面做一个静态化,没有必要使用ssr,我们可以使用Prerendering
build/webpack.client.config.js
const webpack = require("webpack");
const merge = require("webpack-merge");
const base = require("./webpack.base.config");
const path = require("path");
// 预渲染Prerendering,先安个插件 npm i prerender-spa-plugin -D
const PrerenderSPAPlugin = require("prerender-spa-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
// 1.先安装个插件 npm i vue-server-renderer -D
const VueSSRClientPlugin = require("vue-server-renderer/client-plugin");
const config = merge(base, {
entry: {
// 3.入口文件由app.js—改为—>entry-client.js
app: "./src/entry-client.js",
},
resolve: {
alias: {},
},
plugins: [
// strip dev-only code in Vue source
new webpack.DefinePlugin({
"process.env.NODE_ENV": JSON.stringify(
process.env.NODE_ENV || "development"
),
"process.env.VUE_ENV": '"client"',
}),
// 2.加入到webpack插件里面去就可以了。
new VueSSRClientPlugin(),
],
});
//===============================================================================
// client webpack【打包】 => html js css
// PrerenderSPAPlugin headless【无头浏览器】: chrome puperteer,对页面进行渲染,对这个页面做静态化输出
// 正式上线打包时候,Prerender做一下预渲染
if (process.env.NODE_ENV === "production") {
config.plugins.push( // 生产环境加插件
new HtmlWebpackPlugin({ // 使用静态页面的模板要用到的插件
template: "src/prerender.template.html", // 模板路径
}),
new PrerenderSPAPlugin({
staticDir: path.join(__dirname, "../dist"), // 定义静态文件输入到哪个文件下
routes: ["/about"],// 对哪一个路由进行预渲染
})
);
}
module.exports = config;
src/prerender.template.html
<!DOCTYPE html>
<html lang="en">
<head>
<title></title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, minimal-ui" />
</head>
<body>
<!-- 预渲染使用的模板 -->
<div id="app"></div>
</body>
</html>
build/webpack.base.config.js
const path = require("path");
const webpack = require("webpack");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const ManifestPlugin = require("webpack-manifest-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { VueLoaderPlugin } = require("vue-loader");
const isProd = process.env.NODE_ENV === "production";
module.exports = {
mode: isProd ? "production" : "development",
output: {
path: path.resolve(__dirname, "../dist"),
// 如果npm run build:client 打包出的静态页面没有内容 => Prerendering预渲染时修改为"./"
publicPath: "/dist/",
filename: isProd ? "[name].js" : "[name].[chunkhash].js",
},
resolve: {
alias: {
public: path.resolve(__dirname, "../public"),
},
},
module: {
noParse: /es6-promise\.js$/, // avoid webpack shimming process
rules: [
{
test: /\.vue$/,
loader: "vue-loader",
options: {
compilerOptions: {
preserveWhitespace: false,
},
},
},
{
test: /\.js$/,
loader: "babel-loader",
exclude: /node_modules/,
},
{
test: /\.(png|jpg|gif|svg)$/,
loader: "url-loader",
options: {
limit: 10000,
name: "[name].[ext]?[hash]",
},
},
{
test: /\.css$/,
use: isProd ? ["css-loader"] : ["vue-style-loader", "css-loader"],
},
],
},
plugins: isProd
? [new VueLoaderPlugin()]
: [
new VueLoaderPlugin(),
new HtmlWebpackPlugin({
template: "src/index.template.html",
}),
],
optimization: {
splitChunks: {
chunks: "all",
},
},
devtool: isProd ? "" : "inline-source-map",
};