前端开发体系建设日记

2018-02-24 15:49 更新

原文出处:https://github.com/fouber/blog/issues/2
作者:fouber

目录

前端开发体系建设日记

上周写了一篇 文章 介绍前端集成解决方案的基本理论,很多同学看过之后大呼不过瘾。

干货 fuck things 在哪里!

本打算继续完善理论链,形成前端工程的知识结构。但鉴于如今的快餐文化,po主决定还是先写一篇实战介绍,让大家看到前端工程体系能为团队带来哪些好处,调起大家的胃口再说。

ps: 写完才发现这篇文章真的非常非常长,涵盖了前端开发中的很多方面,希望大家能有耐心看完,相信一定会有所斩获。。。

2014年02月12日 - 晴

新到松鼠团队的第二天,小伙伴 @nino 找到我说

nino: 视频项目打算重新梳理一下,希望能引入新的技术体系,解决现有的一些问题。

po主不禁暗喜,好机会,这是我专业啊,蓝翔技校-前端集成解决方案学院-自动化系-打包学专业的文凭不是白给的,于是自信满满的对nino说,有什么需求尽管提!

nino: 我的需求并不多,就这么几条~~

  1. 模块化开发。最好能像写nodejs一样写js,很舒服。css最好也能来个模块化管理!
  2. 性能要好。模块那么多,得有按需加载,请求不能太多。
  3. 组件化开发。一个组件的js、css、模板最好都在一个目录维护,维护起来方便。
  4. 用 handlebars 作为前端模板引擎。这个模板引擎不错,logic-less(轻逻辑)。
  5. 用 stylus 写css挺方便,给我整一个。
  6. 图片base64嵌入。有些小图可能要以base64的形式嵌入到页面、js或者css中使用。嵌入之前记得压缩图片以减小体积。
  7. js/css/图片压缩应该都没问题吧。
  8. 要能与公司的ci平台集。工具里最好别依赖什么系统库,ci的机器未必支持。
  9. 开发体验要好。文件监听,浏览器自动刷新(livereload)一个都不能少。
  10. 我们用nodejs作为服务器,本地要能预览,最好再能抓取线上数据,方便调试。

我倒吸一口凉气,但表面故作镇定的说:恩,确实不多,让我们先来看看第一个需求。。。

还没等我说完,nino打断我说

nino: 桥豆麻袋(稍等),还有一个最重要的需求!

松鼠公司的松鼠浏览器你知道吧,恩,它有很多个版本的样子。
我希望代码发布后能按照版本部署,不要彼此覆盖。

举个例子,代码部署结构可能是这样的:

  release/
    - public/
      - 项目名
        - 1.0.0/
        - 1.0.1/
        - 1.0.2/
        - 1.0.2-alpha/
        - 1.0.2-beta/

让历史浏览器浏览历史版本,没事还能做个灰度发布,ABTest啥的,多好!

此外,我们将来会有多个项目使用这套开发模式,希望能共用一些组件或者模
块,产品也会公布一些api模块给第三方使用,所以共享模块功能也要加上。

总的来说,还要追加两个部署需求:

  1. 按版本部署,采用非覆盖式发布
  2. 允许第三方引用项目公共模块

nino: 怎么样,不算复杂吧,这个项目很赶,3天搞定怎么样?

我凝望着会议室白板上的这些需求,正打算争辩什么,一扭头发现nino已经不见了。。。正在沮丧之际,小伙伴 @hinc 过来找我,跟他大概讲了一下nino的需求,正想跟他抱怨工期问题时,hinc却说

hinc: 恩,这正是我们需要的开发体系,不过我这里还有一个需求。。。

  1. 我们之前积累了一些业务可以共用的模块,放在了公司内的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文件

这样确实很直接,任何智力健全的人都知道每个文件该放在哪里。但是这样的开发概念划分将给项目带来较高的维护成本,并为项目臃肿埋下了工程隐患,理由是:

  1. 如果项目中的一个功能有了问题,维护的时候要在js目录下找到对应的逻辑修改,再到css目录下找到对应的样式文件修改一下,如果图片不对,还要再跑到images目录下找对应的开发资源。
  2. 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属性值中引用分组是非常方便的。正则匹配 + 捕获分组,成为目录规范配置的强有力工具:

fis release

执行 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文件,完成后再执行回调。

现在模块的id有一些问题,因为模块发布会有版本号信息,因此模块id也应该携带版本信息,从前面的依赖树生成配置代码中我们可以看到模块id其实也是文件的一个属性,因此我们可以在roadmap.path中重新为文件赋予id属性,使其携带版本信息:

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>

you see?所有id都会被修改为我们指定的模式,这就是以文件为中心的编译系统的威力。

以文件对象为中心构建系统应该通过配置指定文件的各种属性。插件并不自己实现某种规范规定,而是读取file对象的对应属性值,这样插件的职责单一,规范又能统一起来被用户指定,为完整的前端开发体系设计奠定了坚实规范配置的基础。

接下来还有一个问题,就是模块名太长,开发中写这么长的模块名非常麻烦。我们可以借鉴流行的模块化框架中常用的缩短模块名手段——别名(alias)——来降低开发中模块引用的成本。此外,目前的配置其实会针对所有文件生成依赖关系表,我们的开发概念定义只有components和component_modules目录下的文件才是模块化的,因此我们可以进一步的对文件进行分类,得到这样配置规范:

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
    }
]);

然后我们为一些模块id建立别名:

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]);

再次构建,在注入的代码中就能看到alias字段了:

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');了。

还剩最后一个小小的需求,就是希望能像写nodejs一样开发js模块,也就是要求实现define的自动包裹功能,这个可以通过文件编译的 postprocessor 插件完成。配置为:

//在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;
});

所有在components目录和component_modules目录下的js文件都会被包裹define,并自动根据roadmap.path中的id配置进行模块定义了。

最煎熬的一天终于过去了,睡一觉,拥抱一下周末。

2014年02月15日 - 超晴

周末的天气非常好哇,一觉睡到中午才起,这么好的天气写码岂不是很loser?!

2014年02月16日 - 小雨

居然浪费了一天,剩下的时间不多了,今天要抓紧啊!!!

让我们来回顾一下已经完成了哪些工作:

  • 规范
    • 开发规范
      • 模块化开发,js模块化,css模块化,像nodejs一样的模块化开发
      • 组件化开发,js、css、handlebars维护在一起
    • 部署规范
      • 采用nodejs后端,基本部署规范应该参考 express 项目部署
      • 按版本号做非覆盖式发布
      • 公共模块可发布给第三方共享
  • 框架
    • js模块化框架,支持请求合并,按需加载等性能优化点
  • 工具
    • 可以编译stylus为css
    • 支持js、css、图片压缩
    • 允许图片压缩后以base64编码形式嵌入到css、js或html中
    • 与ci平台集成
    • 文件监听、浏览器自动刷新
    • 本地预览、数据模拟
  • 仓库
    • 支持component模块安装和使用

剩下的几个需求中有些是fis默认支持的,比如base64内嵌功能,图片会先经过编译流程,得到压缩后的内容fis再对其进行base64化的内嵌处理。由于fis的内嵌功能支持任意文件的内嵌,所以,这个语言能力扩展可以同时解决前端模板和图片base64内嵌需求,比如我们有这样的代码:

project
  - components
    - foo
      - foo.js
      - foo.css
      - foo.handlebars
      - foo.png

无需配置,既可以在js中嵌入资源,比如 foo.js 中可以这样写:

//依赖声明
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
以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号