الواجهة الأمامية المستقلة

GoF ، بنية نقية ، كود مثالي - كتيبات المبرمج الحقيقي. ولكن في عالم الواجهة الأمامية ، لا تتوفر العديد من الأفكار من هذه الكتب. من الصعب جدًا العثور على تشابه مع العالم الحقيقي. ربما الواجهة الأمامية قبل الوقت؟ ربما أثبتت البرمجة "الوظيفية" و React بالفعل تفوقهم على OOP؟ في هذه المقالة ، أريد أن أعطي مثالاً لتطبيق قائمة المهام التي حاولت تنفيذها وفقًا للمبادئ والأساليب الموضحة في الكتب الكلاسيكية.

تبعية الإطار


الإطار هو حجر الزاوية للجبهة الحديثة. على الوظائف الشاغرة في hh.ru ، يوجد مطورو React vs Angular vs Vue. لقد عملت مع كل من هذه الأطر ، ولم أستطع لفترة طويلة جدًا أن أفهم لماذا كان علي العمل مع Vue من 3 سنوات لإعادة تلوين زر من الأحمر إلى الأرجواني؟ لماذا أحتاج إلى معرفة كيفية وراثة النماذج الأولية ، أو كيف تعمل حلقة الأحداث من أجل تحريك الزر نفسه من الزاوية اليسرى إلى اليمين؟ الجواب بسيط - نكتب التطبيقات المرتبطة بالمكتبة.

لماذا تعمل الشركات ذات الخبرة الطويلة مع React؟ نعم ، لأن التطبيق يعتمد بشكل كبير على ميزات هذا التفاعل نفسه ، ولكي لا يكسر أي شيء عند إعادة طلاء الزر ، يجب عليك تحطيم رأسك حول كيفية عمل كشف التغيير ، وتقديم شجرة المكونات داخل React ، وكيف يرتبط بمهمة إعادة طلاء الزر. (أوافق ، هذه كلها حالات خاصة ... وفي شركتك ، هل أنت مستعد لتعيين متخصص دون خبرة في العمل مع الإطار؟)
برنامج يستخدم لغة وليس لغة. (ماكونيل)
إطار العمل هو أداة وليست طريقة حياة. (مارتن)
بالنسبة لعالم الجبهة ، فإن هذه الأطروحات عبارة فارغة في أحسن الأحوال ، وفي أسوأ الأحوال تحد لإثبات عكس ذلك. دعونا نلقي نظرة على وثائق React الرسمية ، ونرى مثالاً على تطبيق قائمة مهام بسيط.

مثال على قائمة المهام من موقع React الرسمي
class TodoApp extends React.Component {
  constructor(props) {
    super(props);
    this.state = { items: [], text: '' };
    this.handleChange = this.handleChange.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
  }

  render() {
    return (
      <div>
        <h3> </h3>
        <TodoList items={this.state.items} />
        <form onSubmit={this.handleSubmit}>
          <label htmlFor="new-todo">
              ?
          </label>
          <input
            id="new-todo"
            onChange={this.handleChange}
            value={this.state.text}
          />
          <button>
             #{this.state.items.length + 1}
          </button>
        </form>
      </div>
    );
  }

  handleChange(e) {
    this.setState({ text: e.target.value });
  }

  handleSubmit(e) {
    e.preventDefault();
    if (!this.state.text.length) {
      return;
    }
    const newItem = {
      text: this.state.text,
      id: Date.now()
    };
    this.setState(state => ({
      items: state.items.concat(newItem),
      text: ''
    }));
  }
}

class TodoList extends React.Component {
  render() {
    return (
      <ul>
        {this.props.items.map(item => (
          <li key={item.id}>{item.text}</li>
        ))}
      </ul>
    );
  }
}


"استخدام الدعائم و الدولة ، يمكنك إنشاء صغيرة تأليف لتطبيق القائمة. في هذا المثال ، يتم استخدام الحالة لتتبع قائمة العناصر الحالية ... "

بالنسبة للمبرمج المبتدئ (أي أنا قبل بضع سنوات) ، فإن هذه العبارة تولد المخرجات تلقائيًا:" هنا مثال مثالي لتطبيق قائمة المهام ". لكن من يخزن الدولة في المكون ؟! لهذا هناك مكتبة إدارة الدولة.

مثال على قائمة المهام من وثائق Redux

نعم ، هكذا أصبح التطبيق أكثر قابلية للفهم وأبسط (لا) . هل يمكننا محاولة رسم التبعيات في الاتجاه الصحيح؟

قرار مستقل


دعونا نلقي نظرة على مشكلة قائمة المهام وليس كواجهة أمامية ، أي ننسى أننا بحاجة إلى رسم HTML ("الويب هو التفاصيل"). لن نتمكن من التحقق من النتيجة بأعيننا ، لذا سيتعين علينا كتابة الاختبارات (كما يقول العم بوب ، "يمكنك استخدام TDD بعد ذلك"). وما هي المهمة؟ ما هي قائمة المهام؟ نحن نحاول الكتابة.

Todo.spec.ts
import { Todo } from './Todo';

describe('Todo', () => {
  let todo: Todo;

  beforeEach(() => {
    todo = new Todo('description');
  });

  it('+getItems() should returns Todo[]', () => {
    expect(todo.getTitle()).toBe('description');
  });

  it('+isCompleted() should returns completion flag', () => {
    expect(todo.isCompleted()).toBe(false);
  });

  it('+toggleCompletion() should invert completion flag', () => {
    todo.toggleCompletion();
    expect(todo.isCompleted()).toBe(true);
  });
});


TodoList.spec.ts
import { TodoList } from './TodoList';

describe('TodoList', () => {
  let todoList: TodoList;

  beforeEach(() => {
    todoList = new TodoList();
  });

  it('+getItems() should returns Todo[]', () => {
    expect(todoList.getItems()).toEqual([]);
  });

  it('+add() should create item and add to collection', () => {
    todoList.add('Write tests');
    expect(todoList.getItems()).toHaveLength(1);
  });

  it('+add() should create item with the description', () => {
    const description = 'Write tests';
    todoList.add(description);
    const [item] = todoList.getItems();
    expect(item.getTitle()).toBe(description);
  });

  it('+getCompletedItems() should not returns uncompleted Todo[]', () => {
    const description = 'Write tests';
    todoList.add(description);
    expect(todoList.getCompletedItems()).toEqual([]);
  });

  it('+getCompletedItems() should returns completed Todo[]', () => {
    const description = 'Write tests';
    todoList.add(description);
    const [item] = todoList.getItems();
    item.toggleCompletion();
    expect(todoList.getCompletedItems()).toEqual([item]);
  });

  it('+getUncompletedItems() should returns uncompleted Todo[]', () => {
    const description = 'Write tests';
    todoList.add(description);
    const [item] = todoList.getItems();
    expect(todoList.getUncompletedItems()).toEqual([item]);
  });

  it('+getUncompletedItems() should not returns completed Todo[]', () => {
    const description = 'Write tests';
    todoList.add(description);
    const [item] = todoList.getItems();
    item.toggleCompletion();
    expect(todoList.getUncompletedItems()).toEqual([]);
  });
});


export class Todo {
  private completed: boolean = false;

  constructor(private description: string) {}

  getTitle(): string {
    return this.description;
  }

  isCompleted(): boolean {
    return this.completed;
  }

  toggleCompletion(): void {
    this.completed = !this.completed;
  }
}

import { Todo } from './Todo';

export class TodoList {
  private items: Todo[] = [];

  getItems(): Todo[] {
    return this.items;
  }

  getCompletedItems(): Todo[] {
    return this.items.filter((todo) => todo.isCompleted());
  }

  getUncompletedItems(): Todo[] {
    return this.items.filter((todo) => !todo.isCompleted());
  }

  add(description: string): void {
    this.items.push(new Todo(description));
  }
}

نحصل على فصلين بسيطين مع واجهات غنية بالمعلومات. هل هذا كل شيء؟ تمر الاختبارات. الآن التقط رد الفعل.

import React from 'react';

import { TodoList } from './core/TodoList';

export class App extends React.Component {
  todoList: TodoList = this.createTodoList();

  render(): any {
    return (
      <React.Fragment>
        <header>
          <h1>Todo List App</h1>
        </header>
        <main>
          <TodoListCmp todoList={this.todoList}></TodoListCmp>
          <AddTodoCmp todoList={this.todoList}></AddTodoCmp>
        </main>
      </React.Fragment>
    );
  }

  private createTodoList(): TodoList {
    const todoList = new TodoList();
    todoList.add('Initial created Todo');
    return todoList;
  }
}

export const TodoListCmp: React.FC<{ todoList: TodoList }> = ({ todoList }) => {
  return (
    <div>
      <h2>What to do?</h2>
      <ul>
        {todoList.getItems().map((todo) => (
          <li key={todo.getTitle()}>{todo.getTitle()}</li>
        ))}
      </ul>
    </div>
  );
};

export const AddTodoCmp: React.FC<{ todoList: TodoList }> = ({ todoList }) => {
  return <button onClick={() => todoList.add(`Todo ${todoList.getItems().length}`)}>Add</button>;
};

وتأكد من أن ... إضافة عنصر لا يعمل. حسنًا ... أصبح من الواضح الآن سبب الحاجة إلى كتابة كل شيء في حالة - بحيث يتم إعادة رسم مكون التفاعل بعد التعرف على التغييرات. ولكن هل هذا سبب لانتهاك جميع المبادئ الممكنة ووضع المنطق في عنصر العرض؟ القليل من الصبر والشجاعة. لحل المشكلة ، يعد استدعاء forceUpdate () في حلقة لا نهائية أو نمط المراقب مثاليًا .

أنا أحب مكتبة RxJs ، لكنني لن أقوم بتوصيلها ، ولكن فقط نسخ واجهة برمجة التطبيقات الخاصة بها الضرورية لمهمتنا.

ملحوظ. spec.ts
import { Observable, Subject } from './Observable';

describe('Observable', () => {
  let subject: Subject<any>;
  let observable: Observable<any>;

  beforeEach(() => {
    subject = new Subject();
    observable = subject.asObservable();
  });

  it('should call callback on next value', async () => {
    const spy = jasmine.createSpy();
    observable.subscribe(spy);
    subject.next({});
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('should not call callback on next value if unsubscribed', async () => {
    const spy = jasmine.createSpy();
    const subscription = observable.subscribe(spy);
    subscription.unsubscribe();
    subject.next({});
    await delay();
    expect(spy).not.toHaveBeenCalled();
  });

  it('should send to callback subject.next value', async () => {
    const spy = jasmine.createSpy();
    observable.subscribe(spy);
    const sendingValue = {};
    subject.next(sendingValue);
    await delay();
    expect(spy.calls.first().args[0]).toBe(sendingValue);
  });
});

function delay(timeoutInMs?: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, timeoutInMs));
}


ملحوظة
export interface Observable<T = unknown> {
  subscribe(onNext: (value: T) => void): Subscription;
}

export interface Subscription {
  unsubscribe(): void;
}

export class Subject<T = unknown> implements Observable<T> {
  protected callbackSet: Set<(value: T) => void> = new Set();

  asObservable(): Observable<T> {
    return this;
  }

  subscribe(onNext: (value: T) => void): Subscription {
    this.callbackSet.add(onNext);
    return { unsubscribe: () => this.callbackSet.delete(onNext) };
  }

  next(value: T): void {
    Promise.resolve().then(() => this.callbackSet.forEach((onNext) => onNext(value)));
  }
}


في رأيي ، لا شيء معقد للغاية. إضافة اختبار (إخطار بالتغييرات - هذا هو المنطق).

  it('+TodoList.prototype.add() should emit changes', async () => {
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    todoList.add('description');
    await delay();
    expect(spy).toHaveBeenCalled();
  });

دعونا نفكر للحظة ، ولكن هل يؤثر تغيير عنصر Todo على حالة TodoList؟ التأثيرات - يجب أن تُرجع أساليب getCompletedItems / getUncompletedItems مجموعة مختلفة من العناصر. ربما يجدر نقل تبديل الإكمال إلى فصل TodoList؟ إنها فكرة سيئة - مع هذا النهج ، سيتعين علينا تضخيم TodoList لكل ميزة تتعلق بعنصر Todo جديد (سنعود إلى هذا لاحقًا). ولكن كيف نتعلم التغييرات ، مرة أخرى في الأوبزرفر؟ لتبسيط الأمور ، دع عنصر Todo نفسه ينقل التغييرات من خلال رد الاتصال.

تبدو النسخة الكاملة من البرنامج على هذا النحو.

import React from 'react';
import { Observable, Subject } from 'src/utils/Observable';
import { generateId } from 'src/utils/generateId';

export class Todo {
  private completed: boolean = false;

  id: string = generateId();

  constructor(private description: string, private onCompletionToggle?: (todo: Todo) => void) {}

  getTitle(): string {
    return this.description;
  }

  isCompleted(): boolean {
    return this.completed;
  }

  toggleCompletion(): void {
    this.completed = !this.completed;
    this.onCompletionToggle?.(this);
  }
}

export class TodoList {
  private items: Todo[] = [];
  private changesSubject = new Subject();

  readonly changes: Observable = this.changesSubject.asObservable();

  getItems(): Todo[] {
    return this.items;
  }

  getCompletedItems(): Todo[] {
    return this.items.filter((todo) => todo.isCompleted());
  }

  getUncompletedItems(): Todo[] {
    return this.items.filter((todo) => !todo.isCompleted());
  }

  add(description: string): void {
    this.items.push(new Todo(description, () => this.changesSubject.next({})));
    this.changesSubject.next({});
  }
}

export class App extends React.Component {
  todoList: TodoList = this.createTodoList();

  render(): any {
    return (
      <React.Fragment>
        <header>
          <h1>Todo List App</h1>
        </header>
        <main>
          <TodoListCmp todoList={this.todoList}></TodoListCmp>
          <AddTodoCmp todoList={this.todoList}></AddTodoCmp>
        </main>
      </React.Fragment>
    );
  }

  componentDidMount(): void {
    this.todoList.changes.subscribe(() => this.forceUpdate());
  }

  private createTodoList(): TodoList {
    const todoList = new TodoList();
    todoList.add('Initial created Todo');
    return todoList;
  }
}

export const TodoListCmp: React.FC<{ todoList: TodoList }> = ({ todoList }) => {
  return (
    <div>
      <h2>What to do?</h2>
      <ul>
        {todoList.getUncompletedItems().map((todo) => (
          <TodoCmp key={todo.id} todo={todo}></TodoCmp>
        ))}
        {todoList.getCompletedItems().map((todo) => (
          <TodoCmp key={todo.id} todo={todo}></TodoCmp>
        ))}
      </ul>
    </div>
  );
};

export const TodoCmp: React.FC<{ todo: Todo }> = ({ todo }) => (
  <li
    style={{ textDecoration: todo.isCompleted() ? 'line-through' : '' }}
    onClick={() => todo.toggleCompletion()}
  >
    {todo.getTitle()}
  </li>
);

export const AddTodoCmp: React.FC<{ todoList: TodoList }> = ({ todoList }) => {
  return <button onClick={() => todoList.add(`Todo ${todoList.getItems().length}`)}>Add</button>;
};

يبدو أن هذا ما يجب أن يبدو عليه تطبيق قائمة المهام بشكل مستقل عن إطار العمل. القيد الوحيد هو PL. يمكنك تنفيذ الشاشة لوحدة التحكم ، أو استخدام Angular.

ربما يكون الإصدار الحالي من التطبيق ليس معقدًا بما يكفي للتأكد من أن النهج المستقل يعمل ولإظهار نقاط قوته. لذلك ، سنقوم بربط خيالنا لمحاكاة سيناريو تطوير قائمة المهام بشكل أو بآخر.

تعديلات من العميل


الكابوس الرئيسي لمعظم المشاريع هو تغيير المتطلبات. أنت تعلم أنه لا يمكن تجنب التعديلات ، وتعلم أن هذا أمر طبيعي. ولكن كيف تستعد للتغييرات المستقبلية؟

عناصر تودو الخاصة


إحدى الميزات الرئيسية لـ OOP هي القدرة على حل المشكلة من خلال إدخال أنواع جديدة. ربما يكون هذا هو أقوى تقنية OOP التي يمكنها سحب برنامج معقد ومرهق بمفرده. على سبيل المثال ، لا أعرف ما هو مطلوب لعنصر Todo. قد يكون من الضروري أن تكون قادرًا على تغيير اسمه ، وقد يكون من الضروري إضافة سمات إضافية ، وقد يكون من الممكن تغيير هذا العنصر من خلال الوصول المباشر إلى خادم SpaceX ... لكني متأكد من أن المتطلبات ستتغير ، وسأحتاج إلى أنواع مختلفة من Todo.

export class EditableTodo extends Todo {
  changeTitle(title: string): void {
    this.title = title;
    this.onChange?.(this);
  }
}

يبدو أنه لعرض نوع خاص ، سنحتاج إلى تغيير مكونات العرض أيضًا. من الناحية العملية ، قابلت (وكتبت) مكونات فيها مليون حالة مختلفة تحول كتلة div من الزرافة إلى مدفع رشاش. لتجنب هذه المشكلة ، يمكنك إنشاء مكون خاص بقائمة كبيرة من حالات التبديل. أو قم بتطبيق نمط الزائر والإرسال المزدوج ، ودع عنصر Todo يقرر بنفسه نوع المكون الذي سيتم رسمه.

export class Todo {
  id: string = '';

  constructor(
    protected title: string,
    private completed: boolean = false,
    protected onChange?: (todo: Todo) => void,
  ) {}

  getTitle(): string {
    return this.title;
  }

  isCompleted(): boolean {
    return this.completed;
  }

  toggleCompletion(): void {
    this.completed = !this.completed;
    this.onChange?.(this);
  }

  render(renderer: TodoRenderer): any {
    return renderer.renderSimpleTodo(this);
  }
}

export class EditableTodo extends Todo {
  changeTitle(title: string): void {
    this.title = title;
    this.onChange?.(this);
  }

  render(renderer: TodoRenderer): any {
    return renderer.renderEditableTodo(this);
  }
}

export class TodoRenderer {
  renderSimpleTodo(todo: Todo): any {
    return <SimpleTodoCmp todo={todo}></SimpleTodoCmp>;
  }

  renderFixedTodo(todo: Todo): any {
    return <FixedTodoCmp todo={todo}></FixedTodoCmp>;
  }

  renderEditableTodo(todo: EditableTodo): any {
    return <EditableTodoCmp todo={todo}></EditableTodoCmp>;
  }
}

يكون خيار الإرسال المزدوج مفيدًا بشكل خاص عندما يكون لنوع واحد من العناصر طرق عرض مختلفة. يمكنك تغييرها عن طريق استبدال TodoRenderers مختلفة في طريقة التقديم.

الآن نحن جاهزون. لقد اختفى الخوف من المطالب الجديدة لعناصر تودو "الخاصة". أعتقد أن المطورين أنفسهم يمكن أن يأخذوا زمام المبادرة ، ويقدمون اثنين من الميزات التي تتطلب إدخال أنواع جديدة ، والتي يتم إضافتها الآن عن طريق كتابة رمز جديد والحد الأدنى من التغيير إلى النوع الحالي.

حفظ البيانات على الخادم


ما هو نوع التطبيق دون التفاعل مع الخادم؟ بالطبع ، يجب أن تكون قادرًا على حفظ قائمتنا عبر HTTP - وهو متطلب جديد آخر. نحن نحاول حل المشكلة.

AppTodoList.spec.ts
import { delay } from 'src/utils/delay';
import { TodoType } from '../core/TodoFactory';
import { AppTodoList } from './AppTodoList';

describe('AppTodoList', () => {
  let todoList: AppTodoList;

  beforeEach(() => {
    todoList = new AppTodoList({
      getItems: async () => [{ type: TodoType.Simple, title: 'Loaded todo', completed: false }],
      save: () => delay(),
    });
  });

  it('+resolve() should load saved todo items', async () => {
    await todoList.resolve();
    expect(todoList.getItems().length).toBeGreaterThan(0);
  });

  it('+resolve() should emit changes', async () => {
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    await todoList.resolve();
    await delay(1);
    expect(spy).toHaveBeenCalled();
  });

  it('+add() should emit changes', async () => {
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    todoList.add({ title: '' });
    await delay(1);
    expect(spy).toHaveBeenCalled();
  });

  it('+add() should emit changes after resolve()', async () => {
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    await todoList.resolve();
    todoList.add({ title: '' });
    await delay(1);
    expect(spy).toHaveBeenCalledTimes(2);
  });

  it('+todo.onChange() should emit changes', async () => {
    await todoList.resolve();
    await delay(1);
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    const [todo] = todoList.getItems();
    todo.toggleCompletion();
    await delay(1);
    expect(spy).toHaveBeenCalled();
  });

  it('+add() should call TodoListApi.save', async () => {
    const spy = jasmine.createSpy();
    todoList = new AppTodoList({ getItems: async () => [], save: async () => spy() });
    todoList.add({ title: '' });
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+todo.onChange() should call TodoListApi.save', async () => {
    const spy = jasmine.createSpy();
    todoList = new AppTodoList({ getItems: async () => [{ title: '' }], save: async () => spy() });
    await todoList.resolve();
    const [todo] = todoList.getItems();
    todo.toggleCompletion();
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+add() should save todoList state on success and rollback to it on error', async () => {
    const api = { getItems: async () => [], save: () => Promise.resolve() };
    todoList = new AppTodoList(api);
    todoList.add({ title: '1' });
    await delay(1);
    const savedData = JSON.stringify(todoList.getItems());
    api.save = () => Promise.reject('Mock saving failed');
    todoList.add({ title: '2' });
    expect(todoList.getItems()).toHaveLength(2);
    await delay(1);
    expect(JSON.stringify(todoList.getItems())).toBe(savedData);
  });
});


export interface TodoListApi {
  getItems(): Promise<TodoParams[]>;
  save(todoParamsList: TodoParams[]): Promise<void>;
}

export class AppTodoList implements TodoList {
  private todoFactory = new TodoFactory();
  private changesSubject = new Subject();

  changes: Observable = this.changesSubject.asObservable();

  private state: TodoList = new TodoListImp();
  private subscription: Subscription = this.state.changes.subscribe(() => this.onStateChanges());
  private synchronizedTodoParamsList: TodoParams[] = [];

  constructor(private api: TodoListApi) {}

  async resolve(): Promise<void> {
    const todoParamsList = await this.api.getItems();
    this.updateState(todoParamsList);
  }

  private updateState(todoParamsList: TodoParams[]): void {
    const todoList = new TodoListImp(todoParamsList);
    this.state = todoList;
    this.subscription.unsubscribe();
    this.subscription = todoList.changes.subscribe(() => this.onStateChanges());
    this.synchronizedTodoParamsList = todoParamsList;
    this.changesSubject.next({});
  }

  private async onStateChanges(): Promise<void> {
    this.changesSubject.next({});
    try {
      const params = this.state.getItems().map((todo) => this.todoFactory.serializeTodo(todo));
      await this.api.save(params);
      this.synchronizedTodoParamsList = params;
    } catch {
      this.updateState(this.synchronizedTodoParamsList);
    }
  }

  destroy(): void {
    this.subscription.unsubscribe();
  }

  getItems(): Todo[] {
    return this.state.getItems();
  }

  getCompletedItems(): Todo[] {
    return this.state.getCompletedItems();
  }

  getUncompletedItems(): Todo[] {
    return this.state.getUncompletedItems();
  }

  add(todoParams: TodoParams): void {
    this.state.add(todoParams);
  }
}

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

تغيير التاريخ


"نحن بحاجة إلى القدرة على إلغاء / إعادة الإجراءات" ...

يبدو أن موجة من التعديلات الجديدة تفاجئنا. ولكن في أي حال ، لا يمكنك التضحية بالاختبارات. الآن من غير الواضح تمامًا أي التسلسل الهرمي للميراث هو الأنسب ، وما إذا كان الميراث مناسبًا بشكل عام. لذلك ، لن يحدث شيء سيئ إذا قمنا ببساطة بتكملة فصلنا القذر (سننظر في تفاصيل التنفيذ) ، مع التضحية بمبدأ المسؤولية الوحيدة.

TodoListHistory.spec.ts
import { TodoParams } from 'src/core/TodoFactory';
import { delay } from 'src/utils/delay';
import { TodoListHistory } from './TodoListHistory';

describe('TodoListHistory', () => {
  let history: TodoListHistory;

  beforeEach(() => {
    history = new TodoListHistory();
  });

  it('+getState() should returns TodoParams[]', () => {
    expect(history.getState()).toEqual([]);
  });

  it('+setState() should rewrite current state', () => {
    const newState = [{ title: '' }] as TodoParams[];
    history.setState(newState);
    expect(history.getState()).toBe(newState);
  });

  it('+hasPrev() should returns false on init', () => {
    expect(history.hasPrev()).toBe(false);
  });

  it('+hasPrev() should returns true after setState()', () => {
    history.setState([]);
    expect(history.hasPrev()).toBe(true);
  });

  it('+switchToPrev() should switch on prev state', () => {
    const prevState = [{ title: '' }] as TodoParams[];
    history.setState(prevState);
    history.setState([]);
    history.switchToPrev();
    expect(history.getState()).toBe(prevState);
  });

  it('+hasPrev() should returns false after switch to first', () => {
    history.setState([]);
    history.switchToPrev();
    expect(history.hasPrev()).toBe(false);
  });

  it('+hasNext() should returns false on init', () => {
    expect(history.hasNext()).toBe(false);
  });

  it('+hasNext() should returns true after switchToPrev()', () => {
    history.setState([]);
    history.switchToPrev();
    expect(history.hasNext()).toBe(true);
  });

  it('+switchToNext() should switch on next state', () => {
    const prevState = [{ title: '' }] as TodoParams[];
    history.setState([]);
    history.setState(prevState);
    history.switchToPrev();
    history.switchToNext();
    expect(history.getState()).toBe(prevState);
  });

  it('+hasNext() should returns false after switchToNext()', () => {
    history.setState([]);
    history.switchToPrev();
    history.switchToNext();
    expect(history.hasNext()).toBe(false);
  });

  it('+hasNext() should returns false after setState()', () => {
    history.setState([]);
    history.switchToPrev();
    history.setState([]);
    expect(history.hasNext()).toBe(false);
  });

  it('+switchToPrev() should switch on prev state after setState()', () => {
    const prevState = [{ title: '' }] as TodoParams[];
    history.setState(prevState);
    history.setState([]);
    history.switchToPrev();
    history.setState([]);
    history.switchToPrev();
    expect(history.getState()).toBe(prevState);
  });

  it('+setState() should not emit changes', async () => {
    const spy = jasmine.createSpy();
    history.changes.subscribe(spy);
    history.setState([]);
    await delay();
    expect(spy).not.toHaveBeenCalled();
  });

  it('+switchToPrev() should emit changes', async () => {
    history.setState([]);
    await delay();
    const spy = jasmine.createSpy();
    history.changes.subscribe(spy);
    history.switchToPrev();
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+switchToPrev() should emit changes', async () => {
    history.setState([]);
    history.switchToPrev();
    await delay();
    const spy = jasmine.createSpy();
    history.changes.subscribe(spy);
    history.switchToNext();
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+reset() should reset history and apply initial state', async () => {
    history.setState([]);
    expect(history.hasPrev()).toBe(true);
    const initState = [{ title: '' }] as TodoParams[];
    history.reset(initState);
    expect(history.hasPrev()).toBe(false);
    expect(history.getState()).toBe(initState);
  });
});


AppTodoList.spec.ts
import { delay } from 'src/utils/delay';
import { TodoType } from '../core/TodoFactory';
import { AppTodoList } from './AppTodoList';

describe('AppTodoList', () => {
  let todoList: AppTodoList;

  beforeEach(() => {
    todoList = new AppTodoList({
      getItems: async () => [{ type: TodoType.Simple, title: 'Loaded todo', completed: false }],
      save: () => delay(),
    });
  });

  it('+resolve() should load saved todo items', async () => {
    await todoList.resolve();
    expect(todoList.getItems().length).toBeGreaterThan(0);
  });

  it('+resolve() should emit changes', async () => {
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    await todoList.resolve();
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+add() should emit changes', async () => {
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    todoList.add({ title: '' });
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+add() should emit changes after resolve()', async () => {
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    await todoList.resolve();
    todoList.add({ title: '' });
    await delay();
    expect(spy).toHaveBeenCalledTimes(2);
  });

  it('+todo.onChange() should emit changes', async () => {
    await todoList.resolve();
    await delay();
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    const [todo] = todoList.getItems();
    todo.toggleCompletion();
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+add() should call TodoListApi.save', async () => {
    const spy = jasmine.createSpy();
    todoList = new AppTodoList({ getItems: async () => [], save: async () => spy() });
    todoList.add({ title: '' });
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+todo.onChange() should call TodoListApi.save', async () => {
    const spy = jasmine.createSpy();
    todoList = new AppTodoList({ getItems: async () => [{ title: '' }], save: async () => spy() });
    await todoList.resolve();
    const [todo] = todoList.getItems();
    todo.toggleCompletion();
    await delay();
    expect(spy).toHaveBeenCalled();
  });

  it('+add() should ignore error on save', async () => {
    const api = { getItems: async () => [], save: () => Promise.resolve() };
    todoList = new AppTodoList(api);
    todoList.add({ title: '1' });
    await delay();
    api.save = jasmine.createSpy().and.returnValue(Promise.reject('Mock saving failed'));
    todoList.add({ title: '2' });
    expect(todoList.getItems()).toHaveLength(2);
    await delay();
    expect(api.save).toHaveBeenCalled();
    expect(todoList.getItems()).toHaveLength(2);
  });

  it('+resolve() should provide current todoList state to history', async () => {
    expect(todoList.getItems()).toHaveLength(0);
    expect(todoList.getHistory().getState()).toHaveLength(0);
    await todoList.resolve();
    await delay();
    expect(todoList.getItems()).toHaveLength(1);
    expect(todoList.getHistory().getState()).toHaveLength(1);
  });

  it('+add() should provide current todoList state to history', async () => {
    expect(todoList.getItems()).toHaveLength(0);
    expect(todoList.getHistory().getState()).toHaveLength(0);
    todoList.add({ title: '' });
    await delay();
    expect(todoList.getItems()).toHaveLength(1);
    expect(todoList.getHistory().getState()).toHaveLength(1);
  });

  it('+history.switchToPrev() should change todoList state on prev', async () => {
    todoList.add({ title: '' });
    await delay();
    expect(todoList.getItems()).toHaveLength(1);
    expect(todoList.getHistory().getState()).toHaveLength(1);
    todoList.getHistory().switchToPrev();
    await delay();
    expect(todoList.getHistory().getState()).toHaveLength(0);
    expect(todoList.getItems()).toHaveLength(0);
  });

  it('+history.switchToPrev() should change todoList state on prev after resolve()', async () => {
    await todoList.resolve();
    todoList.add({ title: '' });
    await delay();
    expect(todoList.getItems()).toHaveLength(2);
    expect(todoList.getHistory().getState()).toHaveLength(2);
    todoList.getHistory().switchToPrev();
    await delay();
    expect(todoList.getHistory().getState()).toHaveLength(1);
    expect(todoList.getItems()).toHaveLength(1);
  });

  it('+add() should emit changes after history.switchToPrev()', async () => {
    todoList.add({ title: '' });
    todoList.getHistory().switchToPrev();
    await delay();
    const spy = jasmine.createSpy();
    todoList.changes.subscribe(spy);
    todoList.add({ title: '' });
    await delay();
    expect(spy).toHaveBeenCalled();
  });
});


import { TodoParams } from 'src/core/TodoFactory';
import { Observable, Subject } from 'src/utils/Observable';

export class TodoListHistory {
  private changesSubject = new Subject();
  private history: TodoParams[][] = [this.state];

  changes: Observable = this.changesSubject.asObservable();

  constructor(private state: TodoParams[] = []) {}

  reset(state: TodoParams[]): void {
    this.state = state;
    this.history = [this.state];
  }

  getState(): TodoParams[] {
    return this.state;
  }

  setState(state: TodoParams[]): void {
    this.deleteHistoryAfterCurrentState();
    this.state = state;
    this.history.push(state);
  }

  private nextState(state: TodoParams[]): void {
    this.state = state;
    this.changesSubject.next({});
  }

  private deleteHistoryAfterCurrentState(): void {
    this.history = this.history.slice(0, this.getCurrentStateIndex() + 1);
  }

  hasPrev(): boolean {
    return this.getCurrentStateIndex() > 0;
  }

  hasNext(): boolean {
    return this.getCurrentStateIndex() < this.history.length - 1;
  }

  switchToPrev(): void {
    const prevStateIndex = Math.max(this.getCurrentStateIndex() - 1, 0);
    this.nextState(this.history[prevStateIndex]);
  }

  switchToNext(): void {
    const nextStateIndex = Math.min(this.getCurrentStateIndex() + 1, this.history.length - 1);
    this.nextState(this.history[nextStateIndex]);
  }

  private getCurrentStateIndex(): number {
    return this.history.indexOf(this.state);
  }
}

import { Observable, Subject, Subscription } from 'src/utils/Observable';
import { Todo } from '../core/Todo';
import { TodoFactory, TodoParams } from '../core/TodoFactory';
import { TodoList, TodoListImp } from '../core/TodoList';
import { TodoListApi } from './TodoListApi';
import { HistoryControl, TodoListHistory } from './TodoListHistory';

export class AppTodoList implements TodoList {
  private readonly todoFactory = new TodoFactory();
  private readonly history: TodoListHistory = new TodoListHistory();

  private changesSubject = new Subject();
  readonly changes: Observable = this.changesSubject.asObservable();

  private state: TodoList = new TodoListImp();
  private stateSubscription: Subscription = this.state.changes.subscribe(() =>
    this.onStateChanges(),
  );
  private historySubscription = this.history.changes.subscribe(() => this.onHistoryChanges());

  constructor(private api: TodoListApi) {}

  private onStateChanges(): void {
    const params = this.state.getItems().map((todo) => this.todoFactory.serializeTodo(todo));
    this.history.setState(params);
    this.api.save(params).catch(() => {});
    this.changesSubject.next({});
  }

  private onHistoryChanges(): void {
    const params = this.history.getState();
    this.updateStateTodoList(params);
    this.api.save(params).catch(() => {});
  }

  private updateStateTodoList(todoParamsList: TodoParams[]): void {
    const todoList = new TodoListImp(todoParamsList);
    this.state = todoList;
    this.stateSubscription.unsubscribe();
    this.stateSubscription = this.state.changes.subscribe(() => this.onStateChanges());
    this.changesSubject.next({});
  }

  async resolve(): Promise<void> {
    const todoParamsList = await this.api.getItems();
    this.history.reset(todoParamsList);
    this.updateStateTodoList(todoParamsList);
  }

  destroy(): void {
    this.stateSubscription.unsubscribe();
    this.historySubscription.unsubscribe();
  }

  getHistory(): HistoryControl<TodoParams[]> {
    return this.history;
  }

  getItems(): Todo[] {
    return this.state.getItems();
  }

  getCompletedItems(): Todo[] {
    return this.state.getCompletedItems();
  }

  getUncompletedItems(): Todo[] {
    return this.state.getUncompletedItems();
  }

  add(todoParams: TodoParams): void {
    this.state.add(todoParams);
  }
}

نظرًا لأن سجل التغييرات هو نفسه إلى حد كبير ، فلنفصل إدارة سجل قائمة المهام في الفئة الأساسية.

import { Todo } from 'src/core/Todo';
import { Observable, Subject, Subscription } from 'src/utils/Observable';
import { TodoFactory, TodoParams } from '../core/TodoFactory';
import { TodoList, TodoListImp } from '../core/TodoList';
import { HistoryControl, HistoryState } from './HistoryState';

export class HistoricalTodoList implements TodoList, HistoryControl {
  protected readonly todoFactory = new TodoFactory();
  protected readonly history = new HistoryState<TodoParams[]>([]);

  private changesSubject: Subject = new Subject();
  readonly changes: Observable = this.changesSubject.asObservable();

  private state: TodoList = new TodoListImp();
  private stateSubscription: Subscription = this.state.changes.subscribe(() =>
    this.onStateChanged(this.getSerializedState()),
  );

  constructor() {}

  protected onStateChanged(params: TodoParams[]): void {
    this.history.addState(params);
    this.changesSubject.next({});
  }

  protected onHistorySwitched(): void {
    this.updateState(this.history.getState());
  }

  protected updateState(todoParamsList: TodoParams[]): void {
    this.state = new TodoListImp(todoParamsList);
    this.updateStateSubscription();
    this.changesSubject.next({});
  }

  private updateStateSubscription(): void {
    this.stateSubscription.unsubscribe();
    this.stateSubscription = this.state.changes.subscribe(() =>
      this.onStateChanged(this.getSerializedState()),
    );
  }

  private getSerializedState(): TodoParams[] {
    return this.state.getItems().map((todo) => this.todoFactory.serializeTodo(todo));
  }

  destroy(): void {
    this.stateSubscription.unsubscribe();
  }

  getItems(): Todo[] {
    return this.state.getItems();
  }

  getCompletedItems(): Todo[] {
    return this.state.getCompletedItems();
  }

  getUncompletedItems(): Todo[] {
    return this.state.getUncompletedItems();
  }

  add(todoParams: TodoParams): void {
    this.state.add(todoParams);
  }

  canUndo(): boolean {
    return this.history.hasPrev();
  }

  canRedo(): boolean {
    return this.history.hasNext();
  }

  undo(): void {
    this.history.switchToPrev();
    this.onHistorySwitched();
  }

  redo(): void {
    this.history.switchToNext();
    this.onHistorySwitched();
  }
}

import { TodoParams } from 'src/core/TodoFactory';
import { HistoricalTodoList } from './HistoricalTodoList';
import { TodoListApi } from './TodoListApi';

export class ResolvableTodoList extends HistoricalTodoList {
  constructor(private api: TodoListApi) {
    super();
  }

  async resolve(): Promise<void> {
    const todoParamsList = await this.api.getItems();
    this.history.reset(todoParamsList);
    this.updateState(todoParamsList);
  }

  protected onStateChanged(params: TodoParams[]): void {
    super.onStateChanged(params);
    this.api.save(params).catch(() => this.undo());
  }

  protected onHistorySwitched(): void {
    super.onHistorySwitched();
    this.api.save(this.history.getState()).catch(() => {});
  }
}

جاء حل مشكلة الحفظ من تلقاء نفسه. الآن لا يمكننا القلق بشأن استراتيجية الحفظ التي سيختارها العميل على المدى الطويل. يمكنك تزويده بجميع الخيارات الثلاثة للاختيار من بينها ، وتوسيع الفئة الأساسية.

ملخص


يبدو أنه من قائمة المهام لدينا لدينا نموذج أولي صغير من Google Keep. رابط إلى المستودع لبدء تشغيل التطبيق أو استعراض تاريخ الالتزامات.

ما الذي يميز هذا المثال بشكل أساسي عن معظم التطبيقات الأمامية؟ لم نعتمد على المكتبات ، لذلك ، يمكن للشخص الذي لم يعمل مع React أن يفهم هذا التطبيق. كانت قراراتنا تهدف فقط إلى الحصول على النتيجة ، دون تشتيت الانتباه حول تفاصيل الإطار ، لذلك يعكس الرمز إلى حد ما المشكلة التي يتم حلها. تمكنا من تسهيل إضافة أنواع جديدة من عناصر Todo ، ونحن على استعداد لتغيير استراتيجية الحفظ.

ما الصعوبات التي واجهناها؟ قمنا بحل مشكلة تحديث العرض باستخدام نمط المراقب دون الرجوع إلى الإطار. كما اتضح ، كان تطبيق هذا النمط لا يزال مطلوبًا لحل المشكلة الرئيسية (حتى لو لم نكن بحاجة إلى رسم HTML). لذلك ، لم نتحمل تكاليف بالتخلي عن "خدمات" نظام الكشف عن التغيير المضمّن في الإطار.

أود أن أؤكد أن كتابة الاختبارات لم تكن بأي صعوبة. اختبار كائنات مستقلة بسيطة مع واجهة إعلامية هو متعة. يعتمد تعقيد الشفرة فقط على المهمة نفسها ومهاراتي (أو الانحناء).

ماذا عن مستوى المطور الذي سيتعامل مع هذا؟ هل يستطيع Junior React Developer كتابة مثل هذا الحل؟ "البرمجة أشبه ما تكون بالحرف اليدوية" ، لذلك بدون ممارسة استخدام أسلوب OOP وأنماطه ، أعتقد أنه سيكون صعبًا. لكنك أنت وشركتك تقرران ما تستثمر فيه. هل تمارس OOP أو تفهم تعقيدات الإطار التالي؟ لقد أصبحت مقتنعًا مرة أخرى فقط بأهمية الأعمال الأدبية للمبرمجين ذوي الخبرة ، وأظهرت كيفية البدء في استخدام نصيحة الكلاسيكيين بكامل طاقتهم في المقدمة.

شكرا للقراءة!

All Articles