目標:用 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
