【交流与探讨】大家怎么看前端模板技术。。。

2015-04-22 23:37 2692 3

什么是模板解决方案?

你可以先简单的理解为模板引擎。

事实上前端的模板解决方案已经从“选出一个好用的模板好难”发展到了“从这么多模板中选一个好难的”的阶段,Template-Engine-Chooser!似乎也开始无法跟上节奏了。再加上目前Dom-based的模板技术的崛起(angularjs, knockout等),渐渐让这个领域有乱花渐欲迷人眼的感觉。

这篇文章会对当今前端界的三种截然不同的模板方案做一个全面的对比,它们分别是

  1. String-based 模板技术(基于字符串的parse和compile过程)
  2. Dom-based 模板技术(基于Dom的link或compile过程)
  3. 杂交的Living templating 技术(基于字符串的parse 和 基于dom的compile过程)

同种类型的模板技术的可能性都是相同的,即同样身为dom-based的vuejs如果愿意可以发展为angularjs的相同功能层级。

(注: 其实这么说作者后续思考后觉得并不是很妥当,因为决定这类框架的还有重要一环就是它们的数据管理层:,比如是基于脏检查还是基于setter和getter,就会有截然不同的定位)

另外需要注意的是任何一种类型的模板技术都是不可被替代的,它们甚至可以结合使用,并且很长一段时间内还会继续共存下去。

除此之外另外一种奇葩模板技术本文也会提到即react,了解后你会发现它的特性更接近于Living templating。

在进入介绍之前,我们需要先过一下不得不说的InnerHTML,它是本文的关键因素。

innerHTML

我不认为还需要从innerHTML的细节讲起,我们对它太熟悉了,那就直接从优劣开始讲吧!

innerHTML 毫无疑问是好的

在innerHTML正是成为web标准前,它当之无愧的已经是大家公认的事实标准,这是因为:

1 . 它便于书写并且直观

想象下你必须添加如下的html到你的文档里

<h2 title="header">title</h2> <p>content</p>

直接使用innerHTML

node.innerHTML = "<h2 title="header">title</h2><p>content</p>"

在对比使用Dom API

var header = document.createElement('h2'); var content = document.createElement('p'); h2.setAttribute('title', 'header'); h2.textContent = 'title'; p.textContent = 'content'; node.appendChild(header); node.appendChild(content);

innerHTML毫无疑问赢得了这张比赛.

尽管部分框架例如mootools:Element提供了高效的API来帮助你创建dom结构,innerHTML仍然会是大多数人的最佳选择

2 . 它很快,特别在old IE

随着浏览器的发展,这个测试可能越来越不符合实际,innerHTML和Dom Level 1创建dom结构的差距正变得原来越小

3. 它完成进行了String -> Dom的转换

这个论点有点拗口,事实上后续要提到的两类模板技术都是因为这个特点而与其有了强依赖


然而我们又清楚的被告知:

The recommended way to modify the DOM is to use the DOM Level 1 API.——Chapter 15 of "Javascript: The Definitive Guide_"

为什么?

innerHTML 有时候又是不听话的

1. 安全问题

innerHTML具有安全隐患.,例如:

document.body.innerHTML = "<img src=x   onerror='alert(xss)'/>"

我知道像你这样优秀的程序员不会写出这样的代码,但当html片段不完全由你来控制时(比如从远程服务器中),这会成为一个可能引爆的炸弹。

2. 它很慢

等等,你刚才说了它很快! 是的,但是如果你仅仅为了替换一个属性而用innerHTML替换了所有的Dom节点,这显然不是一个明智的决定,因为你深知这是低效的。所以说:

Context is everything

所有离开背景谈的性能、功能、性功能都是伪科学

3. 它很笨

它会完全移除所有现有的Dom,并重新渲染一遍,包括事件和状态都以不复存在,这点利用innerHTML来进行render的框架(例如Backbone)的开发者应该深有体会,为了减少损失,不能不把View拆的越来越细,从而抱着看似“解耦完美”的架构体系进入了维护的深渊。

注: 其实react的最大贡献就是它差不多是提供了一个更smart的innerHTML解决方案。

4. 有可能会创建出意料之外的节点.

由于html的parser非常的“友好”, 以至于它接受并不规范的写法,从而创建出意料之外的结构,而开发者得不到错误提示。


好了,到现在为止,我们大概了解了innerHTML这个朝夕相处的小伙伴,接下来我们正式聊一聊模板技术,首先我们从最常见的“String-based templating”开始

String-based templating

基于字符串的模板引擎最大的功劳就是把你从大量的夹带逻辑的字符串拼接中解放出来了,由于它的完全基于字符串的特性,它拥有一些无可替代的优势。

It is essentially a way to address the need to populate an HTML view with data in a better way than having to write a big, ugly string concatenation expression. --- cited fromhttp://www.dehats.com/drupal/?q=node/107

示例

  1. mustache及其衍生: 弱逻辑
  2. Dust.js: 强逻辑 (推荐)
  3. doT.js: 超级快

基本原理

string-based

如上图所示,我们发现字符串模板强依赖于innerHTML(渲染), 因为它的输出物就是字符串。由于这篇文章的重点不在这里,我们不会再对它们如何使用作深究。

优点

  1. 快速的初始化时间: 很多angular的簇拥者在奚落String-based templating似乎遗漏了这一点。
  2. 同构性: 完全的dom-independent,即可作为用服务器端和浏览器端(客官先不要急着搬phantomjs哈).
  3. 更强大的语法支持:因为它们都是不是自建DSL就是基于JavaScript语法,Parser的灵活性与受限于HTML的Dom-based模板技术不可同日而语

缺点

  1. 安全隐患: 见innerHTML
  2. 性能问题:见innerHTML.
  3. 不够聪明: 见innerHTML(呵呵),除此之外render之后数据即与view完全分离。

尽管在这几年的发展之下,由于异常激烈的竞争,基于字符串的前端模板技术变得越来越快,但是它们显然大部分都遗漏了一些问题

  1. 大侠们你们没有考虑进把输出字符串加载到Dom的时间,这才是真正瓶颈之一
  2. 不在相同功能前提下的对比有意义么?

Dom-based Template Engine

近几年,借着Angularjs的东风,Dom-based的模板技术开始大行其道,与此同时也出现了一些优秀的替代者,就我们国人而言,近的就有@尤小右的Vuejs和司徒大大的avalonjs。看仓库就可以发现风格也是完全不同:1) 一个简洁优雅 2)一个奔放不羁

示例

  1. Angularjs: 都28000star了还需多说么
  2. Knockout: 在此领域内,对Web前端而言是鼻祖级的

大致流程

dom-based

Dom-based的模板技术事实上并没有完整的parse的过程(先抛开表达式不说),如果你需要从一段字符串创建出一个view,你必然通过innerHTML来获得初始Dom结构. 然后引擎会利用Dom API(attributes,getAttribute,firstChild… etc)层级的从这个原始Dom的属性中提取指令、事件等信息,继而完成数据与View的绑定,使其”活动化”。

所以Dom-based的模板技术更像是一个数据与dom之间的“链接”和*“改写”*过程。

注意,dom-based的模板技术不一定要使用innerHTML,比如所有模板都是写在入口页面中时, 但是此时parse过程仍然是浏览器所为。

优点

  1. 是活动的: 完成compile之后,data与View仍然保持联系,即你可以不依赖与手动操作Dom API来更新View
  2. 运行时高效的: 可以实现局部更新
  3. 指令等强大的附属物帮助我们用声明式的方式开发APP

缺点

  1. 部分请见innerHTML
  2. 没有独立的Parser,必须通过innerHTML(或首屏)获取初始节点,即它的语法是强依赖与HTML,这也导致它有潜在的安全问题
  3. 信息承载于属性中,这个其实是不必要和冗余的。 部分框架在读取属性后会通过诸如removeAttribute的方式移除它们,其实这个不一定必要,而且其实并无解决它们Dom强依赖的特性,比如如果你查看[angular的todomvc]的节点,你会发现它的输出是这样的:angular-todo
  4. FOUC(Flash of unstyled content):即内容闪动,这个无需多说了,只怪它初次进入dom的内容并不是最终想要的内容。

Living Template Engine

String-based 和 Dom-based的模板技术都或多或少的依赖与innerHTML, 它们的区别是一个是主要是为了Rendering一个是为了Parsing提取信息

所以为什么不结合它们两者来完全移除对innerHTML的依赖呢?

事实上,值得庆幸的是,已经有几个现实例子在这么做了。

例子

  1. htmlbar: 运行在handlebar之后的二次编译
  2. ractivejs: 独立
  3. Regularjs独立,本文作者结精之一

基本原理

Living-Template

就如图中所示,parse和compile的过程分别类似于String-based 模板技术 和 Dom-based模板技术。

下面来完整讲述下这两个过程

1 . Parsing

首先我们使用一个内建DSL来解析模板字符串并输出AST。

例如,在regularjs中,下面这段简单的模板字符串

<button {{#if !isLogin}} on-click={{this.login()}} {{/if}}>   {{isLogin? 'Login': 'Wellcome'}} </button>'

会被解析为以下这段数据结构

[   {     "type": "element",     "tag": "button",     "attrs": [       {         "type": "if",         "test": {           "type": "expression",           "body": "(!_d_['isLogin'])",           "constant": false,           "setbody": false         },         "consequent": [           [             {               "type": "attribute",               "name": "on-click",               "value": {                 "type": "expression",                 "body": "_c_['login']()",                 "constant": false,                 "setbody": false               }             }           ]         ],         "alternate": []       }     ],     "children": [       {         "type": "expression",         "body": "_d_['isLogin']?'Login':'Wellcome'",         "constant": false,         "setbody": false       }     ]   } ] 

这个过程有以下特点

  1. 灵活强大的语法,因为它与基于字符串的模板一般,DSL是自治的,完全不依赖与html,你可以想像下dom-based的模板的那些语法相关的指令,事实上它们甚至无法表达上述那段简单的模板的逻辑。
  2. Living模板技术需要同时处理dsl元素与xml元素来实现最终视图层的活动性,即它们是dom-aware的,而在字符串模板中其实xml元素完全可以无需关心,它们被统一视为文本元素。

2 Compiler

结合特定的数据模型(在regularjs中,是一个裸数据), 模板引擎层级游历AST并递归生成Dom节点(不会涉及到innerHTML). 与此同时,指令、事件和插值等binder也同时完成了绑定,使得最终产生的Dom是与Model相维系的,即是活动的.

事实上,Living template的compile过程相对与Dom-based的模板技术更加纯粹, 因为它完全的依照AST生成,而不是在原Dom上的改写。

以上面的模板代码的一个插值为例:{{isLogin? 'Login': 'Wellcome'}}。一旦regularjs的引擎遇到这段模板与代表的语法元素节点,会进入如下函数处理

// some sourcecode from regularjs walkers.expression = function(ast){   var node = document.createTextNode("");   this.$watch(ast, function(newval){     dom.text(node, "" + (newval == null? "": String(newval)));   })   return node; } 

正如我们所见, 归功于$watch函数,一旦表达式发生改变,文本节点也会随之改变,这一切其实与angularjs并无两样(事实上regularjs同样也是基于脏检查)

与Dom-based 模板技术利用Dom节点承载信息所不同的是,它的中间产物AST 承载了所有Compile过程中需要的信息(语句, 指令, 属性…等等). 这带来几个好处

  1. 轻量级, 在Dom中进行读写操作是低效的.
  2. 可重用的.
  3. 可序列化, 你可以在本地或服务器端预处理这个过程。
  4. 安全, 因为安全不需要innerHTML帮我们生成初始Dom

如果你查看Living Template的输出,你会发现是这样的

regular-todo

只有需要的内容被输出了

_总结Living templating _

我们可以发现Living templating几乎同时拥有String-based和Dom-based模板技术的优点

利用一个如字符串模板的自定义DSL来描述结构来达到了语法上的灵活性,并在Parse后承载信息(AST)。而在Compile阶段,利用AST和Dom API来完成View的组装,在组装过程中,我们同样可以引入Dom-based模板技术的诸如Directive等优良的种子。

living template’s 近亲 —— React

React当然也可以称之为一种模板解决方案,它同样也巧妙规避了innerHTML,不过却使用的是截然不同的策略:react使用一种virtual dom的技术,它也同样基于脏检查,不过与众不同的是,它的脏检查发生在view层面,即发生在virtual dom上,从而可以以较小的开销来实现局部更新。

Example

var MyComponent = React.createClass({  render: function() {    if (this.props.first) {      return <div className="first"><span>A Span</span></div>;    } else {      return <div className="second"><p>A Paragraph</p></div>;    }  } }); 

同样的逻辑使用regularjs描述

{{#if first}}   <div className="first"><span>A Span</span></div> {{#else}}   <div className="second"><p>A Paragraph</p></div>; {{/if}} 

仁者见仁智者见智, 反正我倾向于使用模板来描述结构,而不是杂糅Virtual dom和js语句。你呢?

值得一提的是,由于React的特性,它两次render之间,内部节点的替换是无法预计的,所以无法有效的保留信息,所以它也有大量的关于id的placeholder存在。

一个全面的对照表

Contrast /Solutions String-based templating Dom-based templating Living templating
例子 Mustache,Dustjs Angularjs, Vuejs Regularjs 、Ractivejs、htmlbars
语法 ♦♦♦ ♦♦♦ ♦♦♦
活动性 X ♦♦♦ ♦♦♦
性能 初始: ♦♦♦
更新: ♦
初始: ♦
更新: ♦♦♦
初始: ♦
更新: ♦♦♦
安全性 ♦♦ ♦♦♦♦♦
Dom 无关 ♦♦♦♦♦ X ♦♦
SVG support(*1) X ♦♦ ♦♦♦

1. 任何一类无法被另一类全面替代 2. 它们并不是无法同时存在的,比如你可以使用字符串模板来生成Dom-based的模板需要的模板字符串。

(来自:网络)

2015-04-22 23:37:07 再次编辑
  • 猿猿同学 2015-04-23 14:35 3楼

    AMD和CDM规范

    1、AMD和CDM规范来历

    前提:

    CommonJS 原来叫 ServerJS,推出 Modules/1.0 规范后,在 Node.js 等环境下取得了很不错的实践。09年下半年这帮充满干劲的小伙子们想把 ServerJS 的成功经验进一步推广到浏览器端,于是将社区改名叫 CommonJS,同时激烈争论 Modules 的下一版规范。分歧和冲突由此诞生,逐步形成了三大流派:

    1.Modules/1.x 流派。这个观点觉得 1.x 规范已经够用,只要移植到浏览器端就好。要做的是新增 Modules/Transport 规范,即在浏览器上运行前,先通过转换工具将模块转换为符合 Transport 规范的代码。主流代表是服务端的开发人员。现在值得关注的有两个实现:越来越火的 component 和走在前沿的 es6 module transpiler。

    2.Modules/Async 流派。这个观点觉得浏览器有自身的特征,不应该直接用 Modules/1.x 规范。这个观点下的典型代表是 AMD 规范及其实现 RequireJS。

    3.Modules/2.0 流派。这个观点觉得浏览器有自身的特征,不应该直接用 Modules/1.x 规范,但应该尽可能与 Modules/1.x 规范保持一致。这个观点下的典型代表是 BravoJS 和 FlyScript 的作者。BravoJS 作者对 CommonJS 的社区的贡献很大,这份 Modules/2.0-draft 规范花了很多心思。
    FlyScript 的作者提出了 Modules/Wrappings 规范,这规范是 CMD 规范的前身。可惜的是 BravoJS 太学院派,FlyScript 后来做了自我**,将整个网站(flyscript.org)下线了。

    AMD

    异步模块定义(AMD)的编程接口提供了定义模块,及异步加载该模块的依赖的机制。它非常适合于使用于浏览器环境,浏览器的同步加载模块机制会带来性能,可用性,调试和跨域访问的问题。

    本规范只定义了一个函数 "define",它是全局变量。函数的描述为:  

    define(id?, dependencies?, factory);

    id

    是个字符串,它指的是定义中模块的名字,这个参数是可选的。如果没有提供该参数,模块的名字应该默认为模块

    加载器请求的指定脚本的名字。如果提供了该参数,模块名必须是“顶级”的和绝对的(不允许相对名字)。

    dependencies

    是个定义中模块所依赖模块的数组。依赖模块必须根据模块的工厂方法优先级执行,并且执行的结果应该按照依赖数组中的位置顺序以参数的形式传入(定义中模块的)工厂方法中。

    依赖的模块名如果是相对的,应该解析为相对定义中模块。换句话来说,相对名解析为相对与模块的名字,并非相对于寻找该模块的名字的路径。

    本规范定义了截然不同的三种特殊的依赖关键字。如果"require","exports", 或 "module"出现在依赖列表中,参数应该按照CommonJS模块规范自由变量去解析。

    依赖参数是可选的,如果忽略此参数,它应该默认为["require", "exports", "module"]。然而,如果工厂方法的长度属性小于3,加载器会选择以函数的长度属性指定的参数个数调用工厂方法。

    Factory

    为模块初始化要执行的函数或对象。如果为函数,它应该只被执行一次。如果是对象,此对象应该为模块的输出值。

    如果工厂方法返回一个值(对象,函数,或任意强制类型转换为true的值),应该为设置为模块的输出值。

    下面的例子显示了requirejs如何动态加载模块。
    define(function ( require ) {
        var isReady = false, foobar;
     
        require(['foo', 'bar'], function (foo, bar) {
            isReady = true;
            foobar = foo() + bar();
        });
     
        return {
            isReady: isReady,
            foobar: foobar
        };
    });

    上面代码所定义的模块,内部加载了foo和bar两个模块,在没有加载完成前,isReady属性值为false,加载完成后就变成了true。因此,可以根据isReady属性的值,决定下一步的动作。

    require.js

    config方法,用来配置require.js运行参数。config方法接受一个对象作为参数。

    paths

    参数指定各个模块的位置。这个位置可以是同一个服务器上的相对位置,也可以是外部网址。可以为每个模块定义多个位置,如果第一个位置加载失败,则加载第二个位置,上面的示例就表示如果CDN加载失败,则加载服务器上的备用脚本。需要注意的是,指定本地文件路径时,可以省略文件最后的js后缀名

    baseUrl

    参数指定本地模块位置的基准目录,即本地模块的路径是相对于哪个目录的。该属性通常由require.js加载时的data-main属性指定。

    shim

    有些库不是AMD兼容的,这时就需要指定shim属性的值。shim可以理解成“垫片”,用来帮助require.js加载非AMD规范的库。
    require.config({
        paths: {
            "backbone": "vendor/backbone",
            "underscore": "vendor/underscore"
        },
        shim: {
            "backbone": {
                deps: [ "underscore" ],
                exports: "Backbone"
            },
            "underscore": {
                exports: "_"
            }
        }
    });

    上面代码中的backbone和underscore就是非AMD规范的库。shim指定它们的依赖关系(backbone依赖于underscore),以及输出符号(backbone为“Backbone”,underscore为“_”)。

    CDM

    CMD 模块定义规范

    在 CMD 规范中,一个模块就是一个文件。代码的书写格式如下:
    define(factory);

    define 是一个全局函数,用来定义模块。
    define(factory)
    define 接受 factory 参数,factory 可以是一个函数,也可以是一个对象或字符串。

    factory为对象、字符串时,表示模块的接口就是该对象、字符串。比如可以如下定义一个 JSON 数据模块:
    define({ "foo": "bar" });

    factory 为函数时,表示是模块的构造方法。执行该构造方法,可以得到模块向外提供的接口。factory 方法在执行时,默认会传入三个参数:require、exports 和 module:

    define(function(require, exports, module) {
    // 模块代码
    });

    require 是一个方法,接受 模块标识 作为唯一参数,用来获取其他模块提供的接口。

    中间包括两个概念

    相对标识  以 . 开头,只出现在模块环境中(define 的 factory 方法里面)。相对标识永远相对当前模块的 URI 来解析:

    顶级标识  不以点(.)或斜线(/)开始, 会相对模块系统的基础路径(即 Sea.js 的 base 路径)来解析:

    define(id?, deps?, factory)

    define 也可以接受两个以上参数。

    字符串 id 表示模块标识,

    数组 deps 是模块依赖。

    比如:

    define('hello', ['jquery'], function(require, exports, module) {

      // 模块代码

    });
    id 和 deps 参数可以省略

    require

    require 是 factory 函数的第一个参数。

    require.async

    1.require.async 来进行条件加载。

    2.require 是同步往下执行,require.async 则是异步回调执行。require.async 一般用来加载可延迟异步加载的模块。

    require.resolve(id)

    使用模块系统内部的路径解析机制来解析并返回模块路径。该函数不会加载模块,只返回解析后的绝对路径。

    define(function(require, exports) {

      console.log(require.resolve('./b'));
      // ==> http://example.com/path/to/b.js

    });

    这可以用来获取模块路径,一般用在插件环境或需动态拼接模块路径的场景下。

    exports

    exports 是一个对象,用来向外提供模块接口。

    除了给 exports 对象增加成员,还可以使用 return 直接向外提供接口。

    module 

    module是一个对象,上面存储了与当前模块相关联的一些属性和方法。

    module.id
    String
    模块的唯一标识。

    define('id', [], function(require, exports, module) {

      // 模块代码

    });

    上面代码中,define 的第一个参数就是模块标识。

    module.uri

    String

    根据模块系统的路径解析规则得到的模块绝对路径。

    define(function(require, exports, module) {

      console.log(module.uri);
      // ==> http://example.com/path/to/this/file.js

    });
    一般情况下(没有在 define 中手写 id 参数时),module.id 的值就是 module.uri,两者完全相同。

    module.exports

    Object

    当前模块对外提供的接口。

    exports 参数是 module.exports 对象的一个引用。只通过 exports 参数来提供接口,有时无法满足开发者的所有需求。
    比如当模块的接口是某个类的实例时,需要通过 module.exports 来实现:

    define(function(require, exports, module) {

      // exports 是 module.exports 的一个引用
      console.log(module.exports === exports); // true

      // 重新给 module.exports 赋值
      module.exports = new SomeClass();

      // exports 不再等于 module.exports
      console.log(module.exports === exports); // false

    });

    注意:对 module.exports 的赋值需要同步执行,不能放在回调函数里。 

  • simen 2015-04-23 13:30 2楼

    这些分享,大家应该多看看,多讨论,非常有助于我们提升...

  • simen 2015-04-23 13:21 1楼

    用SeaJS 进行前端的模块化开发来看看模块化开发的价值,如下:

    恼人的命名冲突

    我们从一个简单的习惯出发。我做项目时,常常会将一些通用的、底层的功能抽象出来,独立成一个个函数,比如

    
     
    1. function each(arr) {  
    2.   // 实现代码  
    3. }  
    4. function log(str) {  
    5.   // 实现代码  

    并像模像样地把这些函数统一放在 util.js 里。需要用到时,引入该文件就行。这一切工作得很好,同事也很感激我提供了这么便利的工具包。

    直到团队越来越大,开始有人抱怨。

     

    小杨:我想定义一个 each 方法遍历对象,但页头的 util.js 里已经定义了一个,我的只能叫 eachObject 了,好无奈。

    小高:我自定义了一个 log 方法,为什么小明写的代码就出问题了呢?谁来帮帮我。

     

    抱怨越来越多。团队经过一番激烈的讨论,决定参照 Java 的方式,引入命名空间来解决。于是 util.js 里的代码变成了

    
     
    1. var org = {};  
    2. org.CoolSite = {};  
    3. org.CoolSite.Utils = {};  
    4.  
    5. org.CoolSite.Utils.each = function (arr) {  
    6.   // 实现代码  
    7. };  
    8.  
    9. org.CoolSite.Utils.log = function (str) {  
    10.   // 实现代码  
    11. };  

    不要认为上面的代码是为了写这篇文章而故意捏造的。将命名空间的概念在前端中发扬光大,首推 Yahoo! 的 YUI2 项目。下面是一段真实代码,来自 Yahoo! 的一个开源项目。

    
     
    1. if (org.cometd.Utils.isString(response)) {  
    2.   return org.cometd.JSON.fromJSON(response);  
    3. }  
    4. if (org.cometd.Utils.isArray(response)) {  
    5.   return response;  
    6. }  

    通过命名空间,的确能极大缓解冲突。但每每看到上面的代码,都忍不住充满同情。为了调用一个简单的方法,需要记住如此长的命名空间,这增加了记忆负担,同时剥夺了不少编码的乐趣。

    作为前端业界的标杆,YUI 团队下定决心解决这一问题。在 YUI3 项目中,引入了一种新的命名空间机制。

    
     
    1. YUI().use('node'function (Y) {  
    2.   // Node 模块已加载好  
    3.   // 下面可以通过 Y 来调用  
    4.   var foo = Y.one('#foo');  
    5. });  

    YUI3 通过沙箱机制,很好的解决了命名空间过长的问题。然而,也带来了新问题。

    
     
    1. YUI().use('a''b'function (Y) {  
    2.   Y.foo();  
    3.   // foo 方法究竟是模块 a 还是 b 提供的?  
    4.   // 如果模块 a 和 b 都提供 foo 方法,如何避免冲突?  
    5. });  

    看似简单的命名冲突,实际解决起来并不简单。如何更优雅地解决?我们按下暂且不表,先来看另一个常见问题。

    烦琐的文件依赖

    继续上面的故事。基于 util.js,我开始开发 UI 层通用组件,这样项目组同事就不用重复造轮子了。

    其中有一个最被大家喜欢的组件是 dialog.js,使用方式很简单。

    
     
    1. <script src="util.js"></script>  
    2. <script src="dialog.js"></script>  
    3. <script>  
    4.   org.CoolSite.Dialog.init({ /* 传入配置 */ });  
    5. </script>  

    可是无论我怎么写文档,以及多么郑重地发邮件宣告,时不时总会有同事来询问为什么 dialog.js 有问题。通过一番排查,发现导致错误的原因经常是

    
     
    1. <script src="dialog.js"></script>  
    2. <script>  
    3.   org.CoolSite.Dialog.init({ /* 传入配置 */ });  
    4. </script>  

    在 dialog.js 前没有引入 util.js,因此 dialog.js 无法正常工作。同样不要以为我上面的故事是虚构的,在我待过的公司里,至今依旧有类似的脚本报错,特别是在各种快速制作的营销页面中。

    上面的文件依赖还在可控范围内。当项目越来越复杂,众多文件之间的依赖经常会让人抓狂。下面这些问题,我相信每天都在真实地发生着。

    1.通用组更新了前端基础类库,却很难推动全站升级。

    2.业务组想用某个新的通用组件,但发现无法简单通过几行代码搞定。

    3.一个老产品要上新功能,最后评估只能基于老的类库继续开发。

    4.公司整合业务,某两个产品线要合并。结果发现前端代码冲突。

    5.……

    以上很多问题都是因为文件依赖没有很好的管理起来。在前端页面里,大部分脚本的依赖目前依旧是通过人肉的方式保证。当团队比较小时,这不会有什么问题。当团队越来越大,公司业务越来越复杂后,依赖问题如果不解决,就会成为大问题。

    文件的依赖,目前在绝大部分类库框架里,比如国外的 YUI3 框架、国内的 KISSY 等类库,目前是通过配置的方式来解决。

    
     
    1. YUI.add('my-module'function (Y) {  
    2.   // ...  
    3. }, '0.0.1', {  
    4.     requires: ['node''event']  
    5. });  

    上面的代码,通过 requires 等方式来指定当前模块的依赖。这很大程度上可以解决依赖问题,但不够优雅。当模块很多,依赖很复杂时,烦琐的配置会带来不少隐患。

    命名冲突和文件依赖,是前端开发过程中的两个经典问题。下来我们看如何通过模块化开发来解决。为了方便描述,我们使用 SeaJS 来作为模块化开发框架。

    使用 SeaJS 来解决

    SeaJS 是一个成熟的开源项目,核心目标是给前端开发提供简单、极致的模块化开发体验。这里不多做介绍,有兴趣的可以访问 seajs.org 查看官方文档。

    使用 SeaJS,在书写文件时,需要遵守 CMD (Common Module Definition)模块定义规范。一个文件就是一个模块。前面例子中的 util.js 变成

    
     
    1. define(function(require, exports) {  
    2.   exports.each = function (arr) {  
    3.     // 实现代码  
    4.   };  
    5.  
    6.   exports.log = function (str) {  
    7.     // 实现代码  
    8.   };  
    9. });  

    通过 exports 就可以向外提供接口。这样,dialog.js 的代码变成

    
     
    1. define(function(require, exports) {  
    2.   var util = require('./util.js');  
    3.  
    4.   exports.init = function() {  
    5.     // 实现代码  
    6.   };  
    7. });  

    关键部分到了!我们通过 require('./util.js') 就可以拿到 util.js 中通过 exports 暴露的接口。这里的 require 可以认为是 SeaJS 给 JavaScript 语言增加的一个 **语法关键字*8,通过 require 可以获取其他模块提供的接口。

    这其实一点也不神奇。作为前端工程师,对 CSS 代码一定也不陌生。

    1. @import url("base.css");  
    2.  
    3. #id { ... }  
    4. .class { ... }  

    SeaJS 增加的 require 语法关键字,就如 CSS 文件中的 @import 一样,给我们的源码赋予了依赖引入功能。

    如果你是后端开发工程师,更不会陌生。Java、Python、C# 等等语言,都有 include、import 等功能。JavaScript 语言本身也有类似功能,但目前还处于草案阶段,需要等到 ES6 标准得到主流浏览器支持后才能使用。

    这样,在页面中使用 dialog.js 将变得非常简单。

    1. <script src="sea.js"></script>  
    2. <script>  
    3. seajs.use('dialog'function(Dialog) {  
    4.   Dialog.init(/* 传入配置 */);  
    5. });  
    6. </script>  

    首先要在页面中引入 sea.js 文件,这一般通过页头全局把控,也方便更新维护。想在页面中使用某个组件时,只要通过 seajs.use 方法调用。

    好好琢磨以上代码,我相信你已经看到了 SeaJS 带来的两大好处。

    1.通过 exports 暴露接口。这意味着不需要命名空间了,更不需要全局变量。这是一种彻底的命名冲突解决方案。

    2.通过 require 引入依赖。这可以让依赖内置,开发者只需关心当前模块的依赖,其他事情 SeaJS 都会自动处理好。对模块开发者来说,这是一种很好的 关注度分离,能让程序员更多地享受编码的乐趣。

    小结

    除了解决命名冲突和依赖管理,使用 SeaJS 进行模块化开发还可以带来很多好处:

    1.模块的版本管理。通过别名等配置,配合构建工具,可以比较轻松地实现模块的版本管理。

    2.提高可维护性。模块化可以让每个文件的职责单一,非常有利于代码的维护。SeaJS 还提供了 nocache、debug 等插件,拥有在线调试等功能,能比较明显地提升效率。

    3.前端性能优化。SeaJS 通过异步加载模块,这对页面性能非常有益。SeaJS 还提供了 combo、flush 等插件,配合服务端,可以很好地对页面性能进行调优。

    4.跨环境共享模块。CMD 模块定义规范与 Node.js 的模块规范非常相近。通过 SeaJS 的 Node.js 版本,可以很方便实现模块的跨服务器和浏览器共享。

    模块化开发并不是新鲜事物,但在 Web 领域,前端开发是新生岗位,一直处于比较原始的刀耕火种时代。直到最近两三年,随着 Dojo、YUI3、Node.js 等社区的推广和流行,前端的模块化开发理念才逐步深入人心。

    前端的模块化构建可分为两大类。一类是以 Dojo、YUI3、国内的 KISSY 等类库为代表的大教堂模式。在大教堂模式下,所有组件都是颗粒化、模块化的,各组件之间层层分级、环环相扣。另一类是以 jQuery、RequireJS、国内的 SeaJS、OzJS 等类库为基础的集市模式。在集市模式下,所有组件彼此独立、职责单一,各组件通过组合松耦合在一起,协同完成开发。

    这两类模块化构建方式各有应用场景。从长远来看,小而美更具备宽容性和竞争力,更能形成有活力的生态圈。

    总之,模块化能给前端开发带来很多好处。如果你还没有尝试,不妨从试用 SeaJS 开始。

    原文链接:https://github.com/seajs/seajs/issues/547