文章摘要
加载中...|
此内容根据文章生成,并经过人工审核,仅用于文章内容的解释与总结

由于 Algolia 的爬取服务受到网络波动和额度限制,为保证搜索体验百分之百高可用,我基于 MiniSearch 研发了纯离线、本地全文倒排检索备份系统。

安装依赖:
bash
npm install minisearch

1. 构建期:Markdown 纯文本清洗与提取

为了压缩传输索引体积,在 VitePress 构建静态化阶段对 Markdown 源文件进行去格式清洗。

随着博客全量源代码嵌入文档的增加,我后续引入了两项终极性能重构

  1. 增量磁盘缓存 (mtimeMs Cache):利用 fs.stat(item).mtimeMs 对比文件最近修改戳,未改动的文章直接读取 ./.vitepress/cache/search-cache.json 缓存,全站构建读取时间缩短为毫秒级。
  2. 行级线性过滤剔除:将原本极度消耗 CPU 算力进行正则表达式全局匹配的 replace(/```[\s\S]*?```/g, "") 重构为行级 Toggle 标记过滤,直接完全跳过代码块所在的行,彻底规避了大代码块下的正则回溯匹配开销。
javascript
// .vitepress/theme/utils/getPostData.mjs
/**
 * 获取所有文章,包含正文内容(用于本地搜索索引,已集成 mtimeMs 缓存与行级线性代码块过滤)
 */
export const getAllPostsWithContent = async () => {
  try {
    let paths = await getPostMDFilePaths();

    // 载入搜索缓存文件
    const cachePath = "./.vitepress/cache/search-cache.json";
    let cache = {};
    try {
      if (await fs.pathExists(cachePath)) {
        cache = await fs.readJson(cachePath);
      }
    } catch (e) {
      console.warn("读取搜索缓存文件失败,将重新生成:", e.message);
    }

    let posts = await Promise.all(
      paths.map(async (item) => {
        try {
          const stat = await fs.stat(item);
          const mtimeMs = stat.mtimeMs;

          // 命中缓存:文件未修改时直接使用缓存数据
          if (cache[item] && cache[item].mtimeMs === mtimeMs) {
            return cache[item].data;
          }

          const content = await fs.readFile(item, "utf-8");
          const { data, content: body } = matter(content);
          const { title, date, description, hidden } = data;
          
          // 高效过滤:直接跳过 ``` 代码块包裹的内容,不做冗余清洗
          const lines = body.split("\n");
          let inCodeBlock = false;
          const filteredLines = [];
          for (let i = 0; i < lines.length; i++) {
            const line = lines[i];
            if (line.trim().startsWith("```")) {
              inCodeBlock = !inCodeBlock;
              continue;
            }
            if (!inCodeBlock) {
              filteredLines.push(line);
            }
          }
          const bodyWithoutCodeBlocks = filteredLines.join("\n");

          // 简单的正文清理:去除 Markdown 语法
          const cleanBody = bodyWithoutCodeBlocks
            .replace(/!\[.*?\]\(.*?\)/g, "") // 去除图片
            .replace(/\[.*?\]\(.*?\)/g, "$1") // 去除链接保留文字
            .replace(/[#*`>]/g, "") // 去除标题、粗体、代码标记
            .replace(/\s+/g, " ") // 合并空格
            .trim();

          const postData = {
            id: generateId(item),
            title: title || "未命名文章",
            description: description || "",
            body: cleanBody,
            regularPath: `/${item.replace(".md", "")}`,
            hidden: !!hidden,
          };

          // 写入内存缓存
          cache[item] = {
            mtimeMs,
            data: postData
          };

          return postData;
        } catch (error) {
          console.error(`处理文章正文 '${item}' 时出错:`, error);
          throw error;
        }
      }),
    );

    // 将缓存持久化写入磁盘
    try {
      await fs.ensureDir("./.vitepress/cache");
      await fs.writeJson(cachePath, cache, { spaces: 2 });
    } catch (e) {
      console.warn("写入搜索缓存文件失败:", e.message);
    }

    return posts.filter(post => !post.hidden);
  } catch (error) {
    console.error("获取文章全文时出错:", error);
    throw error;
  }
};

2. 客户端:按需实例化与缓存失效控制

前端采用按需触发加载 JSON 索引策略,并引入 buildTime 版本号作缓存破坏 (Cache Busting),保证多级缓存环境下搜索内容始终最新:

javascript
// .vitepress/theme/components/Search.vue
const initMiniSearch = async () => {
  if (isIndexLoaded.value) return;
  const response = await fetch(`/search.json?v=${theme.value.buildTime}`);
  const data = await response.json();
  
  miniSearch.value = new MiniSearch({
    fields: ["title", "description", "body"],
    storeFields: ["title", "description", "body", "regularPath"],
    searchOptions: {
      boost: { title: 3, description: 2, body: 1 }, // 搜索权重分配
      fuzzy: 0.2,   // 开启智能模糊匹配
      prefix: true, // 开启前缀匹配
    },
  });
  miniSearch.value.addAll(data);
  isIndexLoaded.value = true;
};

3. 动态上下文高亮提取

在搜索面板中,根据用户输入的词流,实时切割正文关键词前后 40~60 个字符作为结果的摘要预览,并对搜索词进行黄色荧光背景高亮:

javascript
const getSearchExcerpt = (text, query) => {
  const index = text.toLowerCase().indexOf(query.toLowerCase());
  const start = Math.max(0, index - 40);
  const end = Math.min(text.length, index + 60);
  return highlightText(text.slice(start, end), query);
};

4. 全局搜索组件完整代码 (Search.vue)

点击查看 Search.vue 完整代码
vue
// .vitepress/theme/components/Search.vue
<!-- 全局搜索 -->
<template>
  <Modal
    :show="store.searchShow"
    title="全局搜索"
    titleIcon="search"
    @mask-click="store.changeShowStatus('searchShow')"
    @modal-close="store.changeShowStatus('searchShow')"
  >
    <!-- Algolia 搜索 -->
    <ais-instant-search
      v-if="store.searchType === 'algolia'"
      :search-client="searchClient"
      :future="{
        preserveSharedStateOnUnmount: true,
      }"
      index-name="crawler_test"
      @state-change="searchChange"
    >
      <ais-configure :hits-per-page.camel="8" />
      <ais-search-box placeholder="想要搜点什么" autofocus />
      <ais-hits v-if="hasSearchValue">
        <template v-slot="{ items }">
          <Transition name="fade" mode="out-in">
            <div v-if="formatSearchData(items)?.length" class="search-list">
              <div
                v-for="(item, index) in formatSearchData(items)"
                :key="index"
                class="search-item s-card hover"
                @click="jumpSearch(item.url)"
              >
                <p class="title" v-html="item.title" />
                <p v-if="item?.anchor" class="anchor" v-html="item.anchor" />
                <p v-if="item?.content" class="content s-card" v-html="item.content" />
              </div>
            </div>
            <div v-else class="no-result">
              <i class="iconfont icon-search-empty" />
              <span class="text">搜索结果为空</span>
            </div>
          </Transition>
        </template>
      </ais-hits>
      <ais-pagination v-if="hasSearchValue" />
      <ais-stats>
        <template v-slot="{ processingTimeMS }">
          <div class="information">
            <span v-if="hasSearchValue" class="text"> 本次用时 {{ processingTimeMS }} 毫秒 </span>
          </div>
          <a class="power" href="https://www.algolia.com/" target="_blank">
            <i class="iconfont icon-algolia" />
            <span class="name">Algolia</span>
          </a>
        </template>
      </ais-stats>
    </ais-instant-search>

    <!-- 本地搜索 -->
    <div v-else class="local-search">
      <div class="ais-SearchBox">
        <input
          v-model="localSearchQuery"
          class="ais-SearchBox-input"
          type="search"
          placeholder="搜索文章标题、描述或正文"
          autofocus
          @input="handleLocalSearch"
        />
      </div>
      <div v-if="localSearchQuery" class="ais-Hits">
        <Transition name="fade" mode="out-in">
          <div v-if="localSearchResults.length" class="search-list">
            <div
              v-for="(item, index) in localSearchResults"
              :key="index"
              class="search-item s-card hover"
              @click="jumpSearch(item.regularPath)"
            >
              <p class="title" v-html="highlightText(item.title, localSearchQuery)" />
              <p
                v-if="item.description"
                class="content s-card"
                v-html="highlightText(item.description, localSearchQuery)"
              />
              <p
                v-else-if="item.body"
                class="content s-card"
                v-html="getSearchExcerpt(item.body, localSearchQuery)"
              />
            </div>
          </div>
          <div v-else class="no-result">
            <i class="iconfont icon-search-empty" />
            <span class="text">未找到相关文章</span>
          </div>
        </Transition>
      </div>
      <div class="ais-Stats">
        <div class="information">
          <span v-if="localSearchQuery" class="text">
            共找到 {{ localSearchResults.length }} 篇相关文章
          </span>
        </div>
        <div class="power">
          <i class="iconfont icon-search" />
          <span class="name">本地搜索 (MiniSearch)</span>
        </div>
      </div>
    </div>
  </Modal>
</template>

<script setup>
import { mainStore } from "@/store";
import { liteClient } from "algoliasearch/lite";
import MiniSearch from "minisearch";

const store = mainStore();
const router = useRouter();

const { theme } = useData();
const { appId, apiKey } = theme.value.search;

const searchClient = liteClient(appId, apiKey);

// 是否具有搜索词
const hasSearchValue = ref(false);

// 搜索变化
const searchChange = ({ uiState, setUiState }) => {
  const searchData = Object.values(uiState);
  hasSearchValue.value = searchData.length > 0 && searchData[0].query?.length > 0;
  setUiState(uiState);
};

// 本地搜索数据
const localSearchQuery = ref("");
const localSearchResults = ref([]);
const miniSearch = ref(null);
const isIndexLoaded = ref(false);

// 初始化 MiniSearch
const initMiniSearch = async () => {
  if (isIndexLoaded.value) return;
  try {
    const response = await fetch(`/search.json?v=${theme.value.buildTime}`);
    const data = await response.json();
    
    miniSearch.value = new MiniSearch({
      fields: ["title", "description", "body"], // 搜索字段
      storeFields: ["title", "description", "body", "regularPath"], // 返回字段
      searchOptions: {
        boost: { title: 3, description: 2, body: 1 }, // 权重
        fuzzy: 0.2, // 模糊匹配
        prefix: true, // 前缀匹配
      },
    });
    
    miniSearch.value.addAll(data);
    isIndexLoaded.value = true;
  } catch (error) {
    console.error("加载本地搜索索引失败:", error);
  }
};

// 监听搜索框打开
watch(() => store.searchShow, (val) => {
  if (val && store.searchType === "local") {
    initMiniSearch();
  }
});

// 监听搜索类型切换
watch(() => store.searchType, (val) => {
  if (val === "local" && store.searchShow) {
    initMiniSearch();
  }
});

// 处理本地搜索
const handleLocalSearch = () => {
  if (!localSearchQuery.value) {
    localSearchResults.value = [];
    return;
  }
  
  if (!miniSearch.value) {
    // 如果索引还没加载完,先用基础过滤
    const query = localSearchQuery.value.toLowerCase();
    localSearchResults.value = theme.value.postData.filter((post) => {
      return (
        post.title?.toLowerCase().includes(query) ||
        post.description?.toLowerCase().includes(query)
      );
    });
    return;
  }
  
  // 使用 MiniSearch 全文搜索
  const results = miniSearch.value.search(localSearchQuery.value);
  localSearchResults.value = results;
};

// 获取正文摘要并高亮
const getSearchExcerpt = (text, query) => {
  if (!text || !query) return "";
  const index = text.toLowerCase().indexOf(query.toLowerCase());
  if (index === -1) return text.slice(0, 100) + "...";
  
  const start = Math.max(0, index - 40);
  const end = Math.min(text.length, index + 60);
  let excerpt = text.slice(start, end);
  
  if (start > 0) excerpt = "..." + excerpt;
  if (end < text.length) excerpt = excerpt + "...";
  
  return highlightText(excerpt, query);
};

// 高亮文本
const highlightText = (text, query) => {
  if (!text || !query) return text;
  const regex = new RegExp(`(${query})`, "gi");
  return text.replace(regex, "<mark>$1</mark>");
};

// 处理搜索结果
const formatSearchData = (data) => {
  const results = [];
  // 遍历搜索结果
  for (let i = 0; i < data.length; i++) {
    const search = data[i];
    // 若无 anchor
    // if (search.anchor === "" || search.anchor === "app") continue;
    // 获取数据
    const url = search?.url;
    const type = search.type === "lvl1" ? "post" : "content";
    const title = search._highlightResult?.hierarchy?.lvl1?.value;
    const anchor = search._highlightResult?.hierarchy?.[search.type]?.value;
    const content = search._highlightResult?.content?.value;
    // 生成搜索数据
    const searchData = { url, type, title, anchor, content };
    results.push(searchData);
  }
  return results;
};

// 跳转搜索结果
const jumpSearch = (url) => {
  store.changeShowStatus("searchShow");
  router.go(url);
};

onBeforeUnmount(() => {
  hasSearchValue.value = false;
  localSearchQuery.value = "";
  localSearchResults.value = [];
});
</script>

<style lang="scss">
.ais-InstantSearch,
.local-search {
  height: 100%;
  .ais-SearchBox {
    height: 40px;
    width: 100%;
    .ais-SearchBox-input {
      width: 100%;
      outline: none;
      border-radius: 8px;
      font-size: 16px;
      padding: 0.6rem 1rem;
      color: var(--main-font-color);
      font-family: var(--main-font-family);
      border: 1px solid var(--main-card-border);
      background-color: var(--main-card-second-background);
      transition:
        border-color 0.3s,
        box-shadow 0.3s;
      &:focus {
        border-color: var(--main-color);
        box-shadow: 0 8px 16px -4px var(--main-color-bg);
      }
      &::-webkit-search-cancel-button {
        display: none;
      }
    }
    .ais-SearchBox-loadingIndicator,
    .ais-SearchBox-submit,
    .ais-SearchBox-reset {
      display: none;
    }
  }
  .ais-Hits {
    margin-top: 20px;
    min-height: 300px;
    height: 100%;
    .no-result {
      height: 300px;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      .iconfont {
        font-size: 40px;
        margin-bottom: 12px;
      }
      .text {
        font-size: 18px;
        opacity: 0.6;
      }
    }
    .search-list {
      .search-item {
        margin-bottom: 12px;
        .title {
          display: inline;
          font-size: 16px;
          margin-bottom: 6px;
        }
        .anchor {
          margin-top: 6px;
          color: var(--main-font-second-color);
          font-size: 14px;
          &::before {
            content: "# ";
          }
        }
        .content {
          color: var(--main-font-second-color);
          margin-top: 0.8rem;
          font-size: 12px;
          padding: 8px;
          border-radius: 8px;
        }
        p {
          margin: 0;
          mark {
            background-color: transparent;
            color: var(--main-color);
          }
        }
        &:last-child {
          margin-bottom: 0;
        }
      }
    }
  }
  .ais-Pagination {
    margin-top: 20px;
    .ais-Pagination-list {
      list-style: none;
      margin: 0;
      padding: 0;
      display: flex;
      flex-direction: row;
      align-items: center;
      justify-content: center;
      .ais-Pagination-item {
        margin: 0 4px;
        width: 30px;
        height: 30px;
        border-radius: 8px;
        transition: background-color 0.3s;
        cursor: pointer;
        .ais-Pagination-link {
          display: flex;
          align-items: center;
          justify-content: center;
          width: 100%;
          height: 100%;
          &:hover {
            color: var(--main-font-color);
          }
        }
        &:hover {
          color: var(--main-font-color);
          background-color: var(--main-color);
          .ais-Pagination-link {
            color: var(--main-card-border);
          }
        }
        &.ais-Pagination-item--selected {
          font-weight: bold;
          background-color: var(--main-color);
          .ais-Pagination-link {
            color: var(--main-card-border);
          }
        }
        &.ais-Pagination-item--disabled,
        &.ais-Pagination-item--nextPage,
        &.ais-Pagination-item--lastPage {
          opacity: 0.8;
        }
      }
    }
  }
  .ais-Stats {
    display: flex;
    align-items: center;
    flex-direction: row;
    justify-content: space-between;
    margin-top: 20px;
    opacity: 0.8;
    font-size: 14px;
    .power {
      display: flex;
      flex-direction: row;
      align-items: center;
      font-size: 16px;
      opacity: 0.6;
      transition:
        color 0.3s,
        opacity 0.3s;
      .iconfont {
        margin-right: 4px;
        font-size: 20px;
        transition: color 0.3s;
      }
      .name {
        font-weight: bold;
      }
      &:hover {
        opacity: 1;
        color: var(--main-color);
        .iconfont {
          color: var(--main-color);
        }
      }
    }
    @media (max-width: 512px) {
      justify-content: center;
      .information {
        display: none;
      }
    }
  }
}
</style>
评论 隐私政策