From eca669d62a87d1e6eb17bd656443b3280c1dafc2 Mon Sep 17 00:00:00 2001 From: UnknownMp Date: Thu, 5 Jun 2025 18:02:03 +0800 Subject: [PATCH] =?UTF-8?q?[Base]=20=E6=9B=B4=E6=96=B0=20SSR=20=E6=9C=BA?= =?UTF-8?q?=E5=88=B6,=20=E6=94=AF=E6=8C=81=E6=B5=81=E5=BC=8F=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E6=B3=A8=E5=85=A5=20head=20=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 损失的 TTFB 可以忽略不计 --- index.html | 8 -- server.js | 163 +++++++++++++++++++++------------- src/composables/apiRequest.js | 8 +- src/entry-server.js | 17 +++- src/router.js | 29 ++++-- src/ssr/headHelper.js | 47 ++++++++++ src/stores/route.js | 6 +- src/stores/workRead.js | 5 +- src/views/Work.vue | 69 ++++++++------ 9 files changed, 234 insertions(+), 118 deletions(-) create mode 100644 src/ssr/headHelper.js diff --git a/index.html b/index.html index aee05e6..d42b91e 100644 --- a/index.html +++ b/index.html @@ -2,14 +2,6 @@ - AO3 Mirror - - - - - - - diff --git a/server.js b/server.js index 95cc39e..72d769d 100644 --- a/server.js +++ b/server.js @@ -2,17 +2,15 @@ import fs from 'node:fs/promises' import express from 'express' import cookieParser from 'cookie-parser' import { compress } from 'compress-json' -// Constants + const isProduction = process.env.NODE_ENV === 'production' const port = process.env.PORT || 5173 const base = process.env.BASE || '/' -// Cached production assets const templateHtml = isProduction - ? await fs.readFile('./dist/client/index.html', 'utf-8') - : '' + ? await fs.readFile('./dist/client/index.html', 'utf-8') + : '' -// Create http server const app = express() app.use(cookieParser()); @@ -21,73 +19,110 @@ const MESSAGE = { 0: 'Unknown' } -// Add Vite or respective production middlewares -/** @type {import('vite').ViteDevServer | undefined} */ let vite if (!isProduction) { - const { createServer } = await import('vite') - vite = await createServer({ - server: { middlewareMode: true }, - appType: 'custom', - base, - }) - app.use(vite.middlewares) + const { createServer } = await import('vite') + vite = await createServer({ + server: { middlewareMode: true }, + appType: 'custom', + base, + }) + app.use(vite.middlewares) } else { - //const compression = (await import('compression')).default - const sirv = (await import('sirv')).default - //app.use(compression()) - app.use(base, sirv('./dist/client', { extensions: [] })) + const sirv = (await import('sirv')).default + app.use(base, sirv('./dist/client', { extensions: [] })) } // Serve HTML app.use('*all', async (req, res) => { - try { - const url = req.originalUrl.replace(base, '') - console.log(`Request ${url}`) - /** @type {string} */ - let template - /** @type {import('./src/entry-server.js').render} */ - let render, getRoute - if (!isProduction) { - // Always read fresh template in development - template = await fs.readFile('./index.html', 'utf-8') - template = await vite.transformIndexHtml(url, template) - const module = await vite.ssrLoadModule('/src/entry-server.js') - render = module.render - getRoute = module.getRoute - } else { - template = templateHtml - const module = await import('./dist/server/entry-server.js') - render = module.render - getRoute = module.getRoute - } - const { router, code } = await getRoute(url) - if (code != 200 && !req.accepts('html')) { - res.status(code).set({ 'Content-Type': 'text/plain' }) - res.write(MESSAGE[code] || MESSAGE[0]) - res.end() - return - } - const { stream, piniaState } = await render(router, req.cookies, req.headers.host) - const [htmlStart, htmlEnd] = template.split('') - res.status(code).set({ 'Content-Type': 'text/html' }) - res.write(htmlStart) - for await (const chunk of stream) { - if (res.closed) break - res.write(chunk) - } - const piniaStateContent = JSON.stringify(compress(piniaState)) - const stateScript = `` - res.write(htmlEnd.replace('', stateScript)) - res.end() - } catch (e) { - vite?.ssrFixStacktrace(e) - console.log(e.stack) - res.status(500).end(e.stack) - } + try { + const url = req.originalUrl.replace(base, '') + console.log(`Request ${url}`) + let template + let render, getRoute + if (!isProduction) { + // Always read fresh template in development + template = await fs.readFile('./index.html', 'utf-8') + template = await vite.transformIndexHtml(url, template) + const module = await vite.ssrLoadModule('/src/entry-server.js') + render = module.render + getRoute = module.getRoute + } else { + template = templateHtml + const module = await import('./dist/server/entry-server.js') + render = module.render + getRoute = module.getRoute + } + const { router, code, title, metas, meta } = await getRoute(url) + if (code != 200 && !req.accepts('html')) { + res.status(code).set({ 'Content-Type': 'text/plain' }) + res.write(MESSAGE[code] || MESSAGE[0]) + res.end() + return + } + const { stream, piniaState, headState } = await render(router, req.cookies, req.headers.host) + const [htmlStart, htmlEnd] = template.split('') + if (meta) { + const buffer = [] + let headReady = false + for await (const chunk of stream) { + if (res.closed) break + if (headReady) res.write(chunk) + else { + if (headState.ready) { + res.status(headState.code || code).set({ 'Content-Type': 'text/html' }) + const heads = [`${ headState.title || title }`] + for (const item of [ ...metas, ...headState.meta ]) { + const properties = [] + for (const [key, value] of Object.entries(item)) properties.push(`${key}="${value}"`) + heads.push(``) + } + res.write(htmlStart.replace('',heads.join(''))) + for (const item of buffer) res.write(item) + res.write(chunk) + headReady = true + } else buffer.push(chunk) + } + } + if (!headState.ready) { + console.warn('Page not set meta ready! No stream render at all!') + const heads = [`${ title }`] + for (const item of metas) { + const properties = [] + for (const [key, value] of Object.entries(item)) properties.push(`${key}="${value}"`) + heads.push(``) + } + res.write(htmlStart.replace('',heads.join(''))) + for await (const chunk of buffer) { + if (res.closed) break + res.write(chunk) + } + } + } else { + res.status(code).set({ 'Content-Type': 'text/html' }) + const heads = [`${ title }`] + for (const item of metas) { + const properties = [] + for (const [key, value] of Object.entries(item)) properties.push(`${key}="${value}"`) + heads.push(``) + } + res.write(htmlStart.replace('',heads.join(''))) + for await (const chunk of stream) { + if (res.closed) break + res.write(chunk) + } + } + const piniaStateContent = JSON.stringify(compress(piniaState)) + const stateScript = `` + res.write(htmlEnd.replace('', stateScript)) + res.end() + } catch (e) { + vite?.ssrFixStacktrace(e) + console.log(e.stack) + res.status(500).end(e.stack) + } }) -// Start http server app.listen(port, () => { - console.log(`Server started at http://localhost:${port}`) + console.log(`Server started at port ${port}`) }) diff --git a/src/composables/apiRequest.js b/src/composables/apiRequest.js index 70bb934..9160540 100644 --- a/src/composables/apiRequest.js +++ b/src/composables/apiRequest.js @@ -20,9 +20,7 @@ function replaceUrl(url) { } export function useApiRequest(method, url, data, config = {}) { - const start = Date.now() const baseURL = getEndpoint() - // 若为 GET 请求,将 data 转为查询参数拼接到 URL 上 const fullURL = method === 'GET' && data ? `${baseURL}${url}?${objectToQueryString(data)}` : `${baseURL}${url}` @@ -45,14 +43,14 @@ export function useApiRequest(method, url, data, config = {}) { } ) const exec = async () => { - await execute() + const start = Date.now() + try { await execute() } + catch (e) {} const stop = Date.now() return { status: response.value?.status || (error.value?.response?.status ?? -1), data: response.value?.data || error.value?.response?.data || null, duration: stop - start, - start, - stop, error: error.value, isSSR: import.meta.env.SSR, } diff --git a/src/entry-server.js b/src/entry-server.js index 3bb10d5..dd7cae3 100644 --- a/src/entry-server.js +++ b/src/entry-server.js @@ -1,7 +1,7 @@ import { renderToWebStream } from 'vue/server-renderer' import { createApp } from './main' -import { createSSRRouter } from './router.js' +import { createSSRRouter, defaultHead } from './router.js' export async function getRoute(_url) { const router = createSSRRouter() @@ -9,15 +9,24 @@ export async function getRoute(_url) { await router.isReady() const route = router.currentRoute.value.matched[0] const code = route.meta.code || 200 - return { router, code } + return { router, code, meta: route.meta.meta || false, + title: route.meta.title || route.meta.name || defaultHead.title, + metas: [...defaultHead.meta, ...route.meta.metas || []] + } } export async function render(router, cookies, host) { const { app, pinia } = createApp() app.use(router) - const ctx = { cookies, host } + const headState = { + ready: false, + code: null, + title: null, + meta: [] + } + const ctx = { cookies, host, headState } const stream = renderToWebStream(app, ctx) const piniaState = pinia.state.value - return { stream, piniaState } + return { stream, piniaState, headState } } diff --git a/src/router.js b/src/router.js index 73126b6..bdaf41f 100644 --- a/src/router.js +++ b/src/router.js @@ -1,5 +1,25 @@ import { createMemoryHistory, createWebHistory, createRouter } from 'vue-router' +export const defaultHead = { + title: 'AO3 Mirror', + meta: [ + { name: 'description', + content: 'ArchiveOfOurOwn 镜像站, 使用 Vue 3 与 MDUI 2 重写界面, 优化 UI/UX' }, + { name: 'author', + content: 'UnknownMp' }, + { property: 'og:title', + content: 'AO3 Mirror' }, + { property: 'og:description', + content: 'ArchiveOfOurOwn 重构镜像' }, + { property: 'og:image', + content: 'https://cdn.unknownmp.top/website/ao3mirror.svg' }, + { property: 'og:url', + content: 'https://ao3.unknownmp.top' }, + { property: 'og:type', + content: 'website' }, + ] +} + export const createSSRRouter = () => createRouter({ history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(), scrollBehavior(to, from, savedPosition) { @@ -25,7 +45,8 @@ export const createSSRRouter = () => createRouter({ component: () => import('./views/Work.vue'), meta: { title: '阅读', - hidden: true + hidden: true, + meta: true } },{ path: '/work/:id/:cid', @@ -33,7 +54,8 @@ export const createSSRRouter = () => createRouter({ component: () => import('./views/Work.vue'), meta: { title: '阅读', - hidden: true + hidden: true, + meta: true } },{ path: '/search/simple', @@ -48,7 +70,6 @@ export const createSSRRouter = () => createRouter({ name: '设置', component: () => import('./views/Settings.vue'), meta: { - title: '设置', order: 90 }, },{ @@ -56,7 +77,6 @@ export const createSSRRouter = () => createRouter({ name: '关于', component: () => import('./views/About.md'), meta: { - title: '', order: 100 }, },{ @@ -64,7 +84,6 @@ export const createSSRRouter = () => createRouter({ name: '开发人员选项', component: () => import('./views/Developer.vue'), meta: { - title: '', hidden: true }, },{ diff --git a/src/ssr/headHelper.js b/src/ssr/headHelper.js new file mode 100644 index 0000000..3d9f525 --- /dev/null +++ b/src/ssr/headHelper.js @@ -0,0 +1,47 @@ +import { useSSRContext } from 'vue' + +export function useHeadHelper() { + if (!import.meta.env.SSR) { + return { + headReady: () => {}, + setMeta: () => {}, + addMeta: () => {}, + setTitle: () => {}, + setCode: () => {}, + } + } + + const ssrContext = useSSRContext() + + if (!ssrContext || !ssrContext.headState) { + return { + headReady: () => {}, + setMeta: () => {}, + addMeta: () => {}, + setTitle: () => {}, + setCode: () => {}, + } + } + + return { + headReady: () => { + ssrContext.headState.ready = true + }, + setMeta: (metas = []) => { + ssrContext.headState.meta = [ + ...ssrContext.headState.meta, + ...metas + ] + }, + addMeta: (meta) => { + ssrContext.headState.meta.push(meta) + }, + setTitle: (title) => { + ssrContext.headState.title = title + }, + setCode: (code) => { + ssrContext.headState.code = code + } + } +} + diff --git a/src/stores/route.js b/src/stores/route.js index de1a803..922833e 100644 --- a/src/stores/route.js +++ b/src/stores/route.js @@ -2,6 +2,8 @@ import { ref, computed, watch } from 'vue' import { defineStore } from 'pinia' import { useRouter, useRoute } from 'vue-router' +import { useHeadHelper } from '../ssr/headHelper.js' + export const useRouteStore = defineStore('route', () => { const router = useRouter() const route = useRoute() @@ -16,7 +18,9 @@ export const useRouteStore = defineStore('route', () => { ) const lastFromDrawer = ref(0) const customTitle = ref(null) - const title = computed(() => customTitle.value || route.meta.title || route.name) + const title = import.meta.env.SSR ? + computed(() => route.meta.title || route.name) : + computed(() => customTitle.value || route.meta.title || route.name) function drawerPress(target) { if (lastFromDrawer.value == 0) { lastFromDrawer.value = 1 diff --git a/src/stores/workRead.js b/src/stores/workRead.js index 3765192..05fc039 100644 --- a/src/stores/workRead.js +++ b/src/stores/workRead.js @@ -62,9 +62,10 @@ export const useWorkReadState = defineStore('workRead', () => { if (result.status == 200) { setData(result.data) state.value = 'ready' - } else { - id.value = target + } else if (result.status == 404) { state.value = import.meta.env.SSR ? 'ssrnotfound' : 'notfound' + } else if (result.status == 401) { + state.value = 'unauth' } } return { diff --git a/src/views/Work.vue b/src/views/Work.vue index 5285b41..5c95979 100644 --- a/src/views/Work.vue +++ b/src/views/Work.vue @@ -4,6 +4,8 @@ import { useRouter, useRoute } from 'vue-router' const router = useRouter() const route = useRoute() +import { useHeadHelper } from '../ssr/headHelper.js' + import { useWorkReadState } from '@/stores/workRead.js' import { useRouteStore } from '@/stores/route.js' @@ -43,11 +45,23 @@ const categoryName = { fm: '女/男' } -onServerPrefetch(async () => await workReadState.loadWork(route.params.id, route.params.cid)) +onServerPrefetch(async () => { + const headHelper = useHeadHelper() + await workReadState.loadWork(route.params.id, route.params.cid) + if (workReadState.state == 'ready') headHelper.setTitle(workReadState.title) + else if (workReadState.state == 'ssrnotfound') { + headHelper.setTitle('文章未找到') + headHelper.setCode(404) + } else if (workReadState.state == 'unauth') { + headHelper.setTitle('访问被阻止') + headHelper.setCode(401) + } + headHelper.headReady() +}) onMounted(async () => { watch(() => workReadState.state, (value) => { if (value == 'ready') routeState.customTitle = workReadState.title }) - if (workReadState.state != 'ssrnotfound') await workReadState.loadWork(route.params.id, route.params.cid) + if (workReadState.state != 'ssrnotfound' && workReadState.state != 'ready') await workReadState.loadWork(route.params.id, route.params.cid) if (workReadState.state == 'ready') { routeState.customTitle = workReadState.title if (workReadState.cid !== null && parseInt(route.params.cid) != workReadState.cid) { @@ -94,28 +108,34 @@ async function switchWorkWithIndex(target) {