#SSR(Server-Side Render)
Vue是一个SPA框架,网页的内容完全在客户端完成渲染,如果你有看过请求Vue项目时,返回的第一个html文件,文件中有很多的<script>
标签和一个<div id=app></div>
之后,vue会使用请求到的js文件从这个div开始渲染页面。这导致了SPA项目对于SEO的优化不是很好。使用SSR时,完整的html文件会在服务端完成渲染,第一次请求时会返回完整的内容,搜索引擎可以捕捉这些内容,从而对搜索关键词、搜索结果进行优化。
除此之外,SSR还具有以下优点:
- 更快的初始页面加载时间: Nuxt将完全呈现的HTML页面发送到浏览器,可以立即显示。这可以提供更快的感知页面加载时间和更好的用户体验 (UX),尤其是在较慢的网络或设备上。
- 在低功率设备上更好的性能: 它减少了需要在客户端下载和执行的JavaScript的数量,这对于可能难以处理繁重的JavaScript应用程序的低功率设备是有益的。
- 更好的可访问性: 内容在初始页面加载时立即可用,从而改善依赖屏幕阅读器或其他辅助技术的用户的可访问性。
- 更容易缓存: 页面可以在服务器端缓存,这可以通过减少生成内容并将内容发送到客户端所花费的时间来进一步提高性能。
#API
要构建动态的博客,需要实现动态更新内容,为此,需要有数据库,有了数据库就需要数据交互,需要API。正好Nuxt是一个全栈框架,它运行在Node环境中,框架中已经集成了构建WEB API所需要的依赖,只需要在/server/api/blog/
下新建一个index.get.ts
文件,就可以接收到前端发送的请求,请求路径/api/blog会执行这个文件中的方法。
import { ArticleSchema } from '~/server/models/article.schema';
export default defineEventHandler(async event => {
try {
const article = await ArticleSchema.find({ status: 'PUBLISHED' }, { content: 0 });
if (article) {
return article;
} else {
return { title: '404 Not Found' };
}
} catch (error) {
return new Response(error as string, { status: 500 });
}
});
#数据库
数据库有两种选择,一种是关系型数据库,例如MySQL、Postgre等等,另一种是非关系型数据库,如MongoDB等等。如果选择的时关系型的数据库,还需要一个ORM框架来处理有关数据库的操作。如果使用的是非关系型数据库,如MongoDB可以很好的和Node项目继承,MongoDB直接存储JSON格式的数据,这些数据在查询是会自动映射到JS Object上,可以直接通过object.
来直接获取数据字段的值。在加上ts的支持,是完全可以代替ORM框架的,并且更加简单容易使用。
在Nuxt项目中使用MongoDB数据库,需要以下步骤:
这里使用里一个Nuxt module
来实现。- 安装nuxt-mongoose
pnpm i nuxt-mongoose -D
- 配置连接URI
MongoDB不需要在初始化时建好对应的数据库(Collection)和表(Document),会在第一次写入数据时自动创建对应的数据表。所以,在数据库连接信息中只需要指定数据库名称即可(不需要手动创建)nuxt.config.tsexport default defineNuxtConfig({ modules: [ 'nuxt-mongoose', ], mongoose: { uri: process.env.MONGODB_URI, devtools: true, options: { dbName: 'blog_v2', }, }, })
- 定义数据结构(Document)/server/models/article.schema.ts
import { defineMongooseModel } from '#nuxt/mongoose' export const ArticleSchema = defineMongooseModel({ name: 'Article', schema: { shortLink: { type: String, unique: true, required: true, }, title: { type: String, required: true, }, }, options: { timestamps: true, }, })
- 定义ts类型/server/types/index.ts
export interface IArticle { _id: string; shortLink: string; title: string; description: string; }
- 查询数据
find()
方法中,第一个参数为过滤条件,如设置为{ status: 'PUBLISHED' }
表示只查询status为PUBLISHED的数据const article = await ArticleSchema.find({ status: 'PUBLISHED' });
- 新增数据
await new ArticleSchema(article).save();
- 更新数据
{ new: true }
表示返回新增的数据const article = await ArticleSchema.findByIdAndUpdate(article._id, article, { new: true });
- 删除数据
await ArticleSchema.deleteOne({ shortLink })
#文件上传
在文章编辑器中,使用ctrl
+ v
或者cmd
+ v
粘贴图片会自动上传图片,并在编辑器最后插入图片地址
文件会保存在Cloudflare R2中,通过AWS SDK上传文件,这套文件上传的代码应该可以适用于所有支持AWS协议的服务商
上传时,会将文件临时保存到public目录下,上传到服务器之后会删除临时文件。
- 创建文件上传接口
import process from 'node:process';
import path from 'node:path';
import fs from 'node:fs';
import { uploadToR2, getFileHashSync, uuidv4 } from '~/composables/fileUpload';
export default defineEventHandler(async event => {
const files = await readMultipartFormData(event);
if (!files) return new Response('Bad Request', { status: 400 });
// 将文件保存到public目录,同时对文件进行重命名
const localPath: string[] = [];
const file = files[0];
const filename = file.filename;
if (!file || !filename) return new Response('Bad Request', { status: 400 });
const extension = filename.split('.').pop();
const rename = `${uuidv4()}.${extension}`;
const filePath = path.join(process.cwd(), 'public', rename);
fs.writeFileSync(filePath, file.data);
localPath.push(filePath);
const hash = getFileHashSync(filePath);
const hashName = `${hash}.${extension}`;
// 上传文件
return uploadToR2(filePath, hashName, 'blog');
});
import process from 'node:process';
import fs from 'node:fs';
import crypto from 'node:crypto';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
// @ts-expect-error no type
import mimeTypes from 'mime-types';
export function uploadToR2(filePath: string, filename: string, dir: string) {
const endpoint = process.env.R2_UPLOAD_ENDPOINT;
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
if (!endpoint || !accessKeyId || !secretAccessKey) return;
// 创建S3Client
const client = new S3Client({
region: 'auto',
endpoint: endpoint,
credentials: {
accessKeyId: accessKeyId,
secretAccessKey: secretAccessKey,
},
});
const date = new Date();
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
// 构造文件上传路径
const key = `${dir}/${year}/${month}/${filename}`;
const fileExtension = filename.split('.').pop();
// 获取文件对应的 ContentType
const contentType = mimeTypes.contentType(fileExtension);
// 上传文件,对应PUT操作
const command = new PutObjectCommand({
Bucket: process.env.R2_BUCKET_NAME,
Key: key,
Body: fs.createReadStream(filePath),
ContentType: contentType,
});
// 上传,成功返回文件地址,否则抛出错误
return client
.send(command)
.then(res => {
console.log(res);
return `${process.env.IMAGE_PREVIEW_URI}/${key}`;
})
.catch(err => {
console.warn(err);
throw err;
})
.finally(() => {
fs.unlink(filePath, () => {
console.warn('delete temp file: ', filePath);
});
});
}
// 获取文件哈希值,用于文件永久保存的文件名
export function getFileHashSync(filePath: string, algorithm = 'sha256') {
try {
const hash = crypto.createHash(algorithm);
const data = fs.readFileSync(filePath);
hash.update(data);
return hash.digest('hex');
} catch (err) {
console.error('获取文件哈希值时出错:', err);
return null;
}
}
// 生成uuid,用于临时保存的文件名
export function uuidv4() {
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c: any) =>
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)
);
}
#Markdown渲染
Markdown渲染使用了
通过parse方法会将Markdown内容转换成JSON数据,之后通过MDCRenderer
组件将JSON数据渲染为HTML。
其中Markdown -> JSON这一步会在服务端完成,解析完Markdown之后会将JSON通过API传递给前端,之后由前端完成最终渲染。通常情况下Markdown -> JSON这一步会比较耗时,所以在服务端完成。在一次渲染完成之后,会将渲染的结果保存到Node环境的内存中,下次请求时,会直接返回缓存的结果。
- 实现一个
parse
方法
// composables/useMarkdownParser.ts
// Import package exports
import { createMarkdownParser, rehypeHighlight, createShikiHighlighter } from '@nuxtjs/mdc/runtime';
// Import desired Shiki themes and languages
import GithubDark from 'shiki/themes/github-dark-default.mjs';
import GithubLight from 'shiki/themes/github-light-default.mjs';
import HtmlLang from 'shiki/langs/html.mjs';
import MdcLang from 'shiki/langs/mdc.mjs';
import TsLang from 'shiki/langs/typescript.mjs';
import VueLang from 'shiki/langs/vue.mjs';
export function useMarkdownParser() {
let parser: Awaited<ReturnType<typeof createMarkdownParser>>;
const parse = async (markdown: string) => {
if (!parser) {
parser = await createMarkdownParser({
rehype: {
plugins: {
highlight: {
instance: rehypeHighlight,
options: {
// Pass in your desired theme(s)
theme: ['github-dark-default', 'github-light-default'],
// Create the Shiki highlighter
highlighter: createShikiHighlighter({
bundledThemes: {
'github-dark-default': GithubDark,
'github-light-default': GithubLight,
},
// Configure the bundled languages
bundledLangs: {
html: HtmlLang,
mdc: MdcLang,
vue: VueLang,
yml: YamlLang,
yaml: YamlLang,
ts: TsLang,
typescript: TsLang,
},
}),
},
},
},
},
});
}
return parser(markdown);
};
return parse;
}
- 在服务端使用该方法
该方法包含了渲染Markdown内容、结果缓存、性能日志
import { ArticleSchema } from '~/server/models/article.schema';
import { cache } from '~/config/cache.config';
import process from 'node:process';
import { useMarkdownParser } from '~/composables/useMarkdownParser';
export default defineEventHandler(async event => {
try {
const shortLink = event.context.params?.shortLink;
const parse = useMarkdownParser();
// 通过环境变量控制是否启用内存缓存
if (process.env.MEMORY_CACHE) {
if (shortLink) {
const result = await cache.get(shortLink);
if (result) {
// 缓存存在,直接读取结果
console.log('= recover from cache:', shortLink);
return result;
} else {
// 不存在需要选获取Markdown内容
const queryres = (await ArticleSchema.findOne(
{ shortLink },
{ content: 1 }
)) as { _id: string; content: string };
if (!queryres) {
return new Response('404 Not Found', { status: 404 });
}
const { content } = queryres;
// 渲染同时记录日志
const start = performance.now();
const html = await parse(content);
const end = performance.now();
const executionTime = Math.round(end - start);
console.log(`+ render html for [${shortLink}] takes [${executionTime}] ms`);
await cache.set(shortLink, html);
return html;
}
}
} else {
// 不启用缓存,直接进行查询和渲染(用在开发环境)
const queryres = (await ArticleSchema.findOne({ shortLink }, { content: 1 })) as {
_id: string;
content: string;
};
if (!queryres) {
return new Response('404 Not Found', { status: 404 });
}
const start = performance.now();
const html = await parse(queryres.content);
const end = performance.now();
const executionTime = Math.round(end - start);
console.log(`+ render html for [${shortLink}] takes [${executionTime}] ms`);
return html;
}
} catch (error) {
return new Response(error as string, { status: 500 });
}
});
#解析Github仓库链接
Github仓库的链接会被渲染成一个卡片
例如:
具体实现如下:
- 覆盖原有的渲染逻辑
export default defineNuxtConfig({
mdc: {
components: {
map: {
a: 'LinkRender',
},
},
},
})
- 创建新的LinkRender组件
Tips
该组件必须放在components/global
目录下
<script setup lang="ts">
import type { PropType } from 'vue'
import LinkCard from '~/components/markdown/LinkCard.vue'
const props = defineProps({
href: {
type: String,
default: ''
},
target: {
type: String as PropType<'_blank' | '_parent' | '_self' | '_top' | (string & {}) | null | undefined>,
default: undefined,
required: false
}
})
// 判断链接是否是Github仓库链接
function isGithubRepoLink(link: string): boolean {
// GitHub 仓库链接的格式为 https://github.com/username/repo
const regex = /^https:\/\/github\.com\/[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/;
return regex.test(link);
}
</script>
<template>
<!-- 如果是Github链接,渲染为卡片,否则渲染为常规Link -->
<LinkCard v-if="isGithubRepoLink(props.href)" :link="href" />
<UButton v-else icon="i-ri:external-link-line"
:to="props.href"
:target="props.target" variant="link"
:ui="{ padding: 'px-0' }"
>
{{ props.href }}
</UButton>
</template>
- 通过Github API获取仓库信息
<script setup lang="ts">
const props = defineProps({
link: {
type: String,
require: true
}
})
const repoInfo = ref()
onMounted(async () => {
if (props.link) {
// 获取仓库所有者和仓库名称
const regex = /^https:\/\/github\.com\/([a-zA-Z0-9_-]+)\/([a-zA-Z0-9_-]+)$/;
const match = props.link.match(regex);
if (match) {
const owner = match[1]
const repoName = match[2]
const res = await $fetch(`https://api.github.com/repos/${owner}/${repoName}`)
repoInfo.value = res
}
}
})
</script>
<template>
<ClientOnly>
<div v-if="repoInfo" class="w-full flex flex-row items-center justify-center my-4">
<NuxtLink :href="props.link" target="_blank"
class="h-35 sm:w-100 md:w-100 xl:w-100 lg:w-100 w-full p-4 dark:bg-[#222] bg-[#fafafa] rounded-md shadow hover:shadow-md transition-all flex flex-col justify-between">
<div class="flex flex-row justify-start items-center">
<div class="w-28 object-cover">
<NuxtImg :src="repoInfo.owner.avatar_url" class="rounded" />
</div>
<div class="flex flex-col w-full ml-4">
<div class="text-xl mb-2 line-clamp-1">
{{ repoInfo?.full_name }}
</div>
<div class="text-sm line-clamp-2 text-gray">
{{ repoInfo?.description }}
</div>
</div>
</div>
<div class="w-full text-sm mt-2.5 px-4 flex flex-row justify-between">
<div class="flex flex-row items-center text-amber justify-start">
<div class="i-ri:star-s-fill mr-2"></div>
<div>{{ repoInfo?.stargazers_count }}</div>
</div>
<div class="flex flex-row items-center text-blue justify-center">
<div class="i-ri:git-fork-fill mr-2"></div>
<div>{{ repoInfo?.forks_count }}</div>
</div>
<div class="flex flex-row items-center text-red justify-end">
<div class="i-ri:error-warning-fill mr-2"></div>
<div>{{ repoInfo?.open_issues_count }}</div>
</div>
</div>
</NuxtLink>
</div>
<NuxtLink v-else :href="props.link" target="_blank">
{{ props.link }}
</NuxtLink>
</ClientOnly>
</template>
#链接地址预览
当鼠标hover与链接上时,会获取该链接的预览图,这是借助了
实现。browserless是一个无界面浏览器,可以用它生成网页截图,相关介绍请看- 悬浮显示弹窗组件和数据请求
<script setup lang="ts">
import type { PropType } from 'vue'
import LinkCard from '~/components/markdown/LinkCard.vue'
import { useThrottleFn } from '@vueuse/core'
const props = defineProps({
href: {
type: String,
default: ''
},
target: {
type: String as PropType<'_blank' | '_parent' | '_self' | '_top' | (string & {}) | null | undefined>,
default: undefined,
required: false
}
})
const image = ref('/loading.gif')
const handleFetchImage = useThrottleFn(async () => {
if (image.value === '/loading.gif') {
const data = await $fetch(`/api/link/inspect?url=${props.href}`)
image.value = data as string
}
}, 10000)
function openLink() {
window.open(props.href, props.target as string)
}
</script>
<template>
<LinkCard v-if="isGithubRepoLink(props.href)" :link="href" />
<UPopover v-else mode="hover" :popper="{ placement: 'top-start' }" @mouseover="handleFetchImage">
<UButton icon="i-ri:external-link-line" @click="openLink" :to="props.href" :target="props.target" variant="link"
:ui="{ padding: 'px-0' }">
{{ props.href }}
</UButton>
<template #panel>
<div class="h-full w-full">
<NuxtImg :src="image" alt="popover" class="object-cover h-50 w-full" placeholder="/loading.gif" />
</div>
</template>
</UPopover>
</template>
- 进行网页截图,并将截图上传到Cloudflare R2,并返回图片地址,同时完成数据缓存和缓存读取
该缓存需要持久化,所以使用了MongoDB保存数据
import process from 'node:process';
import puppeteer from 'puppeteer';
import { getFileHashSync, uuidv4, uploadToR2 } from '~/composables/fileUpload';
import fs from 'node:fs';
import path from 'node:path';
import { ScreenshotSchema } from '~/server/models/screenshot.schema';
import { IScreenshot } from '~/server/types';
export default defineEventHandler(async event => {
const { url } = getQuery(event);
if (!url) return new Response('Bad Request, url require', { status: 400 });
// 读取缓存内容
const exist = (await ScreenshotSchema.findOne({ url: url })) as IScreenshot;
// 判断是否过期
if (exist && exist.updatedAt) {
const updateTime = exist.updatedAt;
const now = new Date();
const timeDiff = now.getTime() - updateTime.getTime();
const oneDay = 24 * 60 * 60 * 1000;
if (timeDiff <= 7 * oneDay) {
return exist.filePath; // 7天以内
}
}
let browser;
try {
const endpoint = process.env.SCREEN_URL;
if (!endpoint) {
return new Response('SCREEN_URL is null, func disabled', { status: 400 });
}
// 使用websocket连接到无界面浏览器,设置浏览器宽高为1600x900
browser = await puppeteer.connect({
browserWSEndpoint: `ws://${endpoint}`,
defaultViewport: { width: 1600, height: 900 },
});
// 打开一个新标签页,就好像你真的在用浏览器一样
const page = await browser.newPage();
// 设置颜色主题,你针对不同的主题生成不同的截图
// page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]);
// 在新开的标签页输入地址,并按下Enter
await page.goto(url as string);
// 进行截图,返回截图文件,设置文件格式为webp,质量为25%,缩放到原来的0.5倍
const file = await page.screenshot({
type: 'webp',
quality: 25,
clip: {
width: 1600,
height: 900,
x: 0,
y: 0,
scale: 0.5,
},
});
// 关闭浏览器
browser.close();
// 获取public目录地址
const publicDir = path.join(process.cwd(), 'public');
// 创建文件保存地址
const filePath = path.join(publicDir, `${uuidv4()}.png`);
// 将文件写入到public目录下,之后上传到服务器
const writeFilePromise = new Promise((resolve, reject) => {
fs.writeFile(filePath, file, 'binary', err => {
if (err) {
console.error('save screen fail', err);
reject(err);
} else {
const fileHash = getFileHashSync(filePath);
const previewUrl = uploadToR2(filePath, `${fileHash}.png`, 'screenshot');
console.log('previewUrl:', previewUrl);
resolve(previewUrl);
}
});
});
// 上传完成,返回图片地址
const previewUrl = await writeFilePromise;
// 更新缓存,或创建缓存
if (exist) {
exist.filePath = previewUrl as string;
ScreenshotSchema.updateOne(exist);
} else {
ScreenshotSchema.create({ url, filePath: previewUrl });
}
return previewUrl;
} catch (error) {
if (browser) {
// 关闭浏览器,释放资源
browser.close();
}
return new Response(error as string, { status: 500 });
}
});
#内存缓存
直接使用内存缓存,没有引入Redis。Redis同样是将数据缓存到内存中,既然不需要保证数据不丢失,同样不需要考虑性能问题,那么最好的方式就是将数据直接缓存在内存中。
使用了
作为Node环境下的内存缓存工具。经过简单的配置即可直接使用
import { caching } from 'cache-manager';
export const cache = await caching('memory', {
max: 300,
ttl: 24 * 60 * 1000 * 7, // 7d,
});
#全文搜索
集成了第三方搜索
。该搜索引擎支持全文搜索,Nodejs官网都在用,会通过API接口自动获取数据并建立索引供用户搜索。- 配置
import { OramaClient } from '@oramacloud/client';
import process from 'node:process';
export const orama = new OramaClient({
endpoint: process.env.ORAMA_API_URL as string,
api_key: process.env.ORAMA_API_KEY as string,
});
- 搜索
import { ArticleSchema } from '~/server/models/article.schema';
import { orama } from '~/config/orama.config';
import { IArticle } from '~/server/types';
export default defineEventHandler(async event => {
try {
const body = await readBody(event);
const category = body.category;
const keyword = body.keyword;
if (keyword && category) {
// @ts-ignore
const results = await orama.search({
term: keyword as string,
limit: 10,
});
if (results) {
const ids: string[] = [];
const hits = results.hits;
hits.forEach(hit => {
const doc = hit.document as IArticle;
if (doc.category === category) {
ids.push(hit.document._id as string);
}
});
const articles = await ArticleSchema.find({ _id: { $in: ids } })
.select('-content -html')
.lean();
return articles;
}
return [];
} else {
return [];
}
} catch (error) {
return new Response(error as string, { status: 500 });
}
});