我们每天都在使用各种各样的框架, 这些框架伴随着我们每天的工作. 通过使用这些框架的目的是为了解放我们, 很少人去真正关心这些框架的背后都做了些什么. 我也使用了不少的框架, 通过这些流行框架也让我学习到了一些知识, 就想把这些东西分享出来.
每个标题都是一个独立的主题, 完全可以根据需要挑有兴趣的阅读.
字符串转 DOM
经常使用 jquery 的小伙伴对下面的代码应该一点都不陌生:
- var text = $('<div>hello, world</div>');
- $('body').append(text)
复制代码
以上代码执行的结果就是在页面增加了一个 div 节点. 抛开 jQuery, 代码可能会变得稍稍复杂:
- var strToDom = function(str) {
- var temp = document.createElement('div');
- temp.innerHTML = str;
- return temp.childNodes[0];
- }
- var text = strToDom('<div>hello, world</div>');
- document.querySelector('body').appendChild(text);
复制代码
这段代码, 跟使用 jQuery 的效果是一模一样的, 哈哈 jQuery 也不过如此嘛. 如果你这么想你就错了. 下面两种代码运行的有什么区别:
- var tableTr = $('<tr><td>Simple text</td></tr>');
- $('body').append(tableTr);
- var tableTr = strToDom('<tr><td>Simple text</td></tr>');
- document.querySelector('body').appendChild(tableTr);
复制代码
表面上看没任何的问题, 如果用开发者工具看页面结构的话, 会发现:
strToDom 仅仅创建了一个文本节点, 而不是一个真正的 tr 标签. 原因是包含 HTML 元素的字符串通过解析器在浏览器中运行, 解析器忽略了没有放置在正确的上下文中的标签, 因此我们只能得到一个文本节点.
jQuery 是如何解决这个问题的呢? 通过分析源码, 我找到了下面的代码:
- 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>hello word</td></tr>
中取到 tr:
- var match = /<\s*\w.*?>/g.exec(str);
- var tag = match[0].replace(/</g, '').replace(/>/g,'');
复制代码
剩下的就是根据合适的上下文返回 DOM 元素, 最终我们将 strToDom 进行最终的修改:
- var strToDom = 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 判断是创建的是标签还是文本节点. 这一次我们通过浏览器可以创建一个有效的 DOM 树. 最后通过使用 while 循环, 直到取到我们想要的标签, 最后返回这个标签.
AngularJS 依赖注入
当我们开始使用 AngularJS 时, 它的双向数据绑定让人印象深刻. 此外另一个神奇特征就是依赖注入. 下面是一个简单的例子:
- function TodoCtrl($scope, $http) {
- $http.get('users/users.json').success(function(data) {
- $scope.users = data;
- });
- }
复制代码
这是一个典型的 AngularJS 控制器写法: 通过发起一个 HTTP 请求, 从 JSON 文件获取数据, 并将数据赋值给 $scope.users .AngularJS 框架会自动将 $scope 和 $http 注入控制器中. 让我们看看它是如何实现的.
看一个例子, 我们想将用户姓名显示到页面上, 为了简单起见, 采用的 mock 假数据模拟 http 请求:
- var dataMockup = ['John', 'Steve', 'David'];
- var body = document.querySelector('body');
- var ajaxWrapper = {
- get: function(path, cb) {
- console.log(path + 'requested');
- cb(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)
复制代码
displayUsers(body, ajaxWrapper)
执行需要两个依赖项: body 和 ajaxWrapper. 我们的目标是直接调用 displayUsers() 而没有传递参数, 也能按我们期望的运行.
大部分的框架提供了依赖注入机制有一个模块, 通常叫 injector. 所有的依赖统一在这里注册, 并提供对外访问的接口:
- var injector = {
- storage: {},
- register: function(name, resource) {
- this.storage[name] = resource;
- },
- resolve: function(target) {
- }
- };
复制代码
其中关键的 resolve 的实现: 它接收一个目标对象, 通过返回一个闭包, 包装 target 并调用它. 例如:
- resolve: function(target) {
- return function() {
- target();
- };
- }
复制代码
这样我们就可以调用我们需要的依赖的函数了.
下一步就是获取 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: 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 数组的第二个元素包含了所有的参数, 通过参数名称就可以得到 injector 中存储的依赖项了. 下面是具体的实现:
- 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 转换成数组, 通过检查 injector 中是否注册了同名的依赖, 如果存在, 将依赖项放入一个新的数组作为参数传递给 target 函数.
调用的代码应该是这样的:
- injector.register('domEl', body);
- injector.register('ajax', ajaxWrapper);
- displayUsers = injector.resolve(displayUsers);
- displayUsers();
复制代码
这样的实现的好处是, 我们将 domEl 和 ajax 注入到任意想要的函数中. 我们甚至可以实现应用的配置化. 不再需要将参数传来传去, 代价仅仅是通过 register 和 resolve.
目前为止我们的自动注入并不是完美的, 存在两个缺点:
1, 函数不支持自定义参数.
2, 上线代码压缩导致参数名字改变, 导致无法获取正确的依赖项.
这两个问题 AngualrJS 已经全部解决了, 有兴趣可以看我的另一篇文章: javascript 实现依赖注入的思路 http://www.ajiehome.com/2018/08/02/jsru-he-shi-xian-di/ , 里面详细介绍了依赖注入的完整解决方案.
Ember Computed 属性
可能现在大多数人一听到计算属性, 首先想到的是 Vue 中的 Computed 计算属性. 其实在 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: "Kobe",
- lastName: "Bryant"
- });
- ironMan.get('fullName') // "Kobe Bryant"
复制代码
Person 对象具有 firstName 和 lastName 属性. computed 属性 fullName 返回包含 person 全名的连接字符串. 令人奇怪的地方在于 fullName 的函数使用了 .property 方法. 我们看一下 property 的代码:
- 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 使用 getter 和 setter 来操作对象的数据. 这就简化了计算属性的实现, 因为我们之前还有一层要处理实际的变量. 但是, 如果我们能够将计算属性与普通 js 对象一起使用, 那就更有趣了. 例如:
- var User = {
- firstName: 'Kobe',
- lastName: 'Bryant',
- name: function() {
- // getter + setter
- }
- };
- console.log(User.name); // Kobe Bryant
- User.name = 'LeBron James';
- console.log(User.firstName); // LeBron
- console.log(User.lastName); // James
复制代码
name 作为一个常规属性, 本质上就是一个获取或设置 firstName 和 lastName 的函数.
JavaScript 有一个内置的特性, 可以帮助我们实现这个想法:
- var User = {
- firstName: 'Kobe',
- lastName: 'Bryant',
- };
- 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); // Kobe Bryant
- User.name = 'LeBron James';
- console.log(User.firstName); // LeBron
- console.log(User.lastName); // James
复制代码
Object.defineProperty
虽然是我们想要的, 但显然我们不想每次都这么写. 在理想的情况下, 我们希望提供一个接口. 在本节中, 我们将编写一个名为 Computize 的函数, 它将处理对象并以某种方式将 name 函数转换为具有相同名称的属性.
- var Computize = function(obj) {
- return obj;
- }
- var User = Computize({
- firstName: 'Kobe',
- lastName: 'Bryant',
- name: function() {
- ...
- }
- });
复制代码
我们想使用 name 方法作为 setter, 同时作为 getter. 这类似于 Ember 的计算属性.
现在, 我们将自己的逻辑添加到函数对象的原型中:
- Function.prototype.computed = function() {
- return { computed: true, func: this };
- };
复制代码
这样就可以在每个 Function 定义后直接调用 computed 函数了.
- name: function() {
- ...
- }.computed()
复制代码
name 属性不再是一个函数, 而变成一个对象:
{ computed: true, func: this }
. 其中 computed 等于 true, func 属性指向原本的函数.
真正神奇的事情发生在 Computize helper 的实现中. 它遍历对象的所有属性, 对所有的计算属性使用 object.defineproperty:
- 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;
- }
复制代码
注意: 我们将计算属性 name 删除了, 原因是 Object.defineProperty 在某些浏览器下仅对未定义的属性起作用.
下面是使用. computed() 函数的用户对象的最终版本:
- var User = Computize({
- firstName: 'Kobe',
- lastName: 'Bryant',
- 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 赋值, 最终返回完整的名字.
结束
在大型框架和库的背后包含着许多优秀前辈的经验. 通过学习这些框架能够让我们更好理解这些框架背后的原理, 能够脱离框架开发, 这点很重要.
来源: https://juejin.im/post/5b6dae635188251ace75f422