首页 > 代码库 > 框架是如何炼成的 —— 揭秘前端顶级框架的底层实现原理
框架是如何炼成的 —— 揭秘前端顶级框架的底层实现原理
译者注:本文原文 Revealing the Magic of JavaScript ,原标题“揭秘JavaScript魔法”,本文深入浅出,揭示了几个前沿框架如jQuery、angularJs、EmberJs和React的几个核心功能点的实现技巧,无论是对前端菜鸟还是老鸟,相信都会有一定的启迪。鄙人精力和能力有限,如有错误或生涩之处,还请指出和多多包涵。
我们每天都使用大量的前端库和框架,这些各种各样的库和框架已经成为我们日常工作的一部分,我们之所以使用他们,是因为我们不想重新造轮子,即使我们不明白它们的底层是怎么回事,在这篇文章中,我将揭示流行框架中发生了哪些神奇的过程,同时我们也会探讨如何自己去实现。
使用字符串生成 DOM
随着单页应用的兴起,我们正在用JavaScript做越来越多的事情,我们的应用程序逻辑的很大一部分已经被移植到浏览器,在页面上动态产生或或替换元素变得非常频繁,比如如下的代码:
var text = $(‘<div>Simple text</div>‘); $(‘body‘).append(text);
这段代码的结果是添加一个div元素到文档的body标签。使用jQuery,这个操作只需一行代码,如果没有jQuery,代码有点复杂,但是也不会很多:
var stringToDom = function(str) { var temp = document.createElement(‘div‘); temp.innerHTML = str; return temp.childNodes[0]; } var text = stringToDom(‘<div>Simple text</div>‘); document.querySelector(‘body‘).appendChild(text);
我们定义了工具方法 stringToDom
,它创建1个临时的 <div>
元素,然后设置它的 innerHTML
属性为传入的参数,并在最后简单地返回其第一个孩子节点。它们的工作方式相同,然而,如果我们用下面的代码,将会观察到不一样的结果:
var tableRow = $(‘<tr><td>Simple text</td></tr>‘);//使用jquery $(‘body‘).append(tableRow); var tableRow = stringToDom(‘<tr><td>Simple text</td></tr>‘);//使用我们自己的方法 document.querySelector(‘body‘).appendChild(tableRow);
用肉眼观察页面,看起来没有差异,但是,如果我们使用 Chrome Developer ,我们会得到一个有趣的结果:
似乎我们的 stringToDom
函数创建的只是一个文本节点,而不是想要的 <tr>
标签,但同时 jQuery 的代码却做到了这一点,这是肿么回事?原来含有该元素的 HTML 字符串是通过浏览器解析器运行,而该解析器忽略了没有正确上下文的 HTML,我们得到的只是一个文本节点,一个不在表格中的行显然被浏览器认为是非法的。
jQuery通过创建合适的上下文并提取其中需要的部分成功地解决了这个问题,如果我们查看它的源代码,可以看到这样一个 map:
var wrapMap = { option: [1, ‘<select multiple="multiple">‘, ‘</select>‘], legend: [1, ‘<fieldset>‘, ‘</fieldset>‘], area: [1, ‘<map>‘, ‘</map>‘], param: [1, ‘<object>‘, ‘</object>‘], thead: [1, ‘<table>‘, ‘</table>‘], tr: [2, ‘<table><tbody>‘, ‘</tbody></table>‘], col: [2, ‘<table><tbody></tbody><colgroup>‘, ‘</colgroup></table>‘], td: [3, ‘<table><tbody><tr>‘, ‘</tr></tbody></table>‘], _default: [1, ‘<div>‘, ‘</div>‘] }; wrapMap.optgroup = wrapMap.option; wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead; wrapMap.th = wrapMap.td;
所有需要特殊处理的元素都分配了一个数组,其主要思路是构建正确的 DOM 元素,并依赖于嵌套来获取所需要的层级元素,例如,对于 <tr>
元素,需要创建一个 <table>
节点及子节点 <TBODY>
,因此,嵌套层次有两级。
有一个 map 之后,我们要找出最终想要什么样的标签,下面的代码使用正则表达式从<tr><td>Simple text</td></tr>
提取出tr:
var match = /<\s*\w.*?>/g.exec(str); var tag = match[0].replace(/</g, ‘‘).replace(/>/g, ‘‘);
剩下的是找到正确的上下文并返回DOM元素,下面是函数 stringToDom
的最终变种:
var stringToDom = function(str) { var wrapMap = { option: [1, ‘<select multiple="multiple">‘, ‘</select>‘], legend: [1, ‘<fieldset>‘, ‘</fieldset>‘], area: [1, ‘<map>‘, ‘</map>‘], param: [1, ‘<object>‘, ‘</object>‘], thead: [1, ‘<table>‘, ‘</table>‘], tr: [2, ‘<table><tbody>‘, ‘</tbody></table>‘], col: [2, ‘<table><tbody></tbody><colgroup>‘, ‘</colgroup></table>‘], td: [3, ‘<table><tbody><tr>‘, ‘</tr></tbody></table>‘], _default: [1, ‘<div>‘, ‘</div>‘] }; wrapMap.optgroup = wrapMap.option; wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead; wrapMap.th = wrapMap.td; var element = document.createElement(‘div‘); var match = /<\s*\w.*?>/g.exec(str); if(match != null) { var tag = match[0].replace(/</g, ‘‘).replace(/>/g, ‘‘); var map = wrapMap[tag] || wrapMap._default, element; str = map[1] + str + map[2]; element.innerHTML = str; // Descend through wrappers to the right content var j = map[0]+1; while(j--) { element = element.lastChild; } } else { // if only text is passed element.innerHTML = str; element = element.lastChild; } return element; }
请注意,我们会检查是否在字符串中存在标签 —— match != NULL
,如果不是,则简单返回一个文本节点,我们仍然使用了一个临时的<div>
,但这次我们传递了正确的DOM标签,使浏览器可以创建一个有效的 DOM 树,最终通过一个while循环,不断地递归,直到找到我们想要的标签。
下面是在 CodePen 显示的执行结果:
<tr><td>Simple text</td></tr>
接下来我们来探讨angularjs的依赖注入。
探讨 Angularjs 的依赖注入
当我们开始使用 AngularJS 时,最令人印象深刻的便是双向数据绑定,但我们注意到的第二件事便是其神奇的依赖注入,下面是一个简单的例子:
function TodoCtrl($scope, $http) { $http.get(‘users/users.json‘).success(function(data) { $scope.users = data; }); }
这是一个典型的 AngularJS 控制器,它执行 HTTP 请求,从一个 JSON 文件读取数据,并把它传递给目前的上下文,我们并没有执行 TodoCtrl
函数,甚至没有传递任何参数的机会,框架帮我们做了,这些 $scope 和 $HTTP 变量从哪里来的?这是一个超级酷的功能,像黑魔法一样,让我们来看看它是如何实现的。
现在假设我们的系统有一个显示用户信息的 JavaScript 函数,它需要访问 DOM 元素并放置最终生成的 HTML ,以及一个 Ajax 包装器来获取数据,为了简化这个例子,我们将伪造实体模型数据和 HTTP 请求。
var dataMockup = [‘John‘, ‘Steve‘, ‘David‘]; var body = document.querySelector(‘body‘); var ajaxWrapper = { get: function(path, cb) { console.log(path + ‘ requested‘); cb(dataMockup); } }
我们将使用<body>
标签作为内容载体。 ajaxWrapper 是模拟请求的对象,dataMockup 包含了我们的用户数组。下面是我们将使用的函数:
var displayUsers = function(domEl, ajax) { ajax.get(‘/api/users‘, function(users) { var html = ‘‘; for(var i=0; i < users.length; i++) { html += ‘<p>‘ + users[i] + ‘</p>‘; } domEl.innerHTML = html; }); }
显然,如果我们运行 displayUsers(body,ajaxWrapper)
我们会看到页面上显示了3个名字,控制台输出了/API/users requested
,我们可以说,我们的方法有两个依赖 —— body 和 ajaxWrapper 。现在我们的想法是让函数工作时无需传递参数,也就是我们要得到相同的结果仅通过调用displayUsers()
即可,如果使用现有的代码去执行,结果将是:
Uncaught TypeError: Cannot read property ‘get‘ of undefined
这是正常的,因为 AJAX 参数没有定义。
大多数提供依赖注入的框架通常都有一个称为注入器的模块,使用它需要将依赖注册,然后,我们的资源再由这个注入器模块传递给应用程序逻辑。
现在我们来创建自己的注入器:
var injector = { storage: {}, register: function(name, resource) { this.storage[name] = resource; }, resolve: function(target) { } };
我们只需要两个方法:第一个,register
,它接受我们的资源(依赖)并在内部存储;第二个我们接受注入的目标target
- 即那些有依赖性,需要接受他们作为参数的函数,这里的关键点是注入器不应调用我们的函数,这是我们的工作,我们应该能够控制,我们的解决方法是在 resolve
方法返回一个包裹了 target
的闭包并调用它。例如:
resolve: function(target) { return function() { target(); }; }
使用该方法,我们将有机会来调用函数以及所需的依赖关系,并且,我们未改变应用程序的工作流程,注入器仍然是独立的东西,并没有带任何逻辑。
然而,当传递 displayUsers
函数作为 Resolve
方法的参数时并没有作用:
displayUsers = injector.resolve(displayUsers); displayUsers();
我们仍然得到同样的错误,下一步是要找出什么是 target
的需要,什么是它的依赖?这里是我们从 AngularJS 里挖到的关键代码:
var FN_ARGS = /^function\s*[^\(]*\(\s*([^\)]*)\)/m; var STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg; ... function annotate(fn) { ... fnText = fn.toString().replace(STRIP_COMMENTS, ‘‘); argDecl = fnText.match(FN_ARGS); ... }
我们刻意忽略那些具体的实现,剩下的代码是有趣的,函数 annotate
的功能和 resolve
是一样的东西,它把目标函数源码转为字符串,删除注释(如果有的话),并提取参数,让我们的 resolve
函数使用这段代码并查看结果:
resolve: function(target) { var FN_ARGS = /^function\s*[^\(]*\(\s*([^\)]*)\)/m; var STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg; fnText = target.toString().replace(STRIP_COMMENTS, ‘‘); argDecl = fnText.match(FN_ARGS); console.log(argDecl); return function() { target(); } }
最终结果是:
如果我们得到 argDecl
数组的第二个元素,我们将找到所需的依赖的名称。这正是我们需要的,因为有这个我们就能从注入器的 storage
传送资源,下面是我们覆盖后的一个可以运行的目标版本:
resolve: function(target) { var FN_ARGS = /^function\s*[^\(]*\(\s*([^\)]*)\)/m; var STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg; fnText = target.toString().replace(STRIP_COMMENTS, ‘‘); argDecl = fnText.match(FN_ARGS)[1].split(/, ?/g); var args = []; for(var i=0; i<argDecl.length; i++) { if(this.storage[argDecl[i]]) { args.push(this.storage[argDecl[i]]); } } return function() { target.apply({}, args); } }
请注意,我们使用的是 split(/,?/g)
来把字符串 domEl,ajax
转换为一个数组,之后,我们查询依赖是否在我们的 storage
对象注册了,如果是我们则传递他们到 target
函数,注入器以外的代码看起来像这样:
injector.register(‘domEl‘, body); injector.register(‘ajax‘, ajaxWrapper); displayUsers = injector.resolve(displayUsers); displayUsers();
这种实现的好处是,我们可以在许多函数注入 DOM 元素和 Ajax warpper ,我们甚至可以像这样分发我们的应用程序配置,不再需要在类与类之间传递各种对象,仅需一个 register
和 resolve
方法即可。
当然,我们的注入器是不完美的,还有一些改进的空间,比如对自定义作用域的支持,target
现在在一个全新的作用域内被调用,但通常我们会想使用我们自定义的,另外我们应该支持传递参数给依赖。
如果我们既想保持代码短小又能正常工作,则注入器将变得更加复杂,正如我们所知道的我们这个缩小版替换了函数名,变量和方法的参数,而且因为我们的逻辑依赖于这些名称我们需要考虑变通,一个可能的解决方案是再次使用AngularJs:
displayUsers = injector.resolve([‘domEl‘, ‘ajax‘, displayUsers]);
与之前仅仅传递 displayUsers
不同,现在还传递了实际依赖的名字。
现在看看这个例子的最终结果:
JohnSteveDavid
采用 Ember 的动态属性
Ember 是时下最流行的框架之一,它具有很多实用的功能,有一个特别有趣 - 动态属性。总地来说,动态属性是作为属性的函数。让我们来看看从Ember的文档摘取的一个简单的例子:
App.Person = Ember.Object.extend({ firstName: null, lastName: null, fullName: function() { return this.get(‘firstName‘) + ‘ ‘ + this.get(‘lastName‘); }.property(‘firstName‘, ‘lastName‘) }); var ironMan = App.Person.create({ firstName: "Tony", lastName: "Stark" }); ironMan.get(‘fullName‘) // "Tony Stark"
这里有有个包含firstName和lastName属性的类,其中计算的属性FullName返回一个包含该人的全名的字符串,奇怪的是我们使用 .property
用于 fullname
函数的后面,除此之外没看到其他特别的地方。我们再次来看看这个框架的代码使用了何种魔法:
Function.prototype.property = function() { var ret = Ember.computed(this); // ComputedProperty.prototype.property expands properties; no need for us to // do so here. return ret.property.apply(ret, arguments); };
Ember为全局Function
对象扩充了一个property
方法,这是一个很好的方式,在类的定义中运行一些逻辑。
Ember使用getter
和setter
方法??对对象的数据进行操作。这简化了动态属性的实现,因为我们在操作实际变量之前添加了一个层,然而,如果我们能够使用动态属性与普通的JavaScript对象这将更有趣,例如:
var User = { firstName: ‘Tony‘, lastName: ‘Stark‘, name: function() { // getter + setter } }; console.log(User.name); // Tony Stark User.name = ‘John Doe‘; console.log(User.firstName); // John console.log(User.lastName); // Doe
name
被当成普通的属性使用,但在实现上却是一个函数,其获取或设置firstName
和lastName
。
如下是JavaScript的一个内置的功能,它可以帮助我们实现这个想法,看看下面的代码片段:
var User = { firstName: ‘Tony‘, lastName: ‘Stark‘ }; Object.defineProperty(User, "name", { get: function() { return this.firstName + ‘ ‘ + this.lastName; }, set: function(value) { var parts = value.toString().split(/ /); this.firstName = parts[0]; this.lastName = parts[1] ? parts[1] : this.lastName; } });
Object.defineProperty
方法接受一个对象、对象名称以及需要定义的属性的getter,setter方法,所有我们要做的就是写这两个方法的主体,就是这样,我们将能够运行上面的代码中,我们会得到预期的结果:
console.log(User.name); // Tony Stark User.name = ‘John Doe‘; console.log(User.firstName); // John console.log(User.lastName); // Doe
Object.defineProperty
正是我们所需要的,但我们不希望强制开发人员每次都这样写,我们可能需要提供一个 polyfill(译者注:类似于插件、扩展),用于执行额外的逻辑,或者类似的东西,在理想的情况下,我们希望提供类似于Ember的一个接口,只有一个函数是类定义的一部分,在本节中,我们将编写一个名为Computize
的工具函数来处理我们的对象并以某种方式将name函数转换为同名属性。
var Computize = function(obj) { return obj; } var User = Computize({ firstName: ‘Tony‘, lastName: ‘Stark‘, name: function() { ... } });
我们希望使用name
方法作为setter和getter,这类似于Ember的动态属性。
现在,让我们添加我们自己的逻辑到Function
对象的原型:
Function.prototype.computed = function() { return { computed: true, func: this }; };
一旦我们添加了上面的代码,我们就可以添加computed()
方法到每个函数定义的结尾:
name: function() { ... }.computed()
其结果是,name
属性不再是一个函数了,而是一个对象,其包含值为true的属性computed
以及值为原函数的属性func
,真正的魔法发生在Computize
帮手的实施,它会遍历对象的所有属性,并使用Object.defineProperty来重定义那些computed属性为true的属性:
var Computize = function(obj) { for(var prop in obj) { if(typeof obj[prop] == ‘object‘ && obj[prop].computed === true) { var func = obj[prop].func; delete obj[prop]; Object.defineProperty(obj, prop, { get: func, set: func }); } } return obj; }
请注意,我们正在删除原来的属性名称,因为在某些浏览器Object.defineProperty
只适用于那些尚未定义的属性。
下面是一个使用.Computize()
函数的User对象的最终版本。
var User = Computize({ firstName: ‘Tony‘, lastName: ‘Stark‘, name: function() { if(arguments.length > 0) { var parts = arguments[0].toString().split(/ /); this.firstName = parts[0]; this.lastName = parts[1] ? parts[1] : this.lastName; } return this.firstName + ‘ ‘ + this.lastName; }.computed() });
返回全名的函数用于改变firstName
和lastName
,它检测第一个参数是否存在,若存在,则分割并重新赋值到对应的属性。
我们已经提到过想要的用法,但让我们再来看一个例子:
console.log(User.name); // Tony Stark User.name = ‘John Doe‘; console.log(User.firstName); // John console.log(User.lastName); // Doe console.log(User.name); // John Doe
以下是最终结果:
Tony StarkKrasimirTsonevKrasimir Tsonev
译者注:其实本节描述的本质,就是如何把对象的方法作为一个属性来使用,类似高级语言中的get和set,那么其底层的关键实现是使用了
Object.defineProperty
来重定义属性的getter和setter方法。
疯狂的 React 模板
你可能听说过Facebook的框架 React,它的设计思想是一切皆为组件,其中最有趣的是组件的定义,让我们来看看下面的例子:
<script type="text/jsx">; /** @jsx React.DOM */ var HelloMessage class="keyword operator">= React.createClass({ render: function() { return <div>Hello {this.props.name}</div>; } }); </script>;
我们开始思考的第一件事是,这是一段JavaScript代码,但它是无效的,它有一个 render
函数,但显而易见会抛出一个语法错误,但是这里的诀窍是,这段代码放在一个自定义type的 <script>
标签里,因此浏览器不会处理它,这意味着它可以避免浏览器的语法检查。React 有自己的解析器,会把这段代码转换为有效的JavaScript代码,Facebook的工程师开发了类XML的语言JSX,JSX转换器大小是390K,并包含大约12000行代码,所以,这有点复杂。在本节中,我们将创建一个简单的函数来实现它,但功能还是相当强悍的,该解析器会把HTML模板转换为类似 React 的风格。
Facebook采取的方法是混合JavaScript代码和HTML标记,现在假设我们有下面的模板:
<script type="text/template" id="my-content">; <div class="content">; <h1>;<% title %>;</h1>; </div>; </script>;
然后添加一个类似这样的组件:
var Component = { title: ‘Awesome template‘, render: ‘#my-content‘ }
上面的代码定义了模板的ID和引用的数据,接下来我们需要实现一个融合这两种元素的引擎,让我们命名为 Engine
并像这样启动它:
var Engine = function(comp) { var parse = function(tplHTML) { // ... magic } var tpl = document.querySelector(comp.render); if(tpl) { var html = parse(tpl.innerHTML); return stringToDom(html); } } var el = Engine(Component);
我们先得到的<script type="text/template" id="my-content">
标记的内容,解析并生成HTML字符串,并把生成HTML转换成一个有效的DOM元素,并返回最终结果,请注意,我们使用了本文第一节写的 stringToDom
函数。
现在,让我们来实现 parse
函数,我们的首要任务是从HTML中提取出表达式,即那些在 <%
和 %>
之间的语句,我们使用一个正则表达式来找到他们,并用一个简单的 while 循环来遍历所有的匹配:
var parse = function(tplHTML) { var re = /<%([^%>]+)?%>/g; while(match = re.exec(tplHTML)) { console.log(match); } }
上述代码的运行结果是:
[ "<% title %>", "title", index: 55, input: "<div class="content"><h1><% title %></h1></div>" ]
我们找到了一个表达式,其内容是 title
,一个最简单直观的方法是使用JavaScript的 replace
函数替换<% title %>
为传过来的 Comp
对象的数据,然而,这只适用于简单的对象,如果我们有嵌套的对象,甚至,如果我们想使用函数,例如像这样就行不通了:
var Component = { data: { title: ‘Awesome template‘, subtitle: function() { return ‘Second title‘; } }, render: ‘#my-content‘ }
为了避免创建一个异常复杂的解析器甚至使用Javascript发明一种新的语言,一种最佳的办法就是使用 new Function
:
var fn = new Function(‘arg‘, ‘console.log(arg + 1);‘); fn(2); // outputs 3
我们可以动态构建一个函数体并延迟执行,但我们首先得知道表达式的位置以及其后面的内容,如果我们使用一个临时数组和 cursor
,则 while
循环的代码会是这样:
var parse = function(tplHTML) { var re = /<%([^%>]+)?%>/g; var code = [], cursor = 0; while(match = re.exec(tplHTML)) { code.push(tplHTML.slice(cursor, match.index)); code.push({code: match[1]}); // <-- expression cursor = match.index + match[0].length; } code.push(tplHTML.substr(cursor, tplHTML.length - cursor)); console.log(code); }
在控制台的输出说明我们做对了:
[ "<div class="content"><h1>", { code: "title" }, "</h1></div>" ]
数组 code
最终应该转变为一个字符串,这将是一个函数的主体部分,例如:
return "<div class=\"content\"><h1>" + title + "</h1></div>";
这个很容易实现,我们可以写一个循环,遍历上述代码序列,检测当前项目是字符串还是对象,然而,这仅适用于一部分场景,如果我们有下面的数据和模板:
// component var Component = { title: ‘Awesome template‘, colors: [‘read‘, ‘green‘, ‘blue‘], render: ‘#my-content‘ } // template <script type="text/template" id="my-content"> <div class="content"> <h1><% title %></h1> <% while(c = colors.shift()) { %> <p><% c %></p> <% } %> </div> </script>
如果还是仅连接表达式并期望列出颜色那就错了,考虑不再使用简单的字符串追加,而是把它们收集在数组中,下面便是修改后的解析函数:
var parse = function(tplHTML) { var re = /<%([^%>]+)?%>/g; var code = [], cursor = 0; while(match = re.exec(tplHTML)) { code.push(tplHTML.slice(cursor, match.index)); code.push({code: match[1]}); // <-- expression cursor = match.index + match[0].length; } code.push(tplHTML.substr(cursor, tplHTML.length - cursor)); var body = ‘var r=[];\n‘; while(line = code.shift()) { if(typeof line === ‘string‘) { // escaping quotes line = line.replace(/"/g, ‘\\"‘); // removing new lines line = line.replace(/[\r\t\n]/g, ‘‘); body += ‘r.push("‘ + line+ ‘");\n‘ } else { if(line.code.match(/(^( )?(if|for|else|switch|case|break|while|{|}))(.*)?/g)) { body += line.code + ‘\n‘; } else { body += ‘r.push(‘ + line.code + ‘);\n‘; } } } body += ‘return r.join("");‘; console.log(body); }
在函数的开始,我们把模板中所有语句存储在 code
数组中,之后我们遍历 code
数组,并尝试把每一条语句存储在数组 r
中,如果语句是字符串,则清除换行符和制表符,并用引号包裹,然后通过 push
方法添加到数组 r
中,如果语句是一个代码片段,我们先检查它是不是包含一个有效的JavaScript操作符,如果是无效的,那么我们不将它添加到数组,而只是纯粹地添加到新的一行,最后我们来看下最后一句 console.log
的输出结果:
var r=[]; r.push("<div class=\"content\"><h1>"); r.push(title); r.push("</h1>"); while(c = colors.shift()) { r.push("<p>"); r.push(c); r.push("</p>"); } r.push("</div>"); return r.join("");
非常好,不是吗?格式化成了正确的JavaScript代码,这将在我们 Component
的上下文中执行并产生需要的HTML标记。
最后剩下的一件事是就是创建实际的函数并运行:
body = ‘with(component) {‘ + body + ‘}‘; return new Function(‘component‘, body).apply(comp, [comp]);
我们把生成的代码包裹在 with
语句中,以便在 Component
的上下文中运行它,否则我们需要使用 this.title
和this.colors
来取代 title
和 colors
。
以下是在CodePen的演示结果:
Awesome templatereadgreenblue
总结
在这些风靡的框架和库的背后,隐藏着及其聪明的工程师,他们发现棘手的问题并使用巧妙的解决方案,是不平凡的,甚至有点不可思议,在这篇文章中,我们揭示了这些魔法,这对我们在 JavaScript 的世界学习和使用他们的代码很有好处。
本文的代码可以从GitHub上下载。
框架是如何炼成的 —— 揭秘前端顶级框架的底层实现原理