目标:用 vite2 + vue3 + Ts 搭建一个开箱即用的最简 ssr 通用项目, 包含必要的 vuex vue-router asyncData header管理。
一 通过官方脚手架搭建一个 vue-ts 的 SPA 项目
首先安装 yarn 包管理工具:
创建一个简单的 vue-ts 项目:
1 2 3 4 5 6 7 | // 选择 vue-ts 模版 cd demo yarn yarn dev |
http://localhost:3000/
浏览器打开 http://localhost:3000/ 一个最简单的 vue3 + typescript 的 SPA 单页应用就搭建好了。
二 对 SPA 单页应用,进行 ssr 渲染改造。
在 src 目录下添加两个入口文件
项目目录下 修改 index.html文件
// entry-client.ts
1 2 3 4 5 6 7 8 9 | import { createSSRApp } from 'vue' ; import App from './App.vue' ; const app = createSSRApp(App); app.mount( '#app' , true ); |
// entry-server.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 | 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
1 2 3 4 5 | "scripts" : { "dev" : "node server-env.js" }, |
终端运行 yarn dev, 浏览器打开:http://localhost:3000/ 网页右键“显示页面源码”、
生产环境打包,package.json新增 build 相关命令
//package.json
1 2 3 4 5 6 7 8 9 10 11 12 13 | "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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 | 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 | 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 | 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | export default defineComponent({ setup() { const store = useStore(); return { store }; }, asyncData({ store }) { return store.dispatch( 'fetchMessage' ); }, }); |
修改entry-client.ts中路由守卫, router.beforeResolve( ) 相关。
// entry-client.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 | 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 | 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
1 2 3 4 5 6 7 8 9 10 11 | 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
1 2 3 4 5 6 7 8 9 | 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
1 2 3 4 5 | 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | { 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
1 2 3 4 5 6 7 | const html = template .replace(``, appHtml) .replace(` '' `, JSON.stringify(state)) .replace( '' , state.route.meta.title || 'Index' ); |
在entry-client.ts 文件做个前端路由跳转兼容
// entry-client.ts
1 2 3 4 5 6 7 8 9 | if ( from && ! from .name) { return next(); } else { window.document.title = (to.meta.title || '首页' ) as any; } |
六 增加配置文件,开发环境和生产环境
项目目录下新建 .env.development 和 .env.production 文件
// .env.development
1 2 3 4 5 | NODE_ENV=development VITE_API_URL=/ VITE_ASSET_URL=/ |
// .env.production
1 2 3 | VITE_API_URL=/ VITE_ASSET_URL=/ |
修改 vite.config.ts 文件, 配置文件通过 loadEnv 获取.env files 环境变量,
如果静态资源要发布CDN,可设置 例如: VITE_ASSET_URL=https://cdn.domain.com/
程序内部通过
// vite.config.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | module.exports = { trailingComma: "none" , printWidth: 130, bracketSpacing: true , arrowParens: "always" , tabWidth: 2, semi: true , singleQuote: true , jsxBracketSameLine: true , }; |
修改 ts.config.json 增加两条验证规则。
// ts.config.json
1 2 3 | "noUnusedLocals" : true , // 不允许未使用的变量 "noImplicitReturns" : true , // 函数不含隐式返回值 |
终端运行 yarn dev 和 yarn preview 查看效果。
若生产环境编译需要Ts类型检查 可通过 vue-tsc 插件,但编译会慢很多,修改package.json 配置文件。
// package.json
1 2 3 4 5 | "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
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列:基于图像分类模型对图像进行分类
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 25岁的心里话
· 闲置电脑爆改个人服务器(超详细) #公网映射 #Vmware虚拟网络编辑器
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 零经验选手,Compose 一天开发一款小游戏!
· 通过 API 将Deepseek响应流式内容输出到前端
2019-12-22 TypeScript 和 JavaScript 的区别