The preface

Two years ago, WHEN I was working in a startup company, the company was acquired later. Without any business pressure, I wrote a MVVM framework similar to Vue and realized a Material Design style component library on this framework. The sparrow is small, but it also has all the five organs, so I have a little experience, ready to share with you. Yuhangge.geitee. IO/Jinge-mater…

First of all, I haven’t read the Vue source code. This MVVM framework is based on the well-known direction of “using ES6 Proxy for data binding”. Combined with my past business experience, I wrote a toy-level framework completely by myself, and also forgot to give a little tapping to everyone.

The bidirectional binding core involved in this article, the corresponding source is located at: github.com/jinge-desig…

API design

Let’s get down to business. We all know that the core of the MVVM framework is “bidirectional binding”, and this article chooses “Using ES6 Proxy” to implement this core. Let’s start by designing the core API.

vm

Obviously we need to Proxy a target Object. Such a conversion function is used whether the compiler generates the automatic invocation code or manually in the user’s business code. We’ll call it the VM function. Also, we define the Proxy Proxy result as a ViewModel (VM for short) type. See the comments in the following code:

/** * @param obj * / param obj * / */ function vm(obj: T extends Object): vm <T>; /** * The InnerTypes */ type VM<T> = Proxy<T & InnerType> given by the MVVM framework;Copy the code

watch

Whether the listener code is generated automatically by the compiler or written manually by the user business, a mount listener watch function is required to listen for property assignment operations on the target object. See the comments in the following code:

/** * @param VM is already a proxy object wrapped by the VM function above. * @param props Properties to listen on. The "*" and "**" wildcards are supported. * @param Listener A listener callback when the target object changes (or, more accurately, an assignment occurs). */ function watch(target: VM, propPath: PropertyPath, listener: Listener): void; The /** * attribute (path) can be an array or can be a dot. Interval string. Such as * "0. A. b. c is equivalent to" [" a ", "b", 0, "c"), is to monitor target. A. [0]. C,  */ type PropertyPath = string | number | (string | number)[]; /** * Type Listener = (propPath: PropertyPath) => void;Copy the code

Note that the Listener can only obtain the property path of the current assignment operation. The Listener cannot obtain the old and new values of the current assignment operation. That’s the philosophy of the framework, we don’t do dirty checks.

unwatch

The corresponding listener unwatch function also needs to be uninstalled. See the comment in the following code:

/** * @param target The object on which to cancel the listen * @param props The property path of the listen to cancel. This parameter is optional. If this parameter is not provided, cancel listening on all paths on the target object. * @param Listener Listener callback for the listener to cancel. Cancels all listening callbacks on this path if this parameter is not provided. */ function unwatch(target: VM, propPath? : PropertyPath, listener? : Callback): void;Copy the code

The test case

Let’s consider test-driven, and try to summarize typical use cases first. The reader can try to think of how the ability to write code to implement the ABOVE API, sufficient to support all of the following use cases, can be a good exercise.

Simple assignment

watch(target, 'a') target.a = 10; // Trigger the listener target.b = 20; / / do not trigger the watch (obj, 'a. 0. C') / / equivalent to watch (obj, [' a ', 0 'c']); obj.a[0].c = 3; // Trigger the listener functionCopy the code

Watch (target, ‘a.b’) and watch(target. A, ‘b’) are different:

watch(target, 'a.b') // #1 watch(target.a, 'b') // #2 target.a.b = 10; A = {}; // It will only trigger #1, not #2. // After line 4, #2 is no longer triggered. The old target.a object is no longer referenced and will be reclaimed.Copy the code

Parent object changes

watch(obj, 'a.b.c', listener) obj.a = {}; Obj.a.b = {}; // Trigger obj.a.b.c = "hello"; / / triggersCopy the code

An array of

Set the length

target = vm(['a', 'b', 'c', 'd']) watch(target, 'length') // #1 watch(target, [1]) // #2 target.length = 3; #2 target.length = 1; #1 and #2 watch(target, [6]); // #3 target.length = 10; // The listener functions #1 and #3 are triggeredCopy the code

All sorts of function

The various native functions of arrays need to support:

Target = vm([1,1,2,3,5]) watch(target, 'length') [1]) // #2 target.push(8) // Trigger #1 target.unshift(0); // Trigger #1, #2 // splice, pop, shift, slice...Copy the code

The wildcard

There is no need for wildcard capabilities inside the framework (templates and template compilers), but there will be capabilities in business code that require deep listening, so wildcard support is needed.

Single-layer wildcard *

You can listen on all level 1 paths on the target object:

watch(target, 'a.b.*.c'); target.a.b[0] = {}; // Trigger the listener target.a.b[1]. // Trigger target.a.e.o.c = 34; // Will not triggerCopy the code

Deep wildcard **

Equivalent to deep Watch:

target = [new Todo(), new Todo()]; // localStorage watch(target, '**', () => saveToLocalStorage(target)); // All three cases trigger the listener target.push(new Todo()); target.splice(1, 1); target[0].title = 'new title';Copy the code

The above use case comes from the actual Todo Demo and its source code. It is worth mentioning that this demo is an implementation of TodoMVC.

Combination and transfer

Parent and child objects can be associated. they can also be transferred or removed:

ObjA = vm ({a: {b: {}}}) / / parent object objB = ({}) / / vm subobject watch (objB, 'x') / / # 1 watch (objA, 'A.B.X') / / # 2 objB. X = 10; Obja.a.b = objB; // only #2 obja.a.b.x = 20; Objb.x = 30; // both #1 and #2 obja.a.b = null; // Remove child objects, only #2 objb. x = 40; ObjC = obja.a; // Shift watch(objC, 'b') from parent object // #3, equivalent to watch(obja.a, 'b') objc.b = "oo" // Both #2 and #3 obja.a = null; // only #2 objc.b = "ss"; // Only #3 will be emitted, no longer #2Copy the code

Dynamic composite monitor

Framework templates such as Vue are often written like this:

{{obj.a[obj.b.c][index].name}}</span>Copy the code

In this case, the render function listens on a path like watch(root, [“obj”, “a”, XX, YY, “name”]) where XX and YY are dynamic. In addition, XX itself is also a watch(root, “obj.b.c”) that needs further monitoring. That is, we need to be able to support this dynamic and composite listening capability.

In fact, the ability to dynamically compound listeners itself is not a capability directly supported by the bidirectional binding core, but rather a topic of how the template compiler uses the core capability to support this requirement. This is essentially a test case that is not part of the bidirectional binding core. But the purpose of listing it here is for those of you who like challenges to think ahead about how you can solve this dynamic and complex monitoring requirement. In a future article, we will cover the solution in the template compilation section.

Designed and implemented

Proxy

We first need to focus on how to proxy the target object. As you know, for objects with only one layer, such as {a: 1, b: ‘b’}, proxies can simply capture attribute assignments on them.

But for multilevel objects, such as target = {a: {b: [{c: 0}, {d: A.b [0]. C = 1 if target is used as a Proxy, there is a problem of how to detect the assignment of path A.B. 0. C when target.

The scheme we adopt is a recursive proxy for deep traversal of the target object. Instead of simply propping target, as shown above, target and all its children are propped. insrc/vm/proxy.ts,createViewModel 和 wrapPropThe two functions call each other to form a recursive deep proxy.

ViewModelCore

When you use the watch function to mount a listener on a target object, you need a place to store the listener. When using the emitter. On function, the listener is saved. Using emitters. Emit will notify (fire) the corresponding listener to execute. We designed the core of the EventEmitter concept as a class ViewModelCore. The inner part of this class contains the memory of listener and more member attributes used to support functional logic, as well as functions such as watch and unwatch.

For each layer of object, a new class instance of ViewModelCore is created and attached to target’s $$property, i.e. Target.$$= new ViewModelCore().

Const $$= Symbol(‘InnerViewModelCore’); Object. DefineProperty (target, $$) is also used to mount to the target Object. This ensures that the ViewModelCore is not directly perceived and used by the business layer.

Once mounted, calling the watch,unwatch function actually internally converts to calling the corresponding function on target[$$].

Path-Tree

When using the watch function to mount a listener, in addition to the listener, the listener path also needs to be saved.

The figure above shows the path-tree when watch(target, ‘a.b’, callback_B) is executed. We turn the listening path into a tree structure and actually hang the listener function, callback_B, on tree node B.

Path-tree is dynamically built, meaning that the Tree nodes are generated on demand only when listeners are actually mounted. So in the figure above, there is no C node in the Path-tree. But after watch(target, ‘c’, callback_c) is executed, a new C node is generated in the path-tree and the callback_c function is actually hung on node C.

Note that if watch(target.a, ‘b’, callback_b2) is executed next, the listener is not mounted on b of the path-tree at target[$$]. Instead, it is mounted to node B on the path-tree of the ViewModelCore of the target.a object, as shown below. You can see the difference.

Notify

Using the VM function, we can build a proxy-tree based on the target object (each node of the Tree is a Proxy for the raw data). With the watch function, we build a path-tree of listening paths for proxy objects, and each node of the Tree will hang the actual listening functions.

Next, when performing an assignment such as target.a.b = 20, how can we link proxy-tree and path-tree to achieve full listening?

The process is simple. In proxy-Tree, node B is obviously the first to respond to the assignment operation (via ES6 Proxy capabilities) and then passes the message up through the proxy-tree. Each pass to a node is checked to see if there is a Path-tree on the ViewModelCore of the current node (that is, if there is a listener mounted via the watch function). If any, messages are passed down in the path-tree and called on the listeners that are actually mounted on each node.

Therefore, after an assignment such as target.a.b = 20, both callback_B2 and callback_B are fired, but callback_c is not.

conclusion

In this article, we took a test-driven approach to design a bidirectional binding core. But there are still a lot of details to consider and deal with in the actual coding of the actual design. But we’re not going into the details here, so there’s no pasting code. If you want to understand the code level details of an MVVM framework, it is recommended that you read the Vue or React source code directly. But what I recommend is that after reading this article, you will be inspired to try to implement a bidirectional binding core for yourself, or to try to come up with a more elegant design than the one in this article.

In the future, when time permits, I will share further how to develop a complete MVVM framework step by step based on this bidirectional binding core. Please look forward to it.