目标:用 vite2 + vue3 + Ts 搭建一个开箱即用的最简 ssr 通用项目, 包含必要的 vuex vue-router asyncData header管理。
一 通过官方脚手架搭建一个 vue-ts 的 SPA 项目
首先安装 yarn 包管理工具:
创建一个简单的 vue-ts 项目:
// 选择 vue-ts 模版 cd demo yarn yarn dev
http://localhost:3000/
浏览器打开 http://localhost:3000/ 一个最简单的 vue3 + typescript 的 SPA 单页应用就搭建好了。
二 对 SPA 单页应用,进行 ssr 渲染改造。
在 src 目录下添加两个入口文件
项目目录下 修改 index.html文件
// entry-client.ts
import { createSSRApp } from 'vue';
import App from './App.vue';
const app = createSSRApp(App);
app.mount('#app', true);
// entry-server.js
import { createSSRApp } from 'vue';
import App from './App.vue';
import { renderToString } from '@vue/server-renderer';
export async function render(url, manifest) {
const app = createSSRApp(App);
const context = {};
const appHtml = await renderToString(app, context);
return { appHtml };
}
新建node端web服务器入口文件(开发环境): server-env.js ,官方推荐 express,安装node包: yarn add -D express
const fs = require('fs');
const path = require('path');
const express = require('express');
const { createServer: createViteServer } = require('vite');
async function createServer() {
const app = express();
const vite = await createViteServer({
server: { middlewareMode: true },
});
app.use(vite.middlewares);
app.use('*', async (req, res) => {
// serve index.html - we will tackle this next
const url = req.originalUrl;
try {
// 1. Read index.html
let template = fs.readFileSync(path.resolve(__dirname, 'index.html'), 'utf-8');
// 2. Apply vite HTML transforms.
template = await vite.transformIndexHtml(url, template);
// 3. Load the server entry. vite.ssrLoadModule
const { render } = await vite.ssrLoadModule('/src/entry-server.js');
// 4. render the app HTML.
const { appHtml } = await render(url);
// 5. Inject the app-rendered HTML into the template.
const html = template.replace(``, appHtml);
// 6. Send the rendered HTML back.
res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
} catch (e) {
// If an error is caught,
vite.ssrFixStacktrace(e);
console.error(e);
res.status(500).end(e.message);
}
});
app.listen(3000, () => {
console.log('http://localhost:3000');
});
}
createServer();
package.json文件 新增dev命令
// package.json
"scripts": {
"dev": "node server-env.js"
},
终端运行 yarn dev, 浏览器打开:http://localhost:3000/ 网页右键“显示页面源码”、
生产环境打包,package.json新增 build 相关命令
//package.json
"scripts": {
"dev": "node server-env.js",
"build:client": "vite build --outDir dist/client --ssrManifest",
"build:server": "vite build --outDir dist/server --ssr src/entry-server.js ",
"build": "yarn build:client && yarn build:server",
"preview": "yarn build && node server.js"
},
新建 node 端web服务器入口文件(生产环境): server.js ,个人选择 koa搭建生产环境服务器,安装 node 包:yarn add -D koa koa-static
// server.js
const fs = require('fs');
const path = require('path');
const Koa = require('koa');
const staticPath = require('koa-static');
const app = new Koa();
const resolve = (p) => path.resolve(__dirname, p);
const template = fs.readFileSync(resolve('./dist/client/index.html'), 'utf-8');
const manifest = require('./dist/client/ssr-manifest.json');
const render = require('./dist/server/entry-server.js').render;
app.use(staticPath(resolve('./dist/client'), { index: false }));
app.use(async (ctx, next) => {
const url = ctx.req.url;
try {
const { appHtml } = await render(url, manifest);
let html = template.replace(``, appHtml);
ctx.body = html;
} catch (error) {
console.log(error);
next();
}
});
app.listen(3000, () => {
console.log('http://localhost:3000');
});
终端运行: yarn preview,浏览器打开:http://localhost:3000。最简 ssr 改造完成。
三 安装生产上必备的 vue 全家桶: scss vuex vue-router
首先安装scss支持: yarn add -D sass.
安装vue-router 和 vuex : yarn add vuex@next vue-router@next vuex-router-sync@next
新建 src/store/index.ts 和 src/router/index.ts 两个文件
// src/router/index.ts
import { createRouter, createWebHistory, createMemoryHistory } from 'vue-router';
export default function () {
const routerHistory = import.meta.env.SSR === false ? createWebHistory() : createMemoryHistory();
return createRouter({
history: routerHistory,
routes: [
{
path: '/',
name: 'home',
component: () => import('../views/Home.vue'),
},
{
path: '/about',
name: 'about',
component: () => import('../views/About.vue'),
},
{
path: '/:catchAll(.*)*',
name: '404',
component: () => import('../views/404.vue'),
meta: {
title: '404 Not Found',
},
},
],
});
}
// src/store/index.ts
import { createStore as _createStore } from 'vuex';
export default function createStore() {
return _createStore({
state: {
message: 'Hello vite2 vue3 ssr',
},
mutations: {},
actions: {
fetchMessage: ({ state }) => {
return new Promise((resolve) => {
setTimeout(() => {
state.message = 'Hello vite2 vue3 ssr typescript scss vuex vue-router';
resolve(0);
}, 200);
});
},
},
modules: {},
});
}
新建对应的 src/views/页面 Home.vue About.vue 404.vue, 略。
修改 entry-client.ts 和 entry-server.js文件,加入相应的 vuex 和 router
// entry-client.ts
import { createSSRApp } from 'vue';
import App from './App.vue';
import { sync } from 'vuex-router-sync';
import createStore from './store';
import createRouter from './router';
const router = createRouter();
const store = createStore();
sync(store, router);
const app = createSSRApp(App);
app.use(router).use(store);
router.beforeResolve((to, from, next) => {
next();
});
router.isReady().then(() => {
app.mount('#app', true);
});
// entry-server.js
import { createSSRApp } from 'vue';
import App from './App.vue';
import { renderToString } from '@vue/server-renderer';
import createStore from './store';
import createRouter from './router';
import { sync } from 'vuex-router-sync';
export async function render(url, manifest) {
const router = createRouter();
const store = createStore();
sync(store, router);
const app = createSSRApp(App);
app.use(router).use(store);
router.push(url);
await router.isReady();
const context = {};
const appHtml = await renderToString(app, context);
return { appHtml };
}
App.vue
Home.vue
终端运行: yarn dev 查看开发环境效果。终端运行: yarn preview 查看生产环境效果。
四 服务端预取数据 asyncData
服务端预取数据采用 vue2的 asyncData 方式。
新建 vue-extend.d.ts 文件
// vue-extend.d.ts
import { RouteRecordRaw } from 'vue-router';
export interface AsyncDataContextType {
route: RouteRecordRaw;
store: any; // 类型不决 用 any。 -.-!
}
declare module '@vue/runtime-core' {
interface ComponentCustomOptions {
asyncData?(context: AsyncDataContextType): Promise;
}
}
在Home.vue 添加 asyncData,store里用 setTimeout 模拟异步请求。
// Home.vue
export default defineComponent({
setup() {
const store = useStore();
return { store };
},
asyncData({ store }) {
return store.dispatch('fetchMessage');
},
});
修改entry-client.ts中路由守卫, router.beforeResolve( ) 相关。
// entry-client.ts
router.beforeResolve((to, from, next) => {
let diffed = false;
const matched = router.resolve(to).matched;
const prevMatched = router.resolve(from).matched;
if (from && !from.name) {
return next();
}
const activated = matched.filter((c, i) => {
return diffed || (diffed = prevMatched[i] !== c);
});
if (!activated.length) {
return next();
}
const matchedComponents: any = [];
matched.map((route) => {
matchedComponents.push(...Object.values(route.components));
});
const asyncDataFuncs = matchedComponents.map((component: any) => {
const asyncData = component.asyncData || null;
if (asyncData) {
const config = {
store,
route: to,
};
return asyncData(config);
}
});
try {
Promise.all(asyncDataFuncs).then(() => {
next();
});
} catch (err) {
next(err);
}
});
修改entry-server.js中 render 函数。
// entry-server.js
export async function render(url, manifest) {
const router = createRouter();
const store = createStore();
sync(store, router);
const app = createSSRApp(App);
app.use(router).use(store);
router.push(url);
await router.isReady();
const to = router.currentRoute;
const matchedRoute = to.value.matched;
if (to.value.matched.length === 0) {
return '';
}
const matchedComponents = [];
matchedRoute.map((route) => {
matchedComponents.push(...Object.values(route.components));
});
const asyncDataFuncs = matchedComponents.map((component) => {
const asyncData = component.asyncData || null;
if (asyncData) {
const config = {
store,
route: to,
};
return asyncData(config);
}
});
await Promise.all(asyncDataFuncs);
const context = {};
const appHtml = await renderToString(app, context);
return { appHtml };
}
终端运行 yarn dev查看效果, 服务端预取数据渲染正确,但devtools 有一个报错:Hydration completed but contains mismatches. 是客户端和服务端的 store 未同步
同步方式如下:
index.html 文件添加相应的 window.__INITIAL_STATE__ 标识
修改entry-server.js 的 render函数 返回 state
// entry-server.js
export async function render(url, manifest) {
//...
const appHtml = await renderToString(app, context);
const state = store.state;
return { appHtml, state };
}
在 server-env.js 和 server.js 修改 html模版 注意 `' '`。
// server-env.js 和 server.js
const { appHtml, state } = await render ;
const html = template
.replace(``, appHtml)
.replace(`''`, JSON.stringify(state))
entry-client.ts 文件末添加 store.replaceState()函数同步state。
// entry-client.ts if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__); }
在shims-vue.d.ts 添加 typescript支持
// shims-vue.d.ts
interface Window {
__INITIAL_STATE__: any;
}
终端运行 yarn dev 和 yarn preview 查看效果。
这一阶段源码: https://github.com/damowangzhu/vite2-vue3-ssr_steps/tree/v2
五 Head管理,ssr for SEO
以 title 为例,description 和 keywords 雷同。
在 src/router/indext.ts 写入 meta 信息
// src/router/index.ts
{
path: '/',
name: 'home',
component: () => import('../views/Home.vue'),
meta: {
title: 'Home title',
},
},
在index.html文件添加 title 标记
<!--title-->
在server.js 和 server-env.js 修改模版
// server.js server-env.js
const html = template
.replace(``, appHtml)
.replace(`''`, JSON.stringify(state))
.replace('', state.route.meta.title || 'Index');
在entry-client.ts 文件做个前端路由跳转兼容
// entry-client.ts
if (from && !from.name) {
return next();
} else {
window.document.title = (to.meta.title || '首页') as any;
}
六 增加配置文件,开发环境和生产环境
项目目录下新建 .env.development 和 .env.production 文件
// .env.development
NODE_ENV=development VITE_API_URL=/ VITE_ASSET_URL=/
// .env.production
VITE_API_URL=/ VITE_ASSET_URL=/
修改 vite.config.ts 文件, 配置文件通过 loadEnv 获取.env files 环境变量,
如果静态资源要发布CDN,可设置 例如: VITE_ASSET_URL=https://cdn.domain.com/
程序内部通过
// vite.config.ts
import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd());
return {
base: env.VITE_ASSET_URL,
plugins: [vue()],
};
});
七 代码格式化和 typescript 类型检查
官方推荐 Vscode + Volar, 通过 ide 的插件做类型检查等
Vscode安装 volar 和 Prettier 插件, 新建 .prettierrc.js 文件 , Vscode默认格式化选择 prettier
// .prettierrc.js
module.exports = {
trailingComma: "none",
printWidth: 130,
bracketSpacing: true,
arrowParens: "always",
tabWidth: 2,
semi: true,
singleQuote: true,
jsxBracketSameLine: true,
};
修改 ts.config.json 增加两条验证规则。
// ts.config.json
"noUnusedLocals": true, // 不允许未使用的变量 "noImplicitReturns": true, // 函数不含隐式返回值
终端运行 yarn dev 和 yarn preview 查看效果。
若生产环境编译需要Ts类型检查 可通过 vue-tsc 插件,但编译会慢很多,修改package.json 配置文件。
// package.json
"build:client": "vue-tsc --noEmit && vite build --ssrManifest --outDir dist/client", 最后 升级vue3 到最新版本: yarn add vue@next;
本文源码: https://github.com/ygunoil/vite2-vue3-ssr_steps
参考资料:
https://vitejs.dev/guide/ssr.html
https://cn.vitejs.dev/config/#async-config
https://www.bookstack.cn/read/vitejs-2.4.4-zh/guide-ssr.md
https://github.com/vitejs/vite/tree/main/packages/playground/ssr-vue
https://github.com/vok123/vue3-ts-vite-ssr-starter
