From f7f66eaafcf0456766509a2f6d8bec0498dd452e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B0=A2=E4=BA=9A=E6=98=95?= <1549469775@qq.com> Date: Tue, 30 Sep 2025 16:17:06 +0800 Subject: [PATCH] init --- .gitignore | 175 +++++++++++++++++++++++++++++ .vscode/settings.json | 3 + README.md | 1 + bun.lockb | Bin 0 -> 66141 bytes index.html | 17 +++ package.json | 30 +++++ public/vite.svg | 1 + server.ts | 84 ++++++++++++++ server/app.ts | 9 ++ server/main.ts | 60 ++++++++++ src/App.vue | 37 +++++++ src/assets/vue.svg | 1 + src/components/CookieDemo.vue | 50 +++++++++ src/components/DataFetch.vue | 116 ++++++++++++++++++++ src/components/HelloWorld.vue | 38 +++++++ src/components/SimpleTest.vue | 45 ++++++++ src/compose/README.md | 170 ++++++++++++++++++++++++++++ src/compose/cookieUtils.ts | 65 +++++++++++ src/compose/ssrContext.ts | 41 +++++++ src/compose/useCookie.ts | 43 ++++++++ src/compose/useFetch.ts | 249 ++++++++++++++++++++++++++++++++++++++++++ src/entry-client.ts | 21 ++++ src/entry-server.ts | 38 +++++++ src/main.ts | 16 +++ src/style.css | 79 ++++++++++++++ src/vite-env.d.ts | 7 ++ tsconfig.json | 26 +++++ tsconfig.node.json | 27 +++++ vite.config.ts | 8 ++ 29 files changed, 1457 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 bun.lockb create mode 100644 index.html create mode 100644 package.json create mode 100644 public/vite.svg create mode 100644 server.ts create mode 100644 server/app.ts create mode 100644 server/main.ts create mode 100644 src/App.vue create mode 100644 src/assets/vue.svg create mode 100644 src/components/CookieDemo.vue create mode 100644 src/components/DataFetch.vue create mode 100644 src/components/HelloWorld.vue create mode 100644 src/components/SimpleTest.vue create mode 100644 src/compose/README.md create mode 100644 src/compose/cookieUtils.ts create mode 100644 src/compose/ssrContext.ts create mode 100644 src/compose/useCookie.ts create mode 100644 src/compose/useFetch.ts create mode 100644 src/entry-client.ts create mode 100644 src/entry-server.ts create mode 100644 src/main.ts create mode 100644 src/style.css create mode 100644 src/vite-env.d.ts create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts 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 0000000000000000000000000000000000000000..d7be6daf168232f363d55ca7bff3ae35e425fd4c GIT binary patch literal 66141 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

{Z5xt<+@7(yccK?)vDC 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 + + + + + + + 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()], +})