前端工程与模块化框架
|
这个方式其实和 spm2 的打包基本一致,只是更进了一步,把完整的依赖关系都提取好了。 目前 spm2 以及 Arale 的方式是,a 模块依赖 b ,b 依赖 c 的话,打包 a 时,会把 b 和 c 的依赖都放到 a 的依赖数组里去,这样就不需要下载到 b 时才知道 b 的依赖关系了。 最终使用的都是下面的 Transported CMD 模块: define('a', ['b', 'c'], function(require, exports, module){
require('b');
})
对于浏览器模块来说,提前打包目前来说是大势所趋,线上的实时依赖分析由于现阶段浏览器环境的限制,只保留在调试阶段比较合适。这也是 spm3 对于 CMD 规范的态度。 另外,线下分析是可以根据 AST 去拿到准确的依赖列表,而不只是 |
|
不应该提取到每个顶级模块里,因为逻辑可能在任何地方执行异步加载,提取到框架配置级别会更合理一些,也更灵活。 另外,线下分析,什么实现都可以的,我比较深刻的理解AST,但是放弃他是因为正则的“不严谨”性,使得可以适用于各种混合语言的代码中,比如模板 |
|
全依赖提取到每个顶级模块,这种方式其实不完全合理和准确。但我们不想维护一个完整的依赖列表(YUI最早这么做)的原因是因为工程上的复杂、对编译环境的高要求、以及难于移植。但这样做简单可靠,其实我们方案中的很多决策都是在追求这一点。 |
|
感谢~ |
|
对于特别大的工程,并不是一个完整的表,而是要有“命名空间”的概念,将表拆分成多个命名空间。工具负责维护和提取每个命名空间下的表信息。这条路可以继续探索下去,相信以表为媒介,连接框架和工具以及规范,是比较合理的一个出路。 |
|
大家的方案都差不多,目前来看有以下几种:
@fouber 你真应该来阿里看看呀,很多时候,场景决定方案。FIS 很适合百度的场景,但拿到阿里的场景下,依旧还有许多路要走。 |
|
@fouber 我之前也对构建系统的看法也类似,但以这段时间在苏宁看到的情况来说,这个构建系统太难建成,主要是部门划分和模块划分的不一致性。命名空间式的构建,要求的是互相之间没有交叉,理想状况是一个树形下去,但一交叉就完蛋了,要不然是命名空间的粒度过小,要不然是命名空间横跨部门,给构建带来很大麻烦。 @lifesinger 阿里的细节情况我不清楚,但估计是很类似的,所以我才逐渐明白sea里面有些细节的用意。之前没想到这些情况,没能理解为什么非要这么搞。以我之前公司的情况来说,是有大部门,也有统一的架构组,而且大部分产品是纯AJAX交互,所以连非静态模板的问题都很少有。 但是在网购型系统里,很可能顶部的购物车、支付模块等,不是来自本系统,而是来自其他业务部门,这些东西却非要集成在一个页面里,它们的公共项就很难处理。所以我理解阿里把模块拆得这么碎,然后用看上去很怪异的方式,在nginx那边搞combiner来合并,然后也正是为此,可能js会有乱序,必须晚期依赖。 |
|
在百度,对于大型系统,我们都不是整站构建的,而是按业务拆成了很多个子系统,每个产品会产生一张资源表,跨业务的依赖会引入对应产品库的表,每个业务子系统是独立构建上线的。举个例子:
每个子系统独立构建,并产生独立的表,线上部署的大致效果为:
每个子系统的静态资源id结构为: <?php import('common:lib/jquery/jquery-2.0.2.js'); ?>
<?php import('user:widget/user-info/user-info.js'); ?>
<?php import('user:widget/user-info/user-info.css'); ?>
blablabla
模板中的import函数,会在运行时读取资源表来实现静态资源按需,资源表中也记录了子系统内代码的合并情况,可以在模板运行期间计算静态资源的最优组合(带宽、请求数等) 每个系统独立构建,只有运行时的交叉引用,不会出现整站构建的情况 |
那个,阿里好像刚收购了uc,你们已经在一家了…… |
写文章是想为中小企业应用模块化方案提出一些思路。现在比较堪忧的是中型团队对seajs的理解和应用现状。 以松鼠团队这边为例,之前使用seajs一直是放弃按需加载的,没有使用spm,因为规范不一致问题,所以自己实现了一套比较粗糙的打包方案,放弃了按需的能力,all-in-one.js。但面对移动端spa这种对按需、请求合并要求比较高的应用场景,会比较痛苦。
这是一项非常有难度的工作,因为即便是支付宝目前的解决方案,相信也不能推广给业界使用,因为它极有可能跟支付宝业务有着很深入的整合,包括提到的cms系统。 所以本文想阐述的就是这样一种适用于中小企业的模块化解决方案,确实不能涵盖全部使用场景,也没有关心具体哪种规范,均以生产需要为优先。 对于复杂体系的工程化改进真心是非常痛苦且充满挑战、并要承担巨大风险的工作。就规模而言,我觉得即便在熊掌公司所有经历过的工程化改造团队都没有过支付宝这么大规模的,所以确实不敢妄言,我非常赞同 另外,并不是fis适合熊掌公司的场景,而是基于fis实现的 fis-plus 适合,现在在松鼠团队同样基于fis做的 scrat 及其生态是适合松鼠团队业务形态的。 模块化框架,作为前端工程的 重中之重,是应该被反复锤炼和完善的,而且以我现在的认知来看,模块化框架非常有必要 |
|
楼主在前端工程化上的见解和造诣的确值得我长久学习,现实工作中的项目没有尝试的机会,很多进步的方案都是浅尝辄止甚至读读而已,希望将来有机会在前端工程化能有更多机会尝试。另外,楼主对Browserify这个解决方案怎么看?个人感觉,all in one简单粗暴,但未来可能有很大前景。 |
|
有大概扫过 Browserify ,没有在实际生产中应用过。这篇文章提到了,前端工程的核心是模块化框架,实践总结的是,模块化框架会关联工具、规范、部署等问题的,所以,原则上讲,选择了一种模块化框架,就要选择其配套的工具及规范,类似选了seajs,就要接受spm,接受了require.js,要接受它的r.js一样。当然也可以自己DIY工具,但有些规范基本上是天生定义好了的。 所以模块化系统设计上,我比较推崇自己diy,包括框架和工具,我在文章的末尾中也提到了,不同的场景会有不同的模块化需求,完全通用的可能性不大。 关于all in one,相信是因为不能同时做到按需、合并请求才不得已选择的结果,aio(all in one)的模块化体系,在demo层面看不出有什么问题,这是非常具有迷惑性的,等到项目用上了,达到一定规模了,才会发现这种方式的弊端:
所以,根据实践总结,合理的打包方案应该是:
这三条原则,本身也有一些矛盾的地方,最终确定的打包方案应该是根据业务权衡的。当然,我可以补充一条:
个人觉得,市面上这些模块化框架及其配套工具比较坑爹,demo把缺点隐藏的很好,上手很happy,吃亏的是后面大规模应用。 |
|
@fouber |
问题是这样的包到底有哪些?好像实际业务中基本没有。曾经有一些框架说“让js在前后端都能跑”也是扯淡,看过某些所谓前后端能跑的js,其实都是这样的代码结构: if(runAtServer){
//do something in server
} else {
//do something in browser
}
是的,输出到前端的代码携带了一坨不需要的逻辑分支,这对于要求低带宽的前端来说好矬逼,而且很容易暴露server端的敏感信息。 我在另外一篇blog中也回答过这句话,照搬过来。 |
|
@fouber 还是有这样的模块的。比如md5、hmac等用到的crypto模块。 |
|
@chuguixin |
|
@hax
这个是我个人的理解。如果是统一到html5的话,那应该是我理解错误了。如果是:
这样的话,意思是node的fs的api会修改?应该也不会吧。难道是我直接就把人家的初衷理解错误了? |
|
@chuguixin See https://dvcs.w3.org/hg/streams-api/raw-file/tip/Overview.htm browserify 的初衷确实是让你依赖node标准库的程序也能在浏览器上用,因此它提供了几乎所有node api在浏览器上的对应版本。 但是这并不表示你的程序必须用node的模块。因为现在 html5 的 API 已经非常丰富,特别是,大量API倒过来在node中有polyfill。举例来说,网络通讯我就不用node的http模块,而用 xmlhttprequest 模块,它与浏览器中的 XMLHttpRequest API 一致。 node的fs模块自然不会改,但是不代表你必须用它啊。你完全可以倒过来用File API,用DOM storage,它们都有node上的实现。所以,browserify虽然初衷是让你可以用 node api 写程序跑在浏览器中,但是它只是一个工具,我们也完全可以反其道而行之,利用它让我们写出使用 html5 api 的程序,同时可以跑在浏览器和node中。 |
|
@hax |
|
不仅是感觉舒服,而且我觉得也是合理的。因为API统一作为抽象层总是要付出代价的,关键这代价放在哪里。浏览器端与服务器端(node)比,显然是浏览器端对任何成本更加敏感——比如看看npm的树策略和bower的平面策略就可以看到不同的选择方向。服务器端多数情况下其实无所谓你包了几层——反正node本来的强项也是io密集应用,而API统一的成本是间接调用也就主要是cpu消耗,相比较而言对性能影响估计可忽略不计。至于多的代码占用空间啥的,更是如此。 |
|
@hax 能说明一下browser端程序跑在server端的需求情况么 |
|
@sapjax 我举个工作中的例子。最近我们上了一个在线聊天的功能,使用的是某云服务提供的基于Web Socket的chatting service。他们暂时只有iOS和Android的sdk,所以用于网站的sdk是我们自己写的。在这个项目中我就选择尝试了前述用browerify但是统一于html5 api的方式。主要的原因是两个。第一是这强迫我们必须把界面(大量dom操作,只在浏览器中测试)和通讯和协议层(websocket、ajax以及少量dom storage部分,大部分在Node.js环境中测试,然后通过browerify打包后再在浏览器环境中测试)分离,负责sdk开发的可以专注于协议本身的问题,和第三方的接口也比较方便。第二是后续我们服务器端也需要直接调用云服务,这个时候就直接共用了相同的sdk。 所以这样的需求不见得很多,但是还真的是存在的。 其他的典型例子,比如经典的表单验证。如果能统一到 html5 的 validation API,会方便很多。 |
|
总觉得到最后,用哪个模块化方式,也是要根据业务场景来定。amd和cmd都不是银弹,就算你说的模块化管理新思路,要维护字面量列表,在某些场景下,也是麻烦的。 |
|
关于 维护框架级别 依赖表 我更多支持seajs方案,前端模块标准化已然是一个大趋势,在阿里系web page、web app、移动端应用中,模块化颗粒度越来越细,应用场景也越来越复杂多变,直接 ransported 模块 灵活度更高、稳定性强、成本也小! |
|
@fouber 受益匪浅 |
|
combo服务 想问下是在后端把几个文件合成一个下发吧?那这样如果2个模块a、b共同引用模块x(包含js和css等),那x模块岂不是要被下载两次? |
|
请问对于刚入职半年的新人,是偏重于学习框架还是学习JS基础好一些? |
|
这个很难说,因为相差不大。 我觉得对于新人来说,最重要的是别纠结。不要纠结一个功能是这么实现好还是那么实现好,也别纠结是学习框架好还是学习基础好。答案是不重要。对于新人来说,无论入门学的是哪本书,第一个做的是哪个项目都不重要,因为真正的收获差距并不大。 新人的学习速度很快,随便哪本书或是哪个项目开始,完整坚持下来之后,应该都能接触到大多数知识点,剩下的确实就需要专研了。入门之后思考一下自己跨过那道“坎”的时候遇到了什么问题,再有针对性的学习。 所以,框架 or 基础,我觉得它们对于新人来说是“殊途同归”的。
因此,用发展的眼光看待两种选择,历程可能是:
从这个过程可以看出,对于那些有不断自我完善追求的程序员来说,选择什么样的起点并不重要,最终都能走到一个相同的结果上来。 |
|
@fouber |
|
楼主好,看了DIV.IO, 感觉不错,说说自己的想法,当然我没有邀请码,只是看到了一部分。
|
|
万恶的备案 |
|
求 star 2333333 |
|
我们目前使用AMD方案,也是通过差不多的一个方式,在构建时扫描文件依赖生成依赖表,并根据依赖打包文件以解决重复打包的问题,就是说把combo服务提前到构建做了。 |
|
@fouber 对于all in one在修改代码时带来的流量浪费,我们用增量更新来解决。 |
|
不错,你们的框架以前关注过,很细致的优化方案。我在UC实现了用一种相对比较简单策略来更新localstorage缓存的 你们这个项目的实践情况怎么样?线上产品有哪些?改天仔细研究一下 |
|
@fouber 目前线上有大概10来个的spa webapp使用了,http://mt.tencent.com/ 这里列了几个,其实还有别的项目也使用没有列出来的,总的来说效果还可以,有的项目发布时候增量更新的命中率还是挺高的:) |
|
@fouber 最近一直在研究前端工程和架构的问题,看了您的文章有所启发,感谢您的经验分享! |
|
@fouber 学无止境233~ |
|
@fouber 近来在研究前端工程部署问题,从您的文章中获取不少知识,感谢您的分享! |
|
诶,看完评论,砸吧砸吧嘴。。。觉得意犹未尽 |
|
大神,怎么可以联系到你啊 |
|
@acmeid |
|
@fouber前辈你好, 在学习前端自动化工具fis, -wL,-o,-p,-m -d等基本命令已经熟络了, 前端代码需要requirejs来模块化,然后require文件合并让我头疼不已, 翻了fis官网又去了慕课网看了fis教程, |
|
你贴的那个项目可能有点问题,最好在那个repos的issue中留言问问作者具体用法,我这里不方便展开讨论了。 但针对fis的解决方案,我想多啰嗦几句,希望能耐心读完以下内容: 模块化打包和加载这件事,说起来有点麻烦,原因在当前这篇blog也提到一些,究其根本,要从模块化框架说起,对于模块化框架来说,一般有三个功能: 1. 模块接口导入导出一般模块化框架或方案(requirejs/seajs/nodejs)遵循的是CommonJS的模块内部 上下文规范,也就是模块内上下文中的 在ES6中,给出了 2. 模块定义现阶段模块化框架(requirejs/seajs)为了在浏览器中实现模块定义,提供模块接口导入导出的方式,还须提供模块定义函数 同样,在ES6中,也给出了 3. 模块加载在浏览器端,模块化框架为了解决依赖问题,需要实现按依赖异步加载模块资源,加载完成后才能执行对应的模块代码,这是在浏览器中实现模块化所必须面对的现实问题,其实模块加载和模块化的核心部分关系并不大,它可以被独立实现。seajs和requirejs在模块加载上实现并不一致,连接口名称都不同,由此可见模块加载的特殊性。
以上,就是模块化框架的全部内容了,总结为一幅图大概是: 模块化核心部分(导入导出、定义)代码其实很少,写一个也就4、50行而已,半小时手起刀落应该就能搞定,真正重点是模块加载。 模块加载有这么几个痛点:
模块化框架做的好不好,加载是重点,以上三条,则是重点中的重点。 别人的方案我就不说了,这里就讲讲fis的思想,你可以考虑基于这样的思想来理解fis的一些设计:
确切的说,fis希望使用者,能利用fis深耕模块化加载方案,用最简洁的方式表达自己的工程诉求,如果直接依赖requirejs和seajs,我觉得诸多冗余。 基于前面讲过的这些,模块化框架其实并不难实现,首先用50行代码解决核心的定义、导入、导出问题,具体代码可以参考 umd 超简单的。 然后就是加载部分了,fis的核心功能,其实不是什么压缩、校验这些基础功能,而是扫描所有项目代码,识别其中的依赖关系,并整理出一个 {
"res": {
"a.js": {
"type": "js",
"url": "/static/js/a-912cf3.js",
"deps": [ "b.js", "a.css" ]
},
"a.css": {
"type": "css",
"url": "/static/js/a-c02a39.css",
"deps": [ "e.css" ]
},
"b.js": {
"type": "js",
"url": "/static/js/b-b2ef91.js",
"deps": [ "c.js", "b.css" ]
},
"b.css": {
"type": "css",
"url": "/static/js/b-fc01b2.css"
},
"c.js": {
"type": "js",
"url": "/static/js/c-103cf2.js"
},
"e.css": {
"type": "css",
"url": "/static/js/e-d0f135.css"
},
}
}
如果配置了fis的打包,fis合并文件后还会把文件的合并信息也写入到这个map.json中: {
"res": {
"a.js": {
"type": "js",
"url": "/static/js/a-912cf3.js",
"deps": [ "b.js", "a.css" ],
"pkg": "p0"
},
"a.css": {
"type": "css",
"url": "/static/js/a-c02a39.css",
"deps": [ "e.css" ],
"pkg": "p1"
},
"b.js": {
"type": "js",
"url": "/static/js/b-b2ef91.js",
"deps": [ "c.js", "b.css" ],
"pkg": "p0"
},
"b.css": {
"type": "css",
"url": "/static/js/b-fc01b2.css",
"pkg": "p1"
},
"c.js": {
"type": "js",
"url": "/static/js/c-103cf2.js",
"pkg": "p0"
},
"e.css": {
"type": "css",
"url": "/static/js/e-d0f135.css",
"pkg": "p1"
},
},
"pkg": {
"p0": {
"url": "/static/pkg/p0-ac0f334.js",
"type": "js",
"has": [ "c.js", "b.js", "a.js" ]
},
"p1": {
"url": "/static/pkg/p1-03d5ba.css",
"type": "css",
"has": [ "e.css", "b.css", "a.css" ]
}
}
}
好了,fis的想法是,团队花点时间写一个好一点的loader,利用这张表加载资源,肯定能同时解决前面说到的三个问题,顺便还能做一下静态资源localstorage缓存什么的,不是更妙? 比如源代码这么写: <!DOCTYPE html>
<html>
...
<script src="loader.js"></script>
<script>
loader.registerMap(__FIS_MAP__);
loader.fetch('a.js', function(a){
console.log(a);
});
</script>
...
</html>
通过写一个简单的fis插件,就可以把上述表的内容构建后生成到html中,替换那个我们跟构建工具约定好的 <!DOCTYPE html>
<html>
...
<script src="loader.js"></script>
<script>
loader.registerMap({
"res": {
"a.js": {
"type": "js",
"url": "/static/js/a-912cf3.js",
"deps": [ "b.js", "a.css" ],
"pkg": "p0"
},
"a.css": {
"type": "css",
"url": "/static/js/a-c02a39.css",
"deps": [ "e.css" ],
"pkg": "p1"
},
"b.js": {
"type": "js",
"url": "/static/js/b-b2ef91.js",
"deps": [ "c.js", "b.css" ],
"pkg": "p0"
},
"b.css": {
"type": "css",
"url": "/static/js/b-fc01b2.css",
"pkg": "p1"
},
"c.js": {
"type": "js",
"url": "/static/js/c-103cf2.js",
"pkg": "p0"
},
"e.css": {
"type": "css",
"url": "/static/js/e-d0f135.css",
"pkg": "p1"
},
},
"pkg": {
"p0 |



本文最先发表在 DIV.IO - 高质量前端社区,欢迎大家围观
一直酝酿着写一篇关于模块化框架的文章,因为模块化框架是前端工程中的
最为核心的部分。本来又想长篇大论的写一篇完整且严肃的paper,但看了 @糖饼 在 DIV.IO 的一篇文章 《再谈 SeaJS 与 RequireJS 的差异》觉得可以借着这篇继续谈一下,加上最近spm3发布,在seajs的官网上又引来了一场 口水战 ,我并不想参与到这场论战中,各有所爱的事情不好评论什么,但我想从工程的角度来阐述一下已知的模块化框架相关的问题,并给出一些新的思路,其实也不新啦,都实践了2多年了。基于 @糖饼 的文章 《再谈 SeaJS 与 RequireJS 的差异》,我这里还要补充一些模块化框架在工程方面的缺点:
requirejs和seajs二者在加载上都有缺陷,就是模块的依赖要等到模块加载完成后,通过静态分析(seajs)或者deps参数(requirejs)来获取,这就为
合并请求和按需加载带来了实现上的矛盾:AMD规范在执行callback的时候,要初始化所有依赖的模块,而CMD只有执行到require的时候才初始化模块。所以用AMD实现某种if-else逻辑分支加载不同的模块的时候,就会比较麻烦了。考虑这种情况:
//AMD for SPA require(['page/index', 'page/detail'], function(index, detail){ //在执行回调之前,index和detail模块的factory均执行过了 switch(location.hash){ case '#index': index(); break; case '#detail': detail(); break; } });在执行回调之前,已经同时执行了index和detail模块的factory,而CMD只有执行到require才会调用对应模块的factory。这种差别带来的不仅仅是性能上的差异,也可能为开发增加一点小麻烦,比如不方便实现换肤功能,factory注意不要直接操作dom等。当然,我们可以多层嵌套require来解决这个问题,但又会引起模块请求串行的问题。
导致这个问题的根本原因是
纯前端方式只能在运行时分析依赖关系。解决模块化管理的新思路
由于根本问题出在
运行时分析依赖,因此新思路的策略很简单:不在运行时分析依赖。这就要借助构建工具做线下分析了,其基本原理就是:举个例子,假设我们有一个这样的工程:
工程中,
index.html的源码内容为:<!doctype html> ... <script src="lib/xmd.js"></script> <!-- 模块化框架 --> <script> //等待构建工具生成数据替换 `__FRAMEWORK_CONFIG__' 变量 require.config(__FRAMEWORK_CONFIG__); </script> <script> //用户代码,异步加载模块 require.async(['a', 'e'], function(a, e){ //do something with a and e. }); </script> ...工程中,
mods/a.js的源码内容为(采用类似CMD的书写规范):define('a', function(require, exports, module){ console.log('a.init'); var b = require('b'); var c = require('c'); exports.run = function(){ //do something with b and c. console.log('a.run'); }; });具体实现过程
用工具在下线对工程文件进行扫描,得到依赖关系表:
{ "a" : [ "b", "c" ], "b" : [ "d" ] }工具把依赖表构建到页面或者脚本中,并调用模块化框架的配置接口,
index.html的构建结果为:<!doctype html> ... <script src="lib/xmd.js"></script> <!-- 模块化框架 --> <script> //构建工具生成的依赖数据 require.config({ "deps" : { "a" : [ "b", "c" ], "b" : [ "d" ] } }); </script> <script> //用户代码,异步加载模块 require.async(['a', 'e'], function(a, e){ //do something with a and e. }); </script>模块化框架根据依赖表加载资源,比如上述例子,入口需要加载a、e两个模块,查表得知完整依赖关系,配合combo服务,可以发起一个合并后的请求:
http://www.example.com/??d.js,b.js,c.js,a.js,e.js
先来看一下这种方案的优点
依赖分析完成后可以压缩掉require关键字require.config({...})相关的数据也是可以的。对于小项目,文件全部合并的情况,更加不需要deps表了,只要在入口的require.async调用之前加载所有模块化的文件,依赖关系无需额外维护请求合并,而不用等到一级模块加载完成才能知道后续的依赖关系。再来讨论一下这种方案的缺点:
由于采用require函数作为依赖标记,因此如果需要变量方式require,需要额外声明,这个时候可以实现兼容AMD规范写法,比如
define('a', ['b', 'c'], function(require, exports, module){ console.log('a.init'); var name = isIE ? 'b' : 'c'; var mod = require(name); exports.run = function(){ //do something with mod. console.log('a.run'); }; })只要工具把define函数中的
deps参数,或者factory内的require都作为依赖声明标记来识别,这样工程性就比较完备了。但不管怎样,
线下分析始终依靠了字面量信息,所以开发上可能会有一定的局限性,但总的来说瑕不掩瑜。