Nextjs SSG 模式实现本地全文搜索

flexsearch 是一个非常优秀的全文搜索库,本文参考 respress 源码实现,将文章全文索引到本地,并使用 flexsearch 进行全文搜索。

1. 服务端生成索引文件

  1. search Index 结构化数据
export interface SearchIndexItem {
     id: string;
     title: string;
     content: string;
     routePath: string;
     ...<其他拓展字段>
 }

在 NextJs 项目中,新建一个 search.mjs 的文件。文件中调用数据接口,json字符转换为Uint8Array。 使用WebAssembly模块,加载wasm文件。将Uint8Array数据传入wasm 暴露出的加密函数中。

  1. 加密数据

    按照rspress的搜索方案,全文的数据全部存储在了 search-index.json中,也就是用户可以简单的下载 json 文件,就能拿到全部数据。 为了避免访客可以拿到简单的拿到全部数据,我们使用加密方案,将数据加密后,再返回给用户。当然这还是能破解的,只是需要一定的门槛。

    因为最近在学习代码安全,涉及到了WebAssembly, 因此最终决定使用rust开发加密方案。最终通过构建工具打包成wasm,文件。 主要有俩个函数encryptdecrypt

    encrypt接收Uint8Array数据,

    进行简单的加密算法运算

    flowchart TD
          A[开始] --> B{遍历 json_bytes 和 ENCRYPTION_KEY}
          B -->|Yes| C[取出当前字节 b 和 k]
          C --> D[计算 b ^ k]
          D --> E[将结果添加到 encrypted_bytes]
          E --> F{是否遍历结束}
          F -->|No| B
          F -->|Yes| G[结束]
  2. 压缩输出索引数据

    使用flate2将上一步输出的数据进行压缩 xml 格式,再压缩输出Uint8Array数据。

    flowchart TD
         A[开始] --> B[创建压缩器]
         B --> C{写入数据是否成功?}
         C -->|Yes| D[完成压缩]
         C -->|No| E[设置输出长度为0]
         E --> F[返回空指针]
         D --> G{压缩是否成功?}
         G -->|Yes| H[计算压缩后数据大小]
         G -->|No| E
         H --> I[分配内存]
         I --> J{内存分配是否成功?}
         J -->|Yes| K[复制数据到新内存]
         J -->|No| F
         K --> L[返回指向新内存的指针]

    2、3 详细代码,示例为search-transfor.wasm 文件

  3. Node 将数据写入到本地。

    这个没什么好讲的,直接使用fs写入即可。示例文件叫search-[hash].ahri, 后缀名自定义即可。hash 可以根据数据内容生成,区分版本。

  4. 提前生成索引

    2024/11/01 更新。

    实际操作中,发现数据量级较大时候,flexsearch 生成索引的时间非常长。幸好支持nodejs, 因此放弃了直接存储原始数据,选择在项目构建的时候提前生成索引,直接存储索引文件,减少flexsearch 初始化时间。

export async function createFlexDocument(data, cache) {
        const createOptions = {
            document: {
            id: "id",
            store: true,
            index: ["normalizedTitle", "headers", "normalizedContent"],
            },
            cache: 100,
            tokenize: (str) => tokenize(str, cjkRegex),
        };

        const index = new Document(createOptions);

        if (cache) {
            await Promise.all(
            Object.keys(cache).map((item) => index.import(item, cache[item])),
            )
        } else {
            const pagesForSearch = data.map((page) => ({
                ...page,
                normalizedContent: normalizeTextCase(page.content),
                headers: page.chapters
                    .map((header) => normalizeTextCase(header.title))
                    .join(" "),
            normalizedTitle: normalizeTextCase(page.title),
            }));

            for (const item of pagesForSearch) {
            index?.add(item);
            }
        }
        return {
            index,
        }
    }

执行createFlexDocument,最后把index数据使用fs存储到search-[hash].ahri 即可。

构建时机,因为要使用hash保证唯一性,所以构建的时间选择放到next.config.mjs 中,构建完成后再执行next build, 将Hash使用definePlugin 插件,注入到全局变量中。本文所有hash都是指该全局变量

2. 客户端请求文件

  1. 使用 fetch 加载search-transfor.wasm文件和索引文件search-[hash].ahri, 以下代码还包含了idb 缓存逻辑,具体说明会放到数据缓存模块说明。
const cache = new OfflineSearchCache({ name: "search-cache" });

export async function getDateByWasm() {
  let jsonData = [];
  // @ts-ignore
  let wasmBuffer: ArrayBuffer | null = null;

  await cache.init();
  const wasmCache = await cache.get("wasm_v1");

  if (!wasmCache) {
    const response = await fetch(
      `${process.env.BASE_PATH_URL}/source_transfor.wasm`,
    );

    wasmBuffer = await response.arrayBuffer();
    await cache.transaction({ id: "wasm_v1", data: wasmBuffer });
  } else {
    wasmBuffer = wasmCache.data;
  }
  const { instance } = await WebAssembly.instantiate(wasmBuffer!);
  const wasmModule: any = instance.exports;

  function arrayToPtr(array: any[]) {
    const ptr = wasmModule.alloc_memory(array.length);

    if (ptr === 0) {
      throw new Error("Memory allocation failed");
    }
    new Uint8Array(wasmModule.memory.buffer).set(array, ptr);

    return { ptr, len: array.length };
  }

  try {
    let arrayBuffer = null;
    // @ts-ignore
    const indexCache = await cache.get(`ahri_${SEARCH_INDEX_HASH}`);

    if (!indexCache) {
      const response = await fetch(
        // @ts-ignore
        `${location.origin}${process.env.BASE_PATH_URL}/search/search.${SEARCH_INDEX_HASH}.ahri`,
      );

      arrayBuffer = await response.arrayBuffer();
      await cache.transaction({
        // @ts-ignore
        id: `ahri_${SEARCH_INDEX_HASH}`,
        data: arrayBuffer,
      });
    } else {
      arrayBuffer = indexCache.data;
    }

    const encrypted = new Uint8Array(arrayBuffer);

    // 分配内存并复制加密数据
    const encryptedData = arrayToPtr(encrypted as any);
    const outLenPtr = wasmModule.alloc_memory(4);

    const decryptedPtr = wasmModule.decrypt(
      encryptedData.ptr,
      encrypted.length,
      outLenPtr,
    );

    const decryptedLen = new Uint32Array(
      wasmModule.memory.buffer,
      outLenPtr,
      1,
    )[0];

    if (decryptedPtr === 0 || decryptedLen === 0) {
      throw new Error("Decryption failed");
    }

    // 从内存中获取解密后的数据
    const decryptedData = new Uint8Array(
      wasmModule.memory.buffer,
      decryptedPtr,
      decryptedLen,
    );

    // 转换为字符串
    const decoder = new TextDecoder();
    const decrypted = decoder.decode(decryptedData);

    // 尝试解析 JSON
    try {
      jsonData = JSON.parse(decrypted);
    } catch (e) {
      console.error(e);
    }

    // 清理内存
    wasmModule.free_memory(encryptedData.ptr, encryptedData.len);
    wasmModule.free_memory(decryptedPtr, decryptedLen);
    wasmModule.free_memory(outLenPtr, 4);
  } catch (error: any) {
    console.error("Decryption error:", error);
  }

  return jsonData;
}
  1. 导入索引

上一步的数据是缓存的索引,因此不需要重建索引,只需要导入索引即可。代码直接复用生成索引的代码,传入cache 即可。

const cache = await getDateByWasm()
 const { index } = await createFlexDocument(undfined, cache);

‍3. 具体搜索逻辑

查看拓展web-worker

3. 搜索组件

  1. 使用 flexsearch 搜索。
  2. 生成搜索选项。
  3. 点击搜索选项跳转对应文章或者对应的章节。

这块重要的逻辑就是计算索引的位置,取出搜索关键词前后的内容,标题等。其他的就是正常输入搜索,展示搜索结果的react组件,想了解具体代码的逻辑,可以参考Rspress源码, 不在详细赘述

4. 数据缓存

除了第一步中的索引缓存, 考虑到wasmsearch.[hash].ahri文件 体积很大,而且基本是静态数据,因此绝对缓存到indexDB 中,减少网络传输耗时。

参考客户端获取数据的代码。

  1. 优先去idb 取 wasm文件,没有则通过fetch加载文件,然后缓存ArrayBuffer 到idb, 基本不变使用v1 做版本标识。
  2. 优先去idb 取 索引文件,没有则公共fetch 加载索引文件,然后缓存到idb。使用hash做标识。保证数据的及时性。

做了提前生成索引以及数据缓存之后,整体的搜索模块初始化时间由80s 降低到了2s左右(非首次)

做这一块缓存除了用户搜索体验外,还为了节省cdn流量。每次加载文件的话需要消耗30M +

5. 拓展

  1. 解析文件逻辑是否要编译为虚拟机代码执行?

相关,所以简单的将加解密的逻辑使用rust编写,然后编译成wasm 文件。exprot 加解密函数。

use flate2::write::{DeflateEncoder, DeflateDecoder};
use flate2::Compression;
use std::io::prelude::*;
use std::alloc::{alloc, dealloc, Layout};

const ENCRYPTION_KEY: &[u8] = b"your_secret_key_here";

#[no_mangle]
pub extern "C" fn alloc_memory(size: usize) -> *mut u8 {
    let align = std::mem::align_of::<u8>();
    let layout = Layout::from_size_align(size, align).unwrap();
    unsafe {
        let ptr = alloc(layout);
        if ptr.is_null() {
            return ptr;
        }
        std::ptr::write_bytes(ptr, 0, size);
        ptr
    }
}

#[no_mangle]
pub extern "C" fn free_memory(ptr: *mut u8, size: usize) {
    if !ptr.is_null() && size > 0 {
        unsafe {
            let align = std::mem::align_of::<u8>();
            let layout = Layout::from_size_align(size, align).unwrap();
            dealloc(ptr, layout);
        }
    }
}

#[no_mangle]
pub extern "C" fn encrypt(json_ptr: *const u8, json_len: usize, out_len: *mut usize) -> *mut u8 {
    if json_ptr.is_null() || json_len == 0 {
        unsafe { *out_len = 0; }
        return std::ptr::null_mut();
    }

    let json_bytes = unsafe { std::slice::from_raw_parts(json_ptr, json_len) };

    // 加密
    let encrypted_bytes: Vec<u8> = json_bytes.iter()
        .zip(ENCRYPTION_KEY.iter().cycle())
        .map(|(&b, &k)| b ^ k)
        .collect();

    // 压缩
    let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
    if let Err(_) = encoder.write_all(&encrypted_bytes) {
        unsafe { *out_len = 0; }
        return std::ptr::null_mut();
    }

    let compressed_bytes = match encoder.finish() {
        Ok(bytes) => bytes,
        Err(_) => {
            unsafe { *out_len = 0; }
            return std::ptr::null_mut();
        }
    };

    let output_size = compressed_bytes.len();
    unsafe {
        *out_len = output_size;
        let output_ptr = alloc_memory(output_size);
        if !output_ptr.is_null() {
            std::ptr::copy_nonoverlapping(
                compressed_bytes.as_ptr(),
                output_ptr,
                output_size
            );
        }
        output_ptr
    }
}

#[no_mangle]
pub extern "C" fn decrypt(encrypted_ptr: *const u8, encrypted_len: usize, out_len: *mut usize) -> *mut u8 {
    if encrypted_ptr.is_null() || encrypted_len == 0 {
        unsafe { *out_len = 0; }
        return std::ptr::null_mut();
    }

    let encrypted = unsafe { std::slice::from_raw_parts(encrypted_ptr, encrypted_len) };

    // 解压缩
    let mut decoder = DeflateDecoder::new(Vec::new());
    if let Err(_) = decoder.write_all(encrypted) {
        unsafe { *out_len = 0; }
        return std::ptr::null_mut();
    }

    let decrypted_bytes = match decoder.finish() {
        Ok(bytes) => bytes,
        Err(_) => {
            unsafe { *out_len = 0; }
            return std::ptr::null_mut();
        }
    };

    // 解密
    let result: Vec<u8> = decrypted_bytes.iter()
        .zip(ENCRYPTION_KEY.iter().cycle())
        .map(|(&b, &k)| b ^ k)
        .collect();

    let output_size = result.len();
    unsafe {
        *out_len = output_size;
        let output_ptr = alloc_memory(output_size);
        if !output_ptr.is_null() {
            std::ptr::copy_nonoverlapping(
                result.as_ptr(),
                output_ptr,
                output_size
            );
        }
        output_ptr
    }
}
  1. 浏览器搜索是否使用 web-worker

实际操作中索引的量级过高(压缩后还有 30M+), 导致每次初始化索引(90s 左右)的时候整个输入框会卡住,不响应用户输入。 最终决定搜索逻辑放到web-worker中。

nextjs 默认支持 web-worker,所以直接使用即可。可以参考with-web-worker

// search-worker.ts
import { PageSearcher } from "@/components/Search/logic/search";
let searcher: PageSearcher | null = null;

async function init() {
  searcher = new PageSearcher({} as any);
  console.time("searcherInit"); // 计算索引初始化时间
  await searcher.init();
  console.timeEnd("searcherInit");

  return {
    name: "init",
    data: true,
  };
}

async function match(query: string) {
  const matched = await searcher?.match(query);

  return {
    name: "match",
    query,
    matched,
  };
}

addEventListener("message", async (event: MessageEvent<any>) => {
  if (event.data.name === "init") {
    postMessage(await init());
  }
  if (event.data.name === "match") {
    postMessage(await match(event.data.query));
  }
});
// SearchComponment.tsx
// 示例代码,非真实业务逻辑
function SearchComponment() {
  const [query, setQuery] = useState(""); // 搜索参数
  const workerRef = useRef<Worker | null>(null); //worker实例
  const [searchResult, setSearchResult] = useState<MatchResult>([]); // 搜索结果列表
  const [initing, setIniting] = useState(true); // 是否初始化
  const [isSearching, setIsSearching] = useState(false);

  async function search(query: string) {
    let newQuery = value;
    setQuery(newQuery);
    if (newQuery) {
      workerRef.current?.postMessage({
        name: "match",
        query: newQuery,
      });
    }
  }
  async function initPageSearcher() {
    workerRef.current = new Worker(
      new URL("../../utils/search/search.worker.ts", import.meta.url)
    );
    workerRef.current.postMessage({
      name: "init",
    });

    workerRef.current.onmessage = (e) => {
      if (e.data.name === "init") {
        setIniting(false);
        const NQuery = searchInputRef.current?.value;

        if (NQuery) {
          workerRef.current?.postMessage({
            name: "match",
            query: NQuery,
          });
        }
      }
      if (e.data.name === "match") {
        const NQuery = searchInputRef.current?.value;

        if (e.data.query === NQuery) {
          setSearchResult(e.data.matched);
          setIsSearching(false);
        }
      }
    };
  }

  useEffect(() => {
    initPageSearcher();
    return () => {
      workerRef.current?.terminate();
    };
  }, []);

  return (
    <div>xxxxxx</div> 
  );
}

参考文献

静态站点全文搜索实现原理之 Rspress 篇

flexsearch

rspress

probly-search