前端开发体系建设日记
目录
前端开发体系建设日记
上周写了一篇 文章 介绍前端集成解决方案的基本理论,很多同学看过之后大呼不过瘾。
干货
fuck things在哪里!
本打算继续完善理论链,形成前端工程的知识结构。但鉴于如今的快餐文化,po主决定还是先写一篇实战介绍,让大家看到前端工程体系能为团队带来哪些好处,调起大家的胃口再说。
ps: 写完才发现这篇文章真的非常非常长,涵盖了前端开发中的很多方面,希望大家能有耐心看完,相信一定会有所斩获。。。
2014年02月12日 - 晴
新到松鼠团队的第二天,小伙伴 @nino 找到我说
nino: 视频项目打算重新梳理一下,希望能引入新的技术体系,解决现有的一些问题。
po主不禁暗喜,好机会,这是我专业啊,蓝翔技校-前端集成解决方案学院-自动化系-打包学专业的文凭不是白给的,于是自信满满的对nino说,有什么需求尽管提!
nino: 我的需求并不多,就这么几条~~
- 模块化开发。最好能像写nodejs一样写js,很舒服。css最好也能来个模块化管理!
- 性能要好。模块那么多,得有按需加载,请求不能太多。
- 组件化开发。一个组件的js、css、模板最好都在一个目录维护,维护起来方便。
- 用 handlebars 作为前端模板引擎。这个模板引擎不错,logic-less(轻逻辑)。
- 用 stylus 写css挺方便,给我整一个。
- 图片base64嵌入。有些小图可能要以base64的形式嵌入到页面、js或者css中使用。嵌入之前记得压缩图片以减小体积。
- js/css/图片压缩应该都没问题吧。
- 要能与公司的ci平台集。工具里最好别依赖什么系统库,ci的机器未必支持。
- 开发体验要好。文件监听,浏览器自动刷新(livereload)一个都不能少。
- 我们用nodejs作为服务器,本地要能预览,最好再能抓取线上数据,方便调试。
我倒吸一口凉气,但表面故作镇定的说:恩,确实不多,让我们先来看看第一个需求。。。
还没等我说完,nino打断我说
nino: 桥豆麻袋(稍等),还有一个最重要的需求!
松鼠公司的松鼠浏览器你知道吧,恩,它有很多个版本的样子。
我希望代码发布后能按照版本部署,不要彼此覆盖。
举个例子,代码部署结构可能是这样的:
release/
- public/
- 项目名
- 1.0.0/
- 1.0.1/
- 1.0.2/
- 1.0.2-alpha/
- 1.0.2-beta/
让历史浏览器浏览历史版本,没事还能做个灰度发布,ABTest啥的,多好!
此外,我们将来会有多个项目使用这套开发模式,希望能共用一些组件或者模
块,产品也会公布一些api模块给第三方使用,所以共享模块功能也要加上。
总的来说,还要追加两个部署需求:
- 按版本部署,采用非覆盖式发布
- 允许第三方引用项目公共模块
nino: 怎么样,不算复杂吧,这个项目很赶,
3天
搞定怎么样?
我凝望着会议室白板上的这些需求,正打算争辩什么,一扭头发现nino已经不见了。。。正在沮丧之际,小伙伴 @hinc 过来找我,跟他大概讲了一下nino的需求,正想跟他抱怨工期问题时,hinc却说
hinc: 恩,这正是我们需要的开发体系,不过我这里还有一个需求。。。
- 我们之前积累了一些业务可以共用的模块,放在了公司内的gitlab上,采用 component 作为发布规范,能不能再加上这个组件仓库的支持?
3天时间,13项前端技术元素,靠谱么。。。
2014年02月13日 - 多云
一觉醒来,轻松了许多,但还有任务在身,不敢有半点怠慢。整理一下昨天的需求,我们来做一个简单的划分。
- 规范
- 开发规范
- 模块化开发,js模块化,css模块化,像nodejs一样编码
- 组件化开发,js、css、handlebars维护在一起
- 部署规范
- 采用nodejs后端,基本部署规范应该参考 express 项目部署
- 按版本号做非覆盖式发布
- 公共模块可发布给第三方共享
- 开发规范
- 框架
- js模块化框架,支持请求合并,按需加载等性能优化点
- 工具
- 可以编译stylus为css
- 支持js、css、图片压缩
- 允许图片压缩后以base64编码形式嵌入到css、js或html中
- 与ci平台集成
- 文件监听、浏览器自动刷新
- 本地预览、数据模拟
- 仓库
- 支持component模块安装和使用
这样一套规范、框架、工具和仓库的开发体系,服从我之前介绍的 前端集成解决方案 的描述。前端界每天都团队在设计和实现这类系统,它们其实是有规律可循的。百度出品的 fis 就是一个能帮助快速搭建前端集成解决方案的工具。使用fis我应该可以在3天之内完成这些任务。
ps: 这不是一篇关于fis的软文,如果这样的一套系统基于grunt实现相信会有非常大量的开发工作,3天完成几乎是不可能的任务。
不幸的是,现在fis官网所介绍的 并不是 fis,而是一个叫 fis-plus 的项目,该项目并不像字面理解的那样是fis的加强版,而是在fis的基础上定制的一套面向百度前端团队的解决方案,以php为后端语言,跟smarty有较强的绑定关系,有着 19项
技术要素,密切配合百度现行技术选型。绝大多数非百度前端团队都很难完整接受这19项技术选型,尤其是其中的部署、框架规范,跟百度前端团队相关开发规范、部署规范、以及php、smarty等有着较深的绑定关系。
因此如果你的团队用的不是 php后端
&& smarty模板
&& modjs模块化框架
&& bingo框架
的话,请查看 fis的文档,或许不会有那么多困惑。
ps: fis是一个构建系统内核,很好的抽象了前端集成解决方案所需的通用工具需求,本身不与任何后端语言绑定。而基于fis实现的具体解决方案就会有具体的规范和技术选型了。
言归正传,让我们基于 fis 开始实践这套开发体系吧!
0. 开发概念定义
前端开发体系设计第一步要定义开发概念。开发概念是指针对开发资源的分类概念。开发概念的确立,直接影响到规范的定制。比如,传统的开发概念一般是按照文件类型划分的,所以传统前端项目会有这样的目录结构:
- js:放js文件
- css:放css文件
- images:放图片文件
- html:放html文件
这样确实很直接,任何智力健全的人都知道每个文件该放在哪里。但是这样的开发概念划分将给项目带来较高的维护成本,并为项目臃肿埋下了工程隐患,理由是:
- 如果项目中的一个功能有了问题,维护的时候要在js目录下找到对应的逻辑修改,再到css目录下找到对应的样式文件修改一下,如果图片不对,还要再跑到images目录下找对应的开发资源。
- images下的文件不知道哪些图片在用,哪些已经废弃了,谁也不敢删除,文件越来越多。
ps: 除非你的团队只有1-2个人,你的项目只有很少的代码量,而且不用关心性能和未来的维护问题,否则,以文件为依据设计的开发概念是应该被抛弃的。
以我个人的经验,更倾向于具有一定语义的开发概念。综合前面的需求,我为这个开发体系确定了3个开发资源概念:
- 模块化资源:js模块、css模块或组件
- 页面资源:网站html或后端模板页面,引用模块化框架,加载模块
- 非模块化资源:并不是所有的开发资源都是模块化的,比如提供模块化框架的js本身就不能是一个模块化的js文件。严格上讲,页面也属于一种非模块化的静态资源。
ps: 开发概念越简单越好,前面提到的fis-plus也有类似的开发概念,有组件或模块(widget),页面(page),测试数据(test),非模块化静态资源(static)。有的团队在模块之中又划分出api模块和ui模块(组件)两种概念。
1. 开发目录设计
基于开发概念的确立,接下来就要确定目录规范了。我通常会给每种开发资源的目录取一个有语义的名字,三种资源我们可以按照概念直接定义目录结构为:
project
- modules 存放模块化资源
- pages 存放页面资源
- static 存放非模块化资源
这样划分目录确实直观,但结合前面hinc说过的,希望能使用component仓库资源,因此我决定将模块化资源目录命名为components
,得到:
project
- components 存放模块化资源
- pages 存放页面资源
- static 存放非模块化资源
而nino又提到过模块资源分为项目模块和公共模块,以及hinc提到过希望能从component安装一些公共组件到项目中,因此,一个components目录还不够,想到nodejs用node_modules作为模块安装目录,因此我在规范中又追加了一个 component_modules
目录,得到:
project
- component_modules 存放外部模块资源
- components 存放项目模块资源
- pages 存放页面资源
- static 存放非模块化资源
nino说过今后大多数项目采用nodejs作为后端,express是比较常用的nodejs的server框架,express项目通常会把后端模板放到 views
目录下,把静态资源放到 public
下。为了迎合这样的需求,我将page、static两个目录调整为 views
和 public
,规范又修改为:
project
- component_modules 存放外部模块资源
- components 存放项目模块资源
- views 存放页面资源
- public 存放非模块化资源
考虑到页面也是一种静态资源,而public
这个名字不具有语义性,与其他目录都有概念冲突,不如将其与views
目录合并,views目录负责存放页面和非模块化资源比较合适,因此最终得到的开发目录结构为:
project
- component_modules 存放外部模块资源
- components 存放项目模块资源
- views 存放页面以及非模块化资源
2. 部署目录设计
托nino的福,咱们的部署策略将会非常复杂,根据要求,一个完整的部署结果应该是这样的目录结构:
release
- public
- 项目名
- 1.0.0 1.0.0版本的静态资源都构建到这里
- 1.0.1 1.0.1版本的静态资源都构建到这里
- 1.0.2 1.0.2版本的静态资源都构建到这里
...
- views
- 项目名
- 1.0.0 1.0.0版本的后端模板都构建到这里
- 1.0.1 1.0.1版本的后端模板都构建到这里
- 1.0.2 1.0.2版本的后端模板都构建到这里
...
由于还要部署一些可以被第三方使用的模块,public下只有项目名的部署还不够,应改把模块化文件单独发布出来,得到这样的部署结构:
release
- public
- component_modules 模块化资源都部署到这个目录下
- module_a
- 1.0.0
- module_a.js
- module_a.css
- module_a.png
- 1.0.1
- 1.0.2
...
- 项目名
- 1.0.0 1.0.0版本的静态资源都构建到这里
- 1.0.1 1.0.1版本的静态资源都构建到这里
- 1.0.2 1.0.2版本的静态资源都构建到这里
...
- views
- 项目名
- 1.0.0 1.0.0版本的后端模板都构建到这里
- 1.0.1 1.0.1版本的后端模板都构建到这里
- 1.0.2 1.0.2版本的后端模板都构建到这里
...
由于 component_modules
这个名字太长了,如果部署到这样的路径下,url会很长,这也是一个优化点,因此最终决定部署结构为:
release
- public
- c 模块化资源都部署到这个目录下
- 公共模块
- 版本号
- 项目名
- 版本号
- 项目名
- 版本号 非模块化资源都部署到这个目录下
- views
- 项目名
- 版本号 后端模板都构建到这个目录下
插一句,并不是所有团队都会有这么复杂的部署要求,这和松鼠团队的业务需求有关,但我相信这个例子也不会是最复杂的。每个团队都会有自己的运维需求,前端资源部署经常牵连到公司技术架构,因此很多前端项目的开发目录结构会和部署要求保持一致。这也为项目间模块的复用带来了成本,因为代码中写的url通常是部署后的路径,迁移之后就可能失效了。
解耦开发规范和部署规范是前端开发体系的设计重点。
好了,去吃个午饭,下午继续。。。
3. 配置fis连接开发规范和部署规范
我准备了一个样例项目:
project
- views
- logo.png
- index.html
- fis-conf.js
- README.md
fis-conf.js
是fis工具的配置文件,接下来我们就要在这里进行构建配置了。虽然开发规范和部署规范十分复杂,但好在fis有一个非常强大的 roadmap.path 功能,专门用于分类文件、调整发布结构、指定文件的各种属性等功能实现。
所谓构建,其核心任务就是将文件按照某种规则进行分类(以文件后缀分类,以模块化/非模块化分类,以前端/后端代码分类),然后针对不同的文件做不同的构建处理。
闲话少说,我们先来看一下基本的配置,在 fis-conf.js
中添加代码:
fis.config.set('roadmap.path', [
{
reg : '**.md', //所有md后缀的文件
release : false //不发布
}
]);
以上配置,使得项目中的所有md后缀文件都不会发布出来。release是定义file对象发布路径的属性,如果file对象的release属性为false,那么在项目发布阶段就不会被输出出来。
在fis中,roadmap.pah是一个数组数据,数组每个元素是一个对象,必须定义 reg
属性,用以匹配项目文件路径从而进行分类划分,reg属性的取值可以是路径通配字符串或者正则表达式。fis有一个内部的文件系统,会给每个源码文件创建一个 fis.File 对象,创建File对象时,按照roadmap.path的配置逐个匹配文件路径,匹配成功则把除reg之外的其他属性赋给File对象,fis中各种处理环节及插件都会读取所需的文件对象的属性值,而不会自己定义规范。有关roadmap.path的工作原理可以看这里 以及 这里。
ok,让md文件不发布很简单,那么views目录下的按版本发布要求怎么实现呢?其实也是非常简单的配置:
fis.config.set('roadmap.path', [
{
reg : '**.md', //所有md后缀的文件
release : false //不发布
},
{
//正则匹配【/views/**】文件,并将views后面的路径捕获为分组1
reg : /^\/views\/(.*)$/i,
//发布到 public/proj/1.0.0/分组1 路径下
release : '/public/proj/1.0.0/$1'
}
]);
roadmap.path数组的第二元素据采用正则作为匹配规则,正则可以帮我们捕获到分组信息,在release属性值中引用分组是非常方便的。正则匹配 + 捕获分组,成为目录规范配置的强有力工具:
执行 在调用 现在模块的id有一些问题,因为模块发布会有版本号信息,因此模块id也应该携带版本信息,从前面的依赖树生成配置代码中我们可以看到模块id其实也是文件的一个属性,因此我们可以在roadmap.path中重新为文件赋予id属性,使其携带版本信息: 重新构建项目,我们得到了新的结果: you see?所有id都会被修改为我们指定的模式,这就是以文件为中心的编译系统的威力。 以文件对象为中心构建系统应该通过配置指定文件的各种属性。插件并不自己实现某种规范规定,而是读取file对象的对应属性值,这样插件的职责单一,规范又能统一起来被用户指定,为完整的前端开发体系设计奠定了坚实规范配置的基础。 接下来还有一个问题,就是模块名太长,开发中写这么长的模块名非常麻烦。我们可以借鉴流行的模块化框架中常用的缩短模块名手段——别名(alias)——来降低开发中模块引用的成本。此外,目前的配置其实会针对所有文件生成依赖关系表,我们的开发概念定义只有components和component_modules目录下的文件才是模块化的,因此我们可以进一步的对文件进行分类,得到这样配置规范: 然后我们为一些模块id建立别名: 再次构建,在注入的代码中就能看到alias字段了: 这样,代码中的 还剩最后一个小小的需求,就是希望能像写nodejs一样开发js模块,也就是要求实现define的自动包裹功能,这个可以通过文件编译的 postprocessor 插件完成。配置为: 所有在components目录和component_modules目录下的js文件都会被包裹define,并自动根据roadmap.path中的id配置进行模块定义了。 最煎熬的一天终于过去了,睡一觉,拥抱一下周末。 周末的天气非常好哇,一觉睡到中午才起,这么好的天气写码岂不是很loser?! 居然浪费了一天,剩下的时间不多了,今天要抓紧啊!!! 让我们来回顾一下已经完成了哪些工作: 剩下的几个需求中有些是fis默认支持的,比如base64内嵌功能,图片会先经过编译流程,得到压缩后的内容fis再对其进行base64化的内嵌处理。由于fis的内嵌功能支持任意文件的内嵌,所以,这个语言能力扩展可以同时解决前端模板和图片base64内嵌需求,比如我们有这样的代码: 无需配置,既可以在js中嵌入资源,比如 foo.js 中可以这样写: 编译后得到:fis release -d ../release
之后,得到构建后的内容为:<!doctype html>
<html>
<head>
<title>hello</title>
</head>
<body>
<script type="text/javascript" src="https://atts.w3cschool.cn/attachments/image/cimg/scrat.js"></script>
<script type="text/javascript">
require.config({
"deps": {
"components/bar/bar.js": [
"components/bar/bar.css"
],
"components/foo/foo.js": [
"components/bar/bar.js",
"components/foo/foo.css"
]
}
});
require.async('components/foo/foo.js', function(foo){
//todo
});
</script>
</body>
</html>
require.async('components/foo/foo.js')
之际,模块化框架已经知道了这个foo.js依赖于bar.js、bar.css以及foo.css,因此可以发起两个combo请求去加载所有依赖的js、css文件,完成后再执行回调。fis.config.set('roadmap.path', [
{
reg : '**.md',
release : false,
isHtmlLike : true
},
{
reg : /^\/component_modules\/(.*)$/i,
//追加id属性
id : '$1',
release : '/public/c/$1'
},
{
reg : /^\/components\/(.*)$/i,
//追加id属性,id为【项目名/版本号/文件路径】
id : '${name}/${version}/$1',
release : '/public/c/${name}/${version}/$1'
},
{
reg : /^\/views\/(.*)$/,
//给views目录下的文件加一个isViews属性标记,用以标记文件分类
//我们可以在插件中拿到文件对象的这个值
isViews : true,
release : '/public/${name}/${version}/$1'
},
{
reg : '**',
useStandard : false,
useOptimizer : false
}
]);
<!doctype html>
<html>
<head>
<title>hello</title>
</head>
<body>
<img src="https://atts.w3cschool.cn/attachments/image/cimg/logo.png"/>
<script type="text/javascript" src="https://atts.w3cschool.cn/attachments/image/cimg/scrat.js"></script>
<script type="text/javascript">
require.config({
"deps": {
"proj/1.0.4/bar/bar.js": [
"proj/1.0.4/bar/bar.css"
],
"proj/1.0.4/foo/foo.js": [
"proj/1.0.4/bar/bar.js",
"proj/1.0.4/foo/foo.css"
]
}
});
require.async('proj/1.0.4/foo/foo.js', function(foo){
//todo
});
</script>
</body>
</html>
fis.config.set('roadmap.path', [
{
reg : '**.md',
release : false,
isHtmlLike : true
},
{
reg : /^\/component_modules\/(.*)$/i,
id : '$1',
//追加isComponentModules标记属性
isComponentModules : true,
release : '/public/c/$1'
},
{
reg : /^\/components\/(.*)$/i,
id : '${name}/${version}/$1',
//追加isComponents标记属性
isComponents : true,
release : '/public/c/${name}/${version}/$1'
},
{
reg : /^\/views\/(.*)$/,
isViews : true,
release : '/public/${name}/${version}/$1'
},
{
reg : '**',
useStandard : false,
useOptimizer : false
}
]);
var createFrameworkConfig = function(ret, conf, settings, opt){
var map = {};
map.deps = {};
//别名收集表
map.alias = {};
fis.util.map(ret.src, function(subpath, file){
//添加判断,只有components和component_modules目录下的文件才需要建立依赖树或别名
if(file.isComponents || file.isComponentModules){
//判断一下文件名和文件夹是否同名,如果同名则建立一个别名
var match = subpath.match(/^\/components\/(.*?([^\/]+))\/\2\.js$/i);
if(match && match[1] && !map.alias.hasOwnProperty(match[1])){
map.alias[match[1]] = file.id;
}
if(file.requires && file.requires.length){
map.deps[file.id] = file.requires;
}
}
});
var stringify = JSON.stringify(map, null, opt.optimize ? null : 4);
fis.util.map(ret.src, function(subpath, file){
if(file.isViews && (file.isJsLike || file.isHtmlLike)){
var content = file.getContent();
content = content.replace(/\b__FRAMEWORK_CONFIG__\b/g, stringify);
file.setContent(content);
}
});
};
fis.config.set('modules.postpackager', [createFrameworkConfig]);
require.config({
"deps": {
"proj/1.0.5/bar/bar.js": [
"proj/1.0.5/bar/bar.css"
],
"proj/1.0.5/foo/foo.js": [
"proj/1.0.5/bar/bar.js",
"proj/1.0.5/foo/foo.css"
]
},
"alias": {
"bar": "proj/1.0.5/bar/bar.js",
"foo": "proj/1.0.5/foo/foo.js"
}
});
require('foo');
就等价于 require('proj/1.0.5/foo/foo.js');
了。//在postprocessor对所有js后缀的文件进行内容处理:
fis.config.set('modules.postprocessor.js', function(content, file){
//只对模块化js文件进行包装
if(file.isComponents || file.isComponentModules){
content = 'define("' + file.id +
'", function(require,exports,module){' +
content + '});';
}
return content;
});
2014年02月15日 - 超晴
2014年02月16日 - 小雨
模块化开发,js模块化,css模块化,像nodejs一样的模块化开发组件化开发,js、css、handlebars维护在一起采用nodejs后端,基本部署规范应该参考 express 项目部署按版本号做非覆盖式发布公共模块可发布给第三方共享js模块化框架,支持请求合并,按需加载等性能优化点project
- components
- foo
- foo.js
- foo.css
- foo.handlebars
- foo.png
//依赖声明
var bar = require('../bar/bar.js');
//把handlebars文件的字符串形式嵌入到js中
var text = __inline('foo.handlebars');
var tpl = Handlebars.compile(text);
exports.render = function(data){
return tpl(data);
};
//把图片的base64嵌入到js中
var data = __inline('foo.png');
exports.getImage = function(){
var img = new Image();
img.src = data;
return img;
};
define("proj/1.0.5/foo/foo.js", function(require,exports,module){
//依赖声明
var bar = require('proj/1.0.5/bar/bar.js');
//把handlebars文件的字符串形式嵌入到js中
var text = "<h1>{{title}}</h1>";
var tpl = Handlebars.compile(text);
exports.render = function(data){
return tpl(data);
};
//把图片的b
更多建议: