在中小型Web项目或桌面端工具里,搜索功能通常不需要引入Elasticsearch这类重型组件。SQLite凭借零配置、单文件、支持全文检索的特点,可以成为非常合适的搜索数据源。而Riot.js作为轻量组件框架,负责把搜索交互拆成可维护的前端模块,两者结合能快速落地一个响应灵敏的搜索页面。

为什么选择SQLite作为搜索数据源
SQLite不是一个简单的玩具数据库,它支持事务、索引、触发器和视图,并且在单机读多写少的场景下性能非常出色。对于搜索场景,最重要的是利用SQLite的FTS5扩展。FTS5是官方提供的全文检索模块,它把文本拆分成词元并构建倒排索引,查询时可以通过MATCH语法快速定位包含关键词的行,这比用LIKE '%keyword%'逐行扫描快得多。
与MySQL、PostgreSQL等常驻服务相比,SQLite不需要独立进程,数据就是一个文件,部署时少一个故障点。与Elasticsearch相比,SQLite在数据量低于百万级时查询延迟更低,且不会占用大量内存。如果搜索数据来自本地应用、小型CMS或内部工具,用SQLite作为搜索存储能明显降低维护成本。
同时,SQLite的并发模型以写串行、读并发为主,在搜索请求高并发但数据更新不频繁的系统中非常契合。开发者可以在更新数据时重建索引,在查询时只读数据库,这样前端搜索响应可以控制在几十毫秒内。
Riot搜索前端组件如何划分
Riot.js的语法非常简洁,一个组件包含模板、样式和逻辑三部分。把搜索功能拆成Riot组件后,搜索框、结果列表、过滤条件都能独立开发和测试。比较常见的划分是一个search-box组件负责输入与防抖,一个result-list组件负责渲染后端返回的数据,还有一个filter-bar组件管理分类和排序条件。
例如search-box组件可以监听input事件,当用户停止输入300毫秒后再触发查询。这样避免每敲一个字符都请求后端,减少不必要的SQL查询。Riot组件内部可以使用this.update()方法驱动视图刷新,也可以借助riot.observable或简单的Promise管理异步状态。
<search-box>
<input type="text" placeholder="输入关键词" oninput="{onInput}" />
<script>
this.onInput = function (e) {
clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.trigger('search', e.target.value.trim());
}, 300);
};
</script>
</search-box>
result-list组件通过监听父组件传来的search事件或共享状态来更新结果。对于结果中的高亮关键词,可以在组件内编写一个简单的替换函数,但不能直接使用innerHTML拼接用户输入,否则容易产生XSS漏洞。正确做法是先转义文本,再用标记包裹匹配到的词。
Riot.js的组件通信方式灵活:可以在父组件中注册子组件并通过props传值,也可以使用Riot的Observable实现事件总线。一个完整的搜索前端通常至少包含一个主组件协调各子组件,这样状态流清晰,后续增加自动补全或搜索历史也比较容易。
用SQLite FTS5构建全文检索
在SQLite中启用FTS5后,可以创建一张虚拟表来存储被检索的文本。通常的做法是保留原始数据表,再建立一张FTS5外部内容表,并通过触发器保持两边同步。外部内容表的好处是原始数据仍然可以正常更新,FTS索引只负责加速匹配。
-- 创建原始数据表
CREATE TABLE articles (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL,
category TEXT
);
-- 创建FTS5虚拟表
CREATE VIRTUAL TABLE articles_fts USING fts5(
title,
body,
content='articles',
content_rowid='id',
tokenize='unicode61 remove_diacritics 2'
);
-- 创建触发器保持同步
CREATE TRIGGER articles_ai AFTER INSERT ON articles BEGIN
INSERT INTO articles_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
END;
CREATE TRIGGER articles_ad AFTER DELETE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, body) VALUES('delete', old.id, old.title, old.body);
END;
CREATE TRIGGER articles_au AFTER UPDATE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, body) VALUES('delete', old.id, old.title, old.body);
INSERT INTO articles_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
END;
查询时使用MATCH操作符,例如SELECT id, title FROM articles_fts WHERE articles_fts MATCH 'sqlite' ORDER BY rank;。FTS5会返回相关度排序的结果。如果需要多个关键词,可以用AND、OR、NEAR等语法,如sqlite AND riot表示同时包含两个词。
FTS5默认分词器对英文支持很好,但对中文会按连续字符切分,导致整句中文被当成一个词。解决中文搜索的常用方法有三种:一是写入数据前用分词库把中文文本预先切分成空格分隔的词,再存入FTS表;二是使用trigram tokenizer,它按三个字符一组切分,适合子串匹配;三是简单场景直接使用LIKE加普通索引,虽然灵活但性能不如FTS5。实际项目需要根据数据量和查询需求选择。
前后端搜索链路与优化
Riot搜索前端拿到关键词后,需要调用一个后端接口获取结果。推荐使用Node.js配合better-sqlite3模块访问SQLite数据库,因为better-sqlite3是同步API,代码简洁且性能稳定。接口收到关键词后先做长度校验,再使用参数化查询避免SQL注入。FTS5的MATCH语法如果直接拼接用户输入,同样存在注入风险,因此必须把用户输入作为绑定参数传入。
const Database = require('better-sqlite3');
const db = new Database('search.db');
function searchArticles(keyword, limit = 20) {
const stmt = db.prepare(`
SELECT a.id, a.title, a.category, snippet(articles_fts, 1, '<mark>', '</mark>', '...', 12) AS snippet
FROM articles_fts
JOIN articles a ON a.id = articles_fts.rowid
WHERE articles_fts MATCH ?
ORDER BY rank
LIMIT ?
`);
return stmt.all(keyword, limit);
}
接口还应该设置合理的默认limit,避免一次返回过多数据拖慢前端渲染。前端在拿到结果后,可以按分类统计数量,或者保持滚动加载。另一个常见优化是缓存热门关键词的查询结果,缓存可以放在内存中并设置过期时间,比如5分钟。这样当多个用户搜索相同词时,数据库压力会明显下降。
如果搜索请求延迟仍然较高,可以先分析SQLite执行计划,确认FTS索引被正确使用。对于高并发读取场景,可以使用WAL模式提高读并发。数据库连接应保持复用,不要每次请求都重新打开文件。通过这些小调整,一个基于SQLite和Riot.js的搜索前端完全可以在数千条到数十万条数据上保持流畅体验。