Skip to content

Nuxt3手写一个搜索页面

前言

前面做了一个小型搜索引擎,虽然上线了,但总体来说还略显粗糙。所以最近花了点时间打磨了一下这个搜索引擎的前端部分,使用的技术是Nuxt,UI组件库使用的是Vuetify,关于UI组件库的选择,你也可以查看我之前写的这篇对比

本文不会介绍搜索引擎的其余部分,算是一篇前端技术文...

重要的开源地址,应用部分的代码我也稍微整理了一下开源了,整体来说偏简单,毕竟只有一个页面,算是真正的“单页面应用”了🤣🤣🤣

演示

为什么要重写

这次重写的目的如下:

  1. 之前写的代码太乱了,基本一个页面就只写了一个文件,维护起来有点困难;
  2. 之前的后端使用nest单独写的,其实就调调API,单独起一个后端服务感觉有点重;
  3. 最后一点也是最重要的:使用SSR来优化一下SEO

具体如下:

  1. 比如当用户输入搜索之后,对应的url路径也会发生变化,比如https://ssgo.app/?page=1&query=AI
  2. 如果用户将该url分享到其他平台被搜索引擎抓取之后,搜索引擎得到的数据将不再是空白的搜索框,而是包含相关资源的结果页,
  3. 这样有可能再下一次用户在其他搜索引擎搜索对应资源的时候,有可能会直接跳转到该应用的搜索结果页,这样就可以大大提高该应用的曝光率。

这样,用户之后不仅可以通过搜索“阿里云盘搜索引擎”能搜到这个网站,还有可能通过其他资源的关键词搜索到该网站

页面布局

首先必须支持移动端,因为从后台的访问数据看,移动端的用户更多,所以整体布局以竖屏为主,至于宽屏PC,则增加一个类似于max-width的效果。

其次为了整体实现简单,采取的还是搜索框与搜索结果处在一个页面,而非google\baidu之类的搜索框主页与结果页分别是两个页面,笔者感觉主页也没啥存在的必要(单纯对于搜索功能而言)

页面除了搜索框、列表项,还应该有logo,菜单,最终经过排版如下图所示:

左右两边为移动端的效果演示图,中间为PC端的效果演示。

nitro服务端部分

这里只需要实现两个API:

  1. 搜索接口,如/api/search
  2. 搜索建议的接口,如/api/search/suggest

说到这里就不得不夸一下nuxt的开发者体验,新建一个API是如此的方便:

对比nest-cli中新建一个service/controller要好用不少,毕竟我在nest-cli中基本要help一下。

回到这里,我的server目录结构如下:

├─api
│  └─search            # 搜索接口相关
│          index.ts    # 搜索
│          suggest.ts  # 搜索建议

└─elasticsearch
        index.ts       # es客户端

elasticsearch目录中,我创建了一个ES的客户端,并在search中使用:

ts
// elasticsearch/index.ts

import { Client } from '@elastic/elasticsearch';

export const client = new Client({
  node: process.env.ES_URL,
  auth: {
    username: process.env.ES_AUTH_USERNAME || '',
    password: process.env.ES_AUTH_PASSWORD || ''
  }
});

然后使用,使用部分基本上没有做任何的特殊逻辑,就是调用ES client提供的api,然后组装了一下参数就OK了:

ts
// api/search/index
import { client } from "~/server/elasticsearch";

interface ISearchQuery {
  pageNo: number;
  pageSize: number;
  query: string;
}

export default defineEventHandler(async (event) => {
  const { pageNo = 1, pageSize = 10, query }: ISearchQuery = getQuery(event);

  const esRes = await client.search({
    index: process.env.ES_INDEX,
    body: {
      from: (pageNo - 1) * pageSize, // 从哪里开始
      size: pageSize, // 查询条数
      query: {
        match: {
          title: query, // 搜索查询到的内容
        },
      },
      highlight: {
        pre_tags: ["<span class='highlight'>"],
        post_tags: ['</span>'],
        fields: {
          title: {},
        },
        fragment_size: 40,
      },
    },
  });

  const finalRes = {
    took: esRes.body.took,
    total: esRes.body.hits.total.value,
    data: esRes.body.hits?.hits.map((item: any) => ({
      title: item._source.title,
      pan_url: item._source.pan_url,
      extract_code: item._source.extract_code,
      highlight: item.highlight?.title?.[0] || '',
    })),
  };

  return finalRes;
});
ts
// api/search/suggest
import { client } from "~/server/elasticsearch";

interface ISuggestQuery {
  input: string;
}

export default defineEventHandler(async (event) => {
  const { input }: ISuggestQuery = getQuery(event);

  const esRes = await client.search({
    index: process.env.ES_INDEX,
    body: {
      suggest: {
        suggest: {
          prefix: input,
          completion: {
            field: "suggest"
          }
        }
      }
    },
  });

  const finalRes = esRes.body.suggest.suggest[0]?.options.map((item: any) => item._source.suggest)

  return finalRes;
});

值得注意的是,客户端的ES版本需要与服务端的ES版本相互对应,比如我服务端使用的是ES7,这路也当然得使用ES7,如果你是ES8,这里需要安装对应版本得ES8,并且返回参数有些变化,ES8中上述esRes就没有body属性,而是直接使用后面的属性

page界面部分

首先为了避免出现之前所有代码均写在一个文件中,这里稍微封装了几个组件以使得page/index这个组件看起来相对简单:

/components
    BaseEmpty.vue
    DataList.vue
    LoadingIndicator.vue
    MainMenu.vue
    PleaseInput.vue
    RunSvg.vue
    SearchBar.vue

具体啥意思就不赘述了,基本根据文件名就能猜得大差不差了...

然后下面就是我的主页面部分:

vue
<template>
	<div
		class="d-flex justify-center bg-grey-lighten-5 overflow-hidden overflow-y-hidden"
	>
		<v-sheet
			class="px-md-16 px-2 pt-4"
			:elevation="2"
			height="100vh"
			:width="1024"
			border
			rounded
		>
			<v-data-iterator :items="curItems" :page="curPage" :items-per-page="10">
				<template #header>
					<div class="pb-4 d-flex justify-space-between">
						<span
							class="text-h4 font-italic font-weight-thin d-flex align-center"
						>
							<RunSvg style="height: 40px; width: 40px"></RunSvg>
							<span>Search Search Go...</span>
						</span>
						<MainMenu></MainMenu>
					</div>
					<SearchBar
						:input="curInput"
						@search="search"
						@clear="clear"
					></SearchBar>
				</template>
				<template #default="{ items }">
					<v-fade-transition>
						<DataList
							v-if="!pending"
							:items="items"
							:total="curTotal"
							:page="curPage"
							@page-change="pageChange"
						></DataList>
						<LoadingIndicator v-else></LoadingIndicator>
					</v-fade-transition>
				</template>
				<template #no-data>
					<template v-if="!curInput || !pending">
						<v-slide-x-reverse-transition>
							<BaseEmpty v-if="isInput"></BaseEmpty>
						</v-slide-x-reverse-transition>
						<v-slide-x-transition>
							<PleaseInput v-if="!isInput"></PleaseInput>
						</v-slide-x-transition>
					</template>
				</template>
			</v-data-iterator>
		</v-sheet>
	</div>
</template>

<script lang="ts" setup>
const route = useRoute();
const { query = "", page = 1 } = route.query;
const router = useRouter();
const defaultData = { data: [], total: 0 };

const descriptionPrefix = query ? `正在搜索“ ${query} ”... ,这是` : "";
useSeoMeta({
	ogTitle: "SearchSearchGo--新一代阿里云盘搜索引擎",
	ogDescription: `${descriptionPrefix}一款极简体验、优雅、现代化、资源丰富、免费、无需登录的新一代阿里云盘搜索引擎,来体验找寻资源的快乐吧~`,
	ogImage: "https://ssgo.app/logobg.png",
	twitterCard: "summary",
});

interface IResultItem {
	title: string;
	pan_url: string;
	extract_code: string;
	highlight: string;
}

interface IResult {
	data: IResultItem[];
	total: number;
}

const curPage = ref(+(page || 1));

const curInput = ref((query || "") as string);
const isInput = computed(() => !!curInput.value);

let { data, pending }: { data: Ref<IResult>; pending: Ref<boolean> } =
	await useFetch("/api/search", {
		query: { query: curInput, pageNo: curPage, pageSize: 10 },
		immediate: !!query,
	});
data.value = data.value || defaultData;

const curItems = computed(() => data.value.data);
const curTotal = computed(() => data.value.total);

function search(input: string) {
	curPage.value = 1;
	curInput.value = input;
	router.replace({ query: { ...route.query, query: input, page: 1 } });
}

function pageChange(page: number) {
	curPage.value = page;
	router.replace({ query: { ...route.query, page: page } });
}

function clear() {
	curInput.value = "";
	data.value = defaultData;
	// 这里就不替换参数了,保留上一次的感觉好一些
}
</script>

大部分代码都是调用相关的子组件,传递参数,监听事件之类的,这里也不多说了。比较关键的在于这两部分代码:

ts
useSeoMeta({
	ogTitle: "SearchSearchGo--新一代阿里云盘搜索引擎",
	ogDescription: `${descriptionPrefix}一款极简体验、优雅、现代化、资源丰富、免费、无需登录的新一代阿里云盘搜索引擎,来体验找寻资源的快乐吧~`,
	ogImage: "https://ssgo.app/logobg.png",
	twitterCard: "summary",
});

这里的SEO显示的文字是动态的,比如当前用户正在搜索AI,那么url路径参数也会增加AI,分享出去的页面描述就会包含AI,在twitter中的显示效果如下:

还有部分代码是这一部分:

ts
let { data, pending }: { data: Ref<IResult>; pending: Ref<boolean> } =
	await useFetch("/api/search", {
		query: { query: curInput, pageNo: curPage, pageSize: 10 },
		immediate: !!query,
	});

其中immediate: !!query表示如果当前路径包含搜索词,则会请求数据,渲染结果页,否则不立即执行该请求,而是等一些响应式变量如curInputcurPage发生变化后执行请求。

子组件部分这里就不详细解释了,具体可以查看源码,整体来说并不是很复杂。

其他

除此之外,我还增加了google analytics和百度 analytics,代码都非常简单,在plugins/目录下,如果你需要使用该项目,记得将对应的id改为你自己的id。

最后

这次也算是第一次使用nuxt来开发一个应用,总体来说安装了nuxt插件之后的开发体验非常不错,按照目录规范写代码也可以少掉很多导入导出的一串串丑代码。

关于笔者--justin3go.com