在现代前端开发中,面对大量内容的展示场景(如新闻流、博客列表、商品墙等),直接渲染全部数据会导致页面卡顿、内存占用过高。为解决这一问题,虚拟列表(Virtual List) 成为了性能优化的关键技术。
本文将带你一步步构建一个完整的虚拟列表系统,重点在于:数据从何而来?如何高效获取并结构化处理海量网页内容?
我们将以 博客园首页 为例,使用 x-crawl 框架结合 Puppeteer 和 OpenAI 的 AI 能力,完成一次“智能爬虫”实践,并最终将清洗后的数据导入数据库,支撑前端虚拟列表的数据源。
一、什么是虚拟列表?
虚拟列表是一种按需渲染的技术,它只渲染当前可视区域内的元素,而非一次性加载所有条目。当用户滚动时,动态更新可见项,从而极大减少 DOM 节点数量和内存消耗。
✅ 优势:
- 支持成千上万条数据流畅滚动
- 内存占用低
- 提升用户体验
但无论多么高效的前端渲染机制,其核心前提都是:有结构化的数据可供展示。
那么问题来了——这些数据从哪里来?
二、数据来源:我们自己“造”!
在没有现成 API 的情况下,我们可以选择 网页爬虫(Web Crawler) 来自动化采集目标网站的内容。
目标站点分析:博客园首页
打开 www.cnblogs.com/,我们希望提取的文章信息包括:
- 标题(Title)
- 摘要(Excerpt)
- 发布时间(Date)
- 作者(Author)
- 链接(URL)
三、技术选型:x-crawl —— Node.js 下的强大爬虫框架
x-crawl 是一个灵活且功能强大的 Node.js 多功能爬虫库,支持:
- 页面抓取(基于 Puppeteer)
- 接口调用
- 文件下载
- 内存 DOM 解析(类似浏览器环境)
- AI 辅助内容提取(集成 OpenAI)
我们将在项目根目录下创建 server/crawl 目录,用于存放所有爬虫逻辑。
同时准备 .env 文件存储敏感密钥:
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
四、手动爬取 vs AI 辅助爬取
方式一:传统方式 —— 手动定位 DOM 元素
我们可以像操作浏览器一样,使用 querySelector 手动提取 HTML 片段:
import { config } from 'dotenv';
import { createCrawl } from 'x-crawl';
config();
const crawlApp = createCrawl({
maxRetry: 3,
intervalTime: { min: 1000, max: 2000 }
});
crawlApp.crawlPage({
url: 'https://www.cnblogs.com/',
launchOptions: {
executablePath: 'C:\Program Files\Google\Chrome\Application\chrome.exe'
}
}).then(async (res) => {
const { page, browser } = res.data;
const targetSelector = '#post_list';
await page.waitForSelector(targetSelector);
const html = await page.$eval(targetSelector, el => el.innerHTML);
console.log(html); // 输出原始HTML字符串
await browser.close();
});
这种方式虽然可行,但存在明显缺点:
- 若页面结构变更,选择器失效
- 返回的是 HTML 字符串,仍需进一步解析才能得到结构化 JSON
方式二:AI 辅助爬取 —— 让大模型帮我们“读网页”
这才是 x-crawl 的真正亮点:利用自然语言描述需求,让 AI 自动解析 DOM 并提取所需字段。
使用 createCrawlOpenAI 启动 AI 模式
import { config } from 'dotenv';
import { createCrawl, createCrawlOpenAI } from 'x-crawl';
import {
join
} from 'path';
import {
writeFile
} from 'fs/promises';
config(); // 加载 .env 文件中的环境变量
// 创建一个基础的网页爬虫实例,用于控制浏览器进行页面抓取。
const crawlApp = createCrawl({
maxRetry: 3, //如果请求失败,最多重试 3 次。
intervalTime: { // 每次请求之间随机延迟 1~2 秒,防止被反爬机制封禁(模拟人类操作)。
max: 2000,
min: 1000
}
})
// AI 辅助
// 创建一个集成了 OpenAI 的爬虫实例,可用于在爬取过程中调用 AI 模型分析内容
const crawlOpenAIApp = createCrawlOpenAI({
clientOptions: {
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL
},
defaultModel: {
chatModel: "gpt-4o"
}
})
const writeJSONToFile = async (data, filename) => {
const filePath = join(process.cwd(), filename);
try{
await writeFile(filePath, JSON.stringify(data, null, 2));
}catch(err){
console.error('写入文件错误');
}
}
//开始爬取页面
crawlApp
// 启动浏览器并访问博客园首页
.crawlPage({
url: 'https://www.cnblogs.com/',
// url: 'https://www.cnblogs.com/#p2',// 爬取第二页的数据
// 系统安装chrome,指定Chrome浏览器的路径
launchOptions: {
executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
}
})
// 页面加载完成后执行回调函数
.then(async (res)=>{
const {
page, // 当前页面的 Puppeteer 页面对象,可执行 DOM 操作
browser // 浏览器实例(可用于关闭或其他高级操作)
} = res.data;
// 博客园首页存放文章列表的容器 ID
const targetSelector = '#post_list';
// 等待该元素出现在页面上,确保动态内容已加载完成
await page.waitForSelector(targetSelector);
// $eval 是 Puppeteer 提供的方法,用于在页面上下文中执行函数。
// 获取 #post_list 元素的完整 innerHTML,即所有文章列表的 HTML 字符串。
// 结果保存在 highlyHTML 变量中,可用于后续解析或存储。
const highlyHTML = await page.$eval(targetSelector, (el) => el.innerHTML);
console.log(highlyHTML)
// 暂时注释掉AI分析,避免网络连接问题
const result = await crawlOpenAIApp.parseElements(
highlyHTML,
`
获取每一个.post-item元素里面的.post-item-title里的标题,
.post-item-summary里的纯文本摘要,以JSON格式返回。如:
[
{
"title": "标题",
"content": "摘要"
}
]
`
)
await browser.close();
// 把HTML结果写入文件
await writeJSONToFile({ html: highlyHTML }, 'data/posts.json');
})
//导出两个爬虫实例
export {
crawlApp,
crawlOpenAIApp
}
💡 AI 在背后做了什么?
- 将整个页面的 DOM 结构发送给 GPT 模型
- 模型根据你的
prompt理解语义,自动匹配对应字段- 返回标准 JSON,无需正则或复杂 CSS 选择器
这极大地提升了爬虫的鲁棒性和可维护性,即使网页改版也能自适应提取。
五、持久化:将爬取结果写入数据库
有了干净的 posts.json 数据后,下一步就是将其导入数据库,供前端应用调用。
我们在 Next.js 中创建一个 API 路由 /api/crawl/posts/route.ts 来执行此任务:
import {
NextRequest,
NextResponse
} from 'next/server';
import {
prisma
} from '@/lib/db';
import path from 'path';
import fs from 'fs/promises';
/**
* 处理 GET 请求:从本地 JSON 文件导入文章数据到数据库
*
* 该接口用于一次性将存储在本地 data/posts.json 中的文章批量插入数据库
* 注意:这是一个管理类接口,通常只在初始化数据时使用
*/
export async function GET() {
try {
// 1. 构建 posts.json 文件的绝对路径
// process.cwd() 返回项目根目录,例如 D:\作业\lvmeng\lesson_si_new\next\jwt-refresh-new
// 然后拼接路径到 data/posts.json
const dataPath = path.join(process.cwd(), "data", "posts.json");
// 2. 异步读取文件内容(UTF-8 编码)
const fileContent = await fs.readFile(dataPath, "utf-8");
// 3. 将 JSON 字符串解析为 JavaScript 对象
const data = JSON.parse(fileContent);
// 4. 验证数据格式是否正确:必须包含一个名为 posts 的数组
if (!data.posts || !Array.isArray(data.posts)) {
return NextResponse.json({
error: "invalid data format"
}, {
status: 400 // 返回 400 错误:客户端请求数据格式错误
});
}
// 5. 提取文章数组
const posts = data.posts;
// 6. 遍历每一篇文章,逐条插入数据库
for (const post of posts) {
const createPost = await prisma.post.create({
data: {
title: post.title, // 文章标题
content: post.content, // 文章内容
published: true, // 默认设置为已发布
authorId: 1 // 固定作者 ID 为 1(假设用户 ID=1 存在)
}
});
}
// 7. 所有文章导入成功,返回成功响应
return NextResponse.json({
message: 'Posts import completed', // 成功提示信息
total: posts.length // 导入的文章总数
});
} catch (err) {
// 8. 捕获任何错误(如文件不存在、JSON 格式错误、数据库连接失败等)
console.error("Error importing posts:", err);
// 返回 500 内部服务器错误
return NextResponse.json({
error: "Internal server error",
details: err.message // 可选择性返回错误详情(生产环境建议隐藏)
}, {
status: 500
});
}
}