دليل شامل: بناء وظيفة بحث الملفات المشابهة لـ GitHub باستخدام React

دقائق القراءة: 13

دليل شامل: بناء وظيفة بحث الملفات المشابهة لـ GitHub باستخدام React

في هذا المقال، سنقوم ببناء مشروع يحاكي وظيفة البحث عن الملفات الرائعة ولكن الأقل شهرة التي يوفرها GitHub. لرؤية كيفية عملها، ما عليك سوى الانتقال إلى أي مستودع GitHub والضغط على الحرف t، مما سينقلك إلى واجهة البحث. يمكنك بعد ذلك البحث والتمرير في القائمة في نفس الوقت، كما هو موضح في الصورة المتحركة أدناه:

عرض توضيحي لوظيفة البحث عن الملفات في GitHub

من خلال بناء هذا التطبيق، ستتعلم ما يلي:

  • كيفية إنشاء واجهة مستخدم (UI) مشابهة لمستودع GitHub.
  • كيفية التعامل مع أحداث لوحة المفاتيح (keyboard events) في React.
  • كيفية العمل مع التنقل باستخدام مفاتيح الأسهم على لوحة المفاتيح.
  • كيفية تمييز النص المطابق أثناء البحث.
  • كيفية إضافة الأيقونات في React.
  • كيفية عرض محتوى HTML داخل تعبير JSX.
  • والمزيد غير ذلك بكثير.

يمكنك مشاهدة العرض التوضيحي المباشر للتطبيق هنا.

لنبدأ: إعداد المشروع

سنبدأ بإنشاء مشروع React جديد باستخدام الأمر create-react-app:

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

بمجرد إنشاء المشروع، احذف جميع الملفات من مجلد src وأنشئ ملفات index.js و App.js و styles.scss داخل مجلد src. أنشئ أيضًا مجلدين components و utils داخل مجلد src.

تثبيت الاعتمادات الضرورية

قم بتثبيت المكتبات المطلوبة لتشغيل المشروع:

yarn add moment@2.27.0 node-sass@4.14.1 prop-types@15.7.2 react-icons@3.10.0

افتح ملف styles.scss وأضف المحتويات من هنا بداخله.

بناء المكونات الأساسية

مكون Header.js

أنشئ ملفًا جديدًا باسم Header.js داخل مجلد components بالمحتوى التالي:

import React from 'react';

const Header = () => <h1 className="header">GitHub File Search</h1>;

export default Header;

هذا المكون بسيط للغاية، حيث يعرض عنوان التطبيق.

ملف البيانات الوهمية api.js

أنشئ ملفًا جديدًا باسم api.js داخل مجلد utils وأضف المحتوى من هنا بداخله. في هذا الملف، أنشأنا بيانات ثابتة (static data) لعرضها على الواجهة الأمامية للحفاظ على التطبيق بسيطًا وسهل الفهم.

مكون ListItem.js

أنشئ ملفًا جديدًا باسم ListItem.js داخل مجلد components بالمحتوى التالي:

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;

في هذا الملف، نقوم باستقبال بيانات كل ملف نريد عرضه، ثم نعرض أيقونة المجلد/الملف، واسم الملف، والتعليق (التعليقات)، وآخر وقت تم فيه تعديل الملف. لعرض الأيقونات، سنستخدم مكتبة npm المسماة react-icons. تحتوي هذه المكتبة على موقع ويب رائع يتيح لك البحث بسهولة واستخدام الأيقونات التي تحتاجها. يمكنك التحقق منها هنا. يقبل مكون الأيقونات الخاصيتين color و size لتخصيص الأيقونة، وقد استخدمناهما في الكود أعلاه.

مكون FilesList.js

أنشئ ملفًا جديدًا باسم FilesList.js داخل مجلد components بالمحتوى التالي:

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 ثم نعرض كل عنصر من مصفوفة files باستخدام دالة map الخاصة بالمصفوفة.

مكون App.js الرئيسي

الآن، افتح ملف 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>
    );
  }
}

في هذا الملف، أضفنا حالة (state) لتخزين بيانات الملفات الثابتة التي يمكننا تعديلها عند الحاجة. ثم قمنا بتمريرها إلى مكون FilesList لعرضها على الواجهة الأمامية.

ملف index.js

الآن، افتح ملف 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 من الطرفية أو موجه الأوامر وسترى الشاشة الأولية التالية:

الشاشة الأولية للتطبيق بعد الإعداد

يمكنك العثور على الكود حتى هذه النقطة في هذا الفرع.

إضافة وظيفة البحث الأساسية

الآن، دعنا نضيف الوظيفة التي تغير الواجهة الأمامية وتسمح لنا بالبحث في الملفات عندما نضغط على الحرف t على لوحة المفاتيح.

ملف keyCodes.js

داخل مجلد 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;

هذا الملف سيحتوي على رموز المفاتيح (key codes) لتسهيل إدارتها وتعديلها لاحقًا.

مكون SearchView.js

أنشئ ملفًا جديدًا باسم SearchView.js داخل مجلد components بالمحتوى التالي:

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 هنا لحالتنا ودوال دورة الحياة (lifecycle methods). إذا كنت جديدًا على React Hooks، يمكنك الاطلاع على هذا المقال للحصول على مقدمة.

في هذا الملف، أعلنا أولاً عن حالة (state) لتخزين المدخلات التي يكتبها المستخدم. ثم أضفنا ref باستخدام الخطاف useRef حتى نتمكن من التركيز على حقل الإدخال عند تحميل المكون.

  const inputRef = useRef();

  useEffect(() => {
    inputRef.current.focus();
  }, []);

  ...
  <input type="text" name="input" value={input} ref={inputRef} autoComplete="off" onChange={onInputChange} />

في هذا الكود، من خلال تمرير مصفوفة فارغة [] كوسيط ثانٍ لخطاف useEffect، سيتم تنفيذ الكود داخل خطاف useEffect مرة واحدة فقط عند تحميل المكون. يعمل هذا كدالة componentDidMount في المكونات القائمة على الفئات (class components). ثم قمنا بتعيين ref لحقل الإدخال كـ ref={inputRef}. عند تغيير حقل الإدخال داخل معالج onInputChange، نقوم باستدعاء دالة onSearch التي تم تمريرها كخاصية (prop) إلى المكون من ملف App.js.

تحديث مكون 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

دعنا الآن نفهم الكود من ملف App.js. في هذا الملف، أعلنا أولاً عن isSearchView كمتغير حالة. ثم داخل دوال دورة الحياة componentDidMount و componentWillUnmount، نقوم بإضافة وإزالة معالج حدث keydown على التوالي.

ثم داخل دالة handleEvent، نتحقق من المفتاح الذي ضغطه المستخدم. إذا ضغط المستخدم على مفتاح t، فإننا نضبط حالة isSearchView على true ونحدث مصفوفة حالة filesList لتضمين الملفات فقط واستبعاد المجلدات. إذا ضغط المستخدم على مفتاح escape، فإننا نضبط حالة isSearchView على false ونحدث مصفوفة حالة filesList لتضمين جميع الملفات والمجلدات.

السبب في إعلان HOTKEY_CODE و ESCAPE_CODE في ملفات منفصلة (keyCodes.js بدلاً من استخدام رمز المفتاح مباشرة مثل 84) هو أنه إذا أردنا تغيير المفتاح السريع من t إلى s لاحقًا، فإننا نحتاج فقط إلى تغيير رمز المفتاح في ذلك الملف. سينعكس التغيير في جميع الملفات التي يتم استخدامها فيها دون الحاجة إلى تغييرها في كل ملف.

الآن، دعنا نفهم دالة handleSearch. في هذه الدالة، نتحقق مما إذا كان المستخدم قد أدخل شيئًا في مربع البحث، ثم نقوم بتصفية أسماء الملفات المطابقة التي تتضمن مصطلح البحث. ثم نقوم بتحديث الحالة بالنتائج المصفاة. ثم داخل دالة render، بناءً على قيمة isSearchView، نعرض إما عرض قائمة الملفات أو عرض البحث للمستخدم.

يمكنك العثور على الكود حتى هذه النقطة في هذا الفرع.

إضافة وظيفة التنقل بين الملفات

الآن، دعنا نضيف وظيفة لعرض سهم أمام الملف المحدد حاليًا أثناء التنقل في قائمة الملفات.

مكون InfoMessage.js

أنشئ ملفًا جديدًا باسم InfoMessage.js داخل مجلد components بالمحتوى التالي:

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

الآن، افتح ملف App.js واستورد مكون InfoMessage لاستخدامه:

import InfoMessage from './components/InfoMessage';

أضف متغير حالة جديد يسمى counter بقيمة أولية 0. هذا لتتبع فهرس السهم.

داخل معالج handleEvent، احصل على قيم filesList و counter من الحالة:

    const { filesList, counter } = this.state;

أضف حالتين جديدتين (switch cases):

      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 عندما نضغط على السهم العلوي على لوحة المفاتيح وزيادتها عندما نضغط على السهم السفلي. قم أيضًا باستيراد ثوابت الأسهم العلوية والسفلية في أعلى الملف:

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

الآن، افتح ملف 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

الآن، افتح ملف 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 ${ isSelected ? ' active ' : ''}`}>
        <div className="file">
          {isSearchView && (
            <span className={`arrow-icon ${ isSelected ? ' visible ' : ' invisible '}`} >
              <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. بناءً على ذلك، نعرض السهم أمام هذا الملف فقط. ثم عندما نستخدم السهم السفلي أو العلوي للتنقل في القائمة، نقوم بزيادة أو إنقاص قيمة counter على التوالي في ملف App.js. بناءً على قيمة isSearchView، نعرض أو نخفي عمود التعليق والوقت في عرض البحث على الواجهة الأمامية.

الآن، أعد تشغيل التطبيق عن طريق تنفيذ الأمر yarn start مرة أخرى وتحقق من وظيفته:

البحث والتنقل بين الملفات في التطبيق

يمكنك العثور على الكود حتى هذه النقطة في هذا الفرع.

إضافة وظيفة تمييز النص المطابق

الآن، دعنا نضيف وظيفة لتمييز النص المطابق من اسم الملف عندما نقوم بتصفية الملفات.

تعديل دالة handleSearch في App.js

افتح ملف 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>${match}</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'
);

ثم نستدعي دالة map الخاصة بالمصفوفة على النتيجة التي حصلنا عليها من وظيفة التصفية أعلاه. في دالة map، نستخدم دالة replace الخاصة بالسلسلة النصية. تقبل دالة replace معلمتين:

  • النمط للبحث عنه.
  • الدالة لتنفيذها لكل نمط مطابق.

نستخدم دالة replace للعثور على جميع التطابقات لـ pattern واستبدالها بالسلسلة النصية <mark>${match}</mark>. هنا، سيحتوي match على النص المطابق من اسم الملف.

إذا تحققت من بنية JSON من ملف utils/api.js، فإن بنية كل ملف تبدو كما يلي:

{
  id: 12,
  type: 'file',
  name: 'Search.js',
  comment: 'changes using react context',
  modified_time: '2020-06-30T07:55:33Z'
}

نظرًا لأننا نريد استبدال النص من حقل name فقط، فإننا ننشر خصائص كائن الملف ونغير الاسم فقط، مع الاحتفاظ بالقيم الأخرى كما هي:

{
  ...file,
  name: file.name.replace(pattern, (match) => {
    return `<mark>${match}</mark>`;
  })
}

الآن، أعد تشغيل التطبيق عن طريق تنفيذ الأمر yarn start مرة أخرى وتحقق من وظيفته. سترى أن HTML يتم عرضه كما هو على الواجهة الأمامية عند البحث:

مشكلة عدم عرض HTML بشكل صحيح في واجهة المستخدم

هذا لأننا نعرض اسم الملف في ملف ListItem.js بالطريقة التالية:

<span className="label">{name}</span>

ولمنع هجمات Cross-site scripting (XSS)، يقوم React بتهريب جميع المحتويات المعروضة باستخدام تعبير JSX (الذي يكون بين الأقواس المعقوفة). لذلك إذا أردنا عرض HTML الصحيح فعليًا، نحتاج إلى استخدام خاصية خاصة تُعرف باسم dangerouslySetInnerHTML. تمرر هذه الخاصية الاسم __html مع HTML المراد عرضه كقيمة على النحو التالي:

<span className="label" dangerouslySetInnerHTML={{ __html: name }}></span>

الآن، أعد تشغيل التطبيق عن طريق تنفيذ الأمر yarn start مرة أخرى وتحقق من وظيفته:

التطبيق النهائي مع ميزة إبراز النص المطابق

كما ترى، يتم تمييز مصطلح البحث بشكل صحيح في اسم الملف. هذا كل شيء!

يمكنك العثور على الكود حتى هذه النقطة في هذا الفرع.

الكود المصدري الكامل على GitHub: هنا

عرض حي: هنا

الخلاصة التقنية

لقد نجحنا في بناء نسخة وظيفية من ميزة البحث عن الملفات في GitHub باستخدام React، مع التركيز على التفاعل السريع وتجربة المستخدم. تعلمنا كيفية إدارة حالة التطبيق، والتعامل مع أحداث لوحة المفاتيح بكفاءة، وتنظيم المكونات، والأهم من ذلك، كيفية عرض محتوى HTML ديناميكيًا بأمان. استخدام خطافات React Hooks ودمج مكتبات مثل react-icons و moment أظهر مرونة React في بناء واجهات مستخدم معقدة. هذه التجربة توفر أساسًا متينًا لتطوير تطبيقات ويب تفاعلية وغنية بالميزات، مع الأخذ في الاعتبار أفضل ممارسات الأداء والأمان.

اترك تعليقاً

لن يتم نشر عنوان بريدك الإلكتروني. الحقول الإلزامية مشار إليها بـ *