For a start, mobx, although it is compared with other libraries as a library for managing a state, provides virtually no convenience for working with the state, except for invoking the update of the components of the reactor after the property marked by @observable
decorator changes. We can easily throw away mobx by removing all @observable
and @observer
decorators and get a working application by adding just one line of update()
at the end of all event handlers where we change the state data that is displayed in the components.
onCommentChange(e){ const {comment} = this.props; comment.text = e.target.value; update(); // }
and the update () function will simply call the "rerender" of the reactive application and, thanks to the virtual thought of the reactor, only diff changes will be applied in the real duma
function update(){ ReactDOM.render(<App>, document.getElementById('root'); }
To say that mobx is a whole state manager because it allows you to save one line of update()
in the handlers, somehow too much.
That is, mobx is not the library that simplifies working with the state - so what is its main task? Its main task is to pinpoint update of components, namely, to cause an update of only those components that depend on the data that have changed.
In the example above, every time any data in the application is changed, we perform the "rerender" (virtual thought comparison) of the entire application by calling ReactDOM.render(<App>, document.getElementById('root'))
in the update()
function and, as you can guess, it affects the performance, and on large applications the interface will inevitably slow down.
this.forceUpdate()
only those components in which the data that they output has changed.And this problem is exactly solved by the mobx library and part of the redux library.
But let's try to solve the problem of the point update of components without taking into account these two libraries.
The first approach is to use Immunity and Binary Search - if each state update returns new data objects that have changed and all parent objects (for the case when the state has a hierarchical structure) then we can achieve almost pinpoint update of the components by comparing references to the previous and new state and skip all the subtrees of the components whose data has not changed (newSubtree === oldSubtree) and as a result we will update our application by calling the rerender only the necessary com onenta comparing with data only O (log (n)) components where n - is the number of components.
ChangeDetectionStrategy.OnPush
setting to it. But the top-down solution has a couple of flaws. First, despite the effectiveness of O (log (n)), if a component displays a list of other components, then we have to run through the entire array of components to compare their props, and if each component of the list renders another list then the number of comparisons increases even more. Secondly, the component should depend only on its props which often have to be thrown over the nested components through intermediate ones.mapStateToProps()
function returned (in connect
decorator) in which we specify the dependence on different parts of the state and then they become additional props. But for this redux is forced to perform a check of all n connected components. But even this is still faster than doing an update ( ReactDOM.render(<App>, rootEl);
) of the entire application.But the immutable approach has a couple of serious flaws that impose restrictions on working with the condition.
The first drawback is that we can not now just take and update any property of the data object in the application. Because of the requirement to return each time a new immutable object of the integer state, we need to return a new object and also re-create all parent objects and arrays. For example, if a state object stores an array of projects, each project stores an array of tasks, and each task stores an array of comments:
let AppState = { projects: [ {..}, {...}, {name: 'project3', tasts: [ {...}, {...}, {name: 'task3', comments: [ {...}, {...}, {text: 'comment3' } ]} ]} ] }
So in order to update the text of the comment object, we cannot simply execute comment.text = 'new text'
- we need to first re-create the comment object ( comment = {...comment, text: 'updated text'}
), then we need to recreate the task object and copy links to other comments from there ( task = {...task, tasks: [...task.comments]}
), then recreate the project object and copy references to other tasks there ( project = {...project, tasks: [...project.tasks]}
) and at the end, recreate the state object and also copy links to other projects ( AppStat = {...AppState, projects: [...AppState.projects]}
) .
The second drawback is the impossibility of storing objects that refer to each other in a state. If we need somewhere in the component's handler, we need to get the project in which the task is located - then we cannot simply assign a reference to the parent project when creating the object - task.project = project
because the need to return a new object not only for the task, but also for project leads to the fact that we need to update all other tasks in the project - after all, the link to the project object has changed, which means that we need to update all the tasks by assigning a new link, and updating as we know needs to be done through re-creating object, and if the task is stored comments, we need to do to recreate all of the comments, because they store a reference to the object of the problem, and so we come to recursively recreate the entire state and it would be terribly slow.
As a result, we have to either change the props of the higher-level components each time to transfer the necessary object, or instead of referring to the object, save the task task.project = '12345';
and then somewhere to store and maintain the hash of projects by their ProjectHash ProjectHash['12345'] = project;
Since the solution with immunity has a lot of flaws, let's think about whether it is possible to solve the problem of the point update of components in another way? When we need to change the data in the application, we need to re-render only those components that depend on this data. What means depend? For example, there is a simple comment component that renders the comment text.
class Comment extends React.Component { render(){ const {comment} = this.props; return <div>{comment.text}</div> } }
This component depends on comment.text
and needs to be updated each time comment.text
changes. But also if the component displays <div>{comment.parent.text}</div>
but now you need to update the component each time not only .text
but also .parent
. We can solve this problem without using any immetablistic approach, but using the possibilities of javascript getters and setters, and this is the second approach I know to solve the point update ui problem.
Object.defineProperty(comment, 'text', { get(){ console.log('>text getter'); return this._text; }, set(val){ console.log('>text setter'); this._text = val; } }) comment.text; // >text getter comment.text = 'new text' // >text setter
So, we can put a function on the setter that will be executed each time a new value is assigned and we will call a re-render of the list of components that depend on this property. In order to find out which components depend on which properties, you need to assign the current component to a global variable at the beginning of the render()
function, and when you call any getter object property, add the current component to the dependency list of this property, which is in the global variable. And since the components can be “rendered” tree one must still remember to return the previous component back to this global variable.
let CurrentComponent; class Comment extends React.Component { render(){ const prevComponent = CurrentComponent; CurrentComponent = this; const {comment} = this.props; var result = <div>{comment.text}</div> CurrentComponent = prevComponent; return result } } comment._components = []; Object.defineProperty(comment, 'text', { get(){ this._components.push(CurrentComponent); return this._text }, set(val){ this._text = val; this._components.forEach(component => component.setState({})) } })
I hope you get the idea. With this approach, each property will store an array of its dependent components and, if the property changes, will trigger their update.
Now, in order not to mix the storage of an array of dependent components with data and to simplify the code, we move the logic of such a property to the Cell class, which, as can be seen from the analogy, is very similar to the principle of the cells in excel - if other cells contain formulas on which the current the cell is needed when changing the value to cause updates of all dependent cells.
let CurrentObserver = null; class Cell { constructor(val){ this.value = val; this.reactions = new Set(); // es6 } get(){ if(CurrentObserver){ this.reactions.add(CurrentObserver); } return this.value; } set(val){ this.value = val; for(const reaction of this.reactions){ reaction.run(); } } unsibscribe(reaction){ this.reactions.delete(reaction); } }
But the role of a cell with the formula will be played by the ComputedCell
class which inherits from the Cell
class (because other cells may depend on this cell). The ComputedCell
class takes in a constructor a function (formula) for recalculation and also optionally a function for performing side effects (such as calling .forceUpdate()
components)
class ComputedCell extends Cell { constructor(computedFn, reactionFn, ){ super(undefined); this.computedFn = computedFn; this.reactionFn = reactionFn; } run(){ const prevObserver = CurrentObserver; CurrentObserver = this; const newValue = this.computedFn(); if(newValue !== this.value){ this.value = newValue; CurrentObserver = null; this.reactionFn(); this.reactions.forEach(r=>r.run()); } CurrentObserver = prevObserver; } }
And now, in order not to perform the installation of getters and setters every time, we will use decorators from typescript or babel. Yes, this imposes restrictions on the need to use classes and create objects not through the literal const newComment = {text: 'comment1'}
but through const comment = new Comment('comment1')
but instead of manually installing getters and setters, we can conveniently mark the property as @observable
and continue to work with it as with the usual property.
class Comment { @observable text; constructor(text){ this.text = text; } } function observable(target, key, descriptor){ descriptor.get = function(){ if(!this.__observables) this.__observables = {}; const observable = this.__observables[key]; if (!observable) this.__observables[key] = new Observable() return observable.get(); } descriptor.set = function(val){ if (!this.__observables) this.__observables = {}; const observable = this.__observables[key]; if (!observable) this.__observables[key] = new Observable() observable.set(val); } return descriptor }
And in order not to work directly with the ComputedCell
class inside the component, we can @observer
this code to the @observer
decorator, which simply wraps the render()
method and creates a calculated cell on the first call, passing the render()
method as a formula this.forceUpdate()
call this.forceUpdate()
(in reality, you also need to add a reply in the componentWillUnmount()
method and some moments of the correct wrapping of the components of the reactor, but for now we will keep this option for ease of understanding)
function observer(Component) { const oldRender = Component.prototype.render; Component.prototype.render = function(){ if (!this._reaction) this._reaction = new ComputedCell(oldRender.bind(this), ()=>this.forceUpdate()); return this._reaction.get(); } }
and we will use as
@observer class Comment extends React.Component { render(){ const {comment} = this.props; return <div>{comment.text}</div> } }
Link to the demo
import React from 'react'; import { render } from 'react-dom'; let CurrentObserver; class Cell { constructor(val) { this.value = val; this.reactions = new Set(); } get() { if (CurrentObserver) { this.reactions.add(CurrentObserver); } return this.value; } set(val) { this.value = val; for (const reaction of this.reactions) { reaction.run(); } } unsubscribe(reaction) { this.reactions.delete(reaction); } } class ComputedCell extends Cell { constructor(computedFn, reactionFn) { super(); this.computedFn = computedFn; this.reactionFn = reactionFn; this.value = this.track(); } track(){ const prevObserver = CurrentObserver; CurrentObserver = this; const newValue = this.computedFn(); CurrentObserver = prevObserver; return newValue; } run() { const newValue = this.track(); if (newValue !== this.value) { this.value = newValue; CurrentObserver = null; this.reactionFn(); } } } function observable(target, key) { return { get() { if (!this.__observables) this.__observables = {}; let observable = this.__observables[key]; if (!observable) observable = this.__observables[key] = new Cell(); return observable.get(); }, set(val) { if (!this.__observables) this.__observables = {}; let observable = this.__observables[key]; if (!observable) observable = this.__observables[key] = new Cell(); observable.set(val); } } } function observer(Component) { const oldRender = Component.prototype.render; Component.prototype.render = function(){ if (!this._reaction) this._reaction = new ComputedCell(oldRender.bind(this), ()=>this.forceUpdate()); return this._reaction.get(); } } class Timer { @observable count; constructor(text) { this.count = 0; } } const AppState = new Timer(); @observer class App extends React.Component { onClick=()=>{ this.props.timer.count++ } render(){ console.log('render'); const {timer} = this.props; return ( <div> <div>{timer.count}</div> <button onClick={this.onClick}>click</button> </div> ) } } render(<App timer={AppState}/>, document.getElementById('root'));
In our example, there is one drawback - what if component dependencies can change? Take a look at the next component.
class User extends React.Component { render(){ const {user} = this.props; return <div>{user.showFirstName ? user.firstName : user.lastName}</div> } }
The component depends on the user.showFirstName
property and further, depending on the value, it can depend on either user.firstName
or user.lastName
, that is, if user.showFirstName == true
, then we should not react to the change of user.lastName
and vice versa if user.showFirstName
changed to false then we should not react (and re-render the component) if the user.firstName
property user.firstName
;
This moment is easily solved by adding the dependencies list of this.dependencies = new Set()
to the cell class and a small logic to the run()
function - so that after calling the render () reagent we compare the previous dependencies list with the new ones and unsubscribe from irrelevant dependencies.
class Cell { constructor(){ ... this.dependencies = new Set(); } get() { if (CurrentObserver) { this.reactions.add(CurrentObserver); CurrentObserver.dependencies.add(this); } return this.value; } } class ComputedCell { track(){ const prevObserver = CurrentObserver; CurrentObserver = this; const oldDependencies = this.dependencies; // this.dependencies = new Set(); // const newValue = this.computedFn(); // for(const dependency of oldDependencies){ if(!this.dependencies.has(dependency)){ dependency.unsubscribe(this); } } CurrentObserver = prevObserver; return newValue; } }
The second point is that if we immediately change a lot of properties in the object? Since dependent components will be updated synchronously, we will receive two extra component updates.
comment.text = 'edited text'; // comment.editedCount+=1; //
To avoid unnecessary updates, we can set a global flag at the beginning of this function and our @observer
decorator will not immediately call this.forceUpdate()
but will only call it when we remove this flag. And for simplicity, we will move this logic to the action
decorator and, instead of the flag, we will increase or decrease the counter because decorators can be called inside other decorators.
updatedComment = action(()=>{ comment.text = 'edited text'; comment.editedCount+=1; }) let TransactionCount = 0; let PendingComponents = new Set(); function observer(Component) { const oldRender = Component.prototype.render; Component.prototype.render = function(){ if (!this._reaction) this._reaction = new ComputedCell(oldRender.bind(this), ()=>{ TransactionCount ?PendingComponents.add(this) : this.forceUpdate() }); return this._reaction.get(); } } function action(fn){ TransactionCount++ const result = fn(); TransactionCount-- if(TransactionCount == 0){ for(const component of PendingComponents){ component.forceUpdate(); } } return result; }
As a result, such an approach using the very old "observer" pattern (not to be confused with observable RxJS) is much better suited for the implementation of the task of a point update of components than the approach using immunity.
Among the shortcomings, you can only notice the need to create objects not through literals but through classes, which means that we cannot simply accept some data from the server and transfer to the components - it is necessary to carry out additional data processing by wrapping it in objects of classes with @observable
decorators.
mapStateToProps
function does not occur. Secondly, when we need to update some data, we can just write comment.text = 'new text'
and we don’t have to do much more work on updating the parent state objects, and what's important is that there will be no load on the garbage collector due to permanent re-creation of objects. Well and most importantly - we can simulate states with the help of objects that link to each other and can comfortably work with the state without having to store an ID for the object instead of it and then pull each time from the hash AppState.folders[AppState.projects[AppState.tasks[comment.taskId].projectId].folderId].name
instead of a simple reference to comment.task.project.folder.name
If you understand these examples, then congratulations - you now understand how the "magic" of mobx works from within. mobx @computed
( ) mobx- react-.
Source: https://habr.com/ru/post/340592/
All Articles