🌐构建高性能虚拟列表:从数据爬取到智能解析与数据库持久化

77 阅读4分钟

在现代前端开发中,面对大量内容的展示场景(如新闻流、博客列表、商品墙等),直接渲染全部数据会导致页面卡顿、内存占用过高。为解决这一问题,虚拟列表(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
        });
    }
}