dexteryy/OzJS

对 OzJS 的一些疑惑、建议与探讨

lifesinger opened this issue · 44 comments

  1. 源码中 forEach 的实现,以及 m.deps.forEach 的用法,使得 OzJS 在 IE9 以下无法运行。是有意不支持 Old IE?如果不需要考虑 IE6-8,则 32 行的代码可以省略掉,在文档中说明就好。只支持高级浏览器的话,代码应该还可以简化。

  2. 有个建议,文档上将 AMD 公共模块称呼为 AMD 具名模块,AMD 私有模块称呼为 AMD 匿名模块,这样可能更表意,呵呵。另外,对于 AMD 私有模块,模块 url 的获取,是通过串行加载来保证拿到的 url 是当前 define 的?没仔细看代码,想求证下。如果是串行的话,当开发时私有模块超过 20 多个时,豆瓣内部是怎么缩短模块加载时间的?(之前在淘宝遇到了这个问题,SeaJS 中改成并行才解决,但并行导致代码有些 hacky,不爽)

  3. 异步模块的支持挺有意思,但感觉有悖模块加载器的本职工作。个人觉得模块加载器不应该涉及异步等待逻辑。如果需要等待,可以调整依赖来解决。

  4. 远程模块的设计,个人觉得让 define 承担了不应该承担的职责。比如

    define("a", ["path/to/b.js"], "path/to/a.js")
    // 干了两件事情:
    // 给模块 a 动态添加了依赖 b
    // 声明模块 a 的路径是 path/to/a.js

    上面两件事情,是否通过 config 来配置会更明确?比如

    require.config({
      aliases: {
         "a": "path/to/a.js"
      },
    
      deps: {
         "a": ["path/to/b.js"]
      }
    })

    在 SeaJS 里,通过增加 shim 配置来实现,比如

    seajs.config({
     plugins: ["shim"], // 激活 shim 插件,有这个插件 shim 配置才生效
    
    shim: {
        // jQuery 的 shim 配置
        'jquery': {
          exports: function() { return jQuery; }
        },
    
        // jQuery 插件的 shim 配置
        'jquery-plugins': {
            match: /jquery\.[a-z].*\.js/, // 匹配所有 jquery 插件,自动化
            deps: ['jquery'], // 动态指定依赖
            exports: 'jQuery' 
        }
    }
    })

    shim 配置的方式,功能和 OzJS 的远程模块类似,但在批量处理上,感觉更方便些,比如上面 jquery-plugins 的声明方式,只要命名规则为 jquery.xxx.js 的插件,都自动添加好了依赖,使用上,直接 require 真实路径或 alias 就好。RequireJS 2.0 里,也是类似的处理方式。

  5. 从源码上看,OzJS 中的 require 也是身兼多职。这是我一直很难接受 RequireJS 的重要原因之一。为什么不职责单一一些?全局中的 require 跟 参数中的 require 应该不一样,就像 NodeJS 中的 require 一样,每个模块的 require 是私有的,是有上下文环境的,这样相对路径的解析也更合理。可能是个人喜好,如有冒犯,请忽略。

  6. new! 挺有意思,赞。

  7. mo 里面的模块挺小巧实用的,和 Arale 的理念异曲同工,呵呵。有个小疑问,mo 的模块 id 都是固定的,比如 mo/cookie,这样,当 cookie 版本升级时,如果是一个老页面,有些功能点依赖 cookie 的老版本,但新功能点想依赖 cookie 的新版本,这种情况下,OzJS 里是如何处理的?Arale 里给每个模块都加了版本,比如 arale/cookie/1.2.0/cookie 这种方式,这样两个不同版本的 cookie,可以认为是完全两个不同的模块,因此可以并存。想知道豆瓣这一块是如何处理。

  8. 建议 mo 里的模块可以每个模块一个独立库,这样通过简单的 transport 工具,可以和 Arale 的组件互通起来。对于生态圈,也想听听 @dexteryy 的想法。

先说这些,希望能对 @dexteryy 有所帮助,祝 Oz 能越来越好。

玉伯 / Feb 20, 2013

  1. m.deps.forEach 的用法,使得 OzJS 在 IE9 以下无法运行

thx,好像是之前改漏了,因为在实际使用中动态加载的模块也是打包过的,这个m.deps涉及的依赖会被ozma事先解决掉,所以一直没发现存在bug…

  1. 有个建议,文档上将 AMD 公共模块称呼为 AMD 具名模块,AMD 私有模块称呼为 AMD 匿名模块

这里是故意用这样的称呼,不从『外表』/风格而是从用法/惯例上去分类

对于 AMD 私有模块,模块 url 的获取,是通过串行加载来保证拿到的 url 是当前 define 的?

没理解这句话…oz.js里唯一用到串行加载的地方是那篇文档里的第5项:

define('non_AMD_script_A', ['non_AMD_script_B'], "path/to/non_AMD_script_A.js");

主要是为了保证非AMD文件之间的依赖关系,在实际使用中还是因为全都用了ozma,模块数量不会影响到页面加载过程。

  1. 个人觉得模块加载器不应该涉及异步等待逻辑。如果需要等待,可以调整依赖来解决。

finish是一个实验性的buit-in模块,还没大规模使用,不过它是实现mo/domready的条件,我非常反对requirejs里loader plugin的设计和滥用,比起在特定环境里产生『规范』之外特性的模块,插件让『模块加载器』涉及了更多不必要的逻辑。另外oz.js的初衷并不是『加载器』,module不是实体文件而是语法结构单位,finish的引入也是建立在这种认识之上。

  1. 远程模块的设计,个人觉得让 define 承担了不应该承担的职责

这里我的看法是相反的,requirejs和seajs都用配置来承担了很多程序逻辑的实现,很难避免复杂度的膨胀和适应性的瓶颈,这方面做到极致的负面例子就是XML(比如Ant)。你举的这个例子就恰好让我觉的『承担了不应该承担的职责』。

  1. OzJS 中的 require 也是身兼多职。这是我一直很难接受 RequireJS 的重要原因之一。为什么不职责单一一些?

『参数中的 require』是指var a = require('A');这样的用法?这种写法在ozjs里更多的视作一种声明式语法,跟require(['A'], function(){并无本质上的不同,对上下文环境和参数的处理也是完全一致的,在编译后更是同一种代码,所以我不太理解『身兼多职』的意思…

至于写在global作用域里的require和写在module的作用域里的require,抽象语义上同样是一致的,对应用开发者应该完全透明化,应用开发者只需要考虑我在什么场景下需要引入什么依赖,而具体实现机制、异步/同步、加载/合并、发布粒度等等都应该由另外的层级去处理。这里跟单一职责原则同样关系不大。

此外还有API简单一致的问题,oz.js实际上只提供两个声明式的抽象工具也就是require和define,同时尽最大可能避免配置,应对前端开发中各种环境各种项目的特殊需求的时候,能提供简单的解决方案,同时避免膨胀和蠕变。

  1. new! 挺有意思,赞。

我其实反对引入插件,所以这个也是实验性的…虽然在阿尔法城项目里用的很多…

  1. mo 的模块 id 都是固定的,比如 mo/cookie,这样,当 cookie 版本升级时,如果是一个老页面,有些功能点依赖 cookie 的老版本,但新功能点想依赖 cookie 的新版本,这种情况下,OzJS 里是如何处理的?

模块ID固定跟上面提到的『公共模块/私有模块』有关。至于版本共存的问题,oz.js最初的版本就有"mo/cookie@0.1.0"这样的特性,require("mo/cookie"会自动指向最高的版本号对应的模块ID,因此发布模块时可直接在模块ID上标注版本号,使用模块时仍然可以只写原始名称而忽略版本号,有特殊需求时也可以直接指定版本。不过在实践中几乎没出现过需要用到这个特性的情况,后来就整个移除了。

前端静态文件毕竟不是node_modules,原则上并不存在那么多黑盒,与其提供版本共存的方法,不如让代码更好的适应重构。此外如果是真正的『老页面』或『旧代码』,相关依赖经常是过时和不再维护的,不用考虑更新和并入上游的问题,源代码完全可以直接copy和修改(比如改成匿名)

  1. 建议 mo 里的模块可以每个模块一个独立库,这样通过简单的 transport 工具,可以和 Arale 的组件互通起来。对于生态圈,也想听听 @dexteryy 的想法。

能独立的我都尽可能独立发布了,比如eventmaster, DollarJS, URLKit这些以前都是属于mo的,mo和moui虽然是以library的形式来发布,内部仍然是是细粒度的模块(比如mo/lang/oop),使用的时候可以只依赖其中的小部分,不会像jquery和underscore那样捆绑一堆不用的东西。

生态圈方面,当然要尽可能消除不同体系之间的隔阂和协同成本,我期望中的开源JS module应该源自亲身实践、解决实际问题、实现特定方案、小而精、极简化、去中心化、针对单一层级、不独揽、不捆绑、利用和壮大现有资源、适应本地偏好、自由混搭,让后来者能轻松的站在更高层次思考问题、关注更有价值的事情、更高效的积累和交流。

感谢 @dexteryy 的回复,对 OzJS 背后的设计理念清楚了很多。几个问题继续探讨下:

私有模块的 id 问题

对于 AMD 私有模块,模块 url 的获取,是通过串行加载来保证拿到的 url 是当前 define 的?

这个是想问下,对于

a.js

define(function(require, exports, module) { ... })

a.js 是一个类似上面的私有模块,没有写 id。这时,在 OzJS 的实现里,执行 define 时,是如何知道 a.js 的 id ?是否用路径做 id ?如果用路径做 id 的话,执行 define 时如何拿到 a.js 的路径?

ozma 的使用场景

在 SeaJS 里,build 工具是等到正式提交测试时才运行。在此之前,模块都是原生态,直接开发和调试。因为当开发时模块数量就很多时,比如超过 50 个,就会比较明显地影响到开发效率。

ozma 是在什么时候运行?

插件

我也反对 RequireJS 的插件设计方式,使得 RequireJS 需要为插件多一些不必要的代码。但我觉得还是需要提供插件机制,毕竟很多时候需要对加载器进行扩展。OzJS 里,也有 new! 这种设计,如果不这么设计,直接除去,遇到类似的需求时,应该怎么办?SeaJS 里类似 new! 的一个设计时,像 JavaScript 语言一样暴露一些接口,比如通过下面的方式实现 new!:

var someUrl = ...
var relatedMod = seajs.cache[someUrl]
relatedMod.destroy()

通过暴露 destroy 方法,来销毁某个模块,从而再次加载时,也就是 new! 的效果。

除了暴露一些增加完备性的接口,SeaJS 还提供了一些事件接口,比如发送请求前的 request 事件,使得开发者可以拦截,不用 script element 去加载,而可以从 localStorage 中去读取。SeaJS 的 Node.js 版本,也是利用了自身的 request 事件,拦截下来,用 Node 原生的 require 去同步加载文件。

配置

这一块可能真的是理念差异,我很难忍受一个方法可以干多个事情。比如远程模块,个人觉得用配置来说明更简洁易懂。这个可能纯粹是喜好问题。就如 Grunt,其实不写 initConfig 也可以完成所有事情,但很明显,initConfig 带来了简洁和易理解,不适合放 config 的,再用代码去实现。

比如在 Node.js 里,require 中的路径解析,是相对当前模块的,但 fs.readFile 等接口,则是相对 cwd 的。虽然有很多用户会抱怨为什么 fs.readFile("./b") 不能加载同目录下的 b.js,但这种环境区分,非常有利于程序员保持头脑清晰。RequireJS 里,下面的代码:

define(function(require, module, exports) {
   var b = require('./a')
})
define(function() {
   var b = require('./a')
})

第一个写法是 ok 的,第二个写法是报错。第二个 require 是全局的,实际上是有区分,但却共用同一个名字,有点囧。

OzJS 这一点上好像比 RequireJS 清晰很多,但我觉得还是不妥。看起来是对使用者透明了,但也会造成迷惑性。

多版本共存

这个理论上的确可以通过重构等方式去完美解决,但在阿里的现实场景下,经常行不通,比如

  1. 一个老项目 A,涉及好多页面,依赖了模块 a 的低版本
  2. 一个新项目 B,有部分页面与老项目 A 重合(一个页面经常会是多个项目的产物,比如页头属于一个团队负责,页面中间某个区域是另一个团队负责等等)

当新项目 B 往前开发时,由于各种因素,很难有时间有精力去修改老项目 A 的代码,这时项目 B 有两个选择:

  1. 继续在老项目 A 的模块上开发,这样往往能降低风险、降低成本。但这是一个恶性循环,往往一旦做了这种决策,就意味着相关项目的基础类库再也升不上去了,因为以后升级的成本更大。
  2. 第二个选择是,保证老项目的代码能正常运行,但新项目选择用新的。这样随着时间推移,整个页面的基础类库可以慢慢实现往前演化。

目前支付宝是选择第二个方案,稳定、安全第一位,页面多加载一点文件,问题不是很大。

最后

感谢 @dexteryy 参与讨论。越发觉得是不同的使用场景使得 RequireJS、OzJS、SeaJS 等工具的选择不一样。虽然基础层面的功能大同小异,但上层的实践让彼此的发展走向不同的分支。了解彼此的使用场景和最佳实践挺有帮助。

@lifesinger 嗯其实很多都是基本问题…

私有模块的 id 问题

a.js里的私有模块在define时其实就是没有module ID,谈不上获取的问题,实际上这个顺序反了,oz.js的实现机制是在需要『用到』模块的时候(比如require('./a')define(['./a'])检查这个名称是否声明过,如果声明过,要检查是否是远程模块,而如果没声明过,则直接视作远程模块,基于名称生成相应的路径或URL去加载,加载完之后将最近声明的匿名模块关联到当前需要的模块上,赋予module ID等属性。至于a.js的声明里依赖的相对路径module ID的处理则要复杂一些。

ozma 的使用场景

前端开发者一直习惯于『动态语言』直接解析执行源代码的开发方式,但随着技术的发展和成熟,面向人类的源代码和面向解释器的目标代码之间终将避免不了编译/构建这个环节,无论JS、CSS、模板…都是如此,只存在如何由工具或运行环境来自动化和透明化的问题。OzJS推荐的最佳实践是在本地开发过程中就融入构建环节,调试跟线上环境完全一样的代码(只减去压缩环节),而ozma能尽可能保证零配置和透明化,至于它的运行时机则取决于开发者的偏好,比如每次修改源代码之后都执行、开始调试前执行、IDE手动触发执行…

插件

new!如果是一个核心机制而不是扩展的话,就不需要引入插件,nodejs里的require.cache确实是合理的方法之一。至于hook、事件什么的,在nodejs运行环境里更有意义,浏览器端如果需要用到这些东西,一般都是有哪里搞错了…总之oz.js扮演的是一个很明确的职能角色,工作于单一层面,而不像以前的YUI/jquery的命名空间和构造函数那样,处于『中心』位置,需要用扩展机制去跟其他东西协调。

配置

shim: {
    // jQuery 的 shim 配置
    'jquery': {
      exports: function() { return jQuery; }
    },

    // jQuery 插件的 shim 配置
    'jquery-plugins': {
        match: /jquery\.[a-z].*\.js/, // 匹配所有 jquery 插件,自动化
        deps: ['jquery'], // 动态指定依赖
        exports: 'jQuery' 
    }
}
define('jquery-src', 'lib/jquery.js');
['A', 'B', 'C', 'easing'].forEach(function(plugin){
     define('lib/jquery-plugin/jquery.' + plugin, ['jquery-src']);
     define('jquery.' + plugin, ['lib/jquery-plugin/jquery.' + plugin], function(){
          return window.jQuery;
     });
});
define('jquery', ['jquery.A', 'jquery.easing'], function(){
     var $ = window.jQuery;
     $.easing['jswing'] = $.easing['swing'];
     $.extend($.easing, elib.functions);
     return $;
});

以上是配置和代码的对比,虽然后者多做了一些事…

Grunt受过Ant的影响,但它是一个很好的将配置和代码平衡的例子。

多版本共存

嗯这种情况豆瓣主站也有很多,就像之前说的,我觉得移动和重命名旧版代码就行了

不过这种无人可以控制整个页面的最终输出的情况终归是不好的,而且很多都是受到了一些php/python旧框架的影响,之前在土豆网的时候对这种问题我自认为解决的较好,每个页面从JS数量、内容、监测到广告加载全都在前端的控制之下。

最后

其实我觉得使用场景和问题需求应该是一样的,只是有没有切身体会的问题,我之前专注做阿尔法城的时候oz.js也相对狭隘些,无视了很多问题。

另外我觉得国内前端社区应该把重心放到解决实际问题和带来新价值的模块上,每个人都去做模块加载器或基础设施,有意无意的做差异化或重复建设,是有违这类项目的初衷的,也许能起到证明自身技术的作用,但只会被国外越甩越远。

加载完之后将最近声明的匿名模块关联到当前需要的模块上,赋予module ID等属性。

这个规律不可靠的,最近声明的匿名模块未必是当前需要的模块,特别是在 IE6-9 下。用大写字母表述 define 的执行时机,小写字母表示 define 所在文件的 onload 时间,则

在标准浏览器下   AaBbCc     一个脚本文件在执行后,会马上紧跟 onload 事件
但在 IE 下,以及 Firefox 低版本,没有上面这个保证

比如下面这种写法,

a.js

define(function(require, exports) { exports.name = 'a' })

b.js

define(function(require, exports) { exports.name = 'b' })

c.js

define(function(require, exports) { exports.name = 'c' })

main.js

require(['a', 'b', 'c'], function(a, b, c) {
  alert( a.name === 'a')
  alert( b.name === 'b')
  alert( c.name === 'c')
})

我用 OzJS 试了下,在 IE 下会经常性报错。

这涉及下一个问题

ozma 的使用场景

@dexteryy 描述的在豆瓣的场景应该没问题,也因为如此使用,因此上面那个匿名模块的 url 获取问题也没问题(在 Chrome 或 Firefox 下开发,在 IE 下测试时已经经过 ozma 构建)

SeaJS 当初也考虑过这种方式,让构建工具在开发时就运行,但在实际推广中遇到了比较大的困难,比如

  1. 没有打包前,不能保证在 IE 下的正常运行。这带来的问题是第 2 个。
  2. 调试不方便。打包后,加载的是合并后的文件。普通前端会抱怨出错信息无用(无法直接定位到具体文件名和真实行号)。不打包的话,出了问题,可以很快速定位和修复。
  3. 支付宝的真实环境大量使用了服务端的 combo 服务,很多时候并不需要提前合并后,可以等到线上后,根据实际情况,动态配置来减少 http 数。这样比较灵活方便。
  4. 还有就是当文件很多时,构建工具比较慢。比如依赖层级4-5层,文件超过50多个时,构建工具有时需要耗费秒级以上时间。支付宝之前用 maven 构建方式更慢,抱怨很多。改成 node 构建好,速度快了很多,但依旧有很多同事希望构建能越快越好,甚至不需要构建。
  5. 不需要构建的方案,就是发布或正式提交时,在服务器自动构建。淘宝目前部分实现了这种方式,让开发者只关注于源码的实现,然后什么都不用管了。

至于hook、事件什么的,在nodejs运行环境里更有意义,浏览器端如果需要用到这些东西,一般都是有哪里搞错了…

这个不认可。loader 的事件或插件机制还是有必要的。比如加载 text 模板,这个需求很常见。还有支持服务器的动态 combo,以及方便的 nocache 插件。这些都是和加载相关的,通过插件扩展功能,感觉挺合适。否则另外实现起来不方便也不简洁好用。

插件仅限于扩展加载器本身的功能,比如加载前、加载中、加载后等。与加载无关的,不提供任何扩展。这和 YUI/jQuery 的扩展有很大不同,YUI/jQuery 那种扩展我也不喜欢。

配置

这一段,有个很大的不一样,比如

shim: {
    // jQuery 插件的 shim 配置
    'jquery-plugins': {
        match: /jquery\.[a-z].*\.js/, // 匹配所有 jquery 插件,自动化
        deps: ['jquery'], // 动态指定依赖
        exports: 'jQuery' 
    }
}

上面的 match 可以匹配未知的模块,匹配的是规则。['A', 'B', 'C', 'easing'].forEach 的写法,再怎么写,都是匹配已知的,当有新的 jQuery 插件加入时,需要修改代码。用 shim 配置可以做到一次配置后,遵守约定,就可以不用再修改配置代码。

上面也能简单说明插件的好处。配置里也允许有逻辑,比如 shim 的 exports 可以是一个 function,还有一些配置也可以:

seajs.config({

  charset: function(url) {
     if (url.indexOf('xxx') > 0) {
        return 'gbk'
     }
    return 'utf-8'
  }

})

这类似 Grunt,通用配置 initConfig 搞定,个性化的,可以用脚本或 function 等方式来写。

配置不能太多,有合适的配置能省很多事。

最后

想起最开始我想做的是,完全用 Node 的方式写模块代码

a.js

var b = require('./b')
exports.name = 'a'

然后在浏览器端直接可加载使用。通过构建工具的方式可以很容易实现,并且 loader 可以在 100-200 内搞定,非常精简。

但上面这种方案,我实现过一个,也有配套的构建工具,但普通使用者始终难以接受,觉得 Geek。RequireJS 能流行起来,我觉得最大的一个特性是:不依赖任何服务、任何构建工具,AMD 模块就可以在浏览器运行。这种便利性让很多人开始使用并喜欢上 RequireJS。SeaJS 的发展历史也类似,每去掉一层依赖时,用户接受度就越高。

另外我觉得国内前端社区应该把重心放到解决实际问题和带来新价值的模块上,每个人都去做模块加载器或基础设施,有意无意的做差异化或重复建设,是有违这类项目的初衷的,也许能起到证明自身技术的作用,但只会被国外越甩越远。

这个很赞同,曾想着 seajs 1.3 后再无更新。这次更新到 2.0,也是强烈希望 2.0 后 seajs 能“死掉”。更多的精力去开发模块,去探索具体场景下的最佳前端解决方案。这一块跟 @dexteryy 的想法一致。

先说下require重载的看法,我认为require(id:String)和require(id:Stirng, callback:Function)在重载上还是符合语义的,即都是使用模块,只是后者有回调,前者在factory里作用。

oz里面在factory的require出现作为异步很难接受,因为这有悖重载原则了,使用和异步加载使用还是有区别的,我其实是借鉴了require.async的实现

RequireJS 里,require 的重载是根据第一个参数来区分:require(String) 表示伪同步,require([String, ...]) 表示异步

require('a')
require(['a'])
require('a', callback)
require(['a'], callback)

上面让我很泪奔。SeaJS 里宁可增加 API:

require 关键字:require("String")
模块内的异步:require.async("String", callback)  等同 require.async(["String"], callback)
页面内的模块加载:seajs.use("String", callback)  等同 seajs.use(["String"], callback)

我是将你的1和3合并了,分离出2,看来我们是进行了这玩意儿的排列组合,弄出所有可能性了……

在构建方面,想省略人工构建恐怕目前没有合理的方案。至于调试,将页面压缩后的文件重定位到开发机器上的单元文件,因为我像java那样遵循一对一原则,使用本地http服务器简单配置重定向代理,可以将所有文件代理到原子文件,比较简洁地解决了这个问题。

多文件构建这玩意儿取决于语言的本地io性能吧,多了总是不行。
多版本我也赞成2,冗余还是必要的。

@army8735 …你害得楼乱了

@lifesinger

这个规律不可靠的,最近声明的匿名模块未必是当前需要的模块,特别是在 IE6-9 下

我知道这个执行顺序的问题呀,不记得是不是当年还在土豆网的时候测试的了。之所以不重视这个问题,除了『眼光向前看』,不为不值得的事情引入复杂度之外,更重要的是oz.js从最开始就一直不推荐匿名模块,更不用说动态加载的匿名模块,ozma跟oz.js在开发过程中是同等地位(一个负责静态处理一个负责runtime),会将包括匿名模块在内的各种风格的声明编译成统一的代码,所以到了线上运行的时候,动态加载的发布文件里的模块都不可能会没有module ID,而这种情况下是不需要那个『最近声明』机制的。简单来说提供这个机制只是为了方便新人上手、快速开发原型和调试,并不是给产品环境用的。

让构建工具在开发时就运行,但在实际推广中遇到了比较大的困难

你列举的这些困难是最常见的,但都不是『比较大』的,调试方面我不太理解为什么『无法直接定位到具体文件名和真实行号』,无论自动生成注释和source map都可以快速定位,何况还有用本地的源代码文件直接覆盖打包文件中的模块声明的方法(require.config(' debug: true '); define('A', 'file://..../A.js');)。至于服务器端combo和自动构建这类跟后端web framework紧密绑定的发布方式,正是我一直在努力纠正的(包括在豆瓣),它的弊病包括不同层面的事情混杂在一起、难以跟上技术发展、代表旧的前后端工作方式等等,这里就不详述了。

loader 的事件或插件机制还是有必要的。比如加载 text 模板,这个需求很常见。

加载text模板正是OzJS在努力纠正的错误用法之一,相关项目见这里:grunt-furnace

上面的 match 可以匹配未知的模块,匹配的是规则

…这处逻辑是我故意忽略的呀,因为oz.js目前并不考虑这种需求(实践中没有这样配置jquery插件的),假如要纳入的话,有什么能阻止oz.js提供更简洁更灵活的程序接口呢XD,这个例子想对比的是配置和代码的差别,就像你也承认配置中需要引入function一样,为了适应需求,配置『数据』终会发展到跟程序语言一样或植入程序语言的程度。

完全用 Node 的方式写模块代码

这个已经有现成的主流方案了,也就是TJ的component

https://github.com/component/component/wiki/Components

但这种完全照搬commonjs的模块语法在真实世界的前端『应用代码』中却是存在缺陷的,最明显的就是动态加载。不过对多数JS开源项目来说无需考虑。

AMD和CJS风格的异步module、纯commonjs的component、乃至其他方案,在相当长一段时间里都要共存和协作,到这里又要再次推荐grunt-furnace项目了…

@army8735 require.async也是我一直在纠正的设计和用法,虽然因为跟自身利益关系不大,已经懒得输出价值观了…

多文件或者说多页面的项目里,有时确实需要其他基础设施来补充纯前端的workflow,豆瓣在这方面也有很多好的实践,也在继续探索,不过这里有一点需要注意的是,同样是工具和基础设施,它们工作的层面却未必相同,后端web framework和构建脚本应该在前端的基础设施和workflow之上构建抽象层,混淆在一起讨论是很容易走歪路的…

@army8735

使用本地http服务器简单配置重定向代理,可以将所有文件代理到原子文件,比较简洁地解决了这个问题。

这个我赞同,淘宝还专门搞了台服务器来干这事,开发者只要绑下 host 就行。

我现在是通过 SeaJS 的 plugin-debug 来映射,功能类似重定向代理或 Fiddler,好处有二:1)用这个方式很方便在同事电脑上调试,特别是非技术人员的电脑上。2)跨设备开发时,可以很方便映射到本机编辑。

多版本是种无奈,个人项目更倾向于 @dexteryy 的处理方式,公司的项目有时没办法,特别是支付宝的项目,稳定压倒一切,性能差一点都可以接受。

不需要构建自动用服务器构建,这对稳定性的风险很大。先前我很倾向这种,后来发现赌博得厉害。在单元测试未完善的情况下,底层子模块变动造成高层大范围修改很恐怖,而且前端有很多和浏览器交互挂钩,想单元测试更是难上加难。

@dexteryy 越来越理解你的设计理念和 Oz 背后的坚持了

匿名模块……简单来说提供这个机制只是为了方便新人上手、快速开发原型和调试,并不是给产品环境用的。

如果是这样的话,不如把对匿名模块的支持去掉,更彻底地说明 ozjs 和 ozma 的协同关系。

无论自动生成注释和source map都可以快速定位

这涉及调试问题,source map 目前依旧没解决调试时的映射问题,比如某个变量通过 source map 看到的是 abc,但要知道其值,还得知道这个 abc 压缩后的代码是什么,source map 是个温柔的谎言,对调试基本没用。

至于服务器端combo和自动构建这类跟后端web framework紧密绑定的发布方式,正是我一直在努力纠正的(包括在豆瓣),它的弊病包括不同层面的事情混杂在一起、难以跟上技术发展、代表旧的前后端工作方式等等,这里就不详述了。

不是很认可。服务器端 combo 的价值,是处理那些不适合提前打包的,比如:

seajs.use(["a", "b", "c"], callback)

cdn 开启 combo 服务后,上面的三个模块可以合并为一个请求:??a.js,b.js,c.js

在支付宝 Arale 的实践里,任何模块,通过构建工具构建后,都会变成

define(id, deps, block)

其中 deps 包含了该模块的所有依赖(包括依赖的依赖),这样,任何一个模块,都只需两个请求就可以下载完毕:一个是该模块自身,另一个是该模块所有依赖的 combo 请求。

这使得性能默认优化,构建也变得简单一致。

我比较反对那种针对项目所有页面都合并成一个文件的构建方式,看起来对单页面有性能提升,但整体来看并不好。HTTP 链接数不是越少越好,而是需要找到合适的数量值,这往往跟业务逻辑相关。loader 和构建工具层不应该做太多事,性能优化方面的自主权应该更多地交给业务开发。

YUI3 后来有推出基于服务端的自动打包功能(比如请求 a.js,服务器会自动返回 a 和依赖的合并文件),这个我觉得也有点过火了。但纯文件合并的 combo 服务,我觉得还是很有价值的。

加载text模板正是OzJS在努力纠正的错误用法之一,相关项目见这里:grunt-furnace

如果这样的话,我觉得 oz 有些自相矛盾。为何 js 不直接用 node 的写法? JS -> AMD,将 AMD 彻底从开发者视野中隔离掉,让 AMD 变成纯粹的 Modules/Transport 规范。这也是当初 CommonJS 社区里不少人的选择。

甚至可以直接用 es6 的 module 语法:https://github.com/square/es6-module-transpiler 这个 hax 也想做,国外又走了前面。

这次讨论,感觉核心差异点在:

  1. 构建工具发挥的作用不同。 Oz 非常强调构建工具的重要性,tpl 等需求,通过构建工具来解决。RequireJS 和 SeaJS 中,构建工具在部署上线时才需求。开发时,尽量让 浏览器 + loader 插件 来实现自构建。
  2. loader 自身的定位不同。 Oz 是 JS 语言的模块增强,RequireJS 是跨环境的文件和模块加载器,SeaJS 是 A Module Loader for the Web( Web 端的模块加载器,其中模块不限于 js 模块,也包括 css 模块,和通过插件支持的 tpl 模块)
  3. 对扩展方式的选择不同。 Oz 更多是通过构建工具来实现。RequireJS 通过 loader 插件来实现,对 loader 本身的实现有较大侵入,可扩展点单一。SeaJS 通过事件机制来暴露(类似原生 JS 和 DOM)
  4. 背后推崇的研发模式有差异。Oz 类似传统的编译型模式,RequireJS 和 SeaJS 的背后是 Browser is compiler,F5 就是 compile operation。后两者在研发模式上的差异,体现在文件组织上,RequireJS 的背后是类似 Rails 这种将所有静态文件都包含在项目自身的组织方式(比如每个项目会 copy 一份需要的 libs)。SeaJS 的背后是跨项目、跨系统的文件目录组织方式,libs 通过 cdn 共享,每个项目里,只有与自身相关的业务代码。

最后一点是差异的根源。

配置的问题,依旧不清楚 @dexteryy 为何不喜欢。如果不喜欢配置的话,那么 oz 里的 aliases 配置也可以转换为 define:

define('a', 'path/to/a.js')

这一块未能理会背后的原因究竟是啥 -.-

tpl直接工具就行了吧,书写时就是个以.tpl结尾的普通类html文件之类的,构建变成一个模块,调试解析在重定向中做,逻辑和构建一样,自动将tpl转变为模块。js插件会增加js体积,用工具一个字母都不用。

tpl 的 js 插件仅在开发时用,不会增加 loader 自身的体积。上线时,通过构建工具转换成 js 文件。

如果是这样的话,不如把对匿名模块的支持去掉,更彻底地说明 ozjs 和 ozma 的协同关系。

抱歉这里我没表达清楚,不是说匿名模块彻底的不好,而是说通过鼓励匿名模块这样的语法将模块跟实体文件划等号,照搬nodejs或传统软件环境的方式是不好的,而『私有模块』就是匿名模块的好用法。ozma和oz都是模块机制的提供者,是将模块的依赖管理、加载、调用过程透明化所需的抽象层,这个层级之上的接口和语法都是为实际开发场景服务的,需要稳定、一致、灵活和具备充足的表达能力,所以匿名模块声明是需要的,而到了runtime环境里则是另一回事,ozma/oz就像浏览器里的JIT引擎一样可以不断改进来优化这些细节。

比如某个变量通过 source map 看到的是 abc,但要知道其值,还得知道这个 abc 压缩后的代码是什么

没理解,这个地方涉及的发布文件应该不包含压缩环节的罢…

服务器端 combo 的价值,是处理那些不适合提前打包的

这里也是不太了解seajs的用法,不过OzJS对应的做法是直接创建一个组合后的模块,内容可能会写成:

//abc.js
require(['a', 'b', 'c'], function(){});

然后在main.js里将三个子模块都映射到abc模块上:

// main.js
define('a', 'abc.js');
define('b', 'abc.js');
define('c', 'abc.js');

require(['app'], function(){

});

之后在需要引入依赖的时候仍然写:

// app.js
define(['c', 'd', 'e'], function(c, d, e){
    // do something
    require(['a'], function(a){
        // do something
    });
    if (...) {
        require(['b'], function(a){
            // do something
        });
    }
});

对main.js执行ozma之后,会自动生成dist/main.js和dist/abc.js这样两个发布文件(也就是页面所需的全部JS文件),前者包含app.js和它依赖的c.js, d.js, e.js,后者包含a.js、b.js以及『依赖的依赖』,但不与dist/main.js里的模块重复。

也就是说引入a/b/c依赖的开发者在写代码时无需考虑这三个模块在发布文件里的组织方式和本地/下载的区别,也无需考虑最后有几个文件,只需要按抽象语义来使用require和define

将所有页面的代码完全合成一个文件我也是反对的,不过适度的合并是必须的,比如common.js、project.js和inline script这样的划分,总共应该控制在2~3个以内(动态加载的文件除外)。

为何 js 不直接用 node 的写法? JS -> AMD,将 AMD 彻底从开发者视野中隔离掉,让 AMD 变成纯粹的 Modules/Transport 规范。

这个问题在提到component的时候已经说过了,浏览器中运行的程序有自己的特殊架构特点,照搬commonjs或nodejs的方式是有局限的。

oz.js的定位其实就是偏向ES6 module的,也就是反复表达的『语法结构概念』而不是实体文件概念…

背后推崇的研发模式有差异。Oz 类似传统的编译型模式,RequireJS 和 SeaJS 的背后是 Browser is compiler,F5 就是 compile operation。

浏览器确实就是compiler和runtime,JS引擎是web的虚拟机(--Brendan Eich),不过这个部分的发展速度相对更慢,也不像传统软件开发一样是可以由开发者掌控和部署,另一方面,软件开发的历史主线就是抽象层的垒砌,浏览器的这层抽象之上,终归避免不了其他抽象,比如说现在有人不用scss/stylus/less么…

不过这个抽象的『度』也是一种要平衡的东西,比如coffescript我就是很不喜欢的。ozma/oz.js也只是绕开了旧浏览器的限制而已,只是给浏览器打补丁,还谈不上编译型模式…

@lifesinger 顺便也问一个关于seajs的问题罢…

据我所知『CMD』这种称呼在国外似乎是不存在的,只有AMD和CJS之分,以下两种写法都会被视作AMD:

define(['A', 'B'], function(a, b, require, exports, module){
    return function(){};
});
define(function(require, exports, module){
    var a = require('A');
    var b = require('B');
    module.exports = function(){};
});

本质上也并无区别,经过静态环节的处理后会统一成同一种最容易处理的标准语法(也就是上面那种),即使在运行时里,这种天然的一致让oz.js只需极少的代码就可以同时兼容这两种写法。requirejs好像也同样支持罢。

require.config、oz.js的远程模块声明、seajs.async这类API虽然有设计理念和用法上从小到大的差异,但都可以限制在具体应用的业务逻辑代码中(比如上面的main.js和app.js),类似本地配置。通用的模块、组件和库都不会用到这些东西(包括oz.js支持的动态依赖),所以无论用oz.js、require.js、sea.js还是component、browerify什么的,从业务逻辑中抽象出来的通用代码和开源项目都是可以无隔阂和自由组合的,不必限定在某个体系下。

我觉得以上这个约束是需要大家来共同维护、避免分裂的。不过在这个有约束的差异层之外seajs相对于requirejs提供了什么独特价值,我就有些疑惑了。另外我对spm的印象是『自建生态系统』,有些刻意模仿npm了,包括package的托管方式,这也是对整个JS社区不利的。

你们三位真的应该一起搞一个loader,提供不同场景下的完整解决方案(单页面APP、static只支持一个项目、static支持跨项目等)。

dexteryy:”另外我觉得国内前端社区应该把重心放到解决实际问题和带来新价值的模块上,每个人都去做模块加载器或基础设施,有意无意的做差异化或重复建设,是有违这类项目的初衷的,也许能起到证明自身技术的作用,但只会被国外越甩越远。“

这个很赞。

支持楼上的楼上 @dexteryy 关于AMD的看法, 统一标准,避免差异化很重要。毕竟现在还是AMD的支持者多,无论是@dexteryy 还是 @lifesinger 都切记不要为了技术而技术, 为差异化而刻意差异化。

我是Seajs的支持者,在这之前没有接触过加载器,感谢Seajs。读了上面的一些话,也希望 @lifesinger 能考虑下未来发展吧。 据我所知您当时也是kissy的参与者,我真心觉得kissy有重复造轮子之嫌。

老弟对您有些粗浅建议就是Arale是加载器下一层的东西,感觉这个东西能流行起来,好好整整官网、文档,再做推广吧。到时国外开发者会因为Arale知道支付宝和您的哦。这个世界上最难做的技术工作,就是服务新手了。打个比喻jQuery是油箱,那么框架工具集就是工具箱,而市场上还没谁能把工具箱做好。 其他林林总总的库都解决了各自的不少问题, 技术理念短期难有大创新。

tiye commented

期待一起搞 Loader.. 期待有统一的中文社区和工具库..

@dexteryy

……通过鼓励匿名模块这样的语法将模块跟实体文件划等号,照搬nodejs或传统软件环境的方式是不好的,而『私有模块』就是匿名模块的好用法……

这点我有异议。匿名模块的核心不是让模块跟实体文件划等号,而是鼓励 DRY 原则。SeaJS 也一直支持自定义 id 的用法:

define(id, block)

比如直接写在页面中的内嵌模块,以及一个文件包含多个模块的场景。我觉得 id 的核心是模块的唯一标识符,通过 id 可以获取到对应的模块,和命名空间的本质是一样的。来看命名空间的演化史:

  1. 最开始是全局变量,容易冲突
  2. 后来借鉴 Java 风格,长命名方式,比如 xx.org.common.utils.zzz,这种风格不简洁,有记忆负担
  3. 接着是 YUI2,不再那么长,比如 Yahoo.util.Event,只是稍微简化的 Java 风格,没从根上解决问题
  4. YUI3 进一步演化是都放在 sandbox 的 Y 上,结果是 Y 变成了另一个 global
  5. AMD 风格里手写 id,本质上和全局变量没区别
  6. 反观 Node.js 的处理方式,其实上是强制把 id 用 uri 来表示,由于一个系统中不可能存在两个路径一样的文件,因此彻底解决了命名空间冲突问题

SeaJS 遵循的实践来自 Node.js 的处理方式,这背后是 约定大约配置,约定模块的 id 就是文件的访问路径,不需要用户自己去配置 id ,一旦鼓励用户去配置 id,就存在冲突的可能。

牺牲的是模块不再纯粹,与文件扯上了关系。但这个牺牲,在目前的浏览器架构下,我觉得是合适的。匿名模块可以让用户不用再担心命名冲突,能给用户做减法,我觉得值当。

source map 是一个温柔的谎言

这个再解释下:通过 source map,能拿到出错处的源码行,但无法打断点进行调试(可以停下来,但根本看不懂,很难拿到当前变量的值,堆栈信息也是压缩后的信息)

@dexteryy 说的我明白,用非压缩版本替代,我觉得还是不够方便,不如单个源码文件清晰。调试不是关键,但是若能通过 loader plugin 为调试提供更好的体验,何乐而不为呢?

这里也是不太了解seajs的用法,不过OzJS对应的做法是直接创建一个组合后的模块

// main.js
define('a', 'abc.js');
define('b', 'abc.js');
define('c', 'abc.js');

看到这里,回到了之前关注配置和远程模块的讨论。SeaJS 里,上面是 map 配置:

seajs.config({
    map: [
        [ "a.js", "abc.js" ],
        [ "b.js", "abc.js" ],
        [ "c.js", "abc.js" ]
    ]
})

map 的含义是映射,告诉 loader 模块 a 的实际路径是 abc.js 文件。在实际使用时,多人协作的页面中经常会有很多入口:

<script>
seajs.use(["a", "b"], function(a, b) { ... })
</script>

<script>
seajs.use(["d", "e"], function(d, e) { ... })
</script>

这些内嵌的代码很多不能提前预见。因此在 main.js 里通过 map 配置或 define 远程模块的方式,很很难默认做到性能最优(Speed by Default)。于是有了 combo 服务 + loader plugin 的方式来做:

  1. 通过 loader 的 plugin-combo,可以使得 use(["a", "b"]) 时,发送的请求是 combo 地址:??a.js,b.js
  2. 服务端接收到 ??a.js,b.js 时,自动将文件合并。目前 nginx 有成熟的 combo 模块

这样,就能达成默认最优的效果,map 等配置都可以全部省去,除非需要人肉进行细节调优。

类似的还有 flush 插件,这里不再多说,详见文档: seajs/seajs#226

很核心的一点是,这些插件的机制与 RequireJS 很不一样,RequireJS 会侵入 require.js 的源码,SeaJS 的方式更多的是考虑自身的完备性,就如一个 UI 模块一样,会考虑在显示前等环节适当的暴露事件接口。这些事件接口不是为了特定插件存在,而是一种普适的可扩展机制。loader 本身不用关注插件的实现,loader 广播的只是自身的事件。

软件开发的历史主线就是抽象层的垒砌,浏览器的这层抽象之上,终归避免不了其他抽象,比如说现在有人不用scss/stylus/less么…

认可“软件开发的历史主线就是抽象层的垒砌”。不过现阶段,在阿里用 scss/stylus/less 的还是少数,用在正式项目中的几乎没有。核心原因跟国内社区的氛围有关,但还有一个不容忽视的原因是,stylus 等抽象层的价值还需要时间来证明,我接触过有不少人真实使用过,但后来还是喜欢直接用纯 css 搞定。 这和 @dexteryy 不喜欢 coffee 应该有类似的地方,呵呵。

一对一是利大于弊的,纵观其他语言的public class设计就能明白所有设计者们的苦心。小项目几个人互相之间口头约定下不会冲突,长远大项目人数一多,什么事情都会发生。
是的,这样的缺陷是无法实现内部类的概念。但是在js中,如果想声明一个内部模块,那么不如直接在这个factory中声明变量,根本无需再去考虑内部模块概念。

配置方面我认为是越少越好,配置一多就看得晕,多语义可能就会增加很多别人阅读上的成本。

至于重复造轮子,天下大事合久必分,竞争未必不是好事,真正需要的是标准统一。倘若chrome和firefox当时想的都是不要重复造轮子,都使用ie6,乔布斯不要发明ios继续用塞班,不知是什么情况。
但诸子百家之后也会出现合并,比如opera就会选择v8。分合是看情况的,有时分合本身就是一种尝试错误的性质,只有经过错误才能进步,如果害怕尝试啥都一昧“大师兄说的对”,和尽信书也没啥区别了。

@dexteryy 关于 AMD、Node/Modules、CMD 等,这个可以谈谈历史,很有意思。(主线 @dexteryy 应该都知道,下面纯当八卦轻松下就好)

CommonJS 社区

大概 09年 - 10年期间,CommonJS 社区大牛云集。CommonJS 原来叫 ServerJS,推出 Modules/1.0 规范后,在 Node 等环境下取得了很不错的实践。09年下半年这帮充满干劲的小伙子们想把 ServerJS 的成功经验进一步推广到浏览器端,于是将社区改名叫 CommonJS,同时激烈争论 Modules 的下一版本规范,这里主要有三个主流观点:

  1. Modules/1.x 观点。这个观点觉得 1.x 规范已经可以了,要做的是新增 Modules/Transport 规范。所有模块在浏览器上运行前,通过转换工具转换为符合 Modules/Transport 格式的代码即可。主流代表是服务端的开发人员。现在逐步成型的 component 是代表。
  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 与 RequireJS

再来说 AMD 规范。真正的 AMD 规范在这里:Modules/AsynchronousDefinition。AMD 规范一直没有被 CommonJS 社区认同,核心争议点有两个:

执行时机有异议

看代码

Modules/1.0:

var a = require("./a") // 执行到此处时,a.js 才同步下载并执行

AMD:

define(["require"], function(require) {
  // 在这里,模块 a 已经下载并执行好
  // ...
  var a = require("./a") // 此处仅仅是取模块 a 的 exports

})

AMD 里提前下载 a.js 是浏览器的限制,没办法做到同步下载,这个社区都认可。

但执行,AMD 里是 Early Executing,Modules/1.0 里是第一次 require 时才执行。这个差异很多人不能接受,包括 Modules/2.0 观点的也不能接受。

这个差异,也导致实质上 Node 的模块与 AMD 模块是无法共享的,存在潜在冲突。

模块书写风格有争议

AMD 风格下,通过参数传入依赖模块,破坏了 就近声明 原则。比如:

define(["a", "b", "c", "d", "e", "f"], function(a, b, c, d, e, f) {

    // 等于在最前面申明了并初始化了要用到的所有模块

   if (false) {
       // 即便压根儿没用到某个模块 b,但 b 还是提前执行了
       b.foo()
   }

})

还有就是 AMD 下 require 的用法,以及增加了全局变量 define 等细节,当时在社区被很多人不认可。

最后,AMD 从 CommonJS 社区独立了出去,单独成为了 AMD 社区。有阵子,CommonJS 社区还要求 RequireJS 的文档里,不能再打 CommonJS 的旗帜(这个 CommonJS 社区做得有点小气)。

脱离了 CommonJS 社区的 AMD 规范,实质上演化成了 RequireJS 的附属品。比如

  1. AMD 规范里增加了对 Simplified CommonJS Wrapper 格式的支持。这个背后是因为 RequireJS 社区有很多人反馈想用 require 的方式,最后 RequireJS 作者妥协,才有了这个半残的 CJS 格式支持。(注意这个是伪支持,背后依旧是 AMD 的运行逻辑,比如提前执行等)
  2. AMD 规范的演进,离不开 RequireJS。这有点像 IE…… 可能是我的偏见。

AMD 的流行,很大程度上取决于 RequireJS 作者的推广,这有点像 less 因 bootstrap 而火起来一样。但火起来的东西未必好,比如个人觉得 stylus 就比 less 更优雅好用。

关于 AMD 和 RequireJS,暂且按下不表。来看另一条暗流:Modules/2.0 流派。

Modules/2.0

BravoJS 的作者有很深厚的程序功底,在 CommonJS 社区也非常受人尊敬。但 BravoJS 本身非常学院派,是为了论证 Modules/2.0-draft 规范而写的一个项目。学院派的 BravoJS 在实用派的 RequireJS 面前不堪一击,现在基本上只留存了一些美好的回忆。

这时,Modules/2.0 阵营也有一个实战派:FlyScript。FlyScript 抛去了 Modules/2.0 中的学究气,提出了非常简洁的 Modules/Wrappings 规范:

module.declare(function(require, exports, module)
{
   var a = require("a"); 
   exports.foo = a.name; 
});

这个简洁的规范考虑了浏览器的特殊性,同时也尽可能兼容了 Modules/1.0 规范。悲催的是,FlyScript 在推出正式版和官网之后,RequireJS 当时正直红火。期间 FlyScript 作者和 RequireJS 作者有过一些争论。再后来,FlyScript 作者做了自我阉割,将 GitHub 上的项目和官网都清空了,官网上当时留了一句话,大意是

我会回来的,带着更好的东西。

这中间究竟发生了什么,不得而知。后来有发邮件给 FlyScript 作者询问,FlyScript 作者给了两点挺让我尊重的理由,大意是

  1. 我并非前端出身,RequireJS 的作者 James Burke 比我更懂浏览器。
  2. 我们应该协同起来推动一个社区的发展,即便它不是你喜欢的。

这两句话对我影响很大。也是那之后,开始仔细研究 RequireJS,并通过邮件等方式给 RequireJS 提出过不少建议。

再后来,在实际使用 RequireJS 的过程中,遇到了很多坑。那时 RequireJS 虽然很火,但真不够完善。期间也在寻思着 FlyScript 离开时的那句话:“我会回来的,带着更好的东西”

我没 FlyScript 的作者那么伟大,在不断给 RequireJS 提建议,但不断不被采纳后,开始萌生了自己写一个 loader 的念头。

这就是 SeaJS。

SeaJS 借鉴了 RequireJS 的不少东西,比如将 FlyScript 中的 module.declare 改名为 define 等。SeaJS 更多地来自 Modules/2.0 的观点,尽可能去掉了学院派的东西,加入了一些实战派的功能。

写着写着有点沧桑感,不写了。

@lifesinger

这背后是 约定大约配置,约定模块的 id 就是文件的访问路径,不需要用户自己去配置 id ,一旦鼓励用户去配置 id,就存在冲突的可能。

OzJS同样推崇惯例优先原则,但你有没有注意到选择和声明文件路径本质上就是一种配置,匿名模块的滥用实际上是放任了这种配置能力,以致难以形成『惯例』,比如两个开源项目,同样都依赖mo/lang,一个将其放在../mod/mo/里,写作define(function(require){ var event = require('mo/lang');,另一个则放在./lib里,写作define(function(require){ var event = require('lib/lang');,如此类推会发展成同一体系的模块/库能方便的混搭使用,而不同体系之间的组合则需要繁琐的配置甚至直接修改源代码,第三方包管理工具基于约定的自动安装或配置更难以实现。『命名冲突』在浏览器环境里真的是evil的东西吗?这个问题实际上还是上面反复说的浏览器与nodejs环境的差异。

这些内嵌的代码很多不能提前预见。因此在 main.js 里通过 map 配置或 define 远程模块的方式,很很难默认做到性能最优(Speed by Default)。于是有了 combo 服务 + loader plugin 的方式来做

对于Ozma的一次构建所涉的单个网页来说,不存在不可预见的代码(静态环境里就会模拟运行时),也能自动做到性能最优:不存在重复、连接数最小化、尽可能利用缓存、预加载/延后加载/按需加载任取所需。而你说的combo方式或性能优化插件增加了API的复杂度,把其他层面的逻辑和细节混入进来,降低普通开发者的思维层级,最终效果看上去也不容易达到最优。

P.S. 你看,继aliases, deps, shim, plugin之后,你的代码里又出现了叫map的配置……

@dexteryy 关于 AMD、Node/Modules、CMD 等,这个可以谈谈历史,很有意思。

RequireJS最大的局限不是AMD,而是源自它的出发点(文件加载器)。oz.js是2010年下半年开始『发现』和实践这种API(虽然那时候叫oz.def和oz.require),RequireJS也正是在那个时期的改版中变成AMD的样子,所以oz.js推荐这种API并顺水推舟沿用AMD这个名称并非因为这个规范那个规范或现在『谁更火』,而是因为它以Bottom-up的方式从一线实践中自然诞生,仿佛天生就能简单一致或者说『以不变应万变』的应对各种真实世界中的需求和问题,无需用新增API、配置参数、插件的形式不断打补丁。从这个角度来说RequireJS虽然有相似的API,却似乎不具备这种自然天成,究其根源:它的设计是在script loader的基础上打补丁打出来。

其实我觉得你太过于关注那些规范和争议了,开源社区是一种天然的去中心化组织结构,好的规范和好的设计,大部分都不是由委员会或少数牛人窝在irc、wiki或issues里,自顶向下的设计和争辩出来的,而是后于代码、后于实践,既各自独立同时又受环境影响,伴随着切身需求和解决实际问题的努力而产出的,『Worse is better』,把『更好的设计』本身作为初衷,反而不容易达到效果。

Early Executing正是我一直没动力去改的地方,在实践中这个问题真的重要吗?它带来的好处更多还是带来的问题更多?你说的『这个差异,也导致实质上 Node 的模块与 AMD 模块是无法共享的』,我很想看看实际例子是怎样的。

另外关于『JS社区』,这里有一个很普遍的陷阱是把所有写JS、开发JS项目的人都一概而论,实际上这里面有ruby社区资深成员,有习惯传统模式的java开发者,有脱离一线有因node而首次深入JS领域的开发者,这些人之间的差别有时已经不止是『偏好』了,而是理解和认识上的不同。

@lifesinger
跑下题,

source map 是一个温柔的谎言
这个再解释下:通过 source map,能拿到出错处的源码行,但无法打断点进行调试(可以停下来,但根本看不懂,很难拿到当前变量的值,堆栈信息也是压缩后的信息)

现在 sourcemap 可以在源码上打断点,也能拿到当前变量的值,也可以看到正确的堆栈信息吧。

另外我觉得在 sourcemap 中提供源码文件比单文件要清晰,可以帮助开发者快速在本地代码中定位到有问题的地方。

@dexteryy

匿名模块的滥用实际上是放任了这种配置能力,以致难以形成『惯例』……

匿名模块不能直接给外部调用,提供给外部用之前,要经过构建,即由私有模块变成公共模块。AMD 社区目前的 id 策略个人觉得不妥,比如 jQuery 的 id 就是 jquery,这不光导致 jquery 多版本共存困难,还使得存在命名空间抢注问题,比如有一个模块叫 base 了,就不能再有重名的。mo 里面加了一层命名空间,但我觉得依旧不妥。

id 规则,个人觉得已经不属于 loader 层面。目前 SeaJS 对 id 没有约定,id 仅仅是用来生成 uri,用来获取访问路径。id 的具体规则,属于 spm 或 CommonWeb 层面,目前在 Arale 里,采取的是 id 策略是:

familyName/packageName/version/moduleName

比如 Arale 的 class 模块,最后提供给社区的 id 是:

arale/class/1.0.0/class

之所以还存在 moduleName 层级,是因为一个包可对外提供多个模块。

目前这种 id 规则,可以做到信息完备,目前为止可以满足各种需求,并无隐患。

你看,继aliases, deps, shim, plugin之后,你的代码里又出现了叫map的配置……

SeaJS 自带的配置并不多,总共 8 个:seajs/seajs#262

配置多我觉得不是问题,核心是每个配置的作用要清晰明确,SeaJS 目前每个配置背后都有清晰的语义和使用场景,这比代码简洁多了呀。

也许是个人喜好,目前为止没看到 @dexteryy 明确反对配置的真正原因,可以继续讨论下。

因为它以Bottom-up的方式从一线实践中自然诞生,仿佛天生就能简单一致或者说『以不变应万变』的应对各种真实世界中的需求和问题,无需用新增API、配置参数、插件的形式不断打补丁

这个有点广告了,呵呵

举几个需求:

  1. OzJS 里,如何在 gbk 页面加载 utf-8 文件?
  2. OzJS 里,如何实现 i18n ?
  3. 使用 OzJS,如何实现部分文件从 localStorage 读取?

OzJS 给我的强烈感觉是需要用豆瓣的方式来搞定一切,一旦脱离豆瓣的实际场景,很多需求就会满足不了或比较别扭。从豆瓣的场景出发我觉得没有任何问题,也应该这样做。但以此就觉得可以『以不变应万变』,我觉得未必能应万变。

作为 loader,必须要满足实际项目中的实际需求。在此基础之上,我觉得有必要追求 loader 自身的完备性,力求站在 loader 的角度,做到功能增无可增,减无可减。

『Worse is better』,把『更好的设计』本身作为初衷,反而不容易达到效果

这个相当赞同。这也是我虽然在乎规范,但不愿意去 CommonJS 社区推广 CMD 规范的原因。CMD 规范是 SeaJS 的副产物,更多是一种梳理和总结。SeaJS 更在乎的是满足项目的实际需求,从淘宝到支付宝,一线实践是最关键的。SeaJS 里的不少功能点,都是为了满足项目的实际需求而增加的。

Early Executing 正是我一直没动力去改的地方,在实践中这个问题真的重要吗?它带来的好处更多还是带来的问题更多?

James Burke 当时力推 Early Executing 的背后,就如你说的一样,因为 RequireJS 骨子里是文件加载器。对文件加载器来说,Early Executing 是非常有必要的,否则文件加载了却没有执行,就乱套了。但对模块加载器来说,Early Executing 绝对没有必要。都还没有使用,干嘛要提前就执行好呢?

带来的问题,举例

a.js

export.age = 14

b.js

require('a').age = 22

main.js

var a = require('a')

if (a.age < 18) {
  require('b')
}

上面的代码在 Node.js 里一切正常,但用 AMD 写完后,main.js 会存在逻辑错误。

这种逻辑错误一旦出现了,问题定位起来要抓狂。在 2010 年时,我自己就遇到过不止一次。

提前执行还带来一个弊端是,对 TTI(Time to Interface) 不利。延迟执行更符合直觉,也更有利于页面性能。能懒则懒,As lazy as possible!

OzJS、RequireJS 和 SeaJS 目前都是从一线实战出来的,各自面临的场景和需要解决的问题有不少差异,但也有很大重合度。我觉得最有价值的是把场景描述出来,然后看各自的解决方案是否最优,是否可互相借鉴。求同存异,开放互融。

@allenm 最新版 Chrome 的确可以了,不错。

说说规范之争我的看法。
现在我越来越能觉得就近声明的好处。我也认为应该这样书写。
但是在延迟执行上,我也依然承认它的好处,不过就实际效果来看,性能的提升没什么效果。前端这个应用场景下也不会出现这种情况;反而为了实现它,会稍稍增加一些代码。
目前我也用的是伪的。

如果要改为延迟执行的话,那么声明在define第二个参数的deps会怎么办?是否还要在factory里写require它们?还是写在deps里的不作为延迟,而factory里的require延迟?

@army8735

CMD 的好处是,使得提前声明和就近声明都可行,具体用什么风格,由使用者决定。
但 AMD 的 Early Executing,实际上使得只能采用提前声明,这种因为实现导致的风格强制性个人觉得不妥。

延迟执行对性能的影响,体现在一些与 DOM 操作相关的模块上,比如

define(function(require, exports) {

  var iframe = document.createElement('iframe')

  exports.init = ...
})

define(id, deps, block) 中的 deps 纯粹是依赖声明,有这个数组时,不会再通过正则去解析 require(String)。block 中的代码保持不变,依旧在运行到 require 时才执行。

实现上可参考 https://github.com/seajs/seajs/blob/master/src/module.js#L259

@lifesinger

举几个需求:
OzJS 里,如何在 gbk 页面加载 utf-8 文件?
这个应该由服务器端来做的事情。
OzJS 里,如何实现 i18n ?
这个明显是应用逻辑啊。
使用 OzJS,如何实现部分文件从 localStorage 读取?
这个可以说是加载器应该有的功能。代码会不会不走http请求加载过来(比如通过websocket传过来,或者file api过来,或者比如phonegap里面定义一个特殊接口来加载脚本)。

按我的理解,ozjs的目标是语言层的,加载器只是实现这个目标的方式之一,也可以靠ozma的编译实现。

我想,实现这些功能的东西也是一个个amd模块。我想如果能继续想,甚至可以加载器本身也是amd的模块,可以自由搭配,可以结合不同的载入方式(比如:localStorage loader, ajax loader, etc...), 再通过ozma预编译出一个执行环境(相当于oz.js)。需要几个功能就require几个功能。

想了想,我觉得这有点像 Ender -- 人家是组装一个jquery。


补充, @dexteryy
看了下代码,oz已经可以重定义 getScript 函数了。

oz.config '_getScript', customGetScript

然后是否可以再实现这样的语法, define可以再传入一个config字典参数,或者其他的参数来实现部分文件从localStorage读取?

define 'script', '/path/to/script', {useLocalstorage: true}
define 'script', '/path/to/script', {new: true}
define 'script', '/path/to/script', {chatset: 'utf8'}
define 'script', '/path/to/coffeescript', {coffeescript: true}

但这样会让define”身兼多职“了。

我越来越倾向于延迟了。只是感觉deps里有了,还要再写require一下,重复一遍让我难受。不过倘若借助工具的话,js解析依赖部分甚至可以取消掉。

localStorage的目的是什么?如果是为了缓存,那么文件本身是在静态服务器设置cache头来进行缓存的,放入localStorage有其它特殊需求?

@kebot

需求都有多种实现方式,想探讨用哪种方式最合适。比如 gbk 页面加载 utf8 文件,浏览器通过 charset 属性本身就支持,loader 应该要延续这种支持。

提 i18n 的例子,是考虑 loader 的完备性。对于模块的依赖,很容易想到两种:

  1. 同步依赖。通过 define(["a", "b"]) 中的依赖数组或 "require(String)" 指定的依赖。这些依赖在执行上要顺序同步执行。
  2. 异步依赖。通过 require.async 等方式加载的依赖,这种依赖有 callback,是异步执行的。

对于同步依赖,我们在构建时就能够通过工具扫描分析出来,得到明确的依赖关系。
对于异步依赖,相当于 Ajax 逻辑,本身就是异步的,因此交给运行时就好。

但除了以上两种依赖,还有一种动态依赖:

3、 动态依赖。在构建时,只能确定规律,不能确定具体路径。在执行时,才能得到具体路径,执行时是同步的。

引入动态依赖的概念后,loader 层的支持可以是:

seajs.config({

  // 声明变量
  vars: {
    locale: "zh-cn"
  }

})
define(function(require, exports) {

  // 通过 `{xxx}` 引入变量
  var lang = require('./i18n/{locale}/lang.js')

})

即动态依赖是路径中带变量的依赖,变量值需要在运行时才获取。

这样,不仅能很好的解决 i18n,还能解决换肤、个性化加载等等需求,这个抽象就是动态依赖。如果不用动态依赖,也有其他方案来解决 i18n 等问题。但探究需求背后的真正需求其实是:在不同的环境下加载不同的文件。作为加载器,从完备性上有职责从最底层去提供支持。

localStorage 的例子,是从可扩展性角度来讲。oz 里可以通过覆盖私有属性 _getScript 来提供,但总感觉不那么优雅。作为 loader,核心有几件事情:

  1. 路径解析。比如把 id 转换为 uri
  2. 依赖分析。比如提取依赖
  3. 文件加载。比如通过 script 加载文件
  4. 模块执行。执行文件代码

从可扩展性角度讲,用户经常想干的一些事情是

  1. 在路径解析时,加入一些自定义规则。比如将 a.js 映射到 some/other/path/to/a.js
  2. 在依赖分析时,给模块动态加入某些依赖,比如 oz 里的 define 远程模块。
  3. 在文件加载时,可以实现自定义加载,比如从 localStorage 中加载。
  4. 在模块执行时,可以动态修改某些模块接口。

以上都是可扩展点。在 SeaJS 里是通过事件的方式提供出来,loader 里核心考虑的是:

  1. 什么地方应该提供可扩展点。
  2. 可扩展点可以对外提供哪些数据。

一旦有了这些可扩展点后,用户就可以做很多事情,loader 也就可以实现 @dexteryy 所说的“以不变应万变”,比如 localStorage 的需求可以通过:

seajs.on("request", function(data) {
   var uri = data.uri

   if (uri 满足 localStorage 的加载条件) {
       从 localStorage 加载
   }
})

loader 本身不需要关注 localStorage 的任何逻辑,只需在 request 请求文件时,提供可扩展事件就好。

SeaJS 2.0 的真实希望是把 SeaJS 永远停留在 2.0 版本,永远不再需要更新。这需要做到:

  1. 仔细盘点功能是否有冗余,是否有缺失。
  2. 仔细盘点可扩展点是否完备。

就如 Unix 中的 ls、copy 等命令一样,当把自己该完成的职责都完成后,应该就可以放心“死去”,再也不用更新。然后如 @dexteryy 所说,更多地精力放在提供功能的模块上,比如 mo、moui、arale 等。

@army8735 书写时用 define(function(require, exports)) 格式就没有重复了,依赖提取交给构建工具。

localStorage 的需求来自移动端,尽量缓存,减少 http request,甚至做到纯离线。

如果移动项目缓存的话,那么css和img的缓存同为重要,在体积方面也远远大于js。移动上的浏览器的缓存性也应该和桌面一致。用localstorage来缓存js文件,不太恰当。
纯离线的需求则是app的场景了。说到底,是移动网速不给力。

@lifesinger 最近项目很紧张,特别怕打断,所以没法及时回复了……先说说以下几个问题:

关于module ID的规则

OzJS现在的作法是,一个模块在最初创建时,用最简的文件结构和最简的名称,比如event.js,代码写成匿名模块,可以在项目内以任意形式来使用。如果这个模块足够抽象,不含任何业务逻辑,足以跨项目使用或开源的话,则改成具名模块,这个名称要尽量避开直接表达抽象功能的词汇,或使用专有名词、复合词,比如eventmaster.js。

假如这个模块虽然足够通用却不足以独立发布,比如与其他一系列模块存在交叉依赖,常常组合使用,则作为一个library的子模块来发布,增加一个目录层级,这时用库的名称作为命名空间,比如define('mo/lang',define('moui/overlay'

使用者只要依据『惯例』来建立本地项目的结构,比如把eventmaster.js和moui/放到『baseUrl』下面,就不需要任何额外配置就可以在代码里直接使用这些模块的默认名称。

接下来如果这个模块需要进一步拆分出子模块、建立内部结构或多版本共存,可以创建跟文件名相同的目录,形成类似这样的结构:

baseUrl/
    mo/
        lang/
            type.js
            oop.js
            struct.js
            ...
            2.0/
                type.js
                struct.js
            2.0.js
        lang.js
        ...
    ...

lang.js和2.0.js都是子模块的组合,所以用户可以直接导入整个package:require('mo/lang')require('mo/lang/2.0'),也可以根据本地项目的实际需要,有针对性的导入特定模块:require('mo/lang/oop'),版本共存的问题也同样可以解决。

以上这种『惯例』的好处应该是比较明显的罢……有一点需要强调的是,它是一个『最简』而又能适应『变化』的模式,module可以从最简单最直白的形式开始,不做预先设计,没有繁文缛节,之后基于实践中的需求和检验,自然的向复杂结构发展,同时仍然保持单一成分的简单和用法的稳定。

即将发布的moui/gesture就是这种形式,用户可以导入自己需要的手势:require('moui/gesture/scroll'),而不必像hammer.js那样依赖这整个库

关于gbk编码等特殊需求和OzJS适用的场景

你拿gbk来举例的原因我其实非常了解,土豆网跟淘宝在这方面非常像,都是曾经基于php和gbk编码的mysql,页面上需要加<meta charset="gbk"/>,静态文件很多是gbk/gb18030编码,受此影响我自己的getScript方法就总是少不了charset参数(包括oz.js里面的那个)。

但离开土豆之后我学到很多东西,其中之一是人在一个固定环境里呆久了,很容易看不清一件事物在更大范围内的重要程度和性价比,具体就不展开了,总之oz.js重视惯例和最佳实践、厌恶配置、追求简单一致的设计,但并不排斥需求,也不局限于特定场景,支持charset确实蛮有用,只是到现在为止还没人给我发issue而已…

关于Early Executing

你误会了……我之前说的『我很想看看实际例子是怎样的』,是指你举得这个抽象例子所反映的问题在实际项目中是怎样体现的,因为我真心没有遇见过,就算遇见了一般也会归结为错误实践,问题本身我当然知道呀。

另外把执行延后在实现和设计上并不存在很大的障碍,如果只是基于兴趣和实验而不是实际需求的话,等我有空又不懒的时候可以改一个这种模式的oz.js版本来对比下。

hax commented

module是个好东西,但是也是个麻烦东西。ES6的module从语法到语义改来改去,到现在还没个定案,以至于我的ES6 Loader/Module实现( http://github.com/hax/my.js )现在都扔在那里不高兴弄。我最近半年把精力都投入到了模板语言(http://github.com/hax/jedi )去了,暂时都没时间精力跟module方面的进展呢。但是这帖好,我得先占个坑,嘿嘿。

@lifesinger
讨论很长见识啊!

认可“软件开发的历史主线就是抽象层的垒砌”。不过现阶段,在阿里用 scss/stylus/less 的还是少数,用在正式项目
中的几乎没有。核心原因跟国内社区的氛围有关,但还有一个不容忽视的原因是,stylus 等抽象层的价值还需要时
间来证明,我接触过有不少人真实使用过,但后来还是喜欢直接用纯 css 搞定。 这和 @dexteryy 不喜欢 coffee 应
该有类似的地方,呵呵。

我觉得用less, sass/stylus和coffeescript类比是不对的。
因为css是完全静态的语言,无法使用变量,无法支持mixin等等。 但是less之类的就使得css能够获得适当的代码组织,增加易读性,减少冗余代码,当然最终生成的css还是一模一样。 我个人偏向使用运行环境一样的原生语言,所以很高兴我们团队选择了less, 因为css可以一点都不改就直接把文件rename成less, 然后一样运行。你所需要做的就是慢慢的使用它的功能,慢慢的refactor,一点都没有压力。 而其他几种则变化很大,一开始就要考虑移植问题,需要查reference。

相对于css来说,javascript本身就是编程语言,什么语法结构其实都有了, coffeescript只是把语法精炼了下,提供了些sugar syntax, 当然也可以为不熟悉javascript的人生成"最佳"代码,我们就有组员有时候会用coffeescript写点代码然后生成javascript看怎样才是最“合适”的写法,最后就导致咱代码里面用了好多“self” -_-!!

如今有更多的高级语言比方说Haskell,都号称能编译成javascript之类的。 这类语言也许有更多的发展,但是目前争议也很大,毕竟大部分时间用什么语言写代码本质上没啥特别大的区别,但是一旦出了问题要在目标宿主(比如browser)里面调试,你总不能完全不知道javascript吧。。。 所以多一事不如少一事。 当然,这只是带有严重偏见的个人观点, 毕竟coffeescript确实带来可以很大的便利。

另外提一点:
less有个极大的优点,就是在开发环境的时候,只要 less.watch(), 同时3,4台显示器跑着不同的浏览器,可以更改css无刷新直接看到效果, 你就可以直接看到不同分辨率下各种主流浏览器的效果, 相当省事。而在发布产品的时候直接通过build静态编译,就能支持较老的IE浏览器了。 less的缺点就是没有sass的最大优点, 缺少类似compass的兼容库。。

当然阿里要支持IE6,7, 而IE8以上才支持less,sass/stylus,这可能是很多阿里人不愿意使用这些动态css的原因把。

最后汗汗的问个傻问题, 为啥会有在gbk编码的页面下去加载utf8的需求?
难道现在所有页面不应该都是设计成utf8吗?

@realdah 阿里系,因为历史原因,目前 90% 以上还是用的 GBK 编码。迁移成本很大,主要在数据库层面。

hax commented

我从来没觉得改变编码有多少“迁移成本”。洗数据这种事情都经常做,改一个编码倒不肯。关键是后端本着多一事不如少一事的态度。前端对后端完全没有制约力。