vue3 服务端渲染(SSR)爬坑记、保姆级示例
前言
vue2 SSR 已经被不少大佬讲烂了,vue3 SSR 除了官网 几乎没搜到其他文章,而且官网上也没讲 vuex 方面,那么我就来凑个数吧,相信大家都知道 SSR 是干嘛的,前世今生也不多说了,这儿主要结合官网把 vue3 SSR 的配置和使用过程及遇到的坑给大家排除下。
依赖
首先执行以下命令把依赖装好
npm install @vue/server-renderer // 服务端渲染最核心的包
npm install webpack-manifest-plugin webpack-node-externals webpack lru-cache -D
npm install express // 启动 node 服务的包
npm install cross-env -D // 打包 client 和 server 端会用到的参数,有的小伙伴喜欢用
"build:server": "set SSR=1 && vue-cli-service build --dest dist/server",
但是这个 set SSR=1 在 linux 下不生效,所以还是得用 cross-env
官网 package.json 打包脚本如下
"build:client": "vue-cli-service build --dest dist/client",
"build:server": "cross-env SSR=1 vue-cli-service build --dest dist/server",
"build:ssr": "npm run build:client && npm run build:server",
路由
每个请求都需要有一个干净的路由实例,所以得提供一个路由的工厂函数。 例如下列代码
import { createRouter, RouteRecordRaw } from 'vue-router';
import Home from '../views/Home/index.vue';
export const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'Home',
component: Home,
meta: {
// keepAlive: true,
depth: 1
}
},
{
path: '/popular-now/:link',
name: 'PopularNow',
component: () => import(/* webpackChunkName: "about" */ '../views/PopularNow/index.vue'),
props: true,
meta: {
title: '我一直不明白为什么地球要',
// keepAlive: true,
depth: 1
}
},
{
path: '/subscription',
name: 'Subscription',
component: () => import(/* webpackChunkName: "about" */ '../views/Subscription/index.vue'),
meta: {
title: '绕着太阳转',
// keepAlive: true,
depth: 1
}
},
{
path: '/mediaLibrary',
name: 'MediaLibrary',
component: () => import(/* webpackChunkName: "about" */ '../views/MediaLibrary/index.vue'),
meta: {
title: '直到遇见你',
depth: 1
}
},
{
path: '/history',
name: 'History',
component: () => import(/* webpackChunkName: "about" */ '../views/History/index.vue'),
meta: {
title: '我才蓦然明白',
depth: 1
}
},
{
path: '/search',
name: 'Search',
component: () => import(/* webpackChunkName: "about" */ '../views/Search/index.vue'),
props: route => ({ keywords: route.query.q }),
meta: {
title: '也许是因为有了你',
// keepAlive: true,
depth: 1
}
},
{
path: '/watch/:mvid',
name: 'Watch',
component: () => import(/* webpackChunkName: "about" */ '../views/Watch/index.vue'),
props: true,
meta: {
title: '这世界才有了',
depth: 2
}
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import(/* webpackChunkName: "about" */ '../views/Notfound/index.vue'),
meta: {
title: '春夏秋冬',
depth: 2
}
}
]
export default function (history: any) { // history 是其他地方传过来的,稍后会说
return createRouter({
history,
routes
})
}
// keepAlive: true
keepAlive 是注释掉的,SSR 并不支持 <keep-alive> 组件缓存,如果你原本有如下这样的代码
<router-view v-slot="{ Component }">
<keep-alive>
<component
:class="[{ 'router-paper-full': !isShowSidebar }, 'router-paper']"
:is="Component"
v-if="$route.meta.keepAlive"
:key="$route.name"
/>
</keep-alive>
<component
:class="[{ 'router-paper-full': !isShowSidebar }, 'router-paper']"
:is="Component"
v-if="!$route.meta.keepAlive"
/>
</router-view>
要支持 SSR 请将 <keep-alive> 代码块干掉,不然用户在切换页面时会报如下错误,意思就是新节点插入位置错误
对啦,SSR 也不支持指令,所以你有用到指令的话,请将指令拆掉,改为 util 方式实现,例如:
// util.ts
/**
* 加载滑动事件(原本封装在 autoLoading 指令里,但 ssr 不支持,故封装在此)
*/
const loadScrollEvent = function (el: HTMLElement, binding: any) {
window.addEventListener(
"scroll",
function () {
const temp: any = el.lastElementChild?.previousElementSibling;
if (temp) {
const a = temp.offsetTop + temp.offsetHeight; // 当前元素倒第二个子元素(倒第一个为 RotateLoading)与父元素顶部距离 + 元素高度
const b =
document.documentElement.clientHeight +
(el.parentElement?.parentElement?.scrollTop || 0); // 可视区窗口高度 + 滚动距离
if (binding.value.commandAutoLoading.isCommand && a - 100 <= b) {
binding.value.autoLoading();
}
}
},
true
);
}
要使用的地方把 dom 放进去就行啦
loadScrollEvent(this.$refs.commentList as any, {
value: {
commandAutoLoading: this.commentListAutoLoading,
autoLoading: this.commentListLoading,
},
});
vuex
依旧要提供一个工厂函数,例如:
import { createStore } from 'vuex';
import HistoryMv from '../store/modules/HistoryMv';
import Subscription from '../store/modules/Subscription';
import LikedMv from '../store/modules/LikedMv';
import TopProgressBar from '../store/modules/TopProgressBar';
import HomeMv from '../store/modules/HomeMv';
import PopularNowMv from '../store/modules/PopularNowMv';
import CloudSearchMv from '../store/modules/CloudSearchMv';
import WatchMv from '../store/modules/WatchMv';
export default function () {
// 必须重置各项数据,否者会共享这些命名空间下的 state
HistoryMv.state.historyMvList = [];
Subscription.state.artistList = [];
LikedMv.state.likedMvList = [];
TopProgressBar.state.start = false;
HomeMv.state.mvList = [];
PopularNowMv.state.mvList = [];
CloudSearchMv.state.mvList = [];
WatchMv.state = {
simiMvList: [],
artistMvList: [],
likedCount: 0,
mv: null,
artistDetail: null,
commentMv: {
hotComments: [],
comments: [],
total: 0,
more: false,
},
mvUrl: '',
mvUrlErrorMsg: ''
}
const store = createStore({
modules: {
HistoryMv,
Subscription,
LikedMv,
TopProgressBar,
HomeMv,
PopularNowMv,
CloudSearchMv,
WatchMv
}
})
try {
if (window && (window as any).__INITIAL_STATE__) {
store.replaceState((window as any).__INITIAL_STATE__);
}
} catch (error) {
console.log('有一个错', error.message);
}
return store;
}
注意啦~~~
如果用到 vuex 的命名空间,一定要重置各项数据,否者会共享这些命名空间下的 state !
并且在这个工厂函数中去获取了 node 服务器传过来的 (window as any).INITIAL_STATE 里面的 store 数据。
entry-client.js
客户端入口,该干啥就干啥啦,例如:
import { createSSRApp } from 'vue'
import { createWebHistory } from 'vue-router'
import App from './App.vue'
import createRouter from './router-ssr'
import createStore from './store-ssr'
import { filters } from './share/filters';
import './assets/fonts/iconfont/iconfont.css';
import './assets/fonts/iconfont/iconfont.js';
// 针对客户端的启动逻辑......
const app = createSSRApp(App)
const router = createRouter(createWebHistory('loutube')) // 上文说到的 history 来源
const store = createStore()
app.config.globalProperties.$filters = filters;
app.use(router).use(store)
router.isReady().then(() => {
app.mount('#app')
})
entry-server.js
服务端入口,例如:
import { createSSRApp } from 'vue'
import { createMemoryHistory } from 'vue-router'
import App from './App.vue'
import createRouter from './router-ssr'
import createStore from './store-ssr'
import { filters } from './share/filters';
import './assets/fonts/iconfont/iconfont.css';
// import './assets/fonts/iconfont/iconfont.js'; 一定要注释掉,服务端用不上字体的 js 文件,
导入的话会报错 window 或 document 找不到之类的错误。
export default function () {
const app = createSSRApp(App)
const router = createRouter(createMemoryHistory('loutube')) // 上文说到的 history 来源
const store = createStore()
app.config.globalProperties.$filters = filters;
app.use(router).use(store)
return {
app,
router,
store
}
}
vue.config.js
打包配置文件,例如:
const { WebpackManifestPlugin } = require('webpack-manifest-plugin')
const nodeExternals = require('webpack-node-externals')
const webpack = require('webpack')
// const emptyWebpackBuildDetailPlugin = require("empty-webpack-build-detail-plugin")
const path = require('path')
if (process.env.COMMON) { // 如果不是 SSR 运行方式
return console.log('退出 vue.config.js -----');
}
module.exports = {
chainWebpack: webpackConfig => {
// 我们需要禁用 cache loader,否则客户端构建版本会从服务端构建版本使用缓存过的组件
webpackConfig.module.rule('vue').uses.delete('cache-loader')
webpackConfig.module.rule('js').uses.delete('cache-loader')
webpackConfig.module.rule('ts').uses.delete('cache-loader')
webpackConfig.module.rule('tsx').uses.delete('cache-loader')
if (!process.env.SSR) {
// 将入口指向应用的客户端入口文件
webpackConfig
.entry('app')
.clear()
.add('./src/entry-client.js')
return
}
// 将入口指向应用的服务端入口文件
webpackConfig
.entry('app')
.clear()
.add('./src/entry-server.js')
// 这允许 webpack 以适合于 Node 的方式处理动态导入,
// 同时也告诉 `vue-loader` 在编译 Vue 组件的时候抛出面向服务端的代码。
webpackConfig.target('node')
// 这会告诉服务端的包使用 Node 风格的导出
webpackConfig.output.libraryTarget('commonjs2')
webpackConfig
.plugin('manifest')
.use(new WebpackManifestPlugin({ fileName: 'ssr-manifest.json' }))
// https://webpack.js.org/configuration/externals/#function
// https://github.com/liady/webpack-node-externals
// 将应用依赖变为外部扩展。
// 这使得服务端构建更加快速并生成更小的包文件。
// 不要将需要被 webpack 处理的依赖变为外部扩展
// 也应该把修改 `global` 的依赖 (例如各种 polyfill) 整理成一个白名单
webpackConfig.externals(nodeExternals({ allowlist: /\.(css|vue)$/ }))
webpackConfig.optimization.splitChunks(false).minimize(false)
webpackConfig.plugins.delete('preload')
webpackConfig.plugins.delete('prefetch')
webpackConfig.plugins.delete('progress')
webpackConfig.plugins.delete('friendly-errors')
webpackConfig.plugin('limit').use(
new webpack.optimize.LimitChunkCountPlugin({
maxChunks: 1
})
)
// webpackConfig.plugin('emptyWebpackBuildDetailPlugin').use(
// emptyWebpackBuildDetailPlugin, [{
// path: path.join(process.cwd(), 'log'),
// filename: 'compile-log.md'
// }]
// )
}
}
注意啦~~~
如果打包过程中报错却未显示错误信息,可以使用 empty-webpack-build-detail-plugin 插件记录错误日志,比如你在 SSR 项目中坚持使用指令,那么它会狠狠的不给你错误信息,根本就不给反馈好吧。。。╮(╯▽╰)╭
同构
// server.js
const path = require('path')
const express = require('express')
const fs = require('fs')
const { renderToString } = require('@vue/server-renderer')
const manifest = require('../dist/server/ssr-manifest.json')
const server = express()
const appPath = path.join(__dirname, '../dist', 'server', manifest['app.js'])
const createApp = require(appPath).default
server.use('/img', express.static(path.join(__dirname, '../dist/client', 'img')))
server.use('/js', express.static(path.join(__dirname, '../dist/client', 'js')))
server.use('/css', express.static(path.join(__dirname, '../dist/client', 'css')))
server.use(
'/favicon.ico',
express.static(path.join(__dirname, '../dist/client', 'favicon.ico'))
)
// 此处做了个简单的页面缓存
const LRU = require('lru-cache')
const microCache = new LRU({
max: 100,
maxAge: 1000 * 60 * 60
})
server.get('*', async (req, res) => {
const hit = microCache.get(req.url)
if (hit) {
return res.send(hit)
}
const { app, router, store } = createApp()
await router.push(req.url.replace('/loutube', ''))
await router.isReady()
const matchedComponents = router.currentRoute.value.matched;
// 主动触发所有匹配组件的 asyncData 函数
Promise.all(matchedComponents.map(Component => {
if (Component.components.default.methods.asyncData) {
return Component.components.default.methods.asyncData(
store,
router.currentRoute
);
}
})).then(async () => {
const appContent = await renderToString(app);
fs.readFile(path.join(__dirname, '../dist/client/index.html'), (err, html) => {
if (err) {
throw err
}
html = html
.toString()
.replace('<div id="app">', `<div id="app">${appContent}`)
.replace(
'</script>',
`</script><script type="application/javascript">window.__INITIAL_STATE__=${JSON.stringify(store.state)}</script>`
)
res.setHeader('Content-Type', 'text/html')
res.send(html)
microCache.set(req.url, html)
})
}).catch(error => {
console.error(error);
res.sendStatus(500)
});
})
server.listen(8080)
注意啦~~~
这里要自己主动将 store 放进 html 里
.replace(
'</script>',
`</script><script type="application/javascript">window.__INITIAL_STATE__=${JSON.stringify(store.state)}</script>`
)
组件 asyncData()
// 最普通的用法也就这样了,在这里面去触发 action 请求接口
asyncData(store: any, route: any) {
return store.dispatch("HomeMv/getMvListRequest", {
limit: 24,
loadMoreCount: 0,
});
}
// 如果业务比较复杂的话,你也许需要这样写
asyncData(store: any, route: any) {
return Promise.all([
this.getMvUrl(store, route),
this.getMvLikedCount(store, route),
this.getMvDetail(store, route).then(async () => {
await this.getArtistDetail(store, route);
await this.getArtistMvList(store, route);
await store.dispatch("HistoryMv/addHistoryMv", store.state.WatchMv.mv);
}),
this.getCommentMvList(store, route),
]).then();
}
// 列出上面使用的其中一个函数
async getMvUrl(store?: any, route?: any) {
store = store || this.$store; // 至少要拿到一个 store 吧,毕竟也不是只有 asyncData 函数会用到 getMvUrl
const mvid = this.mvUrlState
? this.mvUrlState.queryParams.mvid
: route.value.params.mvid;
await store.dispatch("WatchMv/getMvUrlRequest", {
id: mvid,
});
}
promise 这一块需要你们自己去研究哈,这还不掌握,内卷的资格都没有。。。╮(╯▽╰)╭
最后
也许你的各个文件路径跟我展示的示例代码不一样而报错,请你仔细排查下。。。 对啦,在列下我的脚本
"scripts": {
"serve": "cross-env COMMON=1 vue-cli-service serve",
"build": "cross-env COMMON=1 vue-cli-service build",
"lint": "vue-cli-service lint",
"build:client": "vue-cli-service build --dest dist/client",
"build:server": "cross-env SSR=1 vue-cli-service build --dest dist/server",
"build:ssr": "npm run build:client && npm run build:server",
"dev:serve": "cross-env WEBPACK_TARGET=node node ./server/server.js",
"dev": "concurrently \"npm run serve\" \"npm run dev:serve\" "
},
那么我们就使用命令打包吧
npm run build:ssr
通过 node 运行打包好的文件
node server.js
转载自:https://juejin.cn/post/7037321595311882277