我们将在本文中模仿开发 GitHub 优秀的文件搜索功能(可能关注这个功能的人比较少)。
要查看其工作原理,请打开任何一个 GitHub 仓库,按字母 t,进入搜索视图,然后,你可以开始搜索并滚动浏览列表,如下所示:
通过构建这个应用程序,你将学到以下内容:
- 如何创建类似于 GitHub 仓库的 UI
- 如何在 React 中使用键盘事件
- 如何使用键盘上的箭头键进行导航
- 如何在搜索时突出显示匹配的文本
- 如何在 React 中添加图标
- 如何在 JSX 表达式中渲染 HTML 内容
等等。
你可以在这里查看该应用程序的 demo。
我们开始吧
用 create-react-app
创建一个新项目:
create-react-app github-file-search-react
创建项目后,删除src
文件夹中的所有文件,然后在里面创建 index.js
,App.js
和 styles.scss
文件,并创建components
和utils
文件夹。
安装必要的依赖项:
yarn add moment@2.27.0 node-sass@4.14.1 prop-types@15.7.2 react-icons@3.10.0
打开 styles.scss
并粘贴这些代码.
在components
文件夹中创建Header.js
文件,包含内容:
import React from 'react';
const Header = () => <h1 className="header">GitHub File Search</h1>;
在components
文件夹中创建一个文件Header.js
,并粘贴这些代码。
在这个文件中,我们创建了 UI 上显示的静态数据,使应用程序简单易懂。
在components
文件夹中新建ListItem.js
文件,包含内容:
import React from 'react';
import moment from 'moment';
import { AiFillFolder, AiOutlineFile } from 'react-icons/ai';
const ListItem = ({ type, name, comment, modified_time }) => {
return (
<React.Fragment>
<div className="list-item">
<div className="file">
<span className="file-icon">
{type === 'folder' ? (
<AiFillFolder color="#79b8ff" size="20" />
) : (
<AiOutlineFile size="18" />
)}
</span>
<span className="label">{name}</span>
</div>
<div className="comment">{comment}</div>
<div className="time" title={modified_time}>
{moment(modified_time).fromNow()}
</div>
</div>
</React.Fragment>
);
};
export default ListItem;
在这个文件中,我们将获取要显示的每个文件的数据,并显示文件夹/文件图标,文件名,注释和上次修改文件的时间。
为了显示图标,我们将使用react-icons
npm 库。它有一个非常不错的网站,可让你轻松搜索和使用所需的图标。
可以给图标组件添加 color
和size
属性,以自定义我们在以上代码中使用的图标。
在components
文件夹中新建FilesList.js
文件,包含内容:
import React from 'react';
import ListItem from './ListItem';
const FilesList = ({ files }) => {
return (
<div className="list">
{files.length > 0 ? (
files.map((file, index) => {
return <ListItem key={file.id} {...file} />;
})
) : (
<div>
<h3 className="no-result">No matching files found</h3>
</div>
)}
</div>
);
};
export default FilesList;
在这个文件中,我们从 api.js
文件中读取静态数据,然后使用数组 Array.proptotype.map()
方法显示文件数组的每个元素。
现在打开 src/App.js
文件,在其中添加以下代码:
import React from 'react';
import Header from './components/Header';
import FilesList from './components/FilesList';
import files from './utils/api';
export default class App extends React.Component {
state = {
filesList: files
};
render() {
const { counter, filesList } = this.state;
return (
<div className="container">
<Header />
<FilesList files={filesList} />
</div>
);
}
}
在这个文件中,我们添加了一个状态来存储静态文件数据,可以在需要时进行修改。然后,我们将其传递给 FilesList
组件以在 UI 上显示。
现在,打开 index.js
文件并在其中添加以下代码:
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import './styles.scss';
ReactDOM.render(<App />, document.getElementById('root'));
现在,在终端运行 yarn start
命令来启动应用程序,你将看到以下初始屏幕:
添加基本搜索功能
现在,我们添加一些功能以更改UI,并允许我们在按键盘上的字母 t 时搜索文件。
在 utils
文件夹中创建新文件 keyCodes.js
,内容如下:
export const ESCAPE_CODE = 27;
export const HOTKEY_CODE = 84; // key code of letter t
export const UP_ARROW_CODE = 38;
export const DOWN_ARROW_CODE = 40;
在components
文件夹中新建SearchView.js
文件,内容如下:
import React, { useState, useEffect, useRef } from 'react';
const SearchView = ({ onSearch }) => {
const [input, setInput] = useState('');
const inputRef = useRef();
useEffect(() => {
inputRef.current.focus();
}, []);
const onInputChange = (event) => {
const input = event.target.value;
setInput(input);
onSearch(input);
};
return (
<div className="search-box">
My Repository <span className="slash">/</span>
<input
type="text"
name="input"
value={input}
ref={inputRef}
autoComplete="off"
onChange={onInputChange}
/>
</div>
);
};
export default SearchView;
我们在这里使用 React Hooks 写状态和生命周期方法。如果你刚开始接触 React Hooks,请查看本文的简介。
在这个文件中,我们首先声明了一个状态来存储用户键入的输入,然后使用 useRef
钩子(hook)添加了一个 ref
,以便在挂载组件时聚焦于输入字段。
const inputRef = useRef();
useEffect(() => {
inputRef.current.focus();
}, []);
...
<input
type="text"
name="input"
value={input}
ref={inputRef}
autoComplete="off"
onChange={onInputChange}
/>
在此代码中,通过将空数组 []
作为第二个参数传递给 useEffect
hook,useEffect
中的代码仅在组件挂载时才执行一次。这就是类组件中的生命周期方法componentDidMount
。
然后,将ref
赋值给输入字段,ref={inputRef}
。当更改 onInputChange
中的输入字段时,即调用onSearch
方法(作为属性从App.js
文件传递到该组件)。
现在,打开App.js
并用以下代码替换其内容:
import React from 'react';
import Header from './components/Header';
import FilesList from './components/FilesList';
import SearchView from './components/SearchView';
import { ESCAPE_CODE, HOTKEY_CODE } from './utils/keyCodes';
import files from './utils/api';
export default class App extends React.Component {
state = {
isSearchView: false,
filesList: files
};
componentDidMount() {
window.addEventListener('keydown', this.handleEvent);
}
componentWillUnmount() {
window.removeEventListener('keydown', this.handleEvent);
}
handleEvent = (event) => {
const keyCode = event.keyCode || event.which;
switch (keyCode) {
case HOTKEY_CODE:
this.setState((prevState) => ({
isSearchView: true,
filesList: prevState.filesList.filter((file) => file.type === 'file')
}));
break;
case ESCAPE_CODE:
this.setState({ isSearchView: false, filesList: files });
break;
default:
break;
}
};
handleSearch = (searchTerm) => {
let list;
if (searchTerm) {
list = files.filter(
(file) =>
file.name.toLowerCase().indexOf(searchTerm.toLowerCase()) > -1 &&
file.type === 'file'
);
} else {
list = files.filter((file) => file.type === 'file');
}
this.setState({
filesList: list
});
};
render() {
const { isSearchView, filesList } = this.state;
return (
<div className="container">
<Header />
{isSearchView ? (
<div className="search-view">
<SearchView onSearch={this.handleSearch} />
<FilesList files={filesList} isSearchView={isSearchView} />
</div>
) : (
<FilesList files={filesList} />
)}
</div>
);
}
}
现在,再次运行命令 yarn start
,重新启动应用程序并检查其功能。
可以看到,一开始是显示所有文件夹和文件。然后,当我们在键盘上按 t
时,视图将变化,可以搜索显示的文件。
现在,我们分析一下 App.js
文件中的代码。
在此文件中,我们首先声明isSearchView
为状态变量。然后在componentDidMount
和componentWillUnmount
生命周期方法内部分别添加和删除事件处理器keydown
。
然后在handleEvent
函数内部,我们检查用户按下了哪个键。
- 如果用户按下 t 键,将
isSearchView
状态设置为true
,并将filesList
状态数组更新为仅包括文件而排除文件夹。 - 如果用户按 esc 键,则将
isSearchView
状态设置为false
,并更新filesList
状态数组以包括所有文件和文件夹。
我们在单独的文件中声明HOTKEY_CODE
和ESCAPE_CODE
(keyCodes.js
),是因为以后如果我们想把热键t
改为s
,那么只需要在该文件中改变 keycode,而无需在每个文件中都进行更改。
现在,让我们理解一下handleSearch
函数。在此函数中,我们检查用户是否在搜索框中输入了某些内容,然后过滤出包含该搜索词的匹配文件名。然后,我们使用过滤后的结果更新状态。
然后,在 render 方法中,基于isSearchView
值,我们向用户显示文件列表视图或搜索视图。
添加文件导航的功能
现在,我们添加功能以在浏览文件列表时在当前选定文件的前面显示箭头。
在components
文件夹中新建名为 InfoMessage.js
的文件,包含内容:
import React from 'react';
const InfoMessage = () => {
return (
<div className="info-message">
You've activated the <em>file finder</em>. Start typing to filter the file
list. Use <span className="navigation">↑</span> and{' '}
<span className="navigation">↓</span> to navigate,{' '}
<span className="navigation">esc</span> to exit.
</div>
);
};
export default InfoMessage;
打开App.js
文件并导入InfoMessage
组件:
import InfoMessage from './components/InfoMessage';
添加一个初始值为0
的新状态变量counter
。这是为了跟踪箭头的索引。
Inside the handleEvent
handler, get the filesList
and counter
values from state:
在handleEvent
处理程序内部,从状态获取filesList
和counter
值:
const { filesList, counter } = this.state;
增加两个switch
情况 :
case UP_ARROW_CODE:
if (counter > 0) {
this.setState({ counter: counter - 1 });
}
break;
case DOWN_ARROW_CODE:
if (counter < filesList.length - 1) {
this.setState({ counter: counter + 1 });
}
break;
在这里,当我们按下键盘上的向上箭头时,counter
值将减小,而当按下向下箭头时,counter
值将增大。
还要在文件顶部导入 up 和 down 数组常量:
import {
ESCAPE_CODE,
HOTKEY_CODE,
UP_ARROW_CODE,
DOWN_ARROW_CODE
} from './utils/keyCodes';
在 handleSearch 函数内部,在函数末尾将 counter 状态重置为 0,以便在过滤文件列表时,箭头始终显示在列表中第一个文件前。
this.setState({
filesList: list,
counter: 0
});
更改render
方法以显示InfoMessage
组件,并传递counter
和isSearchView
属性给FilesList
组件:
render() {
const { isSearchView, counter, filesList } = this.state;
return (
<div className="container">
<Header />
{isSearchView ? (
<div className="search-view">
<SearchView onSearch={this.handleSearch} />
<InfoMessage />
<FilesList
files={filesList}
isSearchView={isSearchView}
counter={counter}
/>
</div>
) : (
<FilesList files={filesList} />
)}
</div>
);
}
现在,打开FilesList.js
文件并接受isSearchView
和counter
属性,将其传递给ListItem
组件。
FilesList.js
文件现在看起来像这样:
import React from 'react';
import ListItem from './ListItem';
const FilesList = ({ files, isSearchView, counter }) => {
return (
<div className="list">
{files.length > 0 ? (
files.map((file, index) => {
return (
<ListItem
key={file.id}
{...file}
index={index}
isSearchView={isSearchView}
counter={counter}
/>
);
})
) : (
<div>
<h3 className="no-result">No matching files found</h3>
</div>
)}
</div>
);
};
export default FilesList;
现在,打开ListItem.js
文件,并将其内容替换为以下内容:
import React from 'react';
import moment from 'moment';
import { AiFillFolder, AiOutlineFile, AiOutlineRight } from 'react-icons/ai';
const ListItem = ({
index,
type,
name,
comment,
modified_time,
isSearchView,
counter
}) => {
const isSelected = counter === index;
return (
<React.Fragment>
<div className={list-item <span class="pl-s1" style="box-sizing: border-box; color: rgb(36, 41, 46);"><span class="pl-kos" style="box-sizing: border-box;">${</span><span class="pl-s1" style="box-sizing: border-box; color: rgb(36, 41, 46);">isSelected</span> ? <span class="pl-s" style="box-sizing: border-box; color: rgb(3, 47, 98);">'active'</span> : <span class="pl-s" style="box-sizing: border-box; color: rgb(3, 47, 98);">''</span><span class="pl-kos" style="box-sizing: border-box;">}</span></span>}>
<div className="file">
{isSearchView && (
<span
className={arrow-icon <span class="pl-s1" style="box-sizing: border-box; color: rgb(36, 41, 46);"><span class="pl-kos" style="box-sizing: border-box;">${</span><span class="pl-s1" style="box-sizing: border-box; color: rgb(36, 41, 46);">isSelected</span> ? <span class="pl-s" style="box-sizing: border-box; color: rgb(3, 47, 98);">'visible'</span> : <span class="pl-s" style="box-sizing: border-box; color: rgb(3, 47, 98);">'invisible'</span><span class="pl-kos" style="box-sizing: border-box;">}</span></span>}
>
<AiOutlineRight color="#0366d6" />
</span>
)}
<span className="file-icon">
{type === 'folder' ? (
<AiFillFolder color="#79b8ff" size="20" />
) : (
<AiOutlineFile size="18" />
)}
</span>
<span className="label">{name}</span>
</div>
{!isSearchView && (
<React.Fragment>
<div className="comment">{comment}</div>
<div className="time" title={modified_time}>
{moment(modified_time).fromNow()}
</div>
</React.Fragment>
)}
</div>
</React.Fragment>
);
};
export default ListItem;
在此文件中,我们首先接受 isSearchView
和 counter
属性。然后,检查列表中当前显示的文件的索引是否与counter
值匹配。
基于此,我们仅在满足条件的文件前面显示箭头。然后,当我们使用向下或向上箭头浏览列表时,分别在App.js
文件中增加或减少counter
值。
基于 isSearchView
值,我们在 UI 的搜索视图中显示或隐藏注释和时间列。
现在,再次运行命令yarn start
来重新启动应用程序并检查其功能:
高亮显示匹配的文本
现在,添加功能以在过滤文件时突出显示文件名中的匹配文本。
打开 App.js
并更改 handleSearch
函数为以下代码:
handleSearch = (searchTerm) => {
let list;
if (searchTerm) {
const pattern = new RegExp(searchTerm, 'gi');
list = files
.filter(
(file) =>
file.name.toLowerCase().indexOf(searchTerm.toLowerCase()) > -1 &&
file.type === 'file'
)
.map((file) => {
return {
...file,
name: file.name.replace(pattern, (match) => {
return <mark><span class="pl-s1" style="box-sizing: border-box; color: rgb(36, 41, 46);"><span class="pl-kos" style="box-sizing: border-box;">${</span><span class="pl-s1" style="box-sizing: border-box; color: rgb(36, 41, 46);">match</span><span class="pl-kos" style="box-sizing: border-box;">}</span></span></mark>;
})
};
});
} else {
list = files.filter((file) => file.type === 'file');
}
this.setState({
filesList: list,
counter: 0
});
};
在这段代码中,首先使用RegExp
构造函数为全局和不区分大小写的搜索创建动态正则表达式:
const pattern = new RegExp(searchTerm, 'gi');
然后,我们筛选出符合搜索条件的文件:
files.filter(
(file) =>
file.name.toLowerCase().indexOf(searchTerm.toLowerCase()) > -1 &&
file.type === 'file'
);
然后,根据从上述filter
获得的结果调用数组map
方法。
在map
方法中,我们使用字符串replace
方法,该replace
方法接受两个参数:
- 搜索模式
- 为每个匹配的模式执行的函数
我们使用replace
方法查找所有匹配pattern
的项,并将其替换为字符串<mark>${match}</mark>
。这里match
将包含文件名中的匹配文本。
如果你检查utils/api.js
文件中的 JSON 结构,每个文件的结构应如下所示:
{
id: 12,
type: 'file',
name: 'Search.js',
comment: 'changes using react context',
modified_time: '2020-06-30T07:55:33Z'
}
因为我们只想替换名称字段中的文本,所以展开(spread syntax)文件对象属性,仅更改名称,并保持其他值不变。
{
...file,
name: file.name.replace(pattern, (match) => {
return <mark><span class="pl-s1" style="box-sizing: border-box; color: rgb(36, 41, 46);"><span class="pl-kos" style="box-sizing: border-box;">${</span><span class="pl-s1" style="box-sizing: border-box; color: rgb(36, 41, 46);">match</span><span class="pl-kos" style="box-sizing: border-box;">}</span></span></mark>;
})
}
现在,再次运行命令yarn start
来重新启动应用程序并检查其功能。
搜索时,你将看到 HTML 在 UI 上按原样显示:
这是因为我们在ListItem.js
通过以下方式在文件中显示文件名:
<span className="label">{name}</span>
为了防止 Cross-site scripting (XSS)
攻击,React 会在使用 JSX 的表达式中转义所有显示的内容。
因此,如果要实际显示正确的 HTML,则需要使用特殊的属性 dangerouslySetInnerHTML
,它传递**html: name**
以显示带有HTML的name
。
<span className="label" dangerouslySetInnerHTML={{ html: name }}></span>
现在,再次运行命令yarn start
来重新启动应用程序并检查其功能:
搜索词在文件名中被正确高亮了
完整的 GitHub 源代码
在线演示
在 Medium,dev.to上查看其他我的关于 React,Node.js 和 Javascript 的文章,并此处订阅以在收件箱中获取每周更新。
原文:How to Build a Clone of GitHub's File Search Functionality,作者:Yogesh Chavan