I. Core Structure
Vue's data binding mechanism:
setter + dirty checking + publish-subscribe management
It has been like this since 0.x, dep.js, watcher.js, observer.js:
Subject: dep.js
Observer: watcher.js (built-in dirty checking)
Setter: observer.js (trigger Subject.notify when set)
By defining setter to listen for data changes, there's a very important question: for deep data structures, do we define setter one by one?
Indeed it is:
If it's an array, observe one by one to define setter, recursively listen to all Object keys
Data that has been touched has __ob__ on it
It seems like there might be a memory explosion problem. If you pass in a super large data object, just the getter/setter would take up a lot of space, but in actual scenarios it's hard to encounter a whole page being a big lump of data. For data-heavy scenarios, generally the data layer is extracted, and a dedicated state management mechanism splits the data, making it hard for memory explosion to occur
The most critical parts are these, to make it run we also need Compiler & Directive to compile and transform source code, structure as follows:
Extract relationships
Create View Listen for changes
tpl —— Compiler —— Subject & Observer & Manager
|
Directive
Input tpl & data, output view, and establish data-view connection
II. Framework
Code that speaks for itself:
// Extract data-view relationships from template
var Compiler = function(tpl) {
// Template compilation, transform to dom operations
};
var Directive = function(directive) {
// Work with compiler, handle more complex DOM operations (repeat, on, bind), establish data-view association
};
// Listen for data changes, implement data-view binding relationships
var Subject = function() {
// Subject
this.obs = [];
};
var Observer = function(updateFn) {
// Observer
};
var Manager = function(data) {
// Define setter, manage Subject and Observer
};
Input looks like this:
<!-- Template -->
<div id="demo" v-cloak>
<h1 v-bind:style="{ items.length ? 'border-bottom: 1px solid #ddd' : 'border: none' }">
{{title}}
</h1>
<p v-if="!items.length">empty</p>
<ul v-for="item in items">
<li v-on:click="item.a[1].a[1].a.a++" style="background-color: #2b80b6">
{{item.a[1].a[1].a.a}}
</li>
</ul>
</div>
// Data
var data = {
title: 'list',
items: [{a: [0, {a: [1, {a: {a: 1}}]}]}]
};
var v = new V({
el: '#demo',
data: data,
created: function() {
console.log(data);
}
});
It seems there's still a long way to go, endless at first glance, let's refine it a bit
Compiler
// Extract data-view relationships from template
var Compiler = function(tpl) {
// Template compilation, transform to dom operations
};
Compiler parses template, should output structure information, so define NodeMeta:
Compiler.NodeMeta = function(tag) {
// Compiler's output DOM structure meta format
// {
// tag: 'ol',
// children: [NodeMeta, NodeMeta...],
// props: [{key: 'id', value: 'ol'}...],
// directives: [{name: 'v-if', value: '!items.length'}],
// // Text node situation is more complex, not considering non-data-bound text and text not wrapped separately here
// textContent: 'item.text',
// // for directive will extend child scope
// extraScope: {'item': Object}
// }
};
Core task is to parse template:
Compiler.prototype.parse = function() {
// Extract tag name, create meta tree
this.nodeMeta = this.matchTag();
};
Compiler.prototype.matchTag = function() {
var rootMeta;
//...Create structure tree
return rootMeta;
};
After getting structure meta tree, it's time to create View:
Compiler.prototype.render = function(vm) {
this.vm = vm;
var render = function(nodeMeta) {
//...Parse directives
//...Create nodes
//...Set attr, bind event handler, implement view-data response
};
var node = render(this.nodeMeta);
// Use created node to replace template element
vm.el.parentNode.replaceChild(node, vm.el);
};
Directive
Directives assist the compiler, responsible for handling some complex things:
var Directive = function(directive) {
// Work with compiler, handle more complex DOM operations (repeat, on, bind), establish data-view association
};
Since it assists the compiler, it should also be responsible for creating View:
Directive.prototype.render = function(vm, node, nodeMeta, compilerRender) {
switch (dir) {
case 'for':
//...Create multiple groups of nodes
break;
case 'if':
//...Conditional creation
break;
case 'on':
//...Bind event handler, establish view-data association
break;
case 'bind':
//...Simple expression evaluation
break;
case 'cloak':
//...No need to handle
break;
default:
console.error('unknown directive: ' + key);
}
};
All directives by default need to establish data-view association, events are special because handler might change data, which is equivalent to view change, data needs to follow, so event directives also need to complete view-data association
Compared to compiler, the complex part of directives is that they need to do expression evaluation and create handler:
Directive.getParams = function(vm, extraScope) {
};
Directive.createFn = function(vm, fnStr, extraScope) {
var handler = function() {
var args = [].slice.call(arguments);
var param = Directive.getParams(vm, extraScope);
var fnBody;
//...Fill in all parts of function definition
var fn = eval(fnBody);
return fn.apply(vm, args.concat(param[1]));
};
return handler;
};
Subject & Observer
Subject (newspaper) in publish-subscribe pattern (observer pattern):
// Listen for data changes, implement data-view binding relationships
var Subject = function() {
// Subject
this.obs = [];
};
Only need to implement a few basic interfaces:
Subject.prototype.add = function(ob) {
this.obs.push(ob);
};
Subject.prototype.remove = function(ob) {
var index = this.obs.indexOf(ob);
if (index !== -1) this.obs.splice(index, 1);
};
Subject.prototype.notify = function(lastValue, newValue) {
this.obs.length && this.obs.forEach(function(ob) {
ob.update();
});
};
Parameter ob is the observer (person subscribing to newspaper) Observer instance, defined as follows:
var Observer = function(updateFn) {
// Observer
if (typeof updateFn === 'function') {
this.update = updateFn;
}
};
Observer.prototype.update = function() {};
When newspaper publishes information, notify all subscribers. Here observer pattern is used to maintain data-view one-to-many relationship
Manager
Manager is what connects generic observer pattern with actual scenarios, mainly responsible for defining setter:
var Manager = function(data) {
this.data = data;
this.dep = new Subject();
// Define setter, manage Subject and Observer
this.observe(data);
};
Manager.prototype.observe = function(obj) {
};
Manager.prototype.observeArray = function(arr) {
};
Recursively define setter, embed data change hook
III. Specific Implementation
Roughly divided into 3 parts:
-
Listen for data changes
-
Parse template, find connection between
viewanddata -
Create View and establish
data-view,view-datarelationships -
Entry point
Listening for data changes is very easy, done in minutes; parsing template is important but not critical part, complexity is average; creating View and establishing data binding is the most critical part, also most complex; of course, finally we need to open an entry point
Listen for Data Changes
Subject & Observer part is right above, doesn't need to be changed, generic part is easy to handle
So mainly Manager defining setter part:
var Manager = function(data) {
this.data = data;
this.dep = new Subject();
// Define setter, manage Subject and Observer
this.observe(data);
};
Hold a Subject instance, subsequently add Observer to it, nothing much to say. Deeply defining setter is relatively easy to implement:
Manager.prototype.observe = function(obj) {
for (var key in obj) {
if (obj.hasOwnProperty(key)) {
// Listen to children first
if (typeof obj[key] === 'object') {
if (Array.isArray(obj[key])) {
self.observeArray(obj[key]);
}
else {
self.observe(obj[key]);
}
}
}
// Define setter
void function() {
// Isolate a value
var value = obj[key];
Object.defineProperty(obj, key, {
set: function(newValue) {
if (typeof newValue === 'object' || newValue !== value) {
console.log('data changed', value, newValue);
value = newValue;
// setter notifies change
obj.__ob__.dep.notify(value, newValue);
}
return newValue;
}
});
}();
}
};
For arrays, iterate and observe:
Manager.prototype.observeArray = function(arr) {
arr.forEach(function(data) {
if (typeof data === 'object') {
if (Array.isArray(data)) {
self.observeArray(data);
}
else {
self.observe(data);
}
}
});
};
P.S. Implementation here is not quite the same as Vue, for simplicity
Parse Template
Read template, find relationship between data and view
Use n regular expressions to extract needed parts:
var Compiler = function(tpl) {
// Template compilation, transform to dom operations
this.tpl = tpl.trim();
this.REGEX = {
tag: /<([^>/\s]+)[^<>]*>/gm,
attrTag: /<([^>/\s]+)\s+([^>]+)>/gm,
text: /<([^>\s]+).*>\s*{{([^}]*)}}\s*<\/\1>/gm,
attr: /(?:([^="\s]+)(?:="([^"]+)")?)/gm
};
};
P.S. Honestly, this part is still quite strenuous (master said it needs 1 year... haha)
Only simple template parsing is implemented here, lazily not supporting bare text nodes, source code is relatively long, only key parts given here:
Compiler.prototype.matchTag = function() {
var openTagStack = [], peak;
while ((tmp = this.REGEX.tag.exec(str)) !== null) {
newMeta = new NodeMeta(tag);
if (lastEndIndex > 0) {
// Construct nodeMeta tree
peak = openTagStack[openTagStack.length - 1];
closeTagRegex = new RegExp('</' + peak + '>', 'm');
skipMatch = str.substring(lastEndIndex, tmp.index);
newMeta.parent = meta;
// Default enter next level
if (meta.children.length > 0){
meta = meta.children[meta.children.length - 1];
}
// Pop one level per match
if (closeTagRegex.test(skipMatch)) {
openTagStack.pop();
meta = meta.parent;
}
meta.children.push(newMeta);
}
openTagStack.push(tag);
// Fill props & directives
attrs = this.matchAttr(thisMatch);
// Fill textContent
}
return rootMeta;
};
Now we have structure meta tree, next we take this configuration data to create View:
Create View and Establish Data Binding
Source code is too long, simple process as follows:
Compiler.prototype.render = function(vm) {
var render = function(nodeMeta) {
// tag
var node = document.createElement(nodeMeta.tag);
// props
node.setAttribute(prop.key, prop.value);
// textContent
var fn = function() {
var exp = Directive.createFn(vm, nodeMeta.textContent, nodeMeta.extraScope);
var text = exp();
node.innerText = text || "";
}
//!!! Implement data-view binding
vm.data.__ob__.dep.add(new Observer(fn));
// directives
var directive = nodeMeta.directives[i];
var d = new Directive(directive);
// Directive render returns false means no need to render node & children
// Returns true means children are already rendered
var renderOrNot = d.render(vm, node, nodeMeta, render);
// children
var childNode = render(meta);
childNode && node.appendChild(childNode);
return node;
};
// Create View, replace template element
var node = render(this.nodeMeta);
vm.el.parentNode.replaceChild(node, vm.el);
};
Add observer to data during first render, subsequent data changes can be captured, thus implementing data-view binding
Important auxiliary Directive is as follows, too long, here taking on directive as example:
Directive.prototype.render = function(vm, node, nodeMeta, compilerRender) {
var dir = this.REGEX.directive.exec(key)[1];
var event, handler, exp, prop, propValue;
switch (dir) {
case 'on':
event = this.REGEX.key.exec(key);
if (event) {
event = event[1];
handler = Directive.createFn(vm, value, nodeMeta.extraScope);
node.addEventListener(event, function() {
handler();
});
}
break;
}
};
Create handler, and addEventListener, under the condition of defining setter to implement data change listening, view-data binding is natural, no extra handling needed, because as long as handler execution changes data, it will trigger setter, then notify the data-view relationship established when creating View, updating View
P.S. The part creating handler is quite rough,拼 a new Function() definition, then eval to get it out, performance explosion, but this step can be done during compilation phase, no problem
Entry Point
At this point it's almost complete, open entry point, connect the flow:
// Entry point
var V = function(config) {
// Basic configuration data (view, data)
this.el = el;
this.data = config.data;
this.methods = config.methods;
// Lifecycle hook
var LIFE_CYCLES = ['created'];
this._init();
};
V.prototype._init = function() {
// Listen for data changes
this._observe();
console.log(this.data);
// Parse relationships, transform to DOM operations
this.compiler = this._render();
};
V.prototype._render = function() {
var c = new Compiler(this.el.outerHTML);
c.parse();
c.render(this);
return c;
};
V.prototype._observe = function() {
this.__ob__ = new Manager(this.data);
};
IV. Online Demo
Demo address: http://ayqy.net/temp/data-binding/vue/index.html
P.S. All source code is in the source code, comments are very clear
Final Words
Life always has gray parts, can't see light, can't find direction. But fortunately the road is under our feet, no original intention, no future, it doesn't matter, just being on the road is fine, keep up
No comments yet. Be the first to share your thoughts.