我司之前部分H5为了追求极致的首屏渲染速度,使用Next.js去开发SSR项目。
去年对我司技术栈升级到Vue3之后,萌生了对SSR项目升级到Nuxt3的想法,奈何当时Nuxt3还不稳定,不建议在生产环境使用。于是打算自己搭个Vue3 SSR服务出来。
长话短说,直接开干
npm create vite vue3-ssr -- --template vue-ts
import { createSSRApp } from 'vue'
import App from './App.vue'
// 因为每次请求都会到达服务器,为了数据不会被相互污染,
// 每次请求我们都需要使用工厂函数创建新的实例,每次都返回全新的vue, router, store等
export const createApp = () => {
const app = createSSRApp(App)
return { app }
}
import { createApp } from './main'
const { app } = createApp()
app.mount("#app")
...
...
上述文件修改完,你会发现仅仅是把main.ts文件拆分出了一个客户端入口文件。其他什么都没改变。所以可直接运行npm run dev
工程就可以运行起来了。~/src/entry-client.ts
文件只是替换了main.ts
作为项目的入口。
npm i koa && npm i @types/koa -D
const Koa = require('koa')
;(async () => {
const app = new Koa()
app.use(async (ctx) => {
ctx.body = `
vue3 SSR
vue3 SSR
`;
});
const port = 3000
app.listen(port, () => {
console.log(`server is listening in ${port}`)
});
})();
const fs = require('fs')
const path = require('path')
const Koa = require('koa')
;(async () => {
const app = new Koa()
// 获取 index.html
const template = fs.readFileSync(path.resolve(__dirname, 'index.html'), 'utf-8')
app.use(async (ctx) => {
ctx.body = template
})
const port = 3000
app.listen(port, () => {
console.log(`server is listening in ${port}`)
});
})();
server.js
后, 我们就会看到对应的index.html内容
了,但是我们需要返回的是 vue模板
,那么我们只需要做个正则的替换。
3.给index.html添加
标记,作为之后替换的占位符。
vue3 ssr
...
(async () => {
const app = new Koa()
// 获取index.html
const template = fs.readFileSync(path.resolve(__dirname, 'index.html'), 'utf-8')
app.use(async (ctx) => {
let vueTemplate = '假装这是一个vue模板
'
// 替换 index.html 中的 标记
let html = template.replace('', vueTemplate)
ctx.body = html
})
const port = 3000
app.listen(port, () => {
console.log(`server is listening in ${port}`)
});
})();
那么到现在服务已正常启动了,但是我们页面模板使用的是 vue,并且 vue返回的是一个 vue实例模板
,所以我就要把这个vue实例模板
转换成可渲染的html
,那么就要用到@vue/server-renderer
了。
npm i @vue/server-renderer --save
import { createApp } from './main'
import { renderToString } from '@vue/server-renderer'
export const render = async () => {
const { app } = createApp()
const renderCtx: {modules?: string[]} = {}
let renderedHtml = await renderToString(app, renderCtx)
return { renderedHtml }
}
const fs = require('fs')
const path = require('path')
const Koa = require('koa')
const koaConnect = require('koa-connect')
const vite = require('vite')
;(async () => {
const app = new Koa()
// 创建 vite 服务
const viteServer = await vite.createServer({
root: process.cwd(),
logLevel: 'error',
server: {
middlewareMode: true,
},
})
app.use(async ctx => {
try {
// 1. 获取index.html
let template = fs.readFileSync(path.resolve(__dirname, 'index.html'), 'utf-8');
// 2. 应用 Vite HTML 转换。这将会注入 Vite HMR 客户端,
template = await viteServer.transformIndexHtml(ctx.path, template)
// 3. 加载服务器入口, vite.ssrLoadModule 将自动转换
const { render } = await viteServer.ssrLoadModule('/src/entry-server.ts')
// 4. 渲染应用的 HTML
const { renderedHtml } = await render(ctx, {})
const html = template.replace('', renderedHtml)
ctx.type = 'text/html'
ctx.body = html
} catch (e) {
viteServer && viteServer.ssrFixStacktrace(e)
console.log(e.stack)
ctx.throw(500, e.stack)
}
})
const port = 3000
app.listen(port, () => {
console.log(`server is listening in ${port}`);
});
})()
开发环境可以
正常的渲染了但在生产环境
应该怎么做呢,因为咱们不可能直接在生产环境
使用vite
吧!生产环境
运行吧...
{
"scripts": {
// 开发环境
"dev": "node server-dev.js",
// 生产环境
"server": "node server-prod.js",
// 构建
"build": "pnpm build:client && pnpm build:server",
"build:client": "vite build --outDir dist/client",
"build:server": "vite build --ssr src/entry-server.ts --outDir dist/server",
},
}
...
注意:为了处理静态资源,需要新增
koa-send
中间件:pnpm i koa-send --save
const Koa = require('koa')
const sendFile = require('koa-send')
const path = require('path')
const fs = require('fs')
const resolve = (p) => path.resolve(__dirname, p)
const clientRoot = resolve('dist/client')
const template = fs.readFileSync(resolve('dist/client/index.html'), 'utf-8')
const render = require('./dist/server/entry-server.js').render
const manifest = require('./dist/client/ssr-manifest.json')
;(async () => {
const app = new Koa()
app.use(async (ctx) => {
// 请求的是静态资源
if (ctx.path.startsWith('/assets')) {
await sendFile(ctx, ctx.path, { root: clientRoot })
return
}
const [ appHtml ] = await render(ctx, manifest)
const html = template
.replace('', appHtml)
ctx.type = 'text/html'
ctx.body = html
});
const port = 8080
app.listen(port, () => {
console.log(`started server on http://localhost:${port}`))
}
})();
开发环境
和生成环境
已经都可以正常访问了,那么是不是就万事无忧了呢?预加载
就必须要安排了。我们知道vue组件
在html中渲染时都是动态去生成的对应的js
和css
等;
那么我们要是在用户获取服务端模板
(也就是执行 vite build
后生成的dist/client
目录) 的时候,直接在html中把对应的js
和css
文件预渲染了,这就是静态站点生成(SSG)
的形式。
build:client
添加--ssrManifest
标志,运行后生成ssr-manifest.json
...
{
"scripts": {
...
"build:client": "vite build --ssrManifest --outDir dist/client",
...
},
}
...
export const render = async (
ctx: ParameterizedContext,
manifest: Record
): Promise<[string, string]> => {
const { app } = createApp();
console.log(ctx, manifest, '');
const renderCtx: { modules?: string[] } = {};
const renderedHtml = await renderToString(app, renderCtx);
const preloadLinks = renderPreloadLinks(renderCtx.modules, manifest);
return [renderedHtml, preloadLinks];
};
/**
* 解析需要预加载的链接
* @param modules
* @param manifest
* @returns string
*/
function renderPreloadLinks(
modules: undefined | string[],
manifest: Record
): string {
let links = '';
const seen = new Set();
if (modules === undefined) throw new Error();
modules.forEach((id) => {
const files = manifest[id];
if (files) {
files.forEach((file) => {
if (!seen.has(file)) {
seen.add(file);
links += renderPreloadLink(file);
}
});
}
});
return links;
}
/**
* 预加载的对应的地址
* 下面的方法只针对了 js 和 css,如果需要处理其它文件,自行添加即可
* @param file
* @returns string
*/
function renderPreloadLink(file: string): string {
if (file.endsWith('.js')) {
return ``;
} else if (file.endsWith('.css')) {
return ``;
} else {
return '';
}
}
占位符
...
(async () => {
const app = new Koa();
app.use(async (ctx) => {
...
const [appHtml, preloadLinks] = await render(ctx, manifest);
const html = template
.replace('', preloadLinks)
.replace('', appHtml);
});
const port = 8080
app.listen(port, () => {
console.log(`started server on http://localhost:${port}`));
}
})();
到这里基本的渲染就完成了,因为我们是需要在浏览器上渲染的,所以要加上路由vue-router
npm i vue-router
2.新增路由页面page1.vue,page2.vue,page3.vue
3.新增~/src/router/index.ts
import {
createRouter as createVueRouter,
createMemoryHistory,
createWebHistory,
Router
} from 'vue-router'
export const createRouter = (type: 'client' | 'server'): Router =>
createVueRouter({
history: type === 'client' ? createWebHistory() : createMemoryHistory(),
routes: [
{
path: '/page1',
name: 'page1',
meta: {
title: 'page1',
keepAlive: true,
requireAuth: true
},
component: () => import('@/pages/page1.vue')
},
{
path: '/page2',
name: 'page2',
meta: {
title: 'page2',
keepAlive: true,
requireAuth: false
},
component: () => import('@/pages/page2.vue')
},
{
path: '/page3',
name: 'page3',
meta: {
title: 'page3',
keepAlive: true,
requireAuth: true
},
component: () => import('@/pages/page3.vue')
}
]
});
import { createApp } from './main'
import { createRouter } from './router'
const router = createRouter('client')
const { app } = createApp()
app.use(router)
router.isReady().then(() => {
app.mount('#app', true)
});
...
import { createRouter } from './router'
const router = createRouter('client')
export const render = async (
ctx: ParameterizedContext,
manifest: Record
): Promise<[string, string]> => {
const { app } = createApp()
// 路由注册
const router = createRouter('server')
app.use(router)
await router.push(ctx.path)
await router.isReady()
...
};
...
友情提示:以上都是对于升级vue3 SSR的预演,实际并未采用,因为目前Vite的SSR支持还处于试验阶段,可能会遇到一些未知 bug ,所以在公司的生产环境请谨慎使用,个人项目请随意。