我们将在本文中模仿开发 GitHub 优秀的文件搜索功能(可能关注这个功能的人比较少)。

要查看其工作原理,请打开任何一个 GitHub 仓库,按字母 t,进入搜索视图,然后,你可以开始搜索并滚动浏览列表,如下所示:

Github_Search-compressed-2
GitHub 的文件搜索功能
Github_Search-compressed-2
GitHub 的文件搜索功能

通过构建这个应用程序,你将学到以下内容:

  • 如何创建类似于 GitHub 仓库的 UI
  • 如何在 React 中使用键盘事件
  • 如何使用键盘上的箭头键进行导航
  • 如何在搜索时突出显示匹配的文本
  • 如何在 React 中添加图标
  • 如何在 JSX 表达式中渲染 HTML 内容

等等。

你可以在这里查看该应用程序的 demo。

我们开始吧

用  create-react-app 创建一个新项目:

create-react-app github-file-search-react

创建项目后,删除src文件夹中的所有文件,然后在里面创建 index.jsApp.jsstyles.scss文件,并创建componentsutils文件夹。

安装必要的依赖项:

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-iconsnpm 库。它有一个非常不错的网站,可让你轻松搜索和使用所需的图标。

可以给图标组件添加 colorsize 属性,以自定义我们在以上代码中使用的图标。

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 命令来启动应用程序,你将看到以下初始屏幕:

68747470733a2f2f7777772e66726565636f646563616d702e6f72672f6e6577732f636f6e74656e742f696d616765732f323032302f30372f696e697469616c5f73637265656e2e706e67
初始画面

添加基本搜索功能

现在,我们添加一些功能以更改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,重新启动应用程序并检查其功能。

68747470733a2f2f7777772e66726565636f646563616d702e6f72672f6e6577732f636f6e74656e742f696d616765732f323032302f30372f7365617263682e676966
初始起作用的搜索功能

可以看到,一开始是显示所有文件夹和文件。然后,当我们在键盘上按 t 时,视图将变化,可以搜索显示的文件。

现在,我们分析一下 App.js 文件中的代码。

在此文件中,我们首先声明isSearchView为状态变量。然后在componentDidMountcomponentWillUnmount生命周期方法内部分别添加和删除事件处理器keydown

然后在handleEvent函数内部,我们检查用户按下了哪个键。

  • 如果用户按下 t 键,将isSearchView状态设置为true,并将filesList状态数组更新为仅包括文件而排除文件夹。
  • 如果用户按 esc 键,则将isSearchView状态设置为false,并更新filesList状态数组以包括所有文件和文件夹。

我们在单独的文件中声明HOTKEY_CODEESCAPE_CODEkeyCodes.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处理程序内部,从状态获取filesListcounter值:

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组件,并传递counterisSearchView属性给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文件并接受isSearchViewcounter属性,将其传递给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来重新启动应用程序并检查其功能:

68747470733a2f2f7777772e66726565636f646563616d702e6f72672f6e6577732f636f6e74656e742f696d616765732f323032302f30372f6e617669676174696f6e2e676966
搜素与导航

高亮显示匹配的文本

现在,添加功能以在过滤文件时突出显示文件名中的匹配文本。

打开  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 &lt;mark&gt;<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>&lt;/mark&gt;;
          })
        };
      });
  } 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 &lt;mark&gt;<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>&lt;/mark&gt;;
  })
}

现在,再次运行命令yarn start来重新启动应用程序并检查其功能。

搜索时,你将看到 HTML 在 UI 上按原样显示:

68747470733a2f2f7777772e66726565636f646563616d702e6f72672f6e6577732f636f6e74656e742f696d616765732f323032302f30372f72656e64657265645f68746d6c2e706e67
HTML 无法正确渲染

这是因为我们在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来重新启动应用程序并检查其功能:

68747470733a2f2f7777772e66726565636f646563616d702e6f72672f6e6577732f636f6e74656e742f696d616765732f323032302f30372f686967686c696768742d312e676966
最终成果

搜索词在文件名中被正确高亮了

完整的 GitHub 源代码
在线演示

Mediumdev.to上查看其他我的关于 React,Node.js 和 Javascript 的文章,并此处订阅以在收件箱中获取每周更新。

原文:How to Build a Clone of GitHub's File Search Functionality,作者:Yogesh Chavan