不知不觉都已经2021年7月了,靓仔依然没用崛起,还在继续躺平。然而大佬们关于vue3的文章已经层出不穷了,而我却没有再项目中使用过。
闲来无事,突然想弄一个vue3+ssr+vite的demo,来做个简单的项目实战。服务端打算用egg(未开始,后续章节会更新)。
首先拿当然是看文档,但是没有找到vue3+vite+ssr的文档,找到的都是vue2的,nuxt好像也不支持vue3+vite+ssr的方式。好在天无绝人之路,我就在vite的github仓库中找到了vue3+vite+ssr的demo。
打开demo,大体逻辑基本上都写的差不多了,但是没有看到重要的router+vuex,那我们就挨个补上呗。
首先大家先来看一下经过我改造后的样子
项目目录
src
|——pages
|————About.vue
|——Home.vue
|——utils
|————index.js
|——App.vue
|——entry-client.js
|——entry-server.js
|——main.js
|——router.js
|——store.js
index.html
pageage.json
server.js
vite.config.js
复制代码
server.js
const express = require('express')
const fs = require('fs')
const path = require('path')
const isTest = process.env.NODE_ENV === 'test' || !!process.env.VITE_TEST_BUILD
const serialize = require('serialize-javascript');
async function createServer(
root = process.cwd(),
isProd = process.env.NODE_ENV === 'production'
) {
const resolve = (p) => path.resolve(__dirname, p)
const indexProd = isProd
? fs.readFileSync(resolve('dist/client/index.html'), 'utf-8')
: ''
const manifest = isProd
? // @ts-ignore
require('./dist/client/ssr-manifest.json')
: {}
const app = express()
/**
* @type {import('vite').ViteDevServer}
*/
let vite
if (!isProd) {
vite = await require('vite').createServer({
root,
logLevel: isTest ? 'error' : 'info',
server: {
middlewareMode: 'ssr',
watch: {
// During tests we edit the files too fast and sometimes chokidar
// misses change events, so enforce polling for consistency
usePolling: true,
interval: 100
}
}
})
// use vite's connect instance as middleware
app.use(vite.middlewares)
} else {
app.use(require('compression')())
app.use(
require('serve-static')(resolve('dist/client'), {
index: false
})
)
}
app.use('*', async (req, res) => {
try {
const url = req.originalUrl
// 读取index.html模板文件
let template, render
if (!isProd) {
// always read fresh template in dev
template = fs.readFileSync(resolve('index.html'), 'utf-8')
template = await vite.transformIndexHtml(url, template)
render = (await vite.ssrLoadModule('/src/entry-server.js')).render
} else {
template = indexProd
render = require('./dist/server/entry-server.js').render
}
// 调用服务端渲染方法,将vue组件渲染成dom结构,顺带分析出需要预加载的js,css等文件。
const [appHtml, preloadLinks, store] = await render(url, manifest)
// 新加 + 将服务端预取数据的store,插入html模板文件
const state = ("<script>window.__INIT_STATE__" + "=" + serialize(store, { isJSON: true }) + "</script>");
// 把html中的展位符替换成相对应的资源文件
const html = template
.replace(`<!--preload-links-->`, preloadLinks)
.replace(`<!--app-html-->`, appHtml)
.replace(`<!--app-store-->`, state)
res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
} catch (e) {
vite && vite.ssrFixStacktrace(e)
console.log(e.stack)
res.status(500).end(e.stack)
}
})
return { app, vite }
}
// 创建node服务器用作ssr
if (!isTest) {
createServer().then(({ app }) =>
app.listen(3000, () => {
console.log('http://localhost:3000')
})
)
}
// for test use
exports.createServer = createServer
复制代码
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<!--preload-links-->
<div id="app"><!--app-html--></div>
<script type="module" src="/src/entry-client.js"></script>
<!--app-store--><!--这个是用来预加载store的-->
</body>
</html>
复制代码
src/main.ts
因为每次请求都会到达服务器,为了数据不会被相互污染,每次请求我们都需要使用工厂函数创建新的实例,每次都返回全新的vue, router, store等
import { createSSRApp } from 'vue'
import { createStore } from './store'
import App from './App.vue'
import { createRouter } from './router'
export function createApp() {
const app = createSSRApp(App)
const router = createRouter()
const store = createStore()
app.use(router)
app.use(store)
return { app, router, store }
}
复制代码
router
import {
createMemoryHistory,
createRouter as _createRouter,
createWebHistory
} from 'vue-router'
// Auto generates routes from vue files under ./pages
// https://vitejs.dev/guide/features.html#glob-import
const pages = import.meta.glob('./pages/*.vue')
const routes = Object.keys(pages).map((path) => {
const name = path.match(/\.\/pages(.*)\.vue$/)[1].toLowerCase()
return {
path: name === '/home' ? '/' : name,
component: pages[path] // () => import('./pages/*.vue')
}
})
export function createRouter() {
return _createRouter({
// use appropriate history implementation for server/client
// import.meta.env.SSR is injected by Vite.
history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
routes
})
}
复制代码
数据预取
服务器端渲染的是应⽤程序的”快照”,如果应⽤依赖于⼀些异步数据, 那么在开始渲染之前,需要先预取和解析好这些数据。
异步获取数据
store
import { createStore as _createStore } from 'vuex'
export function createStore() {
return _createStore({
state() {
return {
count: 0
}
},
mutations: {
increment(state) {
state.count++
},
init(state, count) {
state.count = count
}
},
actions: {
getCount({ commit }) {
return new Promise(resolve => {
setTimeout(() => {
console.log('run here');
commit('init', Math.random() * 100)
resolve()
}, 1000)
})
}
}
})
}
复制代码
src/entry-server.js 服务端渲染入口函数。
import { createApp } from './main'
import { renderToString } from '@vue/server-renderer'
import { getAsyncData } from './utils/'; // 异步处理数据的时候使用
export async function render(url, manifest) {
const { app, router, store } = createApp()
// set the router to the desired URL before rendering
router.push(url)
// store.$setSsrPath(url);
await router.isReady()
await getAsyncData(router, store, true);
// passing SSR context object which will be available via useSSRContext()
// @vitejs/plugin-vue injects code into a component's setup() that registers
// itself on ctx.modules. After the render, ctx.modules would contain all the
// components that have been instantiated during this render call.
const ctx = {}
const html = await renderToString(app, ctx)
ctx.state = store.state
// the SSR manifest generated by Vite contains module -> chunk/asset mapping
// which we can then use to determine what files need to be preloaded for this
// request.
const preloadLinks = ctx.modules
? renderPreloadLinks(ctx.modules, manifest)
: [];
return [html, preloadLinks, store]
}
function renderPreloadLinks(modules, manifest) {
let links = ''
const seen = new Set()
modules.forEach((id) => {
const files = manifest[id]
if (files) {
files.forEach((file) => {
if (!seen.has(file)) {
seen.add(file)
links += renderPreloadLink(file)
}
})
}
})
return links
}
function renderPreloadLink(file) {
if (file.endsWith('.js')) {
return `<link rel="modulepreload" crossorigin href="https://juejin.cn/post/${file}">`
} else if (file.endsWith('.css')) {
return `<link rel="stylesheet" href="https://juejin.cn/post/${file}">`
} else {
// TODO
return ''
}
}
复制代码
src/utils/index.js
// 执行注册store钩子
export const registerModules = (
components,
router,
store
) => {
return components
.filter((i) => typeof i.registerModule === "function")
.forEach((component) => {
component.registerModule({ router: router.currentRoute, store });
});
};
// 调用当前匹配到的组件里asyncData钩子,预取数据
export const prefetchData = (
components,
router,
store
) => {
const asyncDatas = components.filter(
(i) => typeof i.asyncData === "function"
);
return Promise.all(
asyncDatas.map((i) => {
return i.asyncData({ router: router.currentRoute.value, store });
})
);
};
// ssr自定义钩子
export const getAsyncData = (
router,
store,
isServer
) => {
return new Promise(async (resolve) => {
const { matched } = router.currentRoute.value;
// 当前路由匹配到的组件
const components = matched.map((i) => {
return i.components.default;
});
// 动态注册store
registerModules(components, router, store);
if (isServer) {
// 预取数据
await prefetchData(components, router, store);
}
resolve();
});
};
复制代码
组件中的数据预取逻辑, /src/page/Home.vue 服务端数据预取,
export default {
asyncData({store}) {
return store.dispatch('getCount')
}
})
复制代码
src/client.js
客户端入口函数, 客户端在挂载到应⽤程序之前, store 就应该获取到状态
import { createApp } from './main'
const { app, router, store } = createApp()
if(window.__INIT_STATE__) {
// 当使⽤ template 时, context.state 将作为 window.__INIT_STATE__ 状态⾃动嵌⼊到最终的 HTML
// 在客户端挂载到应⽤程序之前, store 就应该获取到状态:
store.replaceState(window.__INIT_STATE__._state.data)
}
router.isReady().then(() => {
app.mount('#app')
})
复制代码
最后附上package.json
"scripts": {
"dev": "node server",
"build": "npm run build:client && npm run build:server",
"build:client": "vite build --ssrManifest --outDir dist/client",
"build:server": "vite build --ssr src/entry-server.js --outDir dist/server",
"generate": "vite build --ssrManifest --outDir dist/static && yarn build:server && node prerender",
"serve": "cross-env NODE_ENV=production node server",
"debug": "node --inspect-brk server"
},
"dependencies": {
"vue": "^3.1.2",
"vue-router": "^4.0.10",
"vuex": "^4.0.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^1.2.3",
"@vitejs/plugin-vue-jsx": "^1.1.6",
"@vue/compiler-sfc": "^3.0.5",
"@vue/server-renderer": "^3.1.2",
"express": "^4.17.1",
"sass": "^1.35.1",
"sass-loader": "^12.1.0",
"serialize-javascript": "^6.0.0",
"vite": "^2.3.8"
}
复制代码
当你做完上述步骤后,运行
npm run dev
复制代码
打开http://localhhost:3000 应该可以看到如下画面
踩过的一些坑
- pageage.json和@vue/server-renderer的版本要一致
- @vitejs/plugin-vue-jsx 这个记得要安装,不然会报错
- vue-router@4.x.x 中没有
router.getMatchedComponents()
这个方法,但是可以使用router.currentRoute.value
来代替 - 暂时没有发现在
.vue
文件下用<script setup>
的写法来写asyncData
,还是只能用
export default defineComponent({
setup() {},
asyncData({store}) {
return store.dispatch('getCount')
}
复制代码
参考资料
玩起来,使用vite,做vue3.0的服务端渲染(ssr)
© 版权声明
文章版权归作者所有,未经允许请勿转载。
THE END