一个 JS 框架需要做什么

jopen 9年前

原文 http://segmentfault.com/a/1190000003505612


学习再多,也是杯水车薪。

为什么这么说?不知道各位有没有发现,虽然前端发展快,但一些有名的框架至少会火热很长时间,比如 Backbone、React、Ember 。如果有心要学,肯定有足够的时间把它学会,毕竟事实摆在面前,很多公司的上线产品就是用 React 来写的,比如 Teambition 的简聊,貌似它是从 Backbone 重构过来的。然而,很多同学在接手新项目时,常常会不知所措,不知道用什么技术去做,或者说,只依赖于擅长的技术,就算在一些场景中它可能并不是最适合的。

因为这些同学平时不够努力吗?不是吧。他们可能会看书到很晚,浏览很多博客,就是为了去了解 CORS 的应用,或者是想知道为什么 Angular 中的 scope 在某些时候不能双向绑定了。对,时间是花了,但遇到问题还是一头雾水。可能前端就是这么一份工作吧,怂恿你去学游泳,蛙泳、自由泳、蝶泳,海啸来了照样被冲走……

那这篇文章要说的是什么呢?就是假设你现在什么都没学,就靠基本功,去完成一个静态页面,当然也有业务逻辑,包括数据的 CRUD、动画,怎么做?有个关于 VanillaJS 的梗不知道大家看过没,你一定会会心一笑的。

没有 jQuery 了,没有 Bootstrap 了,扔掉所有你引以为傲的武器,但大恶魔 IE 6 还在。具体的需求不给了,反正给了你们也不会照着去实现,真有心要做的话,可以做一个 todo app 吧。

DOM 查询

在没有第三方框架可以用的时候,如果真的按照功能列表,从第一条实现到最后一条,每个模块用自执行匿名函数包起来,所有代码写在一个文件中,看上去十分合理,但真这么做的话,恐怕你会疯掉吧。哦,好处是你可以跟别人吹嘘今天写了三四百行代码,产量很高呢!

所以,不使用第三方框架,我们可以自己写,它的功能只要符合应用场景就可以了,不用去考虑各种不会发生的奇葩情况。

好,开始。我们最依赖的功能是通过 CSS 选择器获取相应的 DOM 元素,这里只使用兼容性最高的方式,就是 id 和元素名选择器。

var idRegex         = /^#[\w\-]+/i,      tagRegex        = /^[a-z]+/i;          function query(selector, context) {    context = context || document;      if (idRegex.test(selector)) {      return document.getElementById(selector.substring(1));    } else if (tagRegex.test(selector)) {      return context.getElementsByTagName(selector);    }      return null;  }

对了,我把所有 DOM 操作放在了 F.DOM 命名空间下,所以是这样使用 query 方法的:

F.DOM.query('#id');

的确比 jQuery 的 $('#id') 方式麻烦很多,但“子不嫌母丑,狗不嫌家贫”,自己写的代码,再烂也要用下去。

另外一些必须的操作就不把代码贴出来了,比如说 addClass、removeClass、hasClass 等。

DOM 事件

如果有同学参加过面试的话,我想“怎么去监听一个 DOM 事件?请尽可能考虑浏览器兼容性”这个问题是经常会问到吧。这儿写一个可行方案吧。

// 监听 DOM 事件  function addEventListener(el, event, handler, useCapture) {    if (el.addEventListener) {      el.addEventListener(event, handler, useCapture);    } else if (el.attachEvent) {      el.attachEvent('on' + event, handler);    } else {      // not support    }  }    // 取消 DOM 事件  function removeEventListener(el, event, handler, useCapture) {    if (el.removeEventListener) {      el.removeEventListener(event, handler, useCapture);    } else if (el.detachEvent) {      el.detachEvent('on' + event, handler);    } else {      // not support    }  }

我知道大家可能有更好的,或者更完善的方案,但抱歉这里讨论的重点不是它。

关于 DOM 事件方面,还有一些有用的方法,比如 preventDefault 和 stopPropagation 也可以自己去封装一下。然后这儿想讨论一下 DOM 加载完成的事件。jQuery 中我们会这么用:

$(function() {    // ready  });

如果我们也想封装一个类似的方法,可能会这么写:

addEventListener('window', 'load', callback);

可是 load 事件是在什么情况下触发的呢?当页面上的所有资源,包括图片,加载完之后才触发!也就是说,如果图片很多,网速很慢,那触发 load 要花很长时间。在本地调试时不会有这种延迟的问题,所以往往会被忽略。

那怎么改正呢?第一,可以把 <script> 放到 <body> 中所有元素的下方,就不需要监听任何“加载完成”的事件了。第二,监听 DOMContentLoaded 事件,IE 9+ 支持。至于如何兼容低版本浏览器,可以看 这篇文章 (addDOMLoadEvent)

组件式开发

“组件”这个词其实来源于很多框架,比如 Backbone 中的 View,React 就更不用说了,它为了组件化专门规定了 JSX(当然它有更宏伟的 goal) 。我们这里讨论的组件也是差不多的意思,就是按照功能,把页面上分成一个个独立的模块,模块之间通过消息(事件)进行沟通。关于模块耦合,JSX 是通过类似于 HTML 标签嵌套的方式来表现的,而我们自然没这么高级,就直接把依赖的模块注入到其他模块中,比如:

/**   * 应用顶层,构造一些页面中用到的组件   */  function App() {    F.Component.call(this);  }    App.prototype = new F.Component();    F.extend(App.prototype, {    constructor: App,    init: function() {      this._blogPost = new BlogPost('#blog-post');      this._blogList = new BlogList('#blog-list', this);      this._newsList = new NewsList('#news-wrapper');    }  });    new App();

其中,BlogPost 是发布日志的组件,BlogList 是日志列表。发布日志后必然会显示到列表中,所以在构造日志列表时,会把 BlogPost 注入到 BlogList 中。

每个组件可以提供一个 id 选择器,表示该组件需要绘制在哪个元素内。

消息传播机制

关于 BlogPost 和 BlogList,大家可以想象微博的主页,它上面是一个发布框,下面是微博列表,就是这样一个界面。

当微博发布之后,列表中需要增加新发布的内容,这个过程是谁给谁发消息?按照面向对象的思想,应该是类似于这样:

// 在 发布框组件 中调用 列表组件 的方法  blogList.add(item);

显然是 BlogPost 依赖于 BlogList 对吗?但貌似我们上面的代码不是这个逻辑,而是反过来。那么实际情况就成了这样:

// 发布框:BlogPost 中触发事件  this.emit('add', item);    // 列表:BlogList 中监听事件  this.listenTo(blogPost, 'add', handler);    // 由 handler 处理发布事件

嗯,代码变多了,看来得强行圆回来。

为什么我强烈建议使用后者?假设过了一段时间,某个充满创意的策划突然告诉你,当发布微博之后,可以显示到朋友圈(假设有这么个东西)。那么前者的方式会怎么做?是不是首先给这个发布框多注入一个依赖,即朋友圈,然后调用朋友圈的某个方法?

如果再过段时间,又有新创意了,是不是又得给发布框加依赖了?最后搞得发布框依赖于微博列表、依赖于朋友圈、依赖于其他 8 个组件,真不想用水性杨花来形容它。

这个问题很常见吧?如果用消息机制的方式就会好很多,只需要在新增加的组件中监听发布框的 'add' 事件就可以了。

如果你能接受这个方式,可能想知道怎么去简单地实现它。

var Event = F.Event = function Event() {      // 该组件相关的所有的事件都保存在 _events 对象中    // 格式 - {'eventName': [{handler, context}*]}    this._events = {};  };    F.extend(Event.prototype, {      // 监听事件    on: function(event, handler, context) {        if (!this._events[event]) {        this._events[event] = [];      }        this._events[event].push({        handler: handler,        context: context || this      });    },          // 触发事件    emit: function(event) {      var events    = this._events[event] || [],          args      = [];        // 第一个参数为事件名,后面的参数需要传给处理该事件的方法,记录到 args 中      if (arguments.length > 1) {        args = slice.call(arguments, 1);      }        // 回调时需要传入参数      events.forEach(function(v) {        v.handler.apply(v.context, args);      });    }  }

把重点部分贴了一下。第一,这个 Event 是所有组件的基类,所以每个组件都有 on 和 emit 方法。第二,F.extend 的作用就是把后面对象的方法和属性直接赋值给第一个,extend 的意思是“扩展”而不是“继承”,这点别混淆了。第三,通过改变上下文(就是 this),当一个组件的事件触发时,由另一个组件处理。

由于上面省略了很多代码,一般还要考虑的情况有,怎么取消监听,怎么实现例子中的 listenTo 等。

组件继承

关于继承,这篇文章略有提到(4.2 通过 prototype 实现继承)。

这里就写个 F.extend 技巧好了。一般来说,会在继承之后修改 prototype 的 constructor 属性,并在它上面定义很多方法,就变成了:

A.prototype = new B();  A.prototype.constructor = A;  A.prototype.f1 = function() {};  A.prototype.f2 = function() {};

大家不妨去实现一个 extend 方法,让代码变成:

A.prototype = new B();  extend(A.prototype, { /* 原型上的方法和属性 */ });

DOM 事件代理

一个组件往往会对应一个页面区域,那在这个区域上会有单击按钮等一些 DOM 事件。由于在初始化组件时,这些元素还没有追加到 DOM 上去,所以就不能使用 addEventListener 这个方法来监听单击事件。那要怎么监听呢?

两种方法。一,在生成 HTML 片段时,设置元素的 onclick 属性,比如:

container.innerHTML = '<a href="#" onclick="delegate(' + id + ')">click</a>';

技巧在于,这个 delegate 方法是全局的,并且它能通过组件的 id 来找到对应的组件对象,再调用该组件的回调函数。

二,在子元素添加到 DOM 之前,父容器是存在了的,所以可以对父容器监听 click 事件,然后对 event.target 判断。

addEventListener(container, 'click', delegate)

无论是哪种方法,具体实现时肯定会碰到问题,这些都是预期范围内的,所以不用沮丧。

封装 AJAX

同样地,面试官极有可能问你“请用原生 JS 封装 AJAX 的 GET 请求”。你应该已经熟稔于心,或者至少有笔记记录了怎么写。

现在要讨论的是,如何利用“消息机制”去避免回调。jQuery 中的 ajax 方法需要一个 success 的回调,加上配置 url 等信息,导致完成一次请求所用到的代码非常复杂,很难阅读。ES 6 推出了 Promise,使得我们可以用同步的语法去做异步的事,阅读性得到了提升。

由于我们不能用 Promise,所以就发消息吧,也很优雅。

F.extend(Request.prototype, {    constructor: Request,    get: function() {      var xhr   = createXHR(),      self  = this;        xhr.open('GET', this._api, true);      xhr.onreadystatechange = function() {        if (xhr.readyState === 4 && xhr.status === 200) {          self.emit('success', xhr.responseText);        }      };      xhr.send();    }  });

代码并不全,说明一下,Request 继承自 Event,构造时需要传入一个 url,表示请求的地址。用法类似于:

// 在某个组件中,this 指向该对象的实例  var r = new Request('http://www.example.com/blogs');    r.get();  r.on('success', callback, this);

貌似有点像 Angular 中 new Resource(url); 的用法。

功能性兼容 (Polyfill)

这部分主要是为了兼容比如说 IE 6 不支持 HTML5 元素的样式、数组中的高级用法(forEach 和 map 等)、字符串的高级用法(trim)、Function 的 bind 等。

因为是临时编写的框架,所以业务逻辑的代码中需要什么,就补什么。

兼容 HTML5 元素你可以这么做,很简单:

document.createElement('header');

把所有用到的元素都 createElement 一遍就行了,这段代码必须放在 <head> 中。

至于兼容 forEach、map、bind 这一些,网上应该有一大堆吧,这儿只是为了提醒各位去考虑这些方面。然后,网上的兼容策略可能很复杂,没必要,大家完全可以尝试自己去写,“过早的优化是万恶之源”(这是个人最喜欢的名言了)。

浅谈模板语言

这个虽然不是必须的,并且在我目前写的代码中也没有考虑到,但经过一位高人提醒,就觉得,咦,很多听上去高大上的技术,从原理来讲都是柴米油盐这些基础知识。

如果各位之前对 underscore 中的 _.template 方法并不了解,看完这节应该会帮助你一些。

假设要生成一个用户名的链接,用模板可以这么写:

<a href="#">{{ name }}</a>

而用现在的方式是这么做的:

var html = '<a href="#">' + model.name + '</a>';

那么怎么通过模板的方式去做,不用费劲地拼接字符串呢?答案是正则。

function parse(template, model) {    return template.replace(/\{\{\s*(.+?)\s*\}\}/g, function(match, p1) {      return model[p1] || match;    });  }

替换时,match 表示由正则匹配到的字符串,这里是 '{{ name }}',p1 表示匹配到的字符串中第一个组的值,这里是 'name',问号 ? 是阻止贪婪匹配,最后由返回值替换 match,这里是 model.name 。

小结

文章中的代码可能只展示了一小部分,因为我主要是想说明一些值得考虑的点,并不是教程,至少大家可以用这些作为草稿去开始。

绕来绕去,JS 中的语法也屈指可数,那为什么在学习新技术的时候会很焦灼呢?基础是一个原因,没有基础就造不了任何建筑;知识面是另一个,解决问题时最怕的是不知道有某个答案存在,使用 API 时最讨厌的就是不知道它已经提供这个功能了。所以平时应该多看一些文章,有想法就记下来,无论是笔记的方式还是博客的方式都行,写博客可以强迫你把想法表达出来,这跟“看懂”是不一样的。

至于学习的性价比,我只能说,不要停!

你可能觉得,唉,React 是很好,但眼下又用不到,就算学了也没用,还不如把时间花在绩效上。自己写代码永远是个封闭的空间,包括因为遇到什么问题被动地去 google 也好,如果不是主动去看新鲜事物,能力的增长是十分缓慢的。为什么会不断有新技术产生?这个事情本身就在告诉我们,需要用新的角度去解决新的问题(或者旧的)。举两个例子,IE 在 Windows 系统上是不会自动更新的,现在它死了(Windows 10 Edge);Adobe Flash 适应不了移动平台,而安全漏洞又频出,现在它马上要死了(Adobe 的高层表示并不 care,因为 Flash 只占很少一部分营收)。

互联网它不跟你讲人情的,适者生存。