Як написати компонент React, не використовуючи класи або хуки

З виходом React Hooks я бачив багато публікацій, які порівнювали компоненти класу з функціональними компонентами. Функціональні компоненти не є чимось новим у React, однак до версії 16.8.0 було неможливо створити компонент із станом з доступом до хуків життєвого циклу, використовуючи лише функцію. Або це було?

Називайте мене педантом (багато людей вже це роблять!), Але коли ми говоримо про компоненти класу, ми технічно говоримо про компоненти, створені функціями. У цьому пості я хотів би використовувати React, щоб продемонструвати, що насправді відбувається, коли ми пишемо клас у JavaScript.

Класи проти функцій

По-перше, я хотів би дуже коротко показати, як те, що зазвичай називають функціональними та класовими компонентами, співвідноситься між собою. Ось простий компонент, написаний як клас:

class Hello extends React.Component { render() { return 

Hello!

} }

І тут це записано як функцію:

function Hello() { return 

Hello!

}

Зверніть увагу, що функціональний компонент - це просто метод візуалізації. Через це ці компоненти ніколи не могли утримувати власний стан або виконувати будь-які побічні ефекти в певних точках протягом свого життєвого циклу. Починаючи з React 16.8.0, стало можливим створювати функціональні компоненти, що містять статус, завдяки хукам, що означає, що ми можемо перетворити такий компонент:

class Hello extends React.Component { state = { sayHello: false } componentDidMount = () => { fetch('greet') .then(response => response.json()) .then(data => this.setState({ sayHello: data.sayHello }); } render = () => { const { sayHello } = this.state; const { name } = this.props; return sayHello ? 

{`Hello ${name}!`}

: null; } }

У такий функціональний компонент:

function Hello({ name }) { const [sayHello, setSayHello] = useState(false); useEffect(() => { fetch('greet') .then(response => response.json()) .then(data => setSayHello(data.sayHello)); }, []); return sayHello ? 

{`Hello ${name}!`}

: null; }

Мета цієї статті не в тому, щоб сперечатися, що один кращий за інший, оскільки на цю тему вже є сотні публікацій! Причина показу двох компонентів, наведених вище, полягає в тому, щоб ми могли зрозуміти, що насправді React робить з ними.

У випадку компонента класу React створює екземпляр класу за допомогою newключового слова:

const instance = new Component(props); 

Цей екземпляр є об’єктом. Коли ми говоримо, що компонент є класом, ми маємо на увазі те, що він є об'єктом. Цей новий об’єктний компонент може мати власний стан та методи, деякі з яких можуть бути методами життєвого циклу (render, componentDidMount тощо), які React викликатиме у відповідні точки протягом життя програми.

З функціональним компонентом React просто називає його як звичайну функцію (оскільки це звичайна функція!), І він повертає або HTML, або більше компонентів React.

Тепер потрібно імпортувати методи, за допомогою яких обробляти стан компонента та запускати ефекти в точках протягом життєвого циклу компонента, якщо вони потрібні. Вони працюють цілком виходячи з того порядку, в якому їх викликає кожен компонент, який їх використовує, оскільки вони не знають, який компонент їх викликав. Ось чому ви можете викликати хуки лише на верхньому рівні компонента, і їх не можна викликати умовно.

Функція конструктора

JavaScript не має класів. Я знаю, схоже, у нього є класи, ми щойно написали два! Але під кришкою JavaScript не є мовою, заснованою на класах, вона заснована на прототипі. Класи були додані до специфікації ECMAScript 2015 (також іменованої як ES6) і є лише чистішим синтаксисом існуючої функціональності.

Давайте спробуємо переписати компонент класу React без використання синтаксису класу. Ось компонент, який ми збираємося відтворити:

class Counter extends React.Component { constructor(props) { super(props); this.state = { count: 0 } this.handleClick = this.handleClick.bind(this); } handleClick() { const { count } = this.state; this.setState({ count: count + 1 }); } render() { const { count } = this.state; return (  +1 

{count}

); } }

Це робить кнопку, яка збільшує лічильник при натисканні, це класика! Перше, що нам потрібно створити, це функція конструктора, яка буде виконувати ті самі дії, що і constructorметод у нашому класі, крім виклику, superоскільки це річ лише для класу.

function Counter(props) { this.state = { count: 0 } this.handleClick = this.handleClick.bind(this); } 

Це функція, яку React викличе з newключовим словом. Коли функція викликається разом з newнею, вона розглядається як функція конструктора; створюється новий об'єкт, на thisнього вказується змінна, і функція виконується разом із новим об'єктом, який використовується там, де thisзгадується.

Далі нам потрібно знайти дім для renderі handleClickметодів, і для цього нам слід поговорити про ланцюжок прототипів.

Ланцюжок прототипів

JavaScript дозволяє успадкувати властивості та методи між об'єктами через щось, відоме як ланцюжок прототипів.

Ну, я кажу про спадщину, але насправді маю на увазі делегування. На відміну від інших мов з класами, де властивості копіюються з класу до його екземплярів, об'єкти JavaScript мають внутрішнє посилання прототипу, яке вказує на інший об'єкт. Коли ви викликаєте метод або намагаєтесь отримати доступ до властивості об’єкта, JavaScript спочатку перевіряє властивість самого об’єкта. Якщо він не може знайти його там, він перевіряє прототип об'єкта (посилання на інший об'єкт). Якщо він все ще не може його знайти, тоді він перевіряє прототип прототипу і так далі по ланцюжку, поки не знайде його або не закінчиться прототипи для перевірки.

Взагалі кажучи, усі об'єкти в JavaScript мають Objectвершину ланцюжка прототипів; це те, як ви маєте доступ до таких методів, як toStringі hasOwnPropertyна всіх об'єктах. Ланцюг закінчується, коли об’єкт досягається nullяк його прототип, як правило, це Object.

Спробуємо на прикладі все зрозуміти.

const parentObject = { name: 'parent' }; const childObject = Object.create(parentObject, { name: { value: 'child' } }); console.log(childObject); 

Спочатку ми творимо parentObject. Оскільки ми використовували синтаксис об’єкта, до якого цей об’єкт буде пов’язаний Object. Далі ми використовуємо Object.createдля створення нового об’єкта, використовуючи parentObjectяк його прототип.

Тепер, коли ми використовуємо console.logдля друку нашого, childObjectми повинні побачити:

console output of childObject

The object has two properties, there is the name property which we just set and the __proto___ property. __proto__ isn't an actual property like name, it is an accessor property to the internal prototype of the object. We can expand these to see our prototype chain:

expanded output of childObject

The first __proto___ contains the contents of parentObject which has its own __proto___ containing the contents of Object. These are all of the properties and methods that are available to childObject.

It can be quite confusing that the prototypes are found on a property called __proto__! It's important to realise that __proto__ is only a reference to the linked object. If you use Object.create like we have above, the linked object can be anything you choose, if you use the new keyword to call a constructor function then this linking happens automatically to the constructor function's prototype property.

Ok, back to our component. Since React calls our function with the new keyword, we now know that to make the methods available in our component's prototype chain we just need to add them to the prototype property of the constructor function, like this:

Counter.prototype.render = function() { const { count } = this.state; return (  +1 

{count}

); }, Counter.prototype.handleClick = function () { const { count } = this.state; this.setState({ count: count + 1 }); }

Static Methods

This seems like a good time to mention static methods. Sometimes you might want to create a function which performs some action that pertains to the instances you are creating - but it doesn't really make sense for the function to be available on each object's this. When used with classes they are called Static Methods. I'm not sure if they have a name when not used with classes!

We haven't used any static methods in our example, but React does have a few static lifecycle methods and we did use one earlier with Object.create. It's easy to declare a static method on a class, you just need to prefix the method with the static keyword:

class Example { static staticMethod() { console.log('this is a static method'); } } 

And it's equally easy to add one to a constructor function:

function Example() {} Example.staticMethod = function() { console.log('this is a static method'); } 

In both cases you call the function like this:

Example.staticMethod() 

Extending React.Component

Our component is almost ready, there are just two problems left to fix. The first problem is that React needs to be able to work out whether our function is a constructor function or just a regular function. This is because it needs to know whether to call it with the new keyword or not.

Dan Abramov wrote a great blog post about this, but to cut a long story short, React looks for a property on the component called isReactComponent. We could get around this by adding isReactComponent: {} to Counter.prototype (I know, you would expect it to be a boolean but isReactComponent's value is an empty object. You'll have to read his article if you want to know why!) but that would only be cheating the system and it wouldn't solve problem number two.

In the handleClick method we make a call to this.setState. This method is not on our component, it is "inherited" from React.Component along with isReactComponent. If you remember the prototype chain section from earlier, we want our component instance to first inherit the methods on Counter.prototype and then the methods from React.Component. This means that we want to link the properties on React.Component.prototype to Counter.prototype.__proto__.

Fortunately there's a method on Object which can help us with this:

Object.setPrototypeOf(Counter.prototype, React.Component.prototype); 

It Works!

That's everything we need to do to get this component working with React without using the class syntax. Here's the code for the component in one place if you would like to copy it and try it out for yourself:

function Counter(props) { this.state = { count: 0 }; this.handleClick = this.handleClick.bind(this); } Counter.prototype.render = function() { const { count } = this.state; return (  +1 

{count}

); } Counter.prototype.handleClick = function() { const { count } = this.state; this.setState({ count: count + 1 }); } Object.setPrototypeOf(Counter.prototype, React.Component.prototype);

As you can see, it's not as nice to look at as before. In addtion to making JavaScript more accessible to developers who are used to working with traditional class-based languages, the class syntax also makes the code a lot more readable.

I'm not suggesting that you should start writing your React components in this way (in fact, I would actively discourage it!). I only thought it would be an interesting exercise which would provide some insight into how JavaScript inheritence works.

Although you don't need to understand this stuff to write React components, it certainly can't hurt. I expect there will be occassions when you are fixing a tricky bug where understanding how prototypal inheritence works will make all the difference.

I hope you have found this article interesting and/or enjoyable. You can find more posts that I have written on my blog at hellocode.dev. Thank you.