commit
f7f66eaafc
29 changed files with 1457 additions and 0 deletions
@ -0,0 +1,175 @@ |
|||||
|
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore |
||||
|
|
||||
|
# Logs |
||||
|
|
||||
|
logs |
||||
|
_.log |
||||
|
npm-debug.log_ |
||||
|
yarn-debug.log* |
||||
|
yarn-error.log* |
||||
|
lerna-debug.log* |
||||
|
.pnpm-debug.log* |
||||
|
|
||||
|
# Caches |
||||
|
|
||||
|
.cache |
||||
|
|
||||
|
# Diagnostic reports (https://nodejs.org/api/report.html) |
||||
|
|
||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json |
||||
|
|
||||
|
# Runtime data |
||||
|
|
||||
|
pids |
||||
|
_.pid |
||||
|
_.seed |
||||
|
*.pid.lock |
||||
|
|
||||
|
# Directory for instrumented libs generated by jscoverage/JSCover |
||||
|
|
||||
|
lib-cov |
||||
|
|
||||
|
# Coverage directory used by tools like istanbul |
||||
|
|
||||
|
coverage |
||||
|
*.lcov |
||||
|
|
||||
|
# nyc test coverage |
||||
|
|
||||
|
.nyc_output |
||||
|
|
||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) |
||||
|
|
||||
|
.grunt |
||||
|
|
||||
|
# Bower dependency directory (https://bower.io/) |
||||
|
|
||||
|
bower_components |
||||
|
|
||||
|
# node-waf configuration |
||||
|
|
||||
|
.lock-wscript |
||||
|
|
||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html) |
||||
|
|
||||
|
build/Release |
||||
|
|
||||
|
# Dependency directories |
||||
|
|
||||
|
node_modules/ |
||||
|
jspm_packages/ |
||||
|
|
||||
|
# Snowpack dependency directory (https://snowpack.dev/) |
||||
|
|
||||
|
web_modules/ |
||||
|
|
||||
|
# TypeScript cache |
||||
|
|
||||
|
*.tsbuildinfo |
||||
|
|
||||
|
# Optional npm cache directory |
||||
|
|
||||
|
.npm |
||||
|
|
||||
|
# Optional eslint cache |
||||
|
|
||||
|
.eslintcache |
||||
|
|
||||
|
# Optional stylelint cache |
||||
|
|
||||
|
.stylelintcache |
||||
|
|
||||
|
# Microbundle cache |
||||
|
|
||||
|
.rpt2_cache/ |
||||
|
.rts2_cache_cjs/ |
||||
|
.rts2_cache_es/ |
||||
|
.rts2_cache_umd/ |
||||
|
|
||||
|
# Optional REPL history |
||||
|
|
||||
|
.node_repl_history |
||||
|
|
||||
|
# Output of 'npm pack' |
||||
|
|
||||
|
*.tgz |
||||
|
|
||||
|
# Yarn Integrity file |
||||
|
|
||||
|
.yarn-integrity |
||||
|
|
||||
|
# dotenv environment variable files |
||||
|
|
||||
|
.env |
||||
|
.env.development.local |
||||
|
.env.test.local |
||||
|
.env.production.local |
||||
|
.env.local |
||||
|
|
||||
|
# parcel-bundler cache (https://parceljs.org/) |
||||
|
|
||||
|
.parcel-cache |
||||
|
|
||||
|
# Next.js build output |
||||
|
|
||||
|
.next |
||||
|
out |
||||
|
|
||||
|
# Nuxt.js build / generate output |
||||
|
|
||||
|
.nuxt |
||||
|
dist |
||||
|
|
||||
|
# Gatsby files |
||||
|
|
||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js |
||||
|
|
||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support |
||||
|
|
||||
|
# public |
||||
|
|
||||
|
# vuepress build output |
||||
|
|
||||
|
.vuepress/dist |
||||
|
|
||||
|
# vuepress v2.x temp and cache directory |
||||
|
|
||||
|
.temp |
||||
|
|
||||
|
# Docusaurus cache and generated files |
||||
|
|
||||
|
.docusaurus |
||||
|
|
||||
|
# Serverless directories |
||||
|
|
||||
|
.serverless/ |
||||
|
|
||||
|
# FuseBox cache |
||||
|
|
||||
|
.fusebox/ |
||||
|
|
||||
|
# DynamoDB Local files |
||||
|
|
||||
|
.dynamodb/ |
||||
|
|
||||
|
# TernJS port file |
||||
|
|
||||
|
.tern-port |
||||
|
|
||||
|
# Stores VSCode versions used for testing VSCode extensions |
||||
|
|
||||
|
.vscode-test |
||||
|
|
||||
|
# yarn v2 |
||||
|
|
||||
|
.yarn/cache |
||||
|
.yarn/unplugged |
||||
|
.yarn/build-state.yml |
||||
|
.yarn/install-state.gz |
||||
|
.pnp.* |
||||
|
|
||||
|
# IntelliJ based IDEs |
||||
|
.idea |
||||
|
|
||||
|
# Finder (MacOS) folder config |
||||
|
.DS_Store |
||||
@ -0,0 +1,3 @@ |
|||||
|
{ |
||||
|
"CodeFree.index": true |
||||
|
} |
||||
@ -0,0 +1 @@ |
|||||
|
# 基于koa实现的简易ssr |
||||
Binary file not shown.
@ -0,0 +1,17 @@ |
|||||
|
<!DOCTYPE html> |
||||
|
<html lang="en"> |
||||
|
|
||||
|
<head> |
||||
|
<meta charset="UTF-8" /> |
||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> |
||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
||||
|
<title>Vite + Vue + TS</title> |
||||
|
<!--app-head--> |
||||
|
</head> |
||||
|
|
||||
|
<body> |
||||
|
<div id="app"><!--app-html--></div> |
||||
|
<script type="module" src="/src/entry-client.ts"></script> |
||||
|
</body> |
||||
|
|
||||
|
</html> |
||||
@ -0,0 +1,30 @@ |
|||||
|
{ |
||||
|
"name": "koa-ssr", |
||||
|
"type": "module", |
||||
|
"scripts": { |
||||
|
"dev": "bun run --watch server.ts", |
||||
|
"build": "npm run build:client && npm run build:server", |
||||
|
"build:client": "vite build --outDir dist/client", |
||||
|
"build:server": "vite build --ssr src/entry-server.ts --outDir dist/server", |
||||
|
"preview": "cross-env NODE_ENV=production bun run server.ts", |
||||
|
"check": "vue-tsc" |
||||
|
}, |
||||
|
"devDependencies": { |
||||
|
"@types/bun": "latest", |
||||
|
"@types/koa": "^3.0.0", |
||||
|
"@types/koa-send": "^4.1.6", |
||||
|
"cross-env": "^10.1.0", |
||||
|
"vue-tsc": "^3.1.0" |
||||
|
}, |
||||
|
"peerDependencies": { |
||||
|
"typescript": "^5.0.0" |
||||
|
}, |
||||
|
"dependencies": { |
||||
|
"@vitejs/plugin-vue": "^6.0.1", |
||||
|
"koa": "^3.0.1", |
||||
|
"koa-connect": "^2.1.0", |
||||
|
"koa-send": "^5.0.1", |
||||
|
"vite": "^7.1.7", |
||||
|
"vue": "^3.5.22" |
||||
|
} |
||||
|
} |
||||
|
After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1,84 @@ |
|||||
|
import fs from 'node:fs/promises' |
||||
|
import c2k from 'koa-connect' |
||||
|
import type { ViteDevServer } from 'vite' |
||||
|
import Send from 'koa-send' |
||||
|
import app from "./server/app" |
||||
|
import { bootstrapServer } from "./server/main" |
||||
|
|
||||
|
// Constants
|
||||
|
const isProduction = process.env.NODE_ENV === 'production' |
||||
|
const port = process.env.PORT || 5173 |
||||
|
const base = process.env.BASE || '/' |
||||
|
|
||||
|
bootstrapServer() |
||||
|
|
||||
|
// Cached production assets
|
||||
|
const templateHtml = isProduction |
||||
|
? await fs.readFile('./dist/client/index.html', 'utf-8') |
||||
|
: '' |
||||
|
|
||||
|
let vite: ViteDevServer |
||||
|
if (!isProduction) { |
||||
|
const { createServer } = await import('vite') |
||||
|
vite = await createServer({ |
||||
|
server: { middlewareMode: true }, |
||||
|
appType: 'custom', |
||||
|
base, |
||||
|
}) |
||||
|
app.use(c2k(vite.middlewares)) |
||||
|
} else { |
||||
|
app.use(async (ctx, next) => { |
||||
|
await Send(ctx, ctx.path, { root: './dist/client', index: false }); |
||||
|
if (ctx.status === 404) { |
||||
|
await next() |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
app.use(async (ctx, next) => { |
||||
|
// if (!ctx.originalUrl.startsWith(base)) return await next()
|
||||
|
try { |
||||
|
const url = ctx.originalUrl.replace(base, '') |
||||
|
let template |
||||
|
let render |
||||
|
if (!isProduction) { |
||||
|
// Always read fresh template in development
|
||||
|
template = await fs.readFile('./index.html', 'utf-8') |
||||
|
template = await vite.transformIndexHtml(url, template) |
||||
|
render = (await vite.ssrLoadModule('/src/entry-server.ts')).render |
||||
|
} else { |
||||
|
template = templateHtml |
||||
|
// @ts-ignore
|
||||
|
render = (await import('./dist/server/entry-server.js')).render |
||||
|
} |
||||
|
|
||||
|
// 解析请求 Cookie 到对象(复用通用工具)
|
||||
|
const { parseCookieHeader } = await import('./src/compose/cookieUtils') |
||||
|
const cookies = parseCookieHeader(ctx.request.headers['cookie'] as string) |
||||
|
|
||||
|
const rendered = await render(url, { cookies }) |
||||
|
|
||||
|
const html = template |
||||
|
.replace(`<!--app-head-->`, rendered.head ?? '') |
||||
|
.replace(`<!--app-html-->`, rendered.html ?? '') |
||||
|
ctx.status = 200 |
||||
|
ctx.set({ 'Content-Type': 'text/html' }) |
||||
|
ctx.body = html |
||||
|
|
||||
|
// 设置服务端渲染期间收集到的 Set-Cookie
|
||||
|
const setCookies: string[] = (rendered as any).setCookies || [] |
||||
|
if (setCookies.length > 0) { |
||||
|
ctx.set('Set-Cookie', setCookies) |
||||
|
} |
||||
|
} catch (e: Error | any) { |
||||
|
vite?.ssrFixStacktrace(e) |
||||
|
ctx.status = 500 |
||||
|
ctx.body = e.stack |
||||
|
} |
||||
|
await next() |
||||
|
}) |
||||
|
|
||||
|
// Start http server
|
||||
|
app.listen(port, () => { |
||||
|
console.log(`Server started at http://localhost:${port}`) |
||||
|
}) |
||||
@ -0,0 +1,9 @@ |
|||||
|
|
||||
|
import Koa from "koa" |
||||
|
|
||||
|
const app = new Koa() |
||||
|
|
||||
|
export default app |
||||
|
export { |
||||
|
app |
||||
|
} |
||||
@ -0,0 +1,60 @@ |
|||||
|
import { parseCookieHeader, serializeCookie } from "../src/compose/cookieUtils"; |
||||
|
import app from "./app"; |
||||
|
|
||||
|
export function bootstrapServer() { |
||||
|
async function fetchFirstSuccess(urls) { |
||||
|
for (const url of urls) { |
||||
|
try { |
||||
|
const res = await fetch(url, { |
||||
|
method: "get", |
||||
|
mode: "cors", |
||||
|
redirect: "follow", |
||||
|
}); |
||||
|
if (!res.ok) continue; |
||||
|
const contentType = res.headers.get("content-type") || ""; |
||||
|
let data, type; |
||||
|
if (contentType.includes("application/json")) { |
||||
|
data = await res.json(); |
||||
|
type = "json"; |
||||
|
} else if (contentType.includes("text/")) { |
||||
|
data = await res.text(); |
||||
|
type = "text"; |
||||
|
} else { |
||||
|
data = await res.blob(); |
||||
|
type = "blob"; |
||||
|
} |
||||
|
return { type, data }; |
||||
|
} catch (e) { |
||||
|
// ignore and try next url
|
||||
|
} |
||||
|
} |
||||
|
throw new Error("All requests failed"); |
||||
|
} |
||||
|
|
||||
|
app.use(async (ctx, next) => { |
||||
|
const cookies = parseCookieHeader(ctx.request.headers.cookie as string); |
||||
|
|
||||
|
// 读取
|
||||
|
const token = cookies["demo_2token"]; |
||||
|
|
||||
|
// 写入(HttpOnly 更安全)
|
||||
|
if (!token) { |
||||
|
const setItem = serializeCookie("demo_2token", "from-mw", { |
||||
|
httpOnly: true, |
||||
|
path: "/", |
||||
|
sameSite: "lax", |
||||
|
}); |
||||
|
ctx.set("Set-Cookie", [setItem]); |
||||
|
} |
||||
|
if (ctx.originalUrl !== "/api/pics/random") return await next(); |
||||
|
const { type, data } = await fetchFirstSuccess([ |
||||
|
"https://api.miaomc.cn/image/get", |
||||
|
]); |
||||
|
if (type === "blob") { |
||||
|
ctx.set("Content-Type", "image/jpeg"); |
||||
|
// 下载
|
||||
|
// ctx.set("Content-Disposition", "attachment; filename=random.jpg")
|
||||
|
ctx.body = data; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,37 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
// This starter template is using Vue 3 <script setup> SFCs |
||||
|
// Check out https://vuejs.org/api/sfc-script-setup.html#script-setup |
||||
|
import HelloWorld from './components/HelloWorld.vue'; |
||||
|
import CookieDemo from './components/CookieDemo.vue'; |
||||
|
import DataFetch from './components/DataFetch.vue'; |
||||
|
import SimpleTest from './components/SimpleTest.vue'; |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div> |
||||
|
<a href="https://vite.dev" target="_blank"> |
||||
|
<img src="/vite.svg" class="logo" alt="Vite logo" />AAA |
||||
|
</a> |
||||
|
<a href="https://vuejs.org/" target="_blank"> |
||||
|
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" /> |
||||
|
</a> |
||||
|
</div> |
||||
|
<HelloWorld msg="Vite + Vue" /> |
||||
|
<CookieDemo /> |
||||
|
<SimpleTest /> |
||||
|
<DataFetch /> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
.logo { |
||||
|
height: 6em; |
||||
|
padding: 1.5em; |
||||
|
will-change: filter; |
||||
|
} |
||||
|
.logo:hover { |
||||
|
filter: drop-shadow(0 0 2em #646cffaa); |
||||
|
} |
||||
|
.logo.vue:hover { |
||||
|
filter: drop-shadow(0 0 2em #42b883aa); |
||||
|
} |
||||
|
</style> |
||||
|
After Width: | Height: | Size: 496 B |
@ -0,0 +1,50 @@ |
|||||
|
<template> |
||||
|
<div class="cookie-demo"> |
||||
|
<div class="btns"> |
||||
|
<button @click="setCookie">设置 cookie</button> |
||||
|
<button @click="removeCookie">删除 cookie</button> |
||||
|
</div> |
||||
|
<small>服务端首屏会尝试设置缺失的 HttpOnly cookie,客户端不可读。</small> |
||||
|
</div> |
||||
|
|
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { onMounted, onServerPrefetch } from 'vue' |
||||
|
import { useCookie } from '../compose/useCookie' |
||||
|
|
||||
|
const cookie = useCookie('demo_token', { path: '/', sameSite: 'lax' }) |
||||
|
// 不在页面上渲染 cookie 值,避免 HttpOnly 场景下的水合不一致 |
||||
|
|
||||
|
// 服务端:如果没有 cookie,设置一个默认值(演示 SSR 写入 Set-Cookie) |
||||
|
onServerPrefetch(async () => { |
||||
|
const v = cookie.get() |
||||
|
if (!v) { |
||||
|
// 仅服务端设置 HttpOnly,客户端无法读取 |
||||
|
cookie.set('from-ssr', { httpOnly: true }) |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
// 客户端:水合后读取现有值 |
||||
|
onMounted(() => { |
||||
|
// 客户端不读取也不展示值 |
||||
|
}) |
||||
|
|
||||
|
function setCookie() { |
||||
|
const rnd = Math.random().toString(36).slice(2, 8) |
||||
|
cookie.set(`client-${rnd}`) |
||||
|
} |
||||
|
|
||||
|
function removeCookie() { |
||||
|
cookie.remove() |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<style scoped> |
||||
|
.cookie-demo { padding: 12px; border: 1px solid #eee; border-radius: 8px; } |
||||
|
.btns { display: flex; gap: 8px; margin: 8px 0; } |
||||
|
button { padding: 6px 10px; cursor: pointer; } |
||||
|
code { background: #f6f8fa; padding: 2px 6px; border-radius: 4px; } |
||||
|
</style> |
||||
|
|
||||
|
|
||||
@ -0,0 +1,116 @@ |
|||||
|
<template> |
||||
|
<div class="data-fetch"> |
||||
|
<h2>数据获取示例</h2> |
||||
|
|
||||
|
<!-- 调试信息 --> |
||||
|
<div class="debug-info"> |
||||
|
<p><strong>水合状态:</strong> {{ isHydrated ? '已水合' : '未水合' }}</p> |
||||
|
<p><strong>缓存键:</strong> user-1</p> |
||||
|
</div> |
||||
|
|
||||
|
<div v-if="pending" class="loading"> |
||||
|
加载中... |
||||
|
</div> |
||||
|
|
||||
|
<div v-else-if="error" class="error"> |
||||
|
错误: {{ error.message }} |
||||
|
<button @click="refresh">重试</button> |
||||
|
</div> |
||||
|
|
||||
|
<div v-else-if="data" class="data"> |
||||
|
<h3>用户信息</h3> |
||||
|
<pre>{{ JSON.stringify(data, null, 2) }}</pre> |
||||
|
<button @click="refresh">刷新数据</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { ref, onMounted } from 'vue' |
||||
|
import { useFetch } from '../compose/useFetch' |
||||
|
|
||||
|
// 调试状态 |
||||
|
const isHydrated = ref(false) |
||||
|
|
||||
|
onMounted(() => { |
||||
|
isHydrated.value = true |
||||
|
}) |
||||
|
|
||||
|
// 使用 useFetch hook |
||||
|
const { data, error, pending, refresh } = useFetch( |
||||
|
'https://jsonplaceholder.typicode.com/users/1', |
||||
|
{ |
||||
|
key: 'user-1', // 缓存键 |
||||
|
server: true, // 启用服务端预取 |
||||
|
transform: (userData: any) => ({ |
||||
|
id: userData.id, |
||||
|
name: userData.name, |
||||
|
email: userData.email, |
||||
|
website: userData.website |
||||
|
}), |
||||
|
onError: (err) => { |
||||
|
console.error('Fetch error:', err) |
||||
|
} |
||||
|
} |
||||
|
) |
||||
|
</script> |
||||
|
|
||||
|
<style scoped> |
||||
|
.data-fetch { |
||||
|
padding: 20px; |
||||
|
border: 1px solid #ddd; |
||||
|
border-radius: 8px; |
||||
|
margin: 20px 0; |
||||
|
} |
||||
|
|
||||
|
.debug-info { |
||||
|
background: #f0f8ff; |
||||
|
padding: 10px; |
||||
|
border-radius: 4px; |
||||
|
margin-bottom: 15px; |
||||
|
font-size: 14px; |
||||
|
} |
||||
|
|
||||
|
.debug-info p { |
||||
|
margin: 5px 0; |
||||
|
} |
||||
|
|
||||
|
.loading { |
||||
|
color: #666; |
||||
|
font-style: italic; |
||||
|
} |
||||
|
|
||||
|
.error { |
||||
|
color: #d32f2f; |
||||
|
background: #ffebee; |
||||
|
padding: 10px; |
||||
|
border-radius: 4px; |
||||
|
} |
||||
|
|
||||
|
.data { |
||||
|
background: #f5f5f5; |
||||
|
padding: 15px; |
||||
|
border-radius: 4px; |
||||
|
} |
||||
|
|
||||
|
pre { |
||||
|
background: #fff; |
||||
|
padding: 10px; |
||||
|
border-radius: 4px; |
||||
|
overflow-x: auto; |
||||
|
} |
||||
|
|
||||
|
button { |
||||
|
background: #1976d2; |
||||
|
color: white; |
||||
|
border: none; |
||||
|
padding: 8px 16px; |
||||
|
border-radius: 4px; |
||||
|
cursor: pointer; |
||||
|
margin-top: 10px; |
||||
|
} |
||||
|
|
||||
|
button:hover { |
||||
|
background: #1565c0; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,38 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
import { ref } from 'vue'; |
||||
|
|
||||
|
defineProps<{ msg: string }>(); |
||||
|
|
||||
|
const count = ref(0); |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<h1>{{ msg }}</h1> |
||||
|
|
||||
|
<div class="card"> |
||||
|
<button type="button" @click="count++">count 啊啊撒打算 {{ count }}</button> |
||||
|
<p> |
||||
|
Edit |
||||
|
<code>components/HelloWorld.vue</code> to test HMR |
||||
|
</p> |
||||
|
</div> |
||||
|
|
||||
|
<p> |
||||
|
Check out |
||||
|
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank" |
||||
|
>create-vue</a |
||||
|
>, the official Vue + Vite starter |
||||
|
</p> |
||||
|
<p> |
||||
|
Install |
||||
|
<a href="https://github.com/johnsoncodehk/volar" target="_blank">Volar</a> |
||||
|
in your IDE for a better DX |
||||
|
</p> |
||||
|
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped> |
||||
|
.read-the-docs { |
||||
|
color: #888; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,45 @@ |
|||||
|
<template> |
||||
|
<div class="simple-test"> |
||||
|
<h3>简单测试组件</h3> |
||||
|
<p>数据状态: {{ data ? '已加载' : '未加载' }}</p> |
||||
|
<p>错误: {{ error ? error.message : '无' }}</p> |
||||
|
<p>加载中: {{ pending ? '是' : '否' }}</p> |
||||
|
<div v-if="data" class="data-content"> |
||||
|
<h4>{{ data.title }}</h4> |
||||
|
<p>{{ data.body }}</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { useFetch } from '../compose/useFetch' |
||||
|
|
||||
|
// 使用一个简单的 API 进行测试 |
||||
|
const { data, error, pending } = useFetch( |
||||
|
'https://jsonplaceholder.typicode.com/posts/1', |
||||
|
{ |
||||
|
key: 'post-1', |
||||
|
server: true, |
||||
|
transform: (post: any) => ({ |
||||
|
id: post.id, |
||||
|
title: post.title, |
||||
|
body: post.body.substring(0, 100) + '...' |
||||
|
}) |
||||
|
} |
||||
|
) |
||||
|
</script> |
||||
|
|
||||
|
<style scoped> |
||||
|
.simple-test { |
||||
|
background: #f9f9f9; |
||||
|
padding: 15px; |
||||
|
border: 1px solid #ddd; |
||||
|
border-radius: 4px; |
||||
|
margin: 10px 0; |
||||
|
} |
||||
|
|
||||
|
.simple-test p { |
||||
|
margin: 5px 0; |
||||
|
font-size: 14px; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,170 @@ |
|||||
|
# useFetch SSR Hook |
||||
|
|
||||
|
这是一个专为 Vue 3 SSR 应用设计的 `useFetch` hook,支持服务端预取和客户端水合。 |
||||
|
|
||||
|
## 特性 |
||||
|
|
||||
|
- ✅ **SSR 兼容**: 支持服务端预取和客户端水合 |
||||
|
- ✅ **数据缓存**: 避免重复请求,提升性能 |
||||
|
- ✅ **错误处理**: 完整的错误处理机制 |
||||
|
- ✅ **加载状态**: 内置 loading 状态管理 |
||||
|
- ✅ **TypeScript**: 完整的类型支持 |
||||
|
- ✅ **灵活配置**: 支持自定义缓存键、转换函数等 |
||||
|
|
||||
|
## 基本用法 |
||||
|
|
||||
|
```typescript |
||||
|
import { useFetch } from './compose/useFetch' |
||||
|
|
||||
|
// 基本用法 |
||||
|
const { data, error, pending, refresh } = useFetch('/api/users') |
||||
|
|
||||
|
// 带配置的用法 |
||||
|
const { data, error, pending, refresh } = useFetch( |
||||
|
'https://api.example.com/users/1', |
||||
|
{ |
||||
|
key: 'user-1', // 缓存键 |
||||
|
server: true, // 启用服务端预取 |
||||
|
transform: (data) => ({ // 数据转换 |
||||
|
id: data.id, |
||||
|
name: data.name |
||||
|
}), |
||||
|
onError: (err) => { // 错误处理 |
||||
|
console.error(err) |
||||
|
} |
||||
|
} |
||||
|
) |
||||
|
``` |
||||
|
|
||||
|
## API 参考 |
||||
|
|
||||
|
### useFetch(url, options?) |
||||
|
|
||||
|
#### 参数 |
||||
|
|
||||
|
- `url`: `string | (() => string) | (() => Promise<string>)` - 请求 URL |
||||
|
- `options`: `UseFetchOptions` - 配置选项 |
||||
|
|
||||
|
#### 返回值 |
||||
|
|
||||
|
- `data`: `Ref<T | null>` - 响应数据 |
||||
|
- `error`: `Ref<Error | null>` - 错误信息 |
||||
|
- `pending`: `Ref<boolean>` - 加载状态 |
||||
|
- `refresh()`: `() => Promise<void>` - 刷新数据 |
||||
|
- `execute()`: `() => Promise<void>` - 手动执行请求 |
||||
|
|
||||
|
### UseFetchOptions |
||||
|
|
||||
|
```typescript |
||||
|
interface UseFetchOptions { |
||||
|
key?: string // 缓存键 |
||||
|
server?: boolean // 是否启用服务端预取 |
||||
|
default?: () => any // 默认值 |
||||
|
transform?: (data: any) => any // 数据转换函数 |
||||
|
onError?: (error: Error) => void // 错误处理函数 |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## SSR 集成 |
||||
|
|
||||
|
### 服务端设置 |
||||
|
|
||||
|
在 `entry-server.ts` 中: |
||||
|
|
||||
|
```typescript |
||||
|
import { createSSRContext } from './compose/useFetch' |
||||
|
|
||||
|
export async function render(url: string) { |
||||
|
const { app } = createApp() |
||||
|
|
||||
|
// 创建 SSR 上下文 |
||||
|
const ssrContext = createSSRContext() |
||||
|
app.config.globalProperties.$ssrContext = ssrContext |
||||
|
|
||||
|
const html = await renderToString(app) |
||||
|
|
||||
|
// 将数据序列化到 HTML |
||||
|
const ssrData = JSON.stringify(Array.from(ssrContext.cache?.entries() || [])) |
||||
|
const head = ` |
||||
|
<script> |
||||
|
window.__SSR_CONTEXT__ = { |
||||
|
cache: new Map(${ssrData}) |
||||
|
}; |
||||
|
</script> |
||||
|
` |
||||
|
|
||||
|
return { html, head } |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 客户端设置 |
||||
|
|
||||
|
在 `entry-client.ts` 中: |
||||
|
|
||||
|
```typescript |
||||
|
import { hydrateSSRContext, clearSSRContext } from './compose/useFetch' |
||||
|
|
||||
|
// 水合 SSR 数据 |
||||
|
if (typeof window !== 'undefined' && window.__SSR_CONTEXT__) { |
||||
|
hydrateSSRContext(window.__SSR_CONTEXT__) |
||||
|
} |
||||
|
|
||||
|
app.mount('#app') |
||||
|
|
||||
|
// 水合完成后清理 |
||||
|
clearSSRContext() |
||||
|
``` |
||||
|
|
||||
|
## 高级用法 |
||||
|
|
||||
|
### 动态 URL |
||||
|
|
||||
|
```typescript |
||||
|
const userId = ref(1) |
||||
|
const { data } = useFetch(() => `/api/users/${userId.value}`) |
||||
|
``` |
||||
|
|
||||
|
### 条件请求 |
||||
|
|
||||
|
```typescript |
||||
|
const shouldFetch = ref(false) |
||||
|
const { data } = useFetch( |
||||
|
() => shouldFetch.value ? '/api/data' : null, |
||||
|
{ server: false } // 禁用服务端预取 |
||||
|
) |
||||
|
``` |
||||
|
|
||||
|
### 错误处理 |
||||
|
|
||||
|
```typescript |
||||
|
const { data, error, pending } = useFetch('/api/data', { |
||||
|
onError: (err) => { |
||||
|
// 自定义错误处理 |
||||
|
console.error('请求失败:', err) |
||||
|
// 可以显示用户友好的错误消息 |
||||
|
} |
||||
|
}) |
||||
|
``` |
||||
|
|
||||
|
### 数据转换 |
||||
|
|
||||
|
```typescript |
||||
|
const { data } = useFetch('/api/users', { |
||||
|
transform: (users) => users.map(user => ({ |
||||
|
id: user.id, |
||||
|
name: user.name, |
||||
|
email: user.email |
||||
|
})) |
||||
|
}) |
||||
|
``` |
||||
|
|
||||
|
## 注意事项 |
||||
|
|
||||
|
1. **缓存键**: 确保为不同的请求使用唯一的缓存键 |
||||
|
2. **服务端预取**: 只在需要 SEO 或首屏性能的场景下启用 |
||||
|
3. **错误处理**: 始终提供错误处理逻辑 |
||||
|
4. **内存管理**: 在 SPA 模式下注意清理不需要的缓存 |
||||
|
|
||||
|
## 示例 |
||||
|
|
||||
|
查看 `src/components/DataFetch.vue` 获取完整的使用示例。 |
||||
@ -0,0 +1,65 @@ |
|||||
|
export type CookieOptions = { |
||||
|
path?: string |
||||
|
domain?: string |
||||
|
expires?: Date | string | number |
||||
|
maxAge?: number |
||||
|
secure?: boolean |
||||
|
httpOnly?: boolean |
||||
|
sameSite?: 'lax' | 'strict' | 'none' |
||||
|
} |
||||
|
|
||||
|
export function serializeCookie(name: string, value: string, options: CookieOptions = {}): string { |
||||
|
const enc = encodeURIComponent |
||||
|
let cookie = `${name}=${enc(value)}` |
||||
|
if (options.maxAge != null) cookie += `; Max-Age=${Math.floor(options.maxAge)}` |
||||
|
if (options.expires != null) { |
||||
|
const date = typeof options.expires === 'number' ? new Date(options.expires) : new Date(options.expires) |
||||
|
cookie += `; Expires=${date.toUTCString()}` |
||||
|
} |
||||
|
if (options.domain) cookie += `; Domain=${options.domain}` |
||||
|
if (options.path) cookie += `; Path=${options.path}` |
||||
|
if (options.secure) cookie += `; Secure` |
||||
|
if (options.httpOnly) cookie += `; HttpOnly` |
||||
|
if (options.sameSite) cookie += `; SameSite=${options.sameSite === 'none' ? 'None' : options.sameSite === 'lax' ? 'Lax' : 'Strict'}` |
||||
|
return cookie |
||||
|
} |
||||
|
|
||||
|
export function parseCookieHeader(header: string | undefined): Record<string, string> { |
||||
|
const raw = header || '' |
||||
|
const out: Record<string, string> = {} |
||||
|
raw.split(';').map(s => s.trim()).filter(Boolean).forEach(kv => { |
||||
|
const idx = kv.indexOf('=') |
||||
|
const k = idx >= 0 ? kv.slice(0, idx) : kv |
||||
|
const v = idx >= 0 ? decodeURIComponent(kv.slice(idx + 1)) : '' |
||||
|
out[k] = v |
||||
|
}) |
||||
|
return out |
||||
|
} |
||||
|
|
||||
|
export function parseDocumentCookies(): Record<string, string> { |
||||
|
if (typeof document === 'undefined') return {} |
||||
|
return parseCookieHeader(document.cookie) |
||||
|
} |
||||
|
|
||||
|
|
||||
|
/** |
||||
|
// server 侧中间件
|
||||
|
import { parseCookieHeader, serializeCookie } from './src/compose/cookieUtils' |
||||
|
|
||||
|
app.use(async (ctx, next) => { |
||||
|
const cookies = parseCookieHeader(ctx.request.headers.cookie as string) |
||||
|
|
||||
|
// 读取
|
||||
|
const token = cookies['demo_token'] |
||||
|
|
||||
|
// 写入(HttpOnly 更安全)
|
||||
|
if (!token) { |
||||
|
const setItem = serializeCookie('demo_token', 'from-mw', { |
||||
|
httpOnly: true, path: '/', sameSite: 'lax' |
||||
|
}) |
||||
|
ctx.set('Set-Cookie', [setItem]) |
||||
|
} |
||||
|
|
||||
|
await next() |
||||
|
}) |
||||
|
*/ |
||||
@ -0,0 +1,41 @@ |
|||||
|
// SSR 上下文与 cookie 管理(与业务无关的通用模块)
|
||||
|
|
||||
|
export interface SSRContext { |
||||
|
cache?: Map<string, any> |
||||
|
cookies?: Record<string, string> |
||||
|
setCookies?: string[] |
||||
|
[key: string]: any |
||||
|
} |
||||
|
|
||||
|
export function createSSRContext(): SSRContext { |
||||
|
return { |
||||
|
cache: new Map(), |
||||
|
cookies: {}, |
||||
|
setCookies: [] |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export function hydrateSSRContext(context: SSRContext): void { |
||||
|
if (typeof window !== 'undefined') { |
||||
|
if (context.cache && Array.isArray(context.cache)) { |
||||
|
context.cache = new Map(context.cache) |
||||
|
} |
||||
|
;(window as any).__SSR_CONTEXT__ = context |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export function clearSSRContext(): void { |
||||
|
if (typeof window !== 'undefined') { |
||||
|
delete (window as any).__SSR_CONTEXT__ |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 通用获取 SSR 上下文(客户端从 window,服务端从 app 实例)
|
||||
|
export function resolveSSRContext(instance?: any): SSRContext | null { |
||||
|
if (typeof window !== 'undefined') { |
||||
|
return (window as any).__SSR_CONTEXT__ || null |
||||
|
} |
||||
|
return instance?.appContext?.config?.globalProperties?.$ssrContext || null |
||||
|
} |
||||
|
|
||||
|
|
||||
@ -0,0 +1,43 @@ |
|||||
|
import { getCurrentInstance } from 'vue' |
||||
|
import { serializeCookie, parseDocumentCookies } from './cookieUtils' |
||||
|
import type { CookieOptions } from './cookieUtils' |
||||
|
import { resolveSSRContext } from './ssrContext' |
||||
|
|
||||
|
export function useCookie(name: string, options: CookieOptions = {}) { |
||||
|
const instance = getCurrentInstance() |
||||
|
|
||||
|
const getSSRContext = () => resolveSSRContext(instance) |
||||
|
|
||||
|
const getAll = (): Record<string, string> => { |
||||
|
const ssr = getSSRContext() |
||||
|
if (ssr && ssr.cookies) return ssr.cookies as Record<string, string> |
||||
|
return parseDocumentCookies() |
||||
|
} |
||||
|
|
||||
|
const get = (): string | undefined => { |
||||
|
const all = getAll() |
||||
|
return all[name] |
||||
|
} |
||||
|
|
||||
|
const set = (value: string, opt: CookieOptions = {}) => { |
||||
|
const o = { path: '/', ...options, ...opt } |
||||
|
const str = serializeCookie(name, value, o) |
||||
|
const ssr = getSSRContext() |
||||
|
if (ssr) { |
||||
|
ssr.cookies = ssr.cookies || {} |
||||
|
ssr.cookies[name] = value |
||||
|
ssr.setCookies = ssr.setCookies || [] |
||||
|
ssr.setCookies.push(str) |
||||
|
} else if (typeof document !== 'undefined') { |
||||
|
document.cookie = str |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const remove = (opt: CookieOptions = {}) => { |
||||
|
set('', { ...opt, maxAge: 0, expires: new Date(0) }) |
||||
|
} |
||||
|
|
||||
|
return { get, set, remove } |
||||
|
} |
||||
|
|
||||
|
|
||||
@ -0,0 +1,249 @@ |
|||||
|
import { ref, onMounted, onServerPrefetch, Ref } from 'vue' |
||||
|
import { getCurrentInstance } from 'vue' |
||||
|
import type { SSRContext } from './ssrContext' |
||||
|
import { resolveSSRContext } from './ssrContext' |
||||
|
|
||||
|
// 全局数据缓存,用于 SSR 数据共享
|
||||
|
const globalCache = new Map<string, any>() |
||||
|
|
||||
|
// SSR 上下文类型从 ssrContext.ts 引入
|
||||
|
|
||||
|
// useFetch 的配置选项
|
||||
|
interface UseFetchOptions { |
||||
|
key?: string |
||||
|
server?: boolean |
||||
|
default?: () => any |
||||
|
transform?: (data: any) => any |
||||
|
onError?: (error: Error) => void |
||||
|
} |
||||
|
|
||||
|
// useFetch 返回值类型
|
||||
|
interface UseFetchReturn<T> { |
||||
|
data: Ref<T | null> |
||||
|
error: Ref<Error | null> |
||||
|
pending: Ref<boolean> |
||||
|
refresh: () => Promise<void> |
||||
|
execute: () => Promise<void> |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* SSR 兼容的 useFetch hook |
||||
|
* 支持服务端预取和客户端水合 |
||||
|
*/ |
||||
|
export function useFetch<T = any>( |
||||
|
url: string | (() => string) | (() => Promise<string>), |
||||
|
options: UseFetchOptions = {} |
||||
|
): UseFetchReturn<T> { |
||||
|
const { |
||||
|
key, |
||||
|
server = true, |
||||
|
default: defaultValue, |
||||
|
transform, |
||||
|
onError |
||||
|
} = options |
||||
|
|
||||
|
// 生成缓存键
|
||||
|
const cacheKey = key || (typeof url === 'string' ? url : `fetch-${Date.now()}`) |
||||
|
|
||||
|
// 响应式状态
|
||||
|
const data = ref<T | null>(null) |
||||
|
const error = ref<Error | null>(null) |
||||
|
const pending = ref(false) |
||||
|
|
||||
|
// 获取当前组件实例
|
||||
|
const instance = getCurrentInstance() |
||||
|
|
||||
|
// 获取 SSR 上下文
|
||||
|
const getSSRContext = (): SSRContext | null => resolveSSRContext(instance) |
||||
|
|
||||
|
// 获取缓存
|
||||
|
const getCache = () => { |
||||
|
const ssrContext = getSSRContext() |
||||
|
return ssrContext?.cache || globalCache |
||||
|
} |
||||
|
|
||||
|
// 设置缓存
|
||||
|
const setCache = (key: string, value: any) => { |
||||
|
const cache = getCache() |
||||
|
cache.set(key, value) |
||||
|
} |
||||
|
|
||||
|
// 获取缓存数据
|
||||
|
const getCachedData = () => { |
||||
|
const cache = getCache() |
||||
|
return cache.get(cacheKey) |
||||
|
} |
||||
|
|
||||
|
// 执行 fetch 请求
|
||||
|
const execute = async (): Promise<void> => { |
||||
|
try { |
||||
|
pending.value = true |
||||
|
error.value = null |
||||
|
|
||||
|
// 获取 URL
|
||||
|
const fetchUrl = typeof url === 'function' ? await url() : url |
||||
|
|
||||
|
// 仅在服务端注入 Cookie,客户端浏览器会自动携带
|
||||
|
let requestInit: RequestInit | undefined |
||||
|
if (typeof window === 'undefined') { |
||||
|
const ssrContext = getSSRContext() |
||||
|
const cookieHeader = ssrContext?.cookies |
||||
|
? Object.entries(ssrContext.cookies) |
||||
|
.filter(([k, v]) => k && v != null) |
||||
|
.map(([k, v]) => `${k}=${String(v)}`) |
||||
|
.join('; ') |
||||
|
: undefined |
||||
|
if (cookieHeader) { |
||||
|
requestInit = { headers: { Cookie: cookieHeader } } |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 执行请求
|
||||
|
const response = await fetch(fetchUrl, requestInit) |
||||
|
|
||||
|
if (!response.ok) { |
||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`) |
||||
|
} |
||||
|
|
||||
|
let result = await response.json() |
||||
|
|
||||
|
// 应用转换函数
|
||||
|
if (transform) { |
||||
|
result = transform(result) |
||||
|
} |
||||
|
|
||||
|
data.value = result |
||||
|
setCache(cacheKey, result) |
||||
|
|
||||
|
// 收集服务端返回的 Set-Cookie,回传到最终响应头
|
||||
|
if (typeof window === 'undefined') { |
||||
|
const ssrContext = getSSRContext() |
||||
|
if (ssrContext) { |
||||
|
const setCookieValues: string[] = [] |
||||
|
const anyHeaders: any = response.headers as any |
||||
|
// undici 扩展:getSetCookie()
|
||||
|
if (typeof anyHeaders?.getSetCookie === 'function') { |
||||
|
try { |
||||
|
const arr = anyHeaders.getSetCookie() |
||||
|
if (Array.isArray(arr)) setCookieValues.push(...arr) |
||||
|
} catch {} |
||||
|
} |
||||
|
// node-fetch/raw headers API
|
||||
|
if (typeof anyHeaders?.raw === 'function') { |
||||
|
try { |
||||
|
const raw = anyHeaders.raw() |
||||
|
const arr = raw?.['set-cookie'] |
||||
|
if (Array.isArray(arr)) setCookieValues.push(...arr) |
||||
|
} catch {} |
||||
|
} |
||||
|
// 兜底:单值
|
||||
|
const single = response.headers.get('set-cookie') |
||||
|
if (single) setCookieValues.push(single) |
||||
|
|
||||
|
if (setCookieValues.length) { |
||||
|
if (!Array.isArray(ssrContext.setCookies)) ssrContext.setCookies = [] |
||||
|
ssrContext.setCookies.push(...setCookieValues) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
} catch (err) { |
||||
|
const fetchError = err instanceof Error ? err : new Error(String(err)) |
||||
|
error.value = fetchError |
||||
|
|
||||
|
if (onError) { |
||||
|
onError(fetchError) |
||||
|
} |
||||
|
|
||||
|
// 设置默认值
|
||||
|
if (defaultValue) { |
||||
|
data.value = typeof defaultValue === 'function' ? defaultValue() : defaultValue |
||||
|
} |
||||
|
} finally { |
||||
|
pending.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 刷新数据
|
||||
|
const refresh = async (): Promise<void> => { |
||||
|
// 清除缓存
|
||||
|
const cache = getCache() |
||||
|
cache.delete(cacheKey) |
||||
|
await execute() |
||||
|
} |
||||
|
|
||||
|
// 服务端预取
|
||||
|
if (server && typeof window === 'undefined') { |
||||
|
onServerPrefetch(async () => { |
||||
|
// 检查是否已有缓存数据
|
||||
|
const cachedData = getCachedData() |
||||
|
if (cachedData !== undefined) { |
||||
|
data.value = cachedData |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 执行预取
|
||||
|
await execute() |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// 立即检查缓存数据(服务端和客户端都需要)
|
||||
|
const cachedData = getCachedData() |
||||
|
if (cachedData !== undefined) { |
||||
|
data.value = cachedData |
||||
|
console.log(`[useFetch] 从缓存加载数据: ${cacheKey}`, cachedData) |
||||
|
} else { |
||||
|
console.log(`[useFetch] 缓存中无数据: ${cacheKey}`) |
||||
|
} |
||||
|
|
||||
|
// 客户端水合
|
||||
|
if (typeof window !== 'undefined') { |
||||
|
onMounted(async () => { |
||||
|
// 如果已经有缓存数据,不需要再次请求
|
||||
|
if (cachedData !== undefined) { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 如果没有预取数据,则执行请求
|
||||
|
await execute() |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
data: data as Ref<T | null>, |
||||
|
error: error as Ref<Error | null>, |
||||
|
pending: pending as Ref<boolean>, |
||||
|
refresh, |
||||
|
execute |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 创建 SSR 上下文的辅助函数 |
||||
|
* 在服务端渲染时调用 |
||||
|
*/ |
||||
|
// 删除 createSSRContext,这个职责移动到 ssrContext.ts
|
||||
|
|
||||
|
/** |
||||
|
* 将 SSR 上下文注入到 window 对象 |
||||
|
* 在客户端水合时调用 |
||||
|
*/ |
||||
|
export function hydrateSSRContext(context: SSRContext): void { |
||||
|
if (typeof window !== 'undefined') { |
||||
|
// 确保 Map 对象正确重建
|
||||
|
if (context.cache && Array.isArray(context.cache)) { |
||||
|
context.cache = new Map(context.cache) |
||||
|
} |
||||
|
(window as any).__SSR_CONTEXT__ = context |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 清除 SSR 上下文 |
||||
|
* 在客户端水合完成后调用 |
||||
|
*/ |
||||
|
export function clearSSRContext(): void { |
||||
|
if (typeof window !== 'undefined') { |
||||
|
delete (window as any).__SSR_CONTEXT__ |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,21 @@ |
|||||
|
import './style.css' |
||||
|
import { createApp } from "./main" |
||||
|
import { hydrateSSRContext, clearSSRContext } from './compose/ssrContext' |
||||
|
|
||||
|
// 水合 SSR 上下文(如果存在)
|
||||
|
let ssrContext = null |
||||
|
if (typeof window !== 'undefined' && (window as any).__SSR_CONTEXT__) { |
||||
|
ssrContext = (window as any).__SSR_CONTEXT__ |
||||
|
console.log('[Client] 水合 SSR 上下文:', ssrContext) |
||||
|
hydrateSSRContext(ssrContext) |
||||
|
} else { |
||||
|
console.log('[Client] 未找到 SSR 上下文') |
||||
|
} |
||||
|
|
||||
|
// 使用相同的 SSR 上下文创建应用
|
||||
|
const { app } = createApp(ssrContext) |
||||
|
|
||||
|
app.mount('#app') |
||||
|
|
||||
|
// 水合完成后清除 SSR 上下文
|
||||
|
clearSSRContext() |
||||
@ -0,0 +1,38 @@ |
|||||
|
import { renderToString } from 'vue/server-renderer' |
||||
|
import { createApp } from './main' |
||||
|
import { createSSRContext } from './compose/ssrContext' |
||||
|
|
||||
|
export async function render(_url: string, init?: { cookies?: Record<string, string> }) { |
||||
|
// 创建 SSR 上下文,包含数据缓存与 cookies
|
||||
|
const ssrContext = createSSRContext() |
||||
|
if (init?.cookies) { |
||||
|
ssrContext.cookies = { ...init.cookies } |
||||
|
} |
||||
|
|
||||
|
// 将 SSR 上下文传递给应用创建函数
|
||||
|
const { app } = createApp(ssrContext) |
||||
|
|
||||
|
// 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 = { cache: ssrContext.cache } |
||||
|
const html = await renderToString(app, ctx) |
||||
|
|
||||
|
// 将 SSR 上下文数据序列化到 HTML 中
|
||||
|
// 使用更安全的方式序列化 Map
|
||||
|
const cacheEntries = ssrContext.cache ? Array.from(ssrContext.cache.entries()) : [] |
||||
|
const ssrData = JSON.stringify(cacheEntries) |
||||
|
const cookieInit = JSON.stringify(ssrContext.cookies || {}) |
||||
|
console.log('[SSR] 序列化缓存数据:', cacheEntries) |
||||
|
const head = ` |
||||
|
<script> |
||||
|
window.__SSR_CONTEXT__ = { |
||||
|
cache: new Map(${ssrData}), |
||||
|
cookies: ${cookieInit} |
||||
|
}; |
||||
|
</script> |
||||
|
` |
||||
|
|
||||
|
return { html, head, setCookies: ssrContext.setCookies || [] } |
||||
|
} |
||||
@ -0,0 +1,16 @@ |
|||||
|
import { createSSRApp } from 'vue' |
||||
|
import App from './App.vue' |
||||
|
|
||||
|
// SSR requires a fresh app instance per request, therefore we export a function
|
||||
|
// that creates a fresh app instance. If using Vuex, we'd also be creating a
|
||||
|
// fresh store here.
|
||||
|
export function createApp(ssrContext?: any) { |
||||
|
const app = createSSRApp(App) |
||||
|
|
||||
|
// 如果有 SSR 上下文,注入到应用中
|
||||
|
if (ssrContext) { |
||||
|
app.config.globalProperties.$ssrContext = ssrContext |
||||
|
} |
||||
|
|
||||
|
return { app } |
||||
|
} |
||||
@ -0,0 +1,79 @@ |
|||||
|
:root { |
||||
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; |
||||
|
line-height: 1.5; |
||||
|
font-weight: 400; |
||||
|
|
||||
|
color-scheme: light dark; |
||||
|
color: rgba(255, 255, 255, 0.87); |
||||
|
background-color: #242424; |
||||
|
|
||||
|
font-synthesis: none; |
||||
|
text-rendering: optimizeLegibility; |
||||
|
-webkit-font-smoothing: antialiased; |
||||
|
-moz-osx-font-smoothing: grayscale; |
||||
|
} |
||||
|
|
||||
|
a { |
||||
|
font-weight: 500; |
||||
|
color: #646cff; |
||||
|
text-decoration: inherit; |
||||
|
} |
||||
|
a:hover { |
||||
|
color: #535bf2; |
||||
|
} |
||||
|
|
||||
|
body { |
||||
|
margin: 0; |
||||
|
display: flex; |
||||
|
place-items: center; |
||||
|
min-width: 320px; |
||||
|
min-height: 100vh; |
||||
|
} |
||||
|
|
||||
|
h1 { |
||||
|
font-size: 3.2em; |
||||
|
line-height: 1.1; |
||||
|
} |
||||
|
|
||||
|
button { |
||||
|
border-radius: 8px; |
||||
|
border: 1px solid transparent; |
||||
|
padding: 0.6em 1.2em; |
||||
|
font-size: 1em; |
||||
|
font-weight: 500; |
||||
|
font-family: inherit; |
||||
|
background-color: #1a1a1a; |
||||
|
cursor: pointer; |
||||
|
transition: border-color 0.25s; |
||||
|
} |
||||
|
button:hover { |
||||
|
border-color: #646cff; |
||||
|
} |
||||
|
button:focus, |
||||
|
button:focus-visible { |
||||
|
outline: 4px auto -webkit-focus-ring-color; |
||||
|
} |
||||
|
|
||||
|
.card { |
||||
|
padding: 2em; |
||||
|
} |
||||
|
|
||||
|
#app { |
||||
|
max-width: 1280px; |
||||
|
margin: 0 auto; |
||||
|
padding: 2rem; |
||||
|
text-align: center; |
||||
|
} |
||||
|
|
||||
|
@media (prefers-color-scheme: light) { |
||||
|
:root { |
||||
|
color: #213547; |
||||
|
background-color: #ffffff; |
||||
|
} |
||||
|
a:hover { |
||||
|
color: #747bff; |
||||
|
} |
||||
|
button { |
||||
|
background-color: #f9f9f9; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
/// <reference types="vite/client" />
|
||||
|
|
||||
|
declare module '*.vue' { |
||||
|
import type { DefineComponent } from 'vue' |
||||
|
const component: DefineComponent<{}, {}, any> |
||||
|
export default component |
||||
|
} |
||||
@ -0,0 +1,26 @@ |
|||||
|
{ |
||||
|
"compilerOptions": { |
||||
|
"target": "es2022", |
||||
|
"useDefineForClassFields": true, |
||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"], |
||||
|
"module": "esnext", |
||||
|
"skipLibCheck": true, |
||||
|
|
||||
|
/* Bundler mode */ |
||||
|
"moduleResolution": "bundler", |
||||
|
"allowImportingTsExtensions": true, |
||||
|
"isolatedModules": true, |
||||
|
"moduleDetection": "force", |
||||
|
"noEmit": true, |
||||
|
"jsx": "preserve", |
||||
|
|
||||
|
/* Linting */ |
||||
|
"strict": true, |
||||
|
"noUnusedLocals": true, |
||||
|
"noUnusedParameters": true, |
||||
|
"noFallthroughCasesInSwitch": true, |
||||
|
"noUncheckedSideEffectImports": true |
||||
|
}, |
||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], |
||||
|
"references": [{ "path": "./tsconfig.node.json" }] |
||||
|
} |
||||
@ -0,0 +1,27 @@ |
|||||
|
{ |
||||
|
"compilerOptions": { |
||||
|
"composite": true, |
||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", |
||||
|
"target": "es2023", |
||||
|
"lib": [ |
||||
|
"ES2023" |
||||
|
], |
||||
|
"module": "esnext", |
||||
|
"skipLibCheck": true, |
||||
|
/* Bundler mode */ |
||||
|
"moduleResolution": "bundler", |
||||
|
"allowImportingTsExtensions": true, |
||||
|
"isolatedModules": true, |
||||
|
"emitDeclarationOnly": true, |
||||
|
"moduleDetection": "force", |
||||
|
/* Linting */ |
||||
|
"strict": true, |
||||
|
"noUnusedLocals": true, |
||||
|
"noUnusedParameters": true, |
||||
|
"noFallthroughCasesInSwitch": true, |
||||
|
"noUncheckedSideEffectImports": true |
||||
|
}, |
||||
|
"include": [ |
||||
|
"vite.config.ts" |
||||
|
] |
||||
|
} |
||||
@ -0,0 +1,8 @@ |
|||||
|
import { defineConfig } from 'vite' |
||||
|
import vue from '@vitejs/plugin-vue' |
||||
|
|
||||
|
// https://vite.dev/config/
|
||||
|
export default defineConfig({ |
||||
|
base: './', |
||||
|
plugins: [vue()], |
||||
|
}) |
||||
Loading…
Reference in new issue