Todolist في React Hooks + TypeScript: من البناء إلى الاختبار

بدءًا من الإصدار 16.9 ، تتوفر وظائف جديدة في مكتبة React JS - الخطافات . إنها تجعل من الممكن استخدام وظائف الحالة وغيرها من وظائف React ، مما يحررك من الاضطرار إلى كتابة فصل دراسي. يتيح لك استخدام المكونات الوظيفية جنبًا إلى جنب مع الخطافات تطوير تطبيق عميل كامل.



لنفكر في إنشاء إصدار Todolist من تطبيق React Hooks باستخدام TypeScript .



المجسم



هيكل المشروع كما يلي:



├── src

| ├── مكونات

| ├── index.html

| ├── index.tsx

├── package.json

├── tsconfig.json

├── webpack.config.json



ملف Package.json:
{
  "name": "todo-react-typescript",
  "version": "1.0.0",
  "description": "",
  "main": "index.tsx",
  "scripts": {
    "start": "webpack-dev-server --port 3000 --mode development --open --hot",
    "build": "webpack --mode production"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "ts-loader": "^5.2.1",
    "html-webpack-plugin": "^3.2.0",
    "typescript": "^3.8.2",
    "webpack": "^4.41.6",
    "webpack-cli": "^3.3.11",
    "webpack-dev-server": "^3.10.3"
  },
  "dependencies": {
    "@types/react": "^16.9.23",
    "@types/react-dom": "^16.9.5",
    "react": "^16.12.0",
    "react-dom": "^16.12.0"
  }
}


TypeScript, typescript, ts-loader, tsx- js-, React — @types/react @types/react-dom. html-webpack-plugin, dev- index.html — , production- .



ملف Tsconfig.json:
{
  "compilerOptions": {
    "sourceMap": true,
    "noImplicitAny": false,
    "module": "commonjs",
    "target": "es6",
    "lib": [
      "es2015",
      "es2017",
      "dom"
    ],
    "removeComments": true,
    "allowSyntheticDefaultImports": false,
    "jsx": "react",
    "allowJs": true,
    "baseUrl": "./",
    "paths": {
      "components/*": [
        "src/components/*"
      ]
    }
  }
}


«jsx» . 3 : «preserve», «react» «react-native».







ملف Webpack.config.json:
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    entry: './src/index.tsx',
    resolve: {
        extensions: ['.ts', '.tsx', '.js']
    },
    output: {
        path: path.join(__dirname, '/dist'),
        filename: 'bundle.min.js'
    },
    module: {
        rules: [
            {
                test: /\.ts(x?)$/,
                exclude: /node_modules/,
                use: [
                    {
                        loader: "ts-loader"
                    }
                ]
            }
        ]
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: './src/index.html'
        })
    ]
};


— ./src/index.tsx. resolve.extensions ts/tsx/js . ts-loader html-webpack-plugin. .



تطور ال



في ملف index.html ، نكتب الحاوية حيث سيتم تقديم التطبيق:



<div id="root"></div>


في دليل المكونات ، قم بإنشاء أول مكون فارغ لدينا ، App.tsx.

ملف Index.tsx:



import * as React from 'react';
import * as ReactDOM from 'react-dom';

import App from "./components/App";

ReactDOM.render (
    <App/>,
    document.getElementById("root")
);


سيتضمن تطبيق Todolist الوظائف التالية:



  • إضافة مهمة
  • حذف المهمة
  • تغيير حالة المهمة (مكتمل / غير مكتمل)


سيبدو كالتالي: حقل نصي للإدخال + زر إضافة مهمة ، وفيما يلي قائمة بالمهام المضافة. يمكنك حذف المهام وتغيير حالتها.







لهذه الأغراض ، يمكنك تقسيم التطبيق إلى مكونين فقط - إنشاء مهمة جديدة وقائمة بجميع المهام. لذلك ، سيبدو App.tsx في المرحلة الأولية كما يلي:



import * as React from 'react';
import NewTask from "./NewTask";
import TasksList from "./TasksList";

const App = () => {

    return (
        <>
            <NewTask />
            <TasksList />
        </>
    )
}

export default App;


في الدليل الحالي ، قم بإنشاء وتصدير مكونات NewTask و TasksList فارغة. نظرًا لأننا نحتاج إلى ضمان العلاقة بينهما ، فنحن بحاجة إلى تحديد كيفية حدوث ذلك. هناك طريقتان للتواصل بين المكونات في React:



  1. تخزين الحالة الحالية للتطبيق وجميع طرقه في المكون الرئيسي (في حالتنا ، في App.tsx) وتمريره إلى المكونات الفرعية عبر الدعائم (الطريقة الكلاسيكية) ؛
  2. الفصل بين أساليب إدارة الدولة والحالة. في هذه الحالة ، يجب تغليف التطبيق بمكون خاص - موفر ، ويجب أن يتم تمرير الأساليب والخصائص اللازمة للمكونات الفرعية إليه (باستخدام الخطاف useContext).


سنستخدم الطريقة الثانية وفي هذا المثال سوف نتجاهل الدعائم تمامًا.



TypeScript عند تمرير الدعائم
* , TypeScript :



const NewTask: React.FC<MyProps> = ({taskName}) => {...


React.FC, , ( ) :



interface MyProps {
    taskName: String;
}




useContext



لذلك ، لنقل الحالة ، سنستخدم الخطاف useContext. يسمح لك بالحصول على البيانات وتعديلها في أي من المكونات المغلفة بالموفر.



مثال UseContext
import * as React from 'react';
import {useContext} from "react";

interface Person {
    name: String,
    surname: String
}

export const PersonContext = React.createContext<Partial<Person>>({});

const PersonWrapper = () => {

    const person: Person = {
        name: 'Spider',
        surname: 'Man'
    }

    return (
        <>
            <PersonContext.Provider value={ person }>
                <PersonComponent />
            </PersonContext.Provider>
        </>
    )
}

const PersonComponent = () => {
    const person = useContext(PersonContext);
    return (
        <div>
            Hello, {person.name} {person.surname}!
        </div>
    )
}

export default PersonWrapper;


— name surname, String.



createContext . , TypeScript « » , Partial — .



— person, . , . useContext.



useReducer



ستحتاج أيضًا إلى useReducer لعمل أكثر ملاءمة مع متجر الدولة.



المزيد عن useReducer
useReducer , : , type, — payload. :



import * as React from 'react';
import {useReducer} from "react";

interface PersonState {
    name: String,
    surname: String
}

interface PersonAction {
    type: 'CHANGE',
    payload: PersonState
}


const personReducer = (state: PersonState, action: PersonAction): PersonState => {
    switch (action.type) {
        case 'CHANGE':
            return action.payload;
        default: throw new Error('Unexpected action');
    }
}


const PersonComponent = () => {

    const initialState = {
        name: 'Unknown',
        surname: 'Guest'
    }

    const [person, changePerson] = useReducer<React.Reducer<PersonState, PersonAction>>(personReducer, initialState);

    return (
        <div onClick={() => changePerson({type: 'CHANGE', payload: {name: 'Jackie', surname: 'Chan'}})}>
            Hello, {person.name} {person.surname}!
        </div>
    )
}

export default PersonComponent;


useReducer - personReducer, changePerson.



person initialState, changePerson .



CHANGE, , :



case 'CHANGE':
   return action.payload;
case 'CLEAR':
   return {
      name: 'Undefined',
      surname: 'Undefined'
   };




useContext + useReducer



يمكن أن يكون استخدام السياق مع useReducer بديلاً مثيرًا للاهتمام لمكتبة Redux. في هذه الحالة ، سيتم تمرير نتيجة الخطاف useReducer إلى السياق - الحالة التي تم إرجاعها والوظيفة لتحديثها. دعنا نضيف هذه الخطافات إلى التطبيق:



import * as React from 'react';

import {useReducer} from "react";
import {Action, State, ContextState} from "../types/stateType";
import NewTask from "./NewTask";
import TasksList from "./TasksList";

//   
export const initialState: State = {
    newTask: '',
    tasks: []
}

// <Partial>      
export const ContextApp = React.createContext<Partial<ContextState>>({});

//  ,        Action   type  payload,     - State
export const todoReducer = (state: State, action: Action):State => {
    switch (action.type) {
        case ActionType.ADD: {
            return {...state, tasks: [...state.tasks, {
                    name: action.payload,
                    isDone: false
                }]}
        }
        case ActionType.CHANGE: {
            return {...state, newTask: action.payload}
        }
        case ActionType.REMOVE: {
            return {...state, tasks:  [...state.tasks.filter(task => task !== action.payload)]}
        }
        case ActionType.TOGGLE: {
            return {...state, tasks: [...state.tasks.map((task) => (task !== action.payload ? task : {...task, isDone: !task.isDone}))]}
        }
        default: throw new Error('Unexpected action');
    }
};

const App:  React.FC = () => {
//   todoReducer,     useReduser.     initialState,     (changeState)  .
    const [state, changeState] = useReducer<React.Reducer<State, Action>>(todoReducer, initialState);

    const ContextState: ContextState = {
        state,
        changeState
    };

//      useReducer -     
    return (
        <>
            <ContextApp.Provider value={ContextState}>
                <NewTask />
                <TasksList />
            </ContextApp.Provider>
        </>
    )
}

export default App;


نتيجة لذلك ، تمكنا من جعل الحالة مستقلة عن مكون الجذر ، والتي يمكن استلامها وتغييرها في المكونات داخل الموفر.



المطبوع. إضافة أنواع إلى التطبيق



في ملف stateType ، نكتب أنواع TypeScript للتطبيق:



import {Dispatch} from "react";

//       
export type Task = {
    name: string;
    isDone: boolean
}

export type Tasks = Task[];

//        ,      
export type State = {
    newTask: string;
    tasks: Tasks
}

//      
export enum ActionType {
    ADD = 'ADD',
    CHANGE = 'CHANGE',
    REMOVE = 'REMOVE',
    TOGGLE = 'TOGGLE'
}

//   ADD  CHANGE     
type ActionStringPayload = {
    type: ActionType.ADD | ActionType.CHANGE,
    payload: string
}

//   TOGGLE  REMOVE      Task
type ActionObjectPayload = {
    type: ActionType.TOGGLE | ActionType.REMOVE,
    payload: Task
}

//        
export type Action = ActionStringPayload | ActionObjectPayload;

//       -,     Action.  Dispatch    react
export type ContextState = {
    state: State;
    changeState: Dispatch<Action>
}


باستخدام السياق



الحالة الآن جاهزة ويمكن استخدامها في المكونات. لنبدأ بـ NewTask.tsx:



import * as React from 'react';

import {useContext} from "react";
import {ContextApp} from "./App";

import {TaskName} from "../types/taskType";
import {ActionType} from "../types/stateType";

const NewTask: React.FC = () => {
//  state  dispatch-
    const {state, changeState} = useContext(ContextApp);

//     todoReducer -     .      state .         React-
    const addTask = (event: React.FormEvent<HTMLFormElement>, task: TaskName) => {
        event.preventDefault();
        changeState({type: ActionType.ADD, payload: task})
        changeState({type: ActionType.CHANGE, payload: ''})
    }

//  -     
    const changeTask = (event: React.ChangeEvent<HTMLInputElement>) => {
        changeState({type: ActionType.CHANGE, payload: event.target.value})
    }

    return (
        <>
            <form onSubmit={(event)=>addTask(event, state.newTask)}>
                <input type='text' onChange={(event)=>changeTask(event)} value={state.newTask}/>
                <button type="submit">Add a task</button>
            </form>
        </>
    )
};

export default NewTask;


TasksList.tsx:



import * as React from 'react';

import {Task} from "../types/taskType";
import {ActionType} from "../types/stateType";
import {useContext} from "react";
import {ContextApp} from "./App";

const TasksList: React.FC = () => {
//     ( changeState)
    const {state, changeState} = useContext(ContextApp);

    const removeTask = (taskForRemoving: Task) => {
        changeState({type: ActionType.REMOVE, payload: taskForRemoving})
    }
    const toggleReadiness = (taskForChange: Task) => {
        changeState({type: ActionType.TOGGLE, payload: taskForChange})
    }

    return (
        <>
            <ul>
                {state.tasks.map((task,i)=>(
                    <li key={i} className={task.isDone ? 'ready' : null}>
                        <label>
                            <input type="checkbox" onChange={()=>toggleReadiness(task)} checked={task.isDone}/>
                        </label>
                        <div className="task-name">
                            {task.name}
                        </div>
                        <button className='remove-button' onClick={()=>removeTask(task)}>
                            X
                        </button>
                    </li>
                ))}
            </ul>
        </>
    )
};

export default TasksList;


التطبيق جاهز! يبقى لاختباره.



اختبارات



للاختبار ، سيتم استخدام Jest + Enzyme وكذلك @ testing-library / رد الفعل .

تحتاج إلى تثبيت تبعيات dev:



"@testing-library/react": "^10.4.3",
"@testing-library/react-hooks": "^3.3.0",
"@types/enzyme": "^3.10.5",
"@types/jest": "^24.9.1",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.2",
"enzyme-to-json": "^3.3.4",
"jest": "^26.1.0",
"ts-jest": "^26.1.1",


أضف إعدادات jest إلى package.json:



 "jest": {
    "preset": "ts-jest",
    "setupFiles": [
      "./src/__tests__/setup.ts"
    ],
    "snapshotSerializers": [
      "enzyme-to-json/serializer"
    ],
    "testRegex": "/__tests__/.*\\.test.(ts|tsx)$",
    "moduleFileExtensions": [
      "ts",
      "tsx",
      "js",
      "jsx",
      "json",
      "node"
    ]
  },


وفي كتلة "البرامج النصية" ، أضف نصًا برمجيًا لإجراء الاختبارات:



"test": "jest"


أنشئ دليل __tests__ جديدًا في دليل src وفيه ملف setup.ts بالمحتوى التالي:



import {configure} from 'enzyme';
import * as ReactSixteenAdapter from 'enzyme-adapter-react-16';
const adapter = ReactSixteenAdapter as any;

configure({ adapter: new adapter() });


لنقم بإنشاء ملف todoReducer.test.ts سنختبر فيه المخفض:



import {todoReducer} from "../reducers/todoReducer";
import {ActionType, Action, State} from "../types/stateType";
import {Task} from "../types/taskType";

describe('todoReducer',()=>{
    it('returns new state for "ADD" type', () => {
//      
        const initialState: State = {newTask: '', tasks: []};
//   'ADD'      'new task'
        const updateAction: Action = {type: ActionType.ADD, payload: 'new task'};
//       
        const updatedState = todoReducer(initialState, updateAction);
//      
        expect(updatedState).toEqual({newTask: '', tasks: [{name: 'new task', isDone: false}]});
    });

    it('returns new state for "REMOVE" type', () => {
        const task: Task = {name: 'new task', isDone: false}
        const initialState: State = {newTask: '', tasks: [task]};
        const updateAction: Action = {type: ActionType.REMOVE, payload: task};
        const updatedState = todoReducer(initialState, updateAction);
        expect(updatedState).toEqual({newTask: '', tasks: []});
    });

    it('returns new state for "TOGGLE" type', () => {
        const task: Task = {name: 'new task', isDone: false}
        const initialState: State = {newTask: '', tasks: [task]};
        const updateAction: Action = {type: ActionType.TOGGLE, payload: task};
        const updatedState = todoReducer(initialState, updateAction);
        expect(updatedState).toEqual({newTask: '', tasks: [{name: 'new task', isDone: true}]});
    });

    it('returns new state for "CHANGE" type', () => {
        const initialState: State = {newTask: '', tasks: []};
        const updateAction: Action = {type: ActionType.CHANGE, payload: 'new task'};
        const updatedState = todoReducer(initialState, updateAction);
        expect(updatedState).toEqual({newTask: 'new task', tasks: []});
    });
})


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



يتطلب اختبار مكون App.tsx ، على عكس المخفض ، استخدام طرق إضافية من مكتبات مختلفة. ملف اختبار App.test.tsx:



import * as React from 'react';
import {shallow} from 'enzyme';
import {fireEvent, render, cleanup} from "@testing-library/react";
import App from "../components/App";

describe('<App />', () => {
// jest- afterEach    cleanup        
    afterEach(cleanup);

    it('hasn`t got changes', () => {
//   shallow  enzyme   -,    . 
        const component = shallow(<App />);
//        .           .   snapshots      -u: jest -u
        expect(component).toMatchSnapshot();
    });

//         (   DOM-),    async
    it('should render right input value',  async () => {
// render()     @testing-library/react"    shallow() ,    DOM-   .  container  —   div,     .
        const { container } = render(<App/>);
        expect(container.querySelector('input').getAttribute('value')).toEqual('');
//         'test'
        fireEvent.change(container.querySelector('input'), {
            target: {
                value: 'test'
            },
        })
//      'test'
        expect(container.querySelector('input').getAttribute('value')).toEqual('test');
//     .       
        fireEvent.click(container.querySelector('button'))
//        value
        expect(container.querySelector('input').getAttribute('value')).toEqual('');
    });
})


في مكون TasksList ، تحقق من عرض الحالة التي تم تمريرها بشكل صحيح. ملف TasksList.test.tsx:



import * as React from 'react';
import {ContextApp, initialState} from "../components/App";

import {shallow} from "enzyme";
import {cleanup, render} from "@testing-library/react";

import TasksList from "../components/TasksList";
import {State} from "../types/stateType";

describe('<TasksList />',() => {

    afterEach(cleanup);

//   
    const testState: State = {
        newTask: '',
        tasks: [{name: 'test', isDone: false}, {name: 'test2', isDone: false}]
    }

//   ContextApp   
    const Wrapper = () => {
        return (
            <ContextApp.Provider value={{state: testState}}>
                <TasksList/>
            </ContextApp.Provider>
            )
    }

    it('should render right tasks length', async () => {
        const {container} = render(<Wrapper/>);

//    
        expect(container.querySelectorAll('li')).toHaveLength(testState.tasks.length);
    });

})


يمكن إجراء فحص مماثل لحقل المهمة الجديدة لمكون NewTask عن طريق التحقق من قيمة عنصر الإدخال.



يمكن تنزيل المشروع من مستودع GitHub .



هذا كل شئ شكرا لاهتمامكم.



مصادر



رد فعل شبيبة. خطافات

تعمل مع React Hooks و TypeScript



All Articles