History has always evolved in the sparks of new ideas. From the emergence of React, the front-end has gradually changed from the wild days of jQuery to three camps “competing with each other”. Several major frameworks address the driving relationship between the data layer and the view layer. When the data changes, the frame itself controls the rendering of the eye-to-eye layer, and the key is to rely on collection. How do you know that the data has changed? So Vue uses defineProperty to directly hijack the original operation, angular uses dirty checks to listen for all possible data changes, and the React convention can only trigger data changes using its own setStateAPI. And it’s only in this burgeoning era that we’re starting to think about data flow.
Ask questions
Now that we have a raw object data, we want a callback that can be triggered when the data changes, for example:
var obj = {
name:'Jack'
}
someFunc(function(){
// You want to trigger this function automatically when the value of obj is changed
console.log(obj.name);
});
obj.name = 'Nico';// Or execute some other function that changes the value
Copy the code
Of course, the data type of our object is definitely not fixed, it may be Array, Map, Set, etc., when it executes some native methods (such as push, splice, etc.) to change its value, we need to execute in the convention callback function.
To solve the problem
Regardless of the popular data flow frameworks in the industry, how would we implement them if we did it ourselves? If we don’t rely on any API, we can just write a simple observer model. If we use defineProperty, we can just hijack the set and GET methods.
- Observer model
// Suppose our data object is of fixed type {name:string}
function Observer(obj) {
var self = this;
this._listener = {};
Object.keys(obj).forEach(function (key) {
self._listener[key] = [];
});
}
Observer.prototype.subscribe = function (key, func) {
if (!this._listener[key]) {
this._listener[key] = [];
}
this._listener[key].push(func)
}
Observer.prototype.publish = function () {
var key = Array.prototype.slice.call(arguments);
var clients = this._listener[key];
for (var i = 0; i < clients.length; i++) {
clients[i].apply(this.arguments); }}var object = {name: 'Jack'};
var observer = new Observer(object);
observer.subscribe('name'.function () {
console.log('Hello '+object.name);
});
function changeName(val) {
if(object.name ! == val) { object.name = val; observer.publish('name');
}
}
changeName('Nico');//Hello Nico
Copy the code
There is no need to make fun of the coarsedness of the code. In general, the crude code does what we want, as a way to solve the problem: subscribe directly to a property of the data object, and execute a callback to the subscription when the value of the property changes. The biggest problem, though, is that this assumes a fixed data structure, and if that structure changes, everything becomes uncontrollable.
- defineProperty
var object = {name: 'Jack'};
var value;
Object.defineProperty(object, 'name', {
enumerable: true.configurable: true.set: function (val) {
value = val;
reaction();
},
get: function () {
returnvalue; }});function reaction() {
console.log("Hello " + object.name);
}
object.name = "Nico";//Hello Nico
Copy the code
Also a rough code, this code seems to have a lot less implementation logic than the above, but the same problem, when our data structure changes, the entire implementation logic has to be written again.
Since we are in a short period of time can’t in the moment gives a universal solution, might as well take a look at the industry is how to deal with the problem of bosses, naturally have to mention Redux, Mobx, perhaps, to see you here to laugh again, but this does not affect my next to their understanding, also hope to inspire you.
The philosophy of the story
Redux is a well-known Facebook open-source data flow solution framework with several Flux and ReFlux ReFlux offerings, but it was originally designed to be a data-flow solution for React, which insists it is just a View layer processing. As a result, Redux does not have to be used in conjunction with React. It provides a functional and modular data flow design solution that can be used with jQuery and others. Let’s use Redux with the above example to see its charm again:
var Redux = require('redux');
var object = {
name: 'Jack'
}
var store = Redux.createStore(function (initState = object, action) {
switch (action.type) {
case 'change':
return Object.assign({}, initState, {
name: action.value
});
default:
returninitState; }}); store.subscribe(function () {
console.log('Hello ' + store.getState().name);
});
store.dispatch({
type: 'change'.value: 'Nico'
});
Copy the code
This code is probably familiar to you. This functional, modular code style makes the whole data flow process look very elegant, which is the most attractive part of Redux. On closer inspection, the whole idea is to pass in our original data object, subscribe to a callback function, and dispatch the data. Perhaps different from the observer pattern we described above, data changes are passed directly to Redux as initialized parameters (Redux, of course, calls this reducer). Can we adapt this code to work like Redux?
/ / define the store
function createStore(reducer) {
let currentState = undefined;
let listeners = [];
function dispatch(action) {
currentState = reducer(currentState, action);
for (var i in listeners) {
listeners[i]();
}
return currentState;
}
dispatch({
type: 'INIT'
});
return {
dispatch: dispatch,
subscribe: function (callback) {
listeners.push(callback);
},
getState: function () {
returncurrentState; }}}/ / create a store
let store = createStore(function (state = {name: "Jack"}, action) {
switch (action.type) {
case 'change':
return Object.assign({}, state, {
name: action.value
});
default:
returnstate; }}); store.subscribe(function () {
console.log('Hello '+store.getState().name);//Hello Nico
})
store.dispatch({
type: 'change'.value: 'Nico'
});
Copy the code
At first glance, no, maybe you’re right. These 20 or so lines of code do make it work like Redux. (In fact, the Redux source code is probably only about 200 lines without comments. There are more interfaces like combineReducers and applyMiddleware for plugins), and the overall idea is pretty much the same as the observer mode we talked about above, but who would have thought you could organize code in this way?
After rational thinking, this process can indeed solve the problem we raised at the beginning, but a new “problem” (purely personal opinion) comes along with it. First, every time we dispatch, the callback function will be triggered, which may not be a problem in React. However, when combined with a diff algorithm framework such as jQuery without the virtual DOM, this undiscriminating triggering method becomes a bit uncomfortable. Second, the state structure is directly modified in reducer, which will also lead to some unexpected bugs, as can be seen from the above code, which is a mutable data, so Redux repeatedly emphasized that state cannot be directly modified, but should be returned in the form of a new data. This idea of isolating the side effects of data operations in one step can indeed solve many problems, but all data operations will be done by reducer one by one. This “huge” code organization really makes people a little uncomfortable.
The realization of the Mobx
Mobx is also a battle-tested library, so let’s first look at using it to solve our problems:
var Mobx = require('mobx');
var object = Mobx.observable({
name: 'Jack'
});
Mobx.autorun(function () {
console.log('Hello ' + object.name);
});
object.name = 'Nico'
Copy the code
Compared to the Redux code mentioned above, the biggest experience is that the amount of code is greatly reduced, after the configuration can directly manipulate the object can be triggered in the callback. Obviously, this is done by hijacking the original assignment of data, but Mobx has a much more robust way of handling data than we mentioned above with defineProperty, but it’s pretty simple to say and can be generalized completely. But the technical details are very elaborate. On the explanation of Mobx source code, there must be a lot of articles on the Internet, I also after several twists and turns, starting from scratch with proxy Mobx this article only slowly understand the mystery. Speaking of which, as an aside, I strongly recommend the blog of @Ascoders.
Of course, you must have guessed what I’m going to show you next, after reading the above code, I’m sure you don’t have any pressure in your heart, it’s all from the most basic simple, and the decomposition of Mobx is “trying hard” as always.
From the start, it’s pretty obvious that we only need two functions. One listens on the raw data object, passes it in as a parameter, and hijacks its assignment. Call it Observable. We also need a function to pass in the data of the callback function we need to perform. This callback function will be triggered when the data changes. This function is called observe.
So how can these two functions fulfill our requirements? At the heart of the whole data flow framework is the dependency collection and trigger callback. Dependency collection is bound to the get method of the data, which means that as long as the fetch is performed, we can know which fields of the data need to be “listened” to trigger the callback:
new Proxy(object,{
/** ** @param target Raw data object to be counted * @param key Key value of raw data object to be counted * @param receiver */
get(target, key, receiver) {
let value = Reflect.get(target, key, receiver);
// We can use the target+key relationship as a listener. return value; }})Copy the code
The rest of the callback method must be on the set operation of the data, meaning that when the data is changed, we also execute the corresponding callback function based on the object+key:
new Proxy(object,{
set(target, key, value, receiver) {
const oldValue = Reflect.get(target, key, receiver);
const result = Reflect.set(target, key, value, receiver);
if(value ! == oldValue) {// Execute the corresponding callback function according to target+key. }returnresult; }})Copy the code
(Here, we only “store” the value that needs to be fired and fire it when it is assigned. For example, we only need the name property of object in the callback. We only fire the callback when the name property is changed.
Since set and GET are completely separate operations, a persistent global object, globalState, is intended to be used as a relational store for target+key. Name it objectReactionBindings:
class GlobalState {
public objectReactionBindings = new WeakMap<object,Map<PropertyKey,Set<Reaction>>> ();
}
Copy the code
The data structure of this object will be stored in the target+key relation object, so that we can do the dependency collection by target+key in get, and then do the corresponding callback firing by target+key in set.
Through the above analysis, we observables function is already under way to how to write, there is a callback should observe how haven’t “get”, because we’ll have before the data is assigned to know the specific needs of “listening” is what data, or when the data is changed all don’t know which callback to trigger. As mentioned above, the dependency collection function must be triggered by the GET method. Naturally, we need to perform the observe callback function when the data is initialized to complete the data collection.
Given that the callback is triggered in the set method, the callback will be “mounted” to globalState. In order to extend the other operations to make the “callback” more flexible, we would like it to be a “reaction” object that can be used to trigger the callback. We can also extend operations on some other parameters, which we call Reaction:
type IFunc=(. args:any[]) = >any;
class Reaction {
private callback:IFunc|null;
constructor(callback:IFunc) {this.callback=callback;
}
publictrack(callback? : IFunc) {if(! callback) {return;
}
try {
callback();
} finally{... }}public run() {
if (this.callback) {
this.callback(); }}}Copy the code
Naturally, our observe function can also be written to behave like this:
declare type Func=(. args:any[]) = >any;
function observe(callback:Func){
const reaction = new Reaction((a)= >{
reaction.track(callback);
});
reaction.run();
}
Copy the code
In this code, after reaction is initialized, the run method is executed, and the logic is that the dependency collection mentioned above is executed immediately after initialization.
Finally, we just need to add the set and get logic we started with:
function observable<T extends object> (obj:T = {} as any) :T{
return new Proxy(obj, {
get(target, key, receiver) {
let value = Reflect.get(target, key, receiver);
bindCurrentReaction(target, key);
return value;
},
set(target, key, value, receiver) {
const oldValue = Reflect.get(target, key, receiver);
const result = Reflect.set(target, key, value, receiver);
if(value ! == oldValue) { queueRunReactions<T>(target, key); }returnresult; }}); }// Bind target+key reaction to globalState
function bindCurrentReaction<T extends object> (object: T, key: PropertyKey) {
const { keyBinder } = getBinder(object, key);
if (!keyBinder.has(globalState.currentReaction)) {
keyBinder.add(globalState.currentReaction);
}
}
// The globalState callback is triggered by the query target+key
function queueRunReactions<T extends object> (target: T, key: PropertyKey) {
const { keyBinder } = getBinder(target, key);
Array.from(keyBinder).forEach(reaction= > {
reaction.forEach(observer= >{ observer.run(); })}); }function getBinder(object: any, key: PropertyKey) {
let keysForObject = globalState.objectReactionBindings.get(object);
if(! keysForObject) { keysForObject =new Map(a); globalState.objectReactionBindings.set(object, keysForObject); }let reactionsForKey = keysForObject.get(key);
if(! reactionsForKey) { reactionsForKey =new Set(a); keysForObject.set(key, reactionsForKey); }return {
binder: keysForObject,
keyBinder: reactionsForKey
};
}
Copy the code
Of course, there are some concurrent execution, debugging, and object support such as maps and sets, so the actual code and logic are much more complex than the above code. If you are interested, go to the Git repository to see the source code.
Mobx is also based on this implementation, but Mobx4 does not use a Proxy object for Proxy processing, but defineProperty, which makes it need to collect and bind data through some other parameter objects. Common Array objects, for example, are hijacked in Mobx4 for all operations on arrays, making them return “new objects” that behave like native objects.
If Redux is a bit of a pain in the ass, Mobx isn’t perfect, either, and perhaps its biggest drawback is that it’s hard to find anything wrong with it.
At the end of the day, whatever data flow tools are in the front end are there to solve problems. Now that you have the data layer solution, all that remains is to get through the operations at the view layer. Vue uses built-in “data flow” to bind dependent DOM nodes to specific data objects so that it can automatically complete DOM updates, essentially the same as “Mobx” with DOM nodes. React uses the updated virtual DOMDiff algorithm to render the changes, essentially to greatly simplify unnecessary and tedious view operations (otherwise why not just use jQuery). I personally dislike Vue’s cumbersome binding notation and React’s diff algorithm. (Is this comparison really necessary in modern browsers? Ideally, use Vue like React without extra diff processing and frame bindings.