感谢DevUI社区贡献者 bugbuliu 提供的优质好文!
🔍 背景:传统搜索的阵痛与突围
在某大型代码托管平台的日常运维中,我们经常收到这样的用户反馈:
"为什么不能像Github一样通过'repo:xxx lang:java'直接过滤结果?"
"每次搜索都要反复切换标签页,效率太低了!"
"输错一个字母就搜索失败,连个提示都没有..."
这些吐槽直指传统搜索的三大痛点:
- 维度单一:只能通过切换标签进行单项搜索
- 缺少智能:没有语法识别和输入提示
- 容错率低:完全依赖用户输入准确性
为此我们启动"搜索体验升级"专项,打在代码仓最新升级的搜索专项中,我们打造了一个支持搜索语法的智能输入组件。相比原有只能搜索项目/组织的单一模式,新版搜索组件实现了三大突破:
- 多维度搜索:支持代码内容、仓库类型、描述、语言、文件路径等16+种搜索维度
- 智能联想 :基于词法解析的
suggestions
提示系统,输入过程自动补全,给出智能搜索建议 - 语法兼容 :支持"双引号包裹"、
repo:codehub
等25种高级搜索语法,并实现搜索语法的关键字高亮效果
能力维度 | 旧版搜索 | 新版组件 |
---|---|---|
搜索维度 | 2个固定维度 | 16+可组合维度 |
输入提示 | 无 | 语法感知的上下文提示 |
搜索准确率 | 完全匹配 | 支持通配符/引号包裹/逻辑运算符 |
关键字高亮 | 无 | 自定义关键字高亮样式 |

新旧搜索交互对比图

下面将详细介绍将传统input
输入框升级为contentediable
实现的具备富文本渲染能力的「类编辑器」组件。让我们通过实现细节,一窥复杂输入组件的设计奥义。
💡核心架构:编辑器设计模式落地
MVC架构拆解
我们采用经典MVC模式搭建组件内核:
ts
// 核心类结构
class SearchEditor {
private model: DataModel; // 数据模型
private viewModel: ViewModel; // 视图模型
private controller: Controller; // 事件控制器
}
双模型设计实现数据与视图解耦:

输入事件拦截机制
contentediable
元素实现简易富文本编辑的思路:拦截用户输入/光标事件, 对输入的值进行处理,最后再渲染出来。
通过事件劫持实现精准控制:

支持14种输入场景处理,包括:
- 字符输入
- 中文输入法合成
- 多光标选区操作
- 键盘快捷键响应
- 复制粘贴事件
- 删除事件
- 方向键事件(上下、左右)
- 撤销重做等
以下是部分实现,更多输入事件处理请参考自行处理: Input Events Level 2
ts
// 输入事件拦截
public onBeforeInput(event: InputEvent) {
const target = event.target as HTMLElement;
const eventType = event.inputType;
if (event.inputType === 'historyUndo') {
this.onUndo(event);
}
if (event.inputType === 'historyRedo') {
this.onRedo(event);
}
event.preventDefault();
event.stopPropagation();
// 非重点关注的事件类型直接阻止
if (!ALLOW_INPUT_TYPE.includes(eventType)) {
event.preventDefault();
return;
}
if (eventType === 'deleteContentBackward') {
this.deleteContentBackward();
}
if (eventType === 'deleteContentForward') {
this.deleteContentForward();
}
if (eventType === 'insertText') {
this.insertText(event);
}
if (eventType === 'insertCompositionText') {
this.scrollCursorVisible();
return;
}
}
// 插入字符:拦截输入后需要手动拼接字符串
insertValue(text: string, anchorOffset?: number, focusOffset?: number) {
let startOffset = anchorOffset ?? this.cursorInfo.anchorOffset;
let endOffset = focusOffset ?? this.cursorInfo.focusOffset;
let newCursorOffset = startOffset + text.length;
const { isCollapsed } = this._selection;
if (!isCollapsed) {
startOffset = Math.min(startOffset, endOffset);
endOffset = Math.max(anchorOffset ?? this.cursorInfo.anchorOffset, endOffset);
newCursorOffset = startOffset + text.length;
}
if (this.valueHistory.isEmpty()) {
this.initUndoHistory();
}
this.value = this.value.slice(0, startOffset) + text + this.value.slice(endOffset);
if (this.value.length > this.maxLength) {
this.value = this.value.slice(0, this.maxLength);
}
this.updateSourceCursorInfo(newCursorOffset);
this.cacheUndoHistory();
this.updateCursor();
this.update();
}
🎯组件设计:配置化与扩展性
页面结构设计
从形态上属于一个输入框,左右两边都保留了插槽扩展,同时也会提供默认的插槽内容,中间就是搜索框的输入和渲染区域,同时也保留了一个token插槽支持自定义token节点

html
<div class="multi-search-wrapper" [ngClass]="{ disabled: config.disabled }">
<div class="search-input">
<span class="search-left-template" tabindex="-1">
<ng-container *ngIf="inputLeftTemplate" [ngTemplateOutlet]="inputLeftTemplate"></ng-container>
<d-icon *ngIf="!inputLeftTemplate" class="default-search-icon" [icon]="'icon-search-new'" (click)="enterEvent($event)"></d-icon>
</span>
<div class="search-content-wrapper" #contentScrollContainer>
<div
#searchInputContentEdiable
class="search-input-content"
[spellcheck]="config.spellcheck"
[attr.contenteditable]="contenteditable"
(copy)="handleCopy($event)"
(click)="handleClick($event)"
(paste)="handlePaste($event)"
(input)="handleInput($event)"
(keydown)="handleKeydown($event)"
(keydown.enter)="enterEvent($event)"
(beforeinput)="onBeforeInput($event)"
(compositionupdate)="compositionupdate($event)"
(compositionend)="handleCompositionend($event)"
(compositionstart)="handleCompositionstart($event)"
>
<!-- 渲染模板 -->
<div class="text-line" #inputRow id="search-input-div">
<span class="composition-input-first" *ngIf="!tokenArray?.length"></span>
<ng-container *ngFor="let token of tokenArray">
<ng-container *ngIf="token?.type === tokenType.keyword">
<span
class="search-input-keyword search-input-node search-token"
[ngClass]="{ 'token-empty-value': !token?.value }"
#inputNode
[ngStyle]="config.tokenStyle?.keywords"
>
<span class="search-token-keyword">{{ token?.keyword }}</span>
<span class="search-token-separator">:</span>
<span class="search-token-text">{{ token?.value }}</span>
</span></ng-container
><span
*ngIf="token?.type === tokenType.split"
#inputNode
class="search-split-text search-input-text search-input-node"
[innerText]="token.value"
[ngStyle]="config.tokenStyle?.split"
></span
><span
*ngIf="token?.type === tokenType.connect"
#inputNode
class="search-token token-bg-connected search-input-node"
[ngStyle]="config.tokenStyle?.connect"
>
<span class="search-token-connected">{{ token.keyword }}</span></span
><span
*ngIf="token?.type === tokenType.text"
#inputNode
class="search-token token-bg-default search-input-text search-input-node"
[ngStyle]="config.tokenStyle?.text"
>{{ token.value }}</span
>
<ng-container
*ngIf="tokenTemplate"
[ngTemplateOutlet]="tokenTemplate"
[ngTemplateOutletContext]="{ token: token }"
></ng-container>
</ng-container>
</div>
</div>
<!-- 自定义光标 -->
<span #customCursor class="custom-cursor"></span>
</div>
<span class="search-right-template">
<ng-container *ngIf="inputRightTemplate" [ngTemplateOutlet]="inputRightTemplate"></ng-container>
<d-icon
*ngIf="!inputRightTemplate && config.showClear && value"
class="default-clear-icon"
[icon]="'icon-ipd-close1'"
(click)="clearAll()"
></d-icon>
</span>
<!-- placeholder -->
<span *ngIf="!tokenArray?.length && !isComposing" class="input-placeholder-text" contenteditable="false">{{ config.placeholder }}</span>
</div>
</div>
组件输入参数说明
属性名 | 类型 | 默认值 | 描述说明 |
---|---|---|---|
value | string |
- | 绑定的输入值 |
tokens | Array<Token> |
- | 解析后的token数组 |
placeholder | string |
'' |
输入框占位符文本 |
keywords | Array<string> |
[] |
可识别的搜索关键字列表(如: ["repo:", "lang:", "path:"]) |
connectWords | Array<string> |
[] |
连接词列表(如: ["AND", "OR", "NOT"]) |
tokenStyle | TokenStyle |
- | Token样式配置对象,包含颜色/圆角/字体等样式属性 |
showClear | boolean |
false |
是否显示清除按钮 |
caseSensitive | boolean |
false |
是否启用大小写敏感模式 |
spellcheck | boolean |
false |
是否启用浏览器拼写检查 |
disabled | boolean |
false |
是否禁用输入框 |
maxLength | number |
2000 |
允许输入的最大字符长度 |
tokenParser | (value: string, options: ...) => Array<Token> |
TokenParser.tokenizer |
Token解析函数,可通过传入自定义函数实现特殊解析规则 |
组件输出事件与插槽
名称/插槽 | 类型 | 描述说明 |
---|---|---|
keyDownOrKeyUp |
EventEmitter<{event: KeyboardEvent}> |
键盘按下或释放时触发(带原生事件对象) |
valueChange |
EventEmitter<string> |
输入框内容变化时触发(传递最新值) |
searchEvent |
EventEmitter<void> |
触发搜索行为时发射(如回车键按下) |
suggessionEvent |
EventEmitter<Array<string>> |
展示搜索建议时触发(传递建议列表) |
afterComponentInit |
EventEmitter<void> |
组件完成初始化后触发 |
currentKeywordSuggestionEvent |
EventEmitter<string> |
检测到关键字建议时触发(传递建议文本) |
inputLeftTemplate |
TemplateRef |
输入框左侧自定义模板插槽 |
inputRightTemplate |
TemplateRef |
输入框右侧自定义模板插槽 |
tokenTemplate |
TemplateRef<{token: Token}> |
自定义Token渲染模板(可访问Token上下文) |
组件暴露API:
当前位置插入字符:public insertValue(text: string, anchorOffset?: number, focusOffset?: number): void;
末尾追加字符:public insertValueEnd(text: string): void;
💫关键技术:光标的魔鬼细节
光标与选区前置基础知识
- 在HTML里面,光标是一个对象,
selection
,是一个选区,可通过window.getSelection()
获取range
,是一个片段区域,有开始点和结束点isCollapsed
用来判断选区的起始点与终点是否在同一个位置,即当前选区是光标所在的位置。- 光标在闪,其实只是开始和结束点重叠了
- 设置光标位置:
Selection.collapse(parentNode: Node, offset?: number)
anchor
(锚点) 与focus
(焦点) 的概念,这里 anchor 代表选择文本时鼠标按下开始选择的位置,focus 表示选择文本结束时,鼠标所处的位置,这里的 focus 与元素的 focus 事件不是一个概念。anchorNode
代表锚点所在的节点,focusNode
代表焦点所在的节点anchorOffset
与focusOffset
,他们的计算方式都是从左到右计算到选区的边界,总共有多少个字符
感兴趣的朋友可以参考这篇文章《点亮富文本编辑器的魔力:Selection与Range解密)》
富光标维护方案
光标映射三定律:
- 用户操作视图光标 → 更新模型逻辑位置
- 模型内容变更 → 重新渲染 → 恢复视觉光标
- 跨token光标需要计算字符偏移量

用户操作视图选区变化时,导致视图光标变化,需要同步更新原值光标;
用户输入/删除文本时,导致原值及其光标变化,需要更新视图,再异步更新视图光标;

光标映射算法
实现原始值光标 ↔ 视图光标的双向映射是最大挑战:
ts
// 原始值偏移量 → 视图位置
getCursorNodeIndex(node, targetNode, targetOffset, text) {
if (node.nodeType === Node.COMMENT_NODE || text.offset !== undefined) {
return text;
}
if (node === targetNode && targetOffset === 0) {
return {
matchText: text,
offset: text.length,
};
}
if (node.nodeType === Node.TEXT_NODE) {
if (node === targetNode && targetOffset === 0) {
return {
matchText: text,
offset: text.length,
};
}
for (let i = 0; i < node.textContent.length; i++) {
text += node.textContent[i];
if (node === targetNode && i === targetOffset - 1) {
return {
matchText: text,
offset: text.length,
};
}
}
return text;
} else {
for (let i = 0; i < node.childNodes.length; i++) {
text = this.getCursorNodeIndex(node.childNodes[i], targetNode, targetOffset, text);
if (text.offset !== undefined) {
return text;
}
}
}
return text;
}
// 富文本的光标映射回原始文本
updateCursorInfoFromRich() {
if (this.value === '') {
this.updateSourceCursorInfo(0);
return;
}
const { anchorNode, anchorOffset, focusNode, focusOffset, isCollapsed } = this._selection;
let anchorIndex = this.getCursorNodeIndex(this.inputRow.nativeElement, anchorNode, anchorOffset, '');
if (typeof anchorIndex === 'string') {
anchorIndex = {
offset: this.value.length,
};
}
if (isCollapsed) {
this.updateSourceCursorInfo(anchorIndex?.offset || 0);
} else {
let focusIndex = this.getCursorNodeIndex(this.inputRow.nativeElement, focusNode, focusOffset, '');
if (typeof focusIndex === 'string') {
focusIndex = {
offset: 0,
};
}
this.updateSourceCursorInfo(anchorIndex.offset, focusIndex?.offset);
}
this.scrollCursorVisible();
this.currentKeywordSuggestion();
}
👨🚀语法解析:关键字匹配
对用户的输入进行关键字划分是高亮渲染的关键,我们需要将用户输入的value
进行解析,形成tokens
数组, 最后才能通过html高亮渲染出来。
这里我们使用了简单的正则表达式进行的词法解析,能够满足基础的key: word键值对匹配。如果有需要满足更复杂的搜索语法能力支持,可以对其进行扩展。
ts
// 词法解析流程
export const KEYWORD_REG = /([\w\u4e00-\u9fa5]+):((".*?"[ \t]+)|('.*?'[ \t]+)|(\S*[ \t]+)|)|(".*?"[ \t]+)|(\S+[ \t]+)/g; // 解析 key:value,同时支持双引号或单引号包裹的值(允许值中有空格)
export const SPLIT_START = /^\s+/g;
export const SPLIT_END = /\s+$/g;
export class Token {
value: string;
type: TokenType;
keyword: string;
constructor(type: TokenType, value: string, keyword: string = '') {
this.type = type;
this.keyword = keyword;
this.value = value;
}
}
export class TokenParser {
/**
* token解析器
* @param value
* @returns tokens
*/
static tokenizer = (value: string, options: HubMultiSearchConfig): Array<Token> => {
if(value === '') {
return [];
}
if (value.trim() === '') {
return [new Token(TokenType.split, value)];
}
let match = value.match(SPLIT_START);
let startSplitToken;
if(match) {
startSplitToken = new Token(TokenType.split, match[0]);
}
let valueEndAppend = false;
if (value[value.length - 1] !== ' ') {
value += ' ';
valueEndAppend = true;
}
let keywordObject = this.parseStringToObject(value);
let tokens = this.decorateToken(keywordObject, options);
if (valueEndAppend) {
tokens.pop();
}
if (startSplitToken) {
tokens.unshift(startSplitToken);
}
return tokens;
};
static decorateToken(keywordObject, options: HubMultiSearchConfig): Array<Token> {
let result = [];
for (let i = 0; i < keywordObject.length; i++) {
let tokenItem = keywordObject[i];
if (typeof tokenItem === 'object') {
this.handleObjectToken(tokenItem, result, options.keywords);
} else {
this.handleNonObjectToken(tokenItem, result);
}
}
return result;
}
static handleObjectToken(tokenItem, result, keywords) {
let key = Object.keys(tokenItem)[0];
if (keywords.includes(key)) {
result.push(new Token(TokenType.keyword, tokenItem[key].trim(), key));
} else {
result.push(new Token(TokenType.text, `${key}:${tokenItem[key].trim()}`));
}
this.handleSplitToken(tokenItem[key], result);
return result;
}
static handleNonObjectToken(tokenItem, result) {
result.push(new Token(TokenType.text, tokenItem.trim()));
this.handleSplitToken(tokenItem, result);
return result;
}
static handleSplitToken(tokenValue, result) {
let match = tokenValue.match(SPLIT_END);
if (match) {
result.push(new Token(TokenType.split, match[0]));
}
}
static parseStringToObject(input, regex = KEYWORD_REG) {
const result = [];
let match;
while ((match = regex.exec(input)) !== null) {
const key = match[1];
let value = match[2];
if (!key) {
result.push(match[0]);
} else {
result.push({ [key]: value });
}
}
return result;
}
}
🧪经验总结:踩坑启示录
在开发过程中,遇到的一些问题:
光标同步异步化 :视图更新后通过requestAnimationFrame
延迟处理光标定位
中文输入问题 :
直接监听input
事件导致中文输入法吞字,合成输入法会直接修改dom ✅ 解决方案:通过compositionstart/end
标志位过滤
快速输入时光标随机跳动 :
✅ 定位原因:模型更新与渲染不同步
🛠️ 修复方案:引入更新队列+RAF节流
光标位置超出视口无法自动跟随:
✅ getBoundingClientRect
实现自定义光标 + scrollIntoView滚动
撤销重做事件实现 : beforinput + execCommand
+ undoManager 手动记录堆栈并管理
加入我们
DevUI团队重磅推出~前端智能化场景解决方案MateChat,为项目添加智能化助手~
源码:gitcode.com/DevCloudFE/...(欢迎star~)