commit f7f66eaafcf0456766509a2f6d8bec0498dd452e Author: 谢亚昕 <1549469775@qq.com> Date: Tue Sep 30 16:17:06 2025 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b1ee42 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..f4a7f34 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "CodeFree.index": true +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b1e070 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# 基于koa实现的简易ssr \ No newline at end of file diff --git a/bun.lockb b/bun.lockb new file mode 100644 index 0000000..d7be6da Binary files /dev/null and b/bun.lockb differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..88db749 --- /dev/null +++ b/index.html @@ -0,0 +1,17 @@ + + + + + + + + Vite + Vue + TS + + + + +
+ + + + \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..71ea955 --- /dev/null +++ b/package.json @@ -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" + } +} \ No newline at end of file diff --git a/public/vite.svg b/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..b2de317 --- /dev/null +++ b/server.ts @@ -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(``, rendered.head ?? '') + .replace(``, 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}`) +}) diff --git a/server/app.ts b/server/app.ts new file mode 100644 index 0000000..38d9780 --- /dev/null +++ b/server/app.ts @@ -0,0 +1,9 @@ + +import Koa from "koa" + +const app = new Koa() + +export default app +export { + app +} \ No newline at end of file diff --git a/server/main.ts b/server/main.ts new file mode 100644 index 0000000..a324dce --- /dev/null +++ b/server/main.ts @@ -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; + } + }); +} diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..6d00c7e --- /dev/null +++ b/src/App.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/src/assets/vue.svg b/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/CookieDemo.vue b/src/components/CookieDemo.vue new file mode 100644 index 0000000..8d5ed24 --- /dev/null +++ b/src/components/CookieDemo.vue @@ -0,0 +1,50 @@ + + + + + + + diff --git a/src/components/DataFetch.vue b/src/components/DataFetch.vue new file mode 100644 index 0000000..ead57d0 --- /dev/null +++ b/src/components/DataFetch.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/src/components/HelloWorld.vue b/src/components/HelloWorld.vue new file mode 100644 index 0000000..63f7e72 --- /dev/null +++ b/src/components/HelloWorld.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/src/components/SimpleTest.vue b/src/components/SimpleTest.vue new file mode 100644 index 0000000..98c5cbd --- /dev/null +++ b/src/components/SimpleTest.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/src/compose/README.md b/src/compose/README.md new file mode 100644 index 0000000..442c39f --- /dev/null +++ b/src/compose/README.md @@ -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)` - 请求 URL +- `options`: `UseFetchOptions` - 配置选项 + +#### 返回值 + +- `data`: `Ref` - 响应数据 +- `error`: `Ref` - 错误信息 +- `pending`: `Ref` - 加载状态 +- `refresh()`: `() => Promise` - 刷新数据 +- `execute()`: `() => Promise` - 手动执行请求 + +### 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 = ` + + ` + + 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` 获取完整的使用示例。 diff --git a/src/compose/cookieUtils.ts b/src/compose/cookieUtils.ts new file mode 100644 index 0000000..8667c89 --- /dev/null +++ b/src/compose/cookieUtils.ts @@ -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 { + const raw = header || '' + const out: Record = {} + 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 { + 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() +}) + */ \ No newline at end of file diff --git a/src/compose/ssrContext.ts b/src/compose/ssrContext.ts new file mode 100644 index 0000000..0e8703a --- /dev/null +++ b/src/compose/ssrContext.ts @@ -0,0 +1,41 @@ +// SSR 上下文与 cookie 管理(与业务无关的通用模块) + +export interface SSRContext { + cache?: Map + cookies?: Record + 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 +} + + diff --git a/src/compose/useCookie.ts b/src/compose/useCookie.ts new file mode 100644 index 0000000..1c1bf2e --- /dev/null +++ b/src/compose/useCookie.ts @@ -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 => { + const ssr = getSSRContext() + if (ssr && ssr.cookies) return ssr.cookies as Record + 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 } +} + + diff --git a/src/compose/useFetch.ts b/src/compose/useFetch.ts new file mode 100644 index 0000000..3b5320b --- /dev/null +++ b/src/compose/useFetch.ts @@ -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() + +// SSR 上下文类型从 ssrContext.ts 引入 + +// useFetch 的配置选项 +interface UseFetchOptions { + key?: string + server?: boolean + default?: () => any + transform?: (data: any) => any + onError?: (error: Error) => void +} + +// useFetch 返回值类型 +interface UseFetchReturn { + data: Ref + error: Ref + pending: Ref + refresh: () => Promise + execute: () => Promise +} + +/** + * SSR 兼容的 useFetch hook + * 支持服务端预取和客户端水合 + */ +export function useFetch( + url: string | (() => string) | (() => Promise), + options: UseFetchOptions = {} +): UseFetchReturn { + const { + key, + server = true, + default: defaultValue, + transform, + onError + } = options + + // 生成缓存键 + const cacheKey = key || (typeof url === 'string' ? url : `fetch-${Date.now()}`) + + // 响应式状态 + const data = ref(null) + const error = ref(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 => { + 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 => { + // 清除缓存 + 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, + error: error as Ref, + pending: pending as Ref, + 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__ + } +} diff --git a/src/entry-client.ts b/src/entry-client.ts new file mode 100644 index 0000000..94ec873 --- /dev/null +++ b/src/entry-client.ts @@ -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() diff --git a/src/entry-server.ts b/src/entry-server.ts new file mode 100644 index 0000000..8eefd4d --- /dev/null +++ b/src/entry-server.ts @@ -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 }) { + // 创建 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 = ` + + ` + + return { html, head, setCookies: ssrContext.setCookies || [] } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..3284bfc --- /dev/null +++ b/src/main.ts @@ -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 } +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..f691315 --- /dev/null +++ b/src/style.css @@ -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; + } +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..323c78a --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8a4664f --- /dev/null +++ b/tsconfig.json @@ -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" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..033c9c1 --- /dev/null +++ b/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" + ] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..981be2b --- /dev/null +++ b/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()], +})