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