How are views bound to templates in Vue? If we write an HTML-like structure in the template, how does the rendered page bind data from data? To answer these questions, this article starts with a simple template and analyzes (by hand) the most critical source implementations in Vue.
The template and data are as follows:
<! -- template -->
<div id="myVue">
<div>{{x}}</div>
<button id="addBtn">x++</button>
<input type="text" v-model="x">
</div>
Copy the code
// data
data: {
x: 1,},Copy the code
The final effect to be achieved:
- The data in the template is compiled correctly, that is, the double brace and the instruction are valid.
- The data in the template is reactive, and the view updates automatically when the data changes.
I. Initialization process
Let’s first look at the core process we focus on when Vue is initialized.
First, create MyVue class, perform data monitoring and template compilation in sequence, and make a data proxy, bind data on data directly to Vue instance for easy access.
// MyVue.js
class MyVue {
constructor(options = {}) {
this.$options = options;
this._data = options.data;
observe(this._data); // Make the data responsive
this._initData();
new Compile(options.el, this);
}
/* Bind data directly to the Vue instance for easy access without requiring an additional layer of _data */
_initData() {
Object.keys(this._data).forEach(key= > {
Object.defineProperty(this, key, {
get: () = > {
return this._data[key];
},
set: value= > {
this._data[key] = value; }}); }); }}export default MyVue;
Copy the code
If you compare the table of contents, you may wonder whether the order of the flow chart is different from the order of the analysis in this article. Yes, for the Vue initialization process, data monitoring is done first, followed by template compilation and view updates. The execution process must be the same. The reason why this article first analyzes template compilation is my learning experience:
- Template compilation is the first step to unlocking the mystery of Vue, and you will have a basic understanding of how templates written in templates are displayed on the page.
- Data monitoring is only used when the render function is executed at the end of the template compilation. No data monitoring is just binding failure.
- After template compilation, data monitoring becomes obvious. On the contrary, if you learn data monitoring first, the overall idea is not smooth, and you don’t know what it is useful for after learning.
Second, template compilation
Template compilation refers to parsing template as a template string to generate an AST abstract syntax tree.
1. Generate the AST abstract syntax tree
Ast compilation results
First look at the target. The template above is compiled to generate a similar structure:
{
attrs:[
{
name:'id'.value:'myVue'}].tag:'div'.type:1.children: [...]. .// There is the cache property if the label has directives
directives: {name:'xx'.value:'xx'
},
// If the node type is a text node, it carries the text attribute
text:undefined
}
Copy the code
The core properties are the above, and the outermost DIV with the ID MyVue does not have the caching and text properties, but may be in its children, so this is just a hint. Children is an array of children of MyVue, which is also a similar structure.
Ast generation process
Now that the goal is clear, compile the template string into an AST abstract syntax tree. The challenge is to count each level of labels. The stack structure is used here. When you meet a start tag, you push it onto the stack. When you meet an end tag, you push it off the stack. The specific code is as follows:
// parse.js
import parseAttrs from './attrsString'; // Extract the tag attributes from the template
const SingleTagList = ['input'.'img'.'br']; // Single label special processing, just list some common ones
export default function parse(templateStr) {
let index = 0;
let restStr = templateStr;
const stack = [];
const aResult = [];
const startRexExp = /^<(([a-z][a-z\d]*)(\s.+?) ?). >/; // Match the start tag
const endRegExp = /^<\/([a-z][a-z\d]*)>/; // Matches the closing tag
const contentRegExp = / (. +?) < \ /? [a-z][a-z\d]*(\s.+?) ? >/s; // Matches the label content
while (index < templateStr.length) {
restStr = templateStr.slice(index);
/* Matches the start of the label */
/* The match cannot start with a number, but can be followed by a number */
if (startRexExp.test(restStr)) {
/ / pressure stack
const startTag = RegExp. $1;const tagName = RegExp. $2;const attrsString = RegExp. $3;const oNodeInfo = {
tag: tagName,
type: Node.ELEMENT_NODE,
children: []};const { attrs, directives } = parseAttrs(attrsString);
if (attrs.length) {
oNodeInfo.attrs = attrs;
}
if (directives.length) {
oNodeInfo.directives = directives;
}
if (SingleTagList.includes(tagName)) {
// Single label, single label starts and ends, equivalent to carrying out double label on and off the stack operation
if (stack.length) {
const oLastNodeInfo = stack[stack.length - 1];
oLastNodeInfo.children.push(oNodeInfo);
} else {
/* Matches only plain text nodes for the time being, regardless of what other nodes and text nodes have */aResult.push(oNodeInfo); }}else {
/ / double label
stack.push(oNodeInfo);
}
console.log('Detected start${tagName}`);
// +2 includes the <> symbol
index += startTag.length + 2;
} else if (endRegExp.test(restStr)) {
/* Matches the tag end */
/ / out of the stack
const endTag = RegExp. $1;const oContent = stack.pop();
if(! oContent || endTag ! == oContent.tag) {throw new Error('Template string formatting error');
}
if (stack.length) {
const oLastNodeInfo = stack[stack.length - 1];
oLastNodeInfo.children.push(oContent);
} else {
/* Matches only plain text nodes for the time being, regardless of what other nodes and text nodes have */
aResult.push(oContent);
}
console.log('Detect end${endTag}`);
index += endTag.length + 3;
} else if (contentRegExp.test(restStr)) {
// Matches the text node, skip all empty, all empty indicates a space or a newline, no statistics required
let content = RegExp. $1;/* \s is [\t\v\n\r\f]. Represents whitespace, including Spaces, horizontal tabs, vertical tabs, line feeds, carriage returns, and page feeds. * /
if (!/^\s+$/.test(content)) {
const oLastNodeInfo = stack[stack.length - 1];
oLastNodeInfo.children.push({
type: Node.TEXT_NODE,
content: content.trim() // Put the text node in the stack, remove the space before and after. But index superposition requires space
});
}
index += content.length;
} else {
index++;
throw new Error('Why else? Let's find out what the special circumstances are."); }}return aResult[0];
}
Copy the code
The parseAttrs method is used to extract attributes and directives from the tag as follows:
// attrsString.js
export default function(str) {
const attrs = []; // Attributes store list
const directives = []; // Store the list of instructions
let index = 0;
let restStr = str;
while (index < str.length) {
restStr = str.slice(index);
if (/^(\s+(.+?) = "(. +?) ") /s.test(restStr)) {
const name = RegExp. $2;const value = RegExp. $3;const oInfo = {
name,
value
};
// v- indicates the command
if (name.startsWith('v-')) {
oInfo.name = oInfo.name.slice(2); // The directive's name does not contain v-
if(oInfo.name==='for') {const match = oInfo.value.match(/ ^ (. +?) \s+in\s+(.+)$/);
// The for directive is special and requires some extra processing
oInfo.alias = match[1]
oInfo.for = match[2]
}
directives.push(oInfo);
} else {
attrs.push(oInfo);
}
index += RegExp.$1.length;
} else{ index++; }}return {
attrs,
directives
};
}
Copy the code
2. Generate render function
Once you have the AST abstract syntax tree, you use it to generate the render function. Render function (Vue); render function (Vue); render function (Vue);
render(h) {
return h('div', { attrs: { id: 'myVue' } }, [
h('div'.this.x),
h(
'button'.'x++'
),
h('input', {
attrs: { type: 'text' },
domProps: { value: this.x },
on: {
input: $event => {
this.x = $event.target.value; }}})]); }Copy the code
So the task now is to dynamically generate a function like the one above using the AST. Dynamically generated functions pass in strings using new Function(). But the lexical scope of a function generated in this way is global, not where the function was generated. Vue does a neat trick here by changing the scope with the with keyword. If you don’t want to use this method, you can also use some external variables by passing in the form of parameters. Specific code:
// createRender.js
export default function createRender(ast) {
return new Function('_h'.`with (this) return ${createCode(ast)}`);
}
function createCode(ast) {
const children = ast.children.map(child= > {
if (child.type === 3) {
// Text node, simplify processing, if there are double curly braces, consider only variables, no expression
if (/ {{(. +?) }} /.test(child.text)) {
return `The ${RegExp. $1}`;
} else {
return `"${child.text}"`; }}else if (child.type === 1) {
// Element node
returncreateCode(child); }});const data = {};
if (ast.attrs) {
data.props = {};
// Convert to the format required by the SNabbDOM library h function
ast.attrs.forEach(item= > {
data.props[item.name] = item.value;
});
}
if (ast.directives) {
data.directives = ast.directives;
}
return `_h("${ast.tag}",The ${JSON.stringify(data)}[${children.join(', ')}]) `;
}
Copy the code
The dynamically generated render function looks like this
function anonymous(_h) {
with (this)
return _h('div', { props: { id: 'myVue' } }, [
_h('div', {}, [x]),
_h('button', { props: { id: 'addBtn'}},'x++']),
_h('input', { props: { type: 'text' }, directives: [{ name: 'model'.value: 'x'}}], []]); }Copy the code
This in this function is the vue instance, so the variable being accessed is actually accessing the data in data.
_h here is not the h function used by Vue. Generating the virtual DOM using the H function was not pioneered by Vue, which borrowed from the SNBBDOM library. Therefore, we directly generate the virtual DOM through the H function of SNbbDOM. _H is to do some special processing on the instructions in Vue to make it conform to the parameter passing of the H function of snBBDOM library. The _H code is as follows:
// Compile.js
import { h } from 'snabbdom';
const _h = (sel, data, children) = > {
if (data.directives) {
/* Processing instructions, instruction processing is relatively complex, because different instructions need to do different processing, this example shows the two-way binding, only v-mode instruction processing */
data.directives.forEach(({ name, value }) = > {
if (name === 'model') {
// Use the Watcher class to listen for changes in the data bound by the instruction, and notify the associated dependencies to update the view when they change
new Watcher(this.$vue, value, () = > {
updateMain();
});
const inputFun = $event => {
this.$vue[value] = $event.target.value;
};
data.props.value = this.$vue[value];
data.on = {
input: inputFun
};
}
// If you want to process other instructions, you can expand here
});
}
return h(sel, data, children);
};
Copy the code
3. Execute the render function and patch function
Render function has been created, execute the render function can get the virtual DOM, in order to see the results as soon as possible, you can first use the SNabbDOM library patch function for virtual DOM tree (snabbDOM library patch function creation please refer to). The patch function will be analyzed in detail later. The render and patch functions are executed as updates to the view, so encapsulate them in a single function.
const updateMain = () = > {
this.newVnode = this.renderFun.call(this.$vue, _h);
// For the first time, oldVnode is $el
this.oldVnode = snabbdom.patch(this.oldVnode, this.newVnode);
};
Copy the code
Put all this together, and the view will display correctly even without data monitoring, but without binding to the data. At this point, you need to analyze the data and monitor it. Because without data monitoring, you can’t listen for data changes, and you don’t know when to execute the updateMain function again.
Data monitoring
1. Expected effect
The desired effect of data monitoring has been given in the previous article. Here they are distilled:
observe(this._data); // Make the data responsive
// Use the Watcher class to listen for data changes and notify associated dependencies to update the view
new Watcher(this.$vue, value, () = > {
updateMain();
});
Copy the code
These are two pieces of code that we’ve written before. $vue = this._data; $vue = this._data; Since _initData proxies this.$vue, accessing data on this.$vue is actually accessing this._data. That is, it accesses the data that was passed in. Data ={x:1}, call the observe method to monitor the data, use the new Watcher to monitor the change of its properties, execute the callback function, and then complete the task. Now, some of you might think that this is a very simple thing to do, but maybe the observe-Watcher class is confusing. Actually, because the examples in this article are very simple, the complexity of data monitoring comes from the following:
- Monitoring of data objects is deep monitoring, which means that multiple layers can be monitored. If the data is
data={obj1:{obj2:{x:1}}}
, you can still listen on the x of the last layer or on the properties of any intermediate layer. - You need to implement array listening. If any of the intermediate layers have an array value, you also need to listen for its change.
- The reassigned data, if of an object or array type, also needs to be reactive.
- If the X property of data is used by multiple Vue components, each component is notified when x changes for view updates.
Here is the final test code:
this.testObj = {
a: {
a1: 1
},
b: 2.c: [2.3[4]]}; observe(this.testObj)
/* The Watcher class is called to listen for data used in the template */
new Watcher(this.testObj, 'b'.() = > {
console.log('B has been changed');
});
this.testObj.b = 20
new Watcher(this.testObj, 'a.a1'.() = > {
console.log('A1 has been changed.');
});
this.testObj.a.a1 = 10
new Watcher(this.testObj, 'c'.() = > {
console.log('C has been changed');
});
this.testObj.c.push(3);
Copy the code
Data monitoring is successful as long as the last callback function executes.
2. Data monitoring process
This flowchart is quoted from blog.csdn.net/Mikon_0703/…
The overall flow chart for data monitoring is shown above and can be understood in comparison to the template parsing process above. Here are three key functions:
- Observer: Transforms a common object into one whose properties at each level are responsive (which can be monitored). And dependency collection and dependency triggering.
- Dep class: As a publisher-subscribe publisher, it stores all the dependencies (subscribers) of the data when the dependencies are collected, and circulates the dependency list when the data changes, notifies all the Watcher.
- Watcher class: As a subscriber, executes a callback function when notified of a property change.
The Observer does the most, so it is split into several units, including the Observe function, the Observer class, and the defineReactive function. There are mutual references between these functions and classes, so I draw a flow chart for each unit. Besides helping to understand their own functions, it is mainly to help clarify the call relationship between them.
Method observe
The observe method is called first according to the test code. This method is implemented to perform some judgment and filtering. One is to exclude primitive types, because only objects or arrays need to be processed in response. Second, objects that are already responsive data are not processed repeatedly. The specific process is as follows:
Specific code:
// observe.js
import Observer from './Observer';
export default function observe(value) {
if (typeofvalue ! = ='object') {
return;
}
let ob = null;
if (typeof value.__ob__ === 'undefined') {
ob = new Observer(value);
} else {
ob = value.__ob__;
}
return ob;
}
Copy the code
The Observer class
The Observer class is still fairly simple to implement, distinguishing between incoming data and objects, and then doing different things with it. If it is an object, it is handed over to defineReactive after traversal. If it is an array, first point its prototype to an arrayMethods object (more on that later), then iterate over the child and call the Observe method for recursive listening.
// Observer.js
import defineReactive from './defineReactive';
import { def } from './util'; The def method is to add a attribute using defineProperty and pass in the configuration item.
import { arrayMethods } from './array';
import observe from './observe';
import Dep from './Dep';
// Convert an ordinary Object to an Object whose properties at each level are responsive.
class Observer {
constructor(value) {
// Each Observer instance has a Dep instance, so each layer (object) has a Dep instance
this.dep = new Dep();
// The __ob__ attribute, whose value is an Observer instance object, is named __ob__ to prevent attribute duplication
def(value, '__ob__'.this, { enumerable: false });
if (Array.isArray(value)) {
// If it is an Array, force the Array's Prototype to point to the object we created with Array's Prototype in mind.
// so the current relationship is that arrays in code => arrayMethods created by Vue => array prototype objects
/* / The neat thing here is to create an intermediate object to listen on. I always thought it was written directly on the Array prototype, so I have some questions. Wasted performance? Actually it's not. * /
Object.setPrototypeOf(value, arrayMethods);
/* The previous step listened for a specific variable function in the array, but the next step is to walk through the array, observing the value of each item */
this.observeArray(value);
/* Array dependency collection takes place in the Observer class, and arrayMethods only act as a trigger when data changes */
if (Dep.target) {
this.dep.depend(); }}else {
this.walk(value); }}// Iterate over object properties
walk(obj) {
for (const key in obj) {
if (Object.hasOwnProperty.call(obj, key)) { defineReactive(obj, key); }}}// Iterate over the number of items
observeArray(arr) {
for (let i = 0, l = arr.length; i < l; i++) {
/* Why invoke the observe method instead of defineReactive? Because defineReactive must ensure that the parent is the object and then listen for child properties where each item in the array is of arbitrary data type. Call the observe method to listen from scratch. Implements recursive listening of array types. * /observe(arr[i]); }}}export default Observer;
Copy the code
defineReactive
The Observer class distinguishes between arrays and objects, and the specific listener implementation is placed on the defineReactive method and the arrayMethods prototype object. So there’s a lot of similarities between these two parts. Start with defineReactive. The main function of defineReactive is to use Object.defineProperty to hijack and listen on attributes. The specific functions are as follows:
//defineReactive.js
import observe from './observe';
import Dep from './Dep';
/* Why is this function defined? The reason is simple: if you don't use a function wrapper, you need an intermediate variable transfer to make getters and setters work. * /
export default function defineReactive(data, key, value) {
/* Each attribute has its own DEP, since each attribute may be called by multiple Vue and needs to be collected */
const dep = new Dep();
if (arguments.length === 2) {
value = data[key];
}
// Determine if the attribute value is a reference data type, if so continue to listen for the attribute value. In fact, recursive listening is implemented here.
let childObj = observe(value);
Object.defineProperty(data, key, {
get() {
console.log('You are visiting${key}`);
// Collect dependencies in the getter
// If you are in the dependency collection phase
if (Dep.target) {
dep.depend();
if(childObj) { childObj.dep.depend(); }}return value;
},
set(newValue) {
console.log('You're setting it up${key}`);
/* If a new value is set, the object also needs to be observed. Otherwise, when an object is assigned, it cannot become responsive data */
observe(newValue);
/* When setters trigger dependencies */value = newValue; dep.notify(); }}); }Copy the code
arrayMethods
The arrayMethodsfs stereotype object processing analogy defineReactive really just implements setter-like functionality. Listen for an array value change. For direct access to an item of the array, no data hijacking is done. Because the package of arrayMethods object is relatively unilinear, and it only has a call relationship with the Observer class, so no flow diagram is drawn and the code is directly read:
// array.js
import { def } from './util';
const methodsNeedChange = ['push'.'pop'.'shift'.'unshift'.'splice'.'sort'.'reverse'];
const arrayPrototype = Array.prototype;
// arrayMethods objects prototype arrays to ensure that other arrayMethods are not affected
const arrayMethods = Object.create(arrayPrototype);
methodsNeedChange.forEach(methodName= > {
/* Define arrayMethods to call its own methods */ when the prototype chain is searched
def(
arrayMethods,
methodName,
function() {
/* arguments this must be correct because methods like push change the */ of the value of this
const result = arrayPrototype[methodName].apply(this.arguments); // Call the Array prototype method to ensure that the original function is not affected
/* There are three ways to add items to an array. Make sure that the items added are also responsive */
let inserted = [];
if (methodName === 'push' || methodName === 'unshift') {
inserted = [...arguments];
} else if (methodName === 'splice') {
inserted = [...arguments].slice(2);
}
if (inserted.length) {
// this.__ob__ is the Observer class bound to this array
this.__ob__.observeArray(inserted);
}
this.__ob__.dep.notify();
return result;
},
{ enumerable: false}); });export { arrayMethods };
Copy the code
Seven methods for changing arrays were overridden on the prototype, so Vue could listen on those methods as soon as they were called. So far, you have implemented a common object that is treated as responsive data at each level. If you don’t care about the data being used in multiple places, you can write a custom trigger function when the data is changed to pass back the changed data attribute name and changed value. When we listen for the trigger function during initialization, we can receive changes in the data. To address the problem that data can be used by multiple views, the publish-subscribe model is implemented through Dep and Watcher.
Dep
The Dep, as the publisher, needs to add each subscriber and save those subscribers. When data changes, the corresponding Dep needs to notify the saved subscribers. When do you add subscribers? As you can see in defineReactive above, dependencies are collected in the getter and subscribers are added. The Dep code is as follows:
// Dep.js
let uid = 0;
/* Dep.target is a property of the class itself, so it is actually a global property that can be read and set by introducing Dep */
export default class Dep {
constructor() {
this.id = uid++;
// Use an array to store subscribers
this.subs = [];
}
// Add subscription, that is, add a Watcher instance object
addSub(sub) {
this.subs.push(sub);
}
// Add dependencies
depend() {
// dep. target is actually a global variable
if (Dep.target) {
/* Check whether there are duplicate dependencies. If not, repeat dependencies will always be collected */
if (!this.subs.includes(Dep.target)) {
this.addSub(Dep.target); }}}// Notify all Watcher objects to update the view, i.e. distribute the message.
notify() {
// The source code is written so, why shallow clone a copy?
const subs = this.subs.slice();
console.log(subs, 'subs');
for (let i = 0; i < subs.length; i++) { subs[i].update(); }}}// Set the target property of the Dep to null for the first time
Dep.target = null;
Copy the code
The dep.target property keeps appearing in the code. It adds the dependencies that are currently reading data to its own Dep. The dep. target property is always null until the Watcher class sets dep. target to itself before reading data. The Dep collects the Watcher that is reading the data. After reading the data, set Watcher to NULL.
Watcher
Watcher, as a subscriber, reads a property of the detected object when creating an instance of Watcher, at which point dependency collection is performed, and the Watcher subscribles to the Dep. Dep notifies its own subscribers when data changes. Watcher then executes the incoming callback and returns the new and old values.
// Watcher.js
import Dep from './Dep';
var uid = 0;
/* Why Watcher? When attributes change, the place where we used to inform the data attribute, and use this data and types are different, a lot of need to abstract out a class to handle these situations, and then rely on gathering phase only collect the encapsulated instance, notice is required to notify this one, only then it in responsible for notifying the other place. * /
const parsePath = str= > {
let segments = str.split('. ');
return obj= > {
segments.forEach(segment= > {
obj = obj[segment];
});
return obj;
};
};
export default class Watcher {
constructor(vm, expression, callback) {
this.id = uid++;
this.vm = vm;
this.getter = parsePath(expression);
this.callback = callback;
this.value = this.get();
}
update() {
this.run();
}
run() {
const value = this.get();
// When the value changes
if(value ! = =this.value || typeof value === 'object') {
const oldValue = this.value;
this.value = value;
this.callback.call(this.vm, value, oldValue); }}get() {
// Enter the dependency collection phase with the global dep. target set to the Wathcer instance itself.
Dep.target = this;
const vm = this.vm;
let value;
// Keep looking as long as you can
try {
value = this.getter(vm);
} finally {
// Dependency collection is complete
Dep.target = null;
}
returnvalue; }}Copy the code
At this point, the data monitoring analysis is over, the integration of the above code to run the data monitoring at the beginning of the test code, can run. And it works fine in the initialization process and template compilation. That is, the effect of bidirectional binding has been achieved, but the H and patch functions use the SNabbDOM library. At this point, the template compilation and reactive principle analysis are completed, and the internal implementation of VUE is roughly understood. Finally, the DOM updates are analyzed at a minimum.
Iv. Detailed explanation of virtual DOM and Patch functions
In the last stage of template compilation, h function and patch function of SNabbDOM library are directly called. The h function generates the virtual DOM, and the patch function updates the upper tree and the virtual DOM. Let’s briefly implement these two functions.
Generating the virtual DOM
First, you also need a template, which is not used here at the beginning of this article. The template contains instructions, double braces, and other vUe-specific syntax. The underlying H function actually does only one thing, which is to generate the virtual DOM from passing parameters, regardless of instructions, curly braces, etc. To facilitate the subsequent test of patch function, another template is used here, as follows:
<div id="myH">
<h4 class="x">x</h4>
<ul>
<li>A</li>
<li>B</li>
<li>C</li>
</ul>
</div>
Copy the code
This template uses the h function to generate, should be relatively simple, after all, we are directly in the template compilation code to generate h function dynamically, here just write it statically.
const vnode = h('div', { id: 'myH' }, [
h('ul',[
h('li'.'A'),
h('li'.'B'),
h('li'.'C'),]]);Copy the code
Take a look at what a VNode looks like. Some of you might think this is very similar to the AST abstract syntax tree. They do, because they both represent a real DOM node through a JS object. But there are differences. First, there are differences in the process of formation. The AST abstract syntax tree is generated by parsing strings, and the virtual DOM is generated directly by calling the H function. Secondly, the abstract syntax tree is for vUE service, which will disassemble some special syntax of VUE such as instructions. H doesn’t have that logic. From AST to virtual DOM actually goes through this: THE AST abstract syntax tree => dynamically generates the render function => executes the render function => calls h function inside the render function => gets the virtual DOM.
// vnode
{
"sel": "div"."data": {
"id": "myH"
},
"children": [{"sel": "ul"."data": {},
"children": [{"sel": "li"."data": {},
"text": "A"
},
{
"sel": "li"."data": {},
"text": "B"
},
{
"sel": "li"."data": {},
"text": "C"}]}]}Copy the code
Implementing the h function is an easy step, because the resulting object, whose properties are actually passed in from the outside, just needs to be cleaned up. Note that there is no recursion; the objects in each layer are created by the H function itself. The h function basically makes a judgment on the number and type of arguments. Directly on the code:
import vnode from './vnode';
// h.js
const isObj = obj= > {
return Object.prototype.toString.call(obj) === '[object Object]';
};
// vnode(sel, data, children, text, elm)
export default function(sel, data, c) {
const length = arguments.length;
if (length < 2) {
throw new Error('least 2 params');
}
// If there are only two arguments, i.e. data is omitted or there are no children
if (length === 2) {
// sel + text
if (typeof data === 'string' || typeof data === 'number') {
return vnode(sel, undefined.undefined, data);
}
// sel + data
if (isObj(data)) {
return vnode(sel, data);
}
// sel + children
if (Array.isArray(data)) {
return vnode(sel, undefined, data);
}
throw new Error('Parameter type error! ');
} else {
// Three parameters
if(! isObj(data)) {throw new Error('Parameter type error! ');
}
// sel + data + text
if (typeof c === 'string' || typeof c === 'number') {
return vnode(sel, data, undefined, c);
}
// sel + data+children
if (Array.isArray(c)) {
return vnode(sel, data, c);
}
throw new Error('Parameter type error! '); }}Copy the code
Vnode encapsulates a function that finally outputs the virtual DOM object based on the incoming value, as follows:
// vnode.js
export default function(sel, data, children, text, elm) {
let key;
if (data) {
key = data.key;
} else {
/* Data in patch function cannot be undefined */
data = {};
}
return {
sel,
data,
children,
text,
elm,
key
};
}
Copy the code
Patch function details
Let’s talk about the use of patch first. In fact, it was called at the end of template compilation. Patch requires passing in two parameters, the old virtual node and the new virtual node. The old virtual node can be a DOM object, which is what happens when you first go up the tree. Here, we call patch to create a virtual DOM tree of the vNode generated above.
const container = document.getElementById('container')// put a container on the page
patch(container,vnode)
Copy the code
If the patch function is created successfully, the result of execution is that the page container is replaced with the template DOM structure. Then the handwriting patch function will be officially started.
First, take a look at the overall flow chart of patch function, and then follow this flow chart to realize step by step. And do some validation at each step.
Patch function Step 1
The first step is to analyze before refined comparison. First, take a look at the overall code of patch function.
// patch.js
import vnode from '.. /vnode';
import createEle from './createEle';
export default (oldVnode, newVnode) => {
if (oldVnode.sel === undefined) {
oldVnode = vnode(oldVnode.tagName, {}, [], ' ', oldVnode);
}
// Whether it is the same virtual node
const keyIsSame = oldVnode.key === newVnode.key;
const selIsSame = oldVnode.sel === newVnode.sel;
if (keyIsSame && selIsSame) {
patchVnode(oldVnode, newVnode);
} else {
// Insert new DOM and delete old DOM
const newDom = createEle(newVnode);
if(! oldVnode.elm) {// If the oldVnode passed in is a real DOM, the vnode function will have the ELM attribute. If it is a virtual DOM, it must be a virtual node that has generated a real DOM, that is, with the ELM attribute.
return; } oldVnode.elm.parentNode.insertBefore(newDom, oldVnode.elm); oldVnode.elm.parentNode.removeChild(oldVnode.elm); }};Copy the code
The above code is the final code of patch function. However, the case that it is the same virtual DOM is ignored first, that is, the line of patchVnode function can be commented out. The rest of the code is easier to understand compared to the flow chart. Here, when the virtual DOM is on the tree, it should be noted that the new node must be inserted first, and then the old node must be deleted. The other way around, there is no reference when you insert a new node. This leaves the createEle function. This function creates the virtual DOM as a real DOM. Take a look at the code:
// createEle.js
export default function createEle(vnode) {
const dom = document.createElement(vnode.sel);
/* Handle DOM attributes and listen for events */
if (vnode.data) {
const { props, attributes, on } = vnode.data;
if (props) {
Object.keys(props).forEach(propName= > (dom[propName] = props[propName]));
}
if (attributes) {
Object.keys(attributes).forEach(attrName= > dom.setAttribute(attrName, attributes[attrName]));
}
if (on) {
Object.keys(on).forEach(evetName= >{ dom.addEventListener(evetName, on[evetName]); }); }}if (vnode.children && vnode.children.length) {
vnode.children.forEach(childVnode= > {
dom.appendChild(createEle(childVnode));
});
} else if (vnode.text) {
// The simplified version does not allow both children and text; they are mutually exclusive
dom.innerHTML = vnode.text;
}
vnode.elm = dom;
return dom;
}
Copy the code
The code above is easier to understand, but internally it is recursive, because vNode may have children, and each child needs to generate a real DOM and be appended to the parent element.
Here, the patch function’s first function, the tree on the virtual DOM, has actually been completed and can be used for testing. Execute patch(Container,vnode) as described above. You can see the effect on the page.
Patch function Step 2
When does a detailed comparison of the old and new virtual DOM child nodes, namely the children property, need to be made? That’s when children are still arrays. If either of the children is not an array (indicating that only text nodes are left in the child nodes), the detailed comparison is unnecessary.
The logic of this part is in the patchVnode function, and the code is as follows:
//patch.js
const patchVnode = (oldVnode, newVnode) = > {
// It is the same node for a fine comparison
if (newVnode.text || newVnode.text === ' ') {
if (newVnode.text === oldVnode.text) {
// Both the old and new nodes are text nodes, and the text is the same. No processing is required
newVnode.elm = oldVnode.elm;
return;
} else{ oldVnode.elm.innerText = newVnode.text; newVnode.elm = oldVnode.elm; }}else if (newVnode.children) {
if (oldVnode.text) {
// 1. Empty old DOM
oldVnode.elm.innerHTML = ' ';
// 2. Add the children attribute of the new virtual node
newVnode.children.forEach(child= > {
oldVnode.elm.appendChild(createEle(child));
});
newVnode.elm = oldVnode.elm;
} else if (oldVnode.children) {
// In the most complex case, both the old and new virtual nodes have children, so we need to compare each child node
updateChildren(oldVnode.elm, oldVnode.children, newVnode.children);
// 个人思路编写updateChildren
// updateChildren_self(oldVnode.elm, oldVnode.children, newVnode.children);newVnode.elm = oldVnode.elm; }}/* Update the props property to make the example bidirectional binding work, without comparing the old and new properties */
if (newVnode.data) {
const { props } = newVnode.data;
if (props) {
Object.keys(props).forEach(propName= >(newVnode.elm[propName] = props[propName])); }}};Copy the code
This part of the logic is still not complicated compared to the flow chart, again ignore the part of the updateChildren function, and then you can do the test. According to the process, a virtual DOM with the text attribute is generated, and a new virtual DOM with the Text attribute and children attribute is used to compare with the old virtual DOM. When testing, you can click the debugger in patchVnode to test whether the execution is as drawn in the flowchart and whether the new virtual DOM is updated to the view at last.
Patch function Step 3
When both the old and new virtual DOM have children, the child node comparison needs to be updated. The update dren function. This function is, I think, the most complicated part of the paper. Let me first explain what this function does and what it does. In my opinion, this function is an efficient match between the children of old and new virtual nodes. So it does three things:
- When the new and old virtual nodes are matched successfully, patchVnode is called to update the node. If the matched child nodes still have the children attribute, the recursion will continue.
- After the loop is complete, new virtual nodes that do not match are counted. These virtual nodes are new and need to be inserted.
- After the loop ends, the old virtual nodes that are not matched need to be deleted.
Personal ideas to achieve child node update
The function is complicated by efficient matching, so let’s implement the updateChildren function ourselves. After the implementation, there is a certain understanding of it, and then according to the source code ideas, and can be compared to the ingenious source code ideas.
//patch.js
/* The function receives 3 parameters, the old virtual node DOM node, the old virtual node child node array */
const updateChildren_self = (parentNode, oldCh, newCh) = > {
// Go through the new node to match the old node
newCh.forEach(newChild= > {
const matchOldChild = oldCh.find(oldChild= > isSameVnode(oldChild, newChild));
if (matchOldChild) {
// Add an attribute to the child node to indicate that the node was successfully matched
matchOldChild.isMatched = true;
newChild.isMatched = true;
// Update the child nodepatchVnode(matchOldChild, newChild); }});// After the traversal is complete, the new and old virtual child nodes that are not matched are counted
const noMatchNewCh = newCh.filter(newChild= >! newChild.isMatched);const noMatchOldCh = oldCh.filter(oldChild= >! oldChild.isMatched);// New virtual child nodes that are not matched need to be inserted
if (noMatchNewCh.length) {
noMatchNewCh.forEach(noMatchNew= > {
parentNode.appendChild(createEle(noMatchNew));
});
}
// Unmatched old virtual child nodes need to be deleted
if (noMatchOldCh.length) {
noMatchOldCh.forEach(noMatchOld= >{ parentNode.removeChild(noMatchOld.elm); }); }};Copy the code
The function above basically realizes the child node update, friends can test by themselves. But it’s a far cry from updateChildren in the source code. There are two main points:
- Efficiency is low. Each lookup loops through the old and new virtual child nodes. Logically, elements that have already been matched do not actually need to participate in the search.
- There is no focus on order. If the new virtual child node matches the old one, it does not mean that the order is not changed. If the new virtual child node is inserted, it is inserted to the last item by default. This is not considered in updateChildren_self.
Source code ideas to achieve child node update
Source code only a cycle, the use of the diff algorithm of the four hit search, efficient matching old and new virtual child nodes. Pointer ideas are used in the source code. Four Pointers are prepared, which are new before, old before, new after and old after. The so-called “new front” is the index of the first item of the new virtual DOM child node. If this item is matched in the old virtual DOM child node, then the new front pointer moves back one bit. At this point, the second pointer of the new virtual DOM child node becomes the pointer of the new front, which is similar to the old front. The new end starts with the index of the last item of the new virtual DOM child node and moves forward one bit once a match is made. Explain the four types of hit lookups:
- New before matching with old before: In the actual application, if the array length of v-for loop does not change, only the contents are changed. This can be done only with this kind of judgment. If an item is added sequentially to the end of the array, all other items except the addition item are also matched by that item. None of the four matches of the addition item can be matched because the old virtual node does not exist. So in fact, the new front and the old front are the two most likely situations in development.
- New and old match: this match is for non-end-of-line inserts. According to the insertion position, the item before the insertion position matches the old one by the new one, and the item after the insertion position matches the old one by the new one.
- New back to old front: this type of match is mainly to match the position of the class, such as moving an item to the end of the array. Move the new node to the old node
- Similar to the third case — it involves moving the node, the node to which the new back points, to the previous one.
Four matches of the Diff algorithm, once one match is made, no more matches are made. So put the most likely scenario at the beginning. The other four matches do not cover all cases. There’s still the possibility of a mismatch. The source code does not match the use of double loop, but for the old virtual DOM child node does not match the definition of a cache object, directly using the attribute name (key) to match. All right, let’s get started, and finally take a look at the updateChildren function implemented in the source code.
// Update policy for child nodes
const updateChildren = (parentNode, oldCh, newCh) = > {
let testI = 0; // Prevent dead loop during development, delete after development.
let oldStartIdx = 0; // Old pointer
let newStartIdx = 0; // new pointer
let oldEndIdx = oldCh.length - 1;// Old after pointer
let newEndIdx = newCh.length - 1;// new after pointer
let oldStartVnode = oldCh[oldStartIdx];// The old pre-virtual DOM
let oldEndVnode = oldCh[oldEndIdx];// The old post-virtual DOM
let newStartVnode = newCh[newStartIdx];// The new virtual ODM
let newEndVnode = newCh[newEndIdx];// The new virtual DOM
let keyMap = null; // Old virtual DOM cache object
/* The new virtual node has been traversed, regardless of whether it matches. Either the old virtual node has already been matched. Either of these two conditions occurs, and the loop terminates */
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx && testI < 1000) {
/* Both cases indicate that oldStartVnode or oldEndVnode has already been processed */
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx];
continue;
} else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx];
continue;
}
if (isSameVnode(oldStartVnode, newStartVnode)) {
/* If the array length of the v-for loop does not change, only the contents of the loop are changed. This can be done only with this kind of judgment. If an item is added sequentially to the end of the array, all other items except the addition item are also matched by that item. None of the four matches of the addition item can be matched because the old virtual node does not exist. So the new front and the old front are the two most likely scenarios in development. * /
console.log('1 hit');
patchVnode(oldStartVnode, newStartVnode);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (isSameVnode(oldEndVnode, newEndVnode)) {
console.log('2 hit');
/* This match applies to non-end-of-line inserts. According to the insertion position, the item before the insertion position matches the old one by the new one, and the item after the insertion position matches the old one by the new one */
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (isSameVnode(oldStartVnode, newEndVnode)) {
console.log('3 hit');
// The new and the old
patchVnode(oldStartVnode, newEndVnode);
/* insertBefore Inserts a DOM node that is already in the DOM tree, and that DOM node moves, which is critical. * /
// Move the new node to the old node. Since it is now a new hit, the final sort, insertion node must be at the end of the already matched element. So it's the old queen
parentNode.insertBefore(newEndVnode.elm, oldEndVnode.elm.nextSibling);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (isSameVnode(oldEndVnode, newStartVnode)) {
console.log('4 hit');
// New before and old after
patchVnode(oldEndVnode, newStartVnode);
// The new node is moved to the previous node, because the current match is the new one, so the final order, the inserted node must be before the matched element. So it's old before before.
parentNode.insertBefore(newStartVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// If all four cases are not matched, loop through the old virtual DOM to match.
/* Source code in this section of processing, the focus is to improve the efficiency of matching. With a traditional array looping search, you iterate over the old and new virtual nodes each time. With keyMap, you only iterate over the old virtual node items that haven't been matched the first time. The subsequent matching acts like reading the cache, which is very efficient. * /
if(! keyMap) { keyMap = {};// To find the map of the key, go to the old virtual node to find the key
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
const key = oldCh[i].key;
if(key ! =null) { keyMap[key] = i; }}}// Find the current item (newStartIndex), the location of the item in the keyMap.
const idxInOld = keyMap[newStartVnode.key];
if (idxInOld == undefined) {
// If idxInOld is undefined, it is new and needs to be added
parentNode.insertBefore(createEle(newStartVnode), oldStartVnode.elm);
} else {
// If it is not undefined, it is not a new item, it exists in the old virtual node, but four kinds of matches are not matched
// In fact, there is another case, that is, although the key is the same, but the SEL is different, this case is not considered in the weakened version.
const elmToMove = oldCh[idxInOld];
patchVnode(elmToMove, newStartVnode);
// Move the node position to the front of the old virtual node, so that it matches the order of the new virtual node.
// Set the processed item to undefined and skip the next loop
oldCh[idxInOld] = undefined;
parentNode.insertBefore(elmToMove.elm, oldStartVnode.elm);
}
newStartVnode = newCh[++newStartIdx];
}
testI++;
}
// Verify that there is a problem with the program because testI jumped out of the loop with problems
console.log(testI, 'testI');
/* The loop ends */
if (newStartIdx <= newEndIdx) {
// The new virtual DOM has nodes that are not matched. These nodes need to be added
/* New virtual nodes may not have elm attributes. As long as newEndIdx is not the last one, that is, if the last element has been matched, patchVnode function will be carried out and elm node will be added. If it was the last one that was never matched, before is null, and you add */ to the end
const before = newCh[newEndIdx + 1] = =null ? null : newCh[newEndIdx + 1].elm;
for (let index = newStartIdx; index <= newEndIdx; index++) {
InsertBefore if the second argument is null, the dom is considered to be inserted at the end of the parent node, equivalent to appendChild */parentNode.insertBefore(createEle(newCh[index]), before); }}else if (oldStartIdx <= oldEndIdx) {
// There are unmatched nodes in the old virtual DOM. These unmatched nodes need to be deleted
for (letindex = oldStartIdx; index <= oldEndIdx; index++) { parentNode.removeChild(oldCh[index].elm); }}};Copy the code
Well, that’s it. The bidirectional binding example in this article can finally be verified by your own code. The code shown above is only part of it. The complete code can be viewed here for the project git address.
Five, the conclusion
At the end of the article, there are many implementations in this article that only make a lot of simplification or special treatment for this example. Personally, it is more important to master the internal implementation of Vue as a whole. Finally in view of my level is limited, if the article has mistakes, please correct, thank you!