shanggqm / blog

My Frontend Blog

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

搜狗商业前端工程化蜕变之路

shanggqm opened this issue · comments

1. 前言

2006年yahoo前端团队发布了一篇名为《Best Practices for Speeding Up Your Web Site》的博文,在此后的十年间,这篇文章被前端开发人士奉为Web性能优化的圣经。同年8月jQuery发布了第一个版本,支持由Google在2005年使用在google map上的Ajax技术,由此开启了前端开发的一个全新时代。

借助Ajax技术带来的革命性体验,前端第一次拥有了数据,后端大部分渲染逻辑逐渐前移到浏览器,客户端浏览器负责的工作越来越多,也因此逐渐产生了那个年代的“富客户端”开发的挑战:

  1. JS体积日益庞大;
  2. JavaScript语言缺陷带来的可维护性问题
  3. 命名空间冲突问题
  4. 性能问题
  5. 等等……

前端工程化正是在这样的背景下开始萌芽的,而在早期,前端开发者还不知道这个新鲜的词语,更多的是在按照雅虎军规做性能优化;使用IIFE隔离作用域;使用YUI Compressor 对JS代码进行压缩和丑化;这些大多都是业务开发之外的工作,一般情况只有在生产环境出了问题或者提高了用户体验要求才会去做的事情。

2006到2010年间,随着前端开发的复杂度逐渐增长,JavaScript无Class的设计逐渐成为了JS代码难以维护的罪魁祸首,一时间提供了Class解决方案的各种JS框架和类库都出现在我们眼前:
YUI.js、DOJO.js、EXT.js、MooTools、prototype。那是一个百花齐放的年代,JS社区从未如此活跃。前端产品形态也早已从静态站,简单交互的动态站转变成了复杂交互的动态站。

搜狗商业平台正是在这个时代创建的。并且从此一路走来,经过了模块化、组件化时代的洗礼,
尤其是2009年Node.js的横空出世,更是为前端行业带来了极大的繁荣和发展。而这背后,确是更多的选择、更大的复杂度,也正式这个时期,迎来了前端工程化的蓬勃发展契机。

本文将会按照搜狗商业系统前端经历过的时代主线来介绍我们的架构和工程化方案的发展和蜕变。

2. Ajax 时代

起初为了求快和灵活,我们并未使用诸如 YUIEXT.js 这些大型框架,而是采用了jQuery+jQueryUI+ 自研UI组件的架构,使用多级命名空间的模块管理方式来组织代码:

sogou.dom = {};

sogou.manage.planListTable = {};
//...

上线也并未做任何的压缩、构建,js模块也并未形成统一的拆分管理规则,也存在部分模块同时写在一个文件里的情况。很快随着产品的不断迭代,功能越来越多,代码的可维护性、线上的性能都逐渐的产生问题。继续按照这种方式开发势必会导致系统维护成本越来越高,因此大家讨论决定引入当时正在兴起的模块化的开发方式。

3. 模块化时代

3.1 模块化和MVC架构升级

所谓的模块化,其实就是使用Javascript现有的语言特性,抽象出一种Javascript所没有的模块边界(类似Java这一类OOP语言,天然带有模块边界,比如一个java文件和另外一个java文件之间,是不能直接互相访问的。而JS天生就可以),让模块与模块之间的变量无法直接互相访问,必须通过模块化规范指定的方式暴露到公共环境。

模块化之前

//foo.js
var foo = 'foo';
var bar = 'bar in foo'
//bar.js
var bar = 'bar in bar';
console.log(foo, ' | ',bar);
//----output: foo | bar in bar

//index.html
<script src="foo.js"></script>
<script src="bar.js"></script>

模块化之后

/*a AMD module demo*/

//foo.js
define([],function(require, module, exports){
    var foo = 'foo';
    var bar = 'bar in foo';
    module.exports = {foo:foo, bar:bar};
})

//bar.js
define(['./foo'], function(require, module, exports){
    var Foo = require('./foo');
    var bar = 'bar in bar';
    
    module.export = function(){
        console.log(Foo.foo, ' | ',Foo.bar);
    }
})

//index.html
require('./bar', function(bar){
    bar();
    //----output: foo | bar in foo
})

模块化带来了js文件之间的访问边界,一个模块就像是一个黑盒,只暴露出必要的接口即可,各模块内部的逻辑不会互相产生任何影响。这无疑带来了非常好的代码管理的模式:

按照模块拆分文件,思考模块哪些要对外开放,哪些要对外封闭

除此之外,模块化的好处还体现在了两个方面:

  1. 解决散落在各个非模块js文件中的命名空间冲突的问题,如上述代码的bar变量
  2. 解决了模块的依赖加载问题,避免了人肉管理script标签的顺序问题

第一个方面是通过 模块化规范 来解决的,诸如AMDCMD这些浏览器端模块化规范都要求模块必须包裹在一个define函数参数中声明的一个factory function中,以便实现模块的延迟执行(不会一下载到浏览器就被执行)和私有空间(factory function里声明的变量默认都是private的,只有通过exports暴露出来的才是public的)。

第二个方面是通过遵循某种模块化规范创建的模块加载器(Loader)来实现的,最富盛名的AMD规范的加载器实现是require.js;国内程序员所熟知的CMD规范的加载器便是sea.js

模块化带来了优势非常明显,但同时也带来了相应的问题:

模块的书写规范问题:模块是否严格按照规范要求书写,还是使用一些简写模式。不同的写法都可以在线上正常运行,简写的方式虽然带来了书写上的便利性,但确需要通过构建来解决运行时的模块依赖加载问题。如下所示:

//完整CMD写法
define('bar'/*module name*/, ['./foo'/*dependencies*/], function(require, module,exports){
    var Foo = require('./foo');
});

//简写
define(function(require, module, exports){
    var Foo = require('./foo');
})

这个问题通常由loader內建静态分析机制来解决,一般不需要开发者关注。但是当需要通过合并模块来减少页面请求的时候,就产生了新的问题。

模块简写在合并模块时带来的问题

上线合并的时候,模块之间的界限不再以物理文件来区分,而是合并在一个文件里。loader并不能直接处理这样的问题,因为当所有简写的模块代码被合并在一起的时候,loader并不能区分出哪部分代码属于哪个模块,因此需要做特殊的补全处理。我们在不同的项目里同时使用了seajs和require.js作为模块加载器,因此两种方案各有不同:

  • 在seajs的项目里,我们使用了seajs-combo插件,配合在服务器端Java中实现的Filter来处理页面的js请求,并根据参数对模块进行依赖提取、补全CMD的完整写法,以及合并物理文件,生成文件缓存、压缩等一系列的操作。整体架构图如下所示:

  • 在requirejs的项目里,则是通过r.js进行构建优化,通过提供一个用于执行构建的sh脚本,嵌入在Java工程编译的流程中实现。

这个时代我们的系统架构大概是下图这个样子:

我们引入老牌的MVC框架Backbone.js作为模块业务逻辑和数据处理的拆分,引入无逻辑的Mustache.js作为前端模板,使用jQuery屏蔽浏览器之间的差异。

3.2 前后端分离开发和SPA架构升级

随着Node.js的流行和普及,利用Javascript技术实现一个webserver变得异常简单。Node.js为前端开发人员提供前后端分离的契机和可能性。

在此之前,我们的系统作为一个JavaWeb工程,前端开发和后端环境是完全绑定在一起的,开发调试都需要配置Java运行环境。更糟糕的情况是:在协同开发的时候,后端经常会因为随便提交了未经自测的代码从而造成了环境无法正常启动,阻塞开发调试。因此这段时期我们决定借鉴社区的经验,实现前后端分离开发。

要进行分离开发我们首先要解决几个问题:

  1. 需要webserver发布前端页面和资源
  2. 需要mock数据模拟对后台发起的Ajax请求的响应
  3. 需要提供自动化构建方案来替代之前的线上自动化构建方案解决性能优化和上线部署的问题

前面两个问题的解决方案比较简单,如下图所示:

自己开发了一个简单的支持按照url类型进行路由的功能,并在本地提供后端接口的mock数据文件,比如一个典型的mock数据文件是这样的:

{
    "enabled": true,
    "value": "success",
    "success": {
        "flag": "0",
        "msg": [],
        "data": [{
            "id": 1,
            "name": "xx健"
        }]
    },
    "error": {
        "flag": "1",
        "msg": ["input error"],
        "data": []
    }
}

针对第3个问题,当时采用了比较流行的Grunt来做构建任务管理,引入了模块化处理、js合并压缩和混淆、css合并压缩、以及静态资源版本追加MD5版本号等插件来实现上线前的性能优化。

持续集成仍然是通过在后端Java工程中的pom文件里添加了一个前端构建的sh脚本调用的插件来实现的,最终上线仍然是和后端打成一个war包进行上线。

通过前后端分离改造,前端基本上实现了无阻塞的开发,同时也将前端代码重构成一个标准的Node.js工程,通过package.json来描述工程和管理项目的依赖,通过npm scripts脚本实现了开发、测试、构建等流程的自动化。

3.3 前端工程化架构升级

前后端分离开发为前端开发人员带来了优雅的体验,解决了一直以来技术栈耦合、环境依赖的问题,使得前端开发的效率得到大大的提升。不过,与此同时,新技术新**也带来了新的挑战:

  1. npm仓库访问不稳定的问题
  2. npm仓库发布私有包需要收费
  3. 各个产品线在mock数据和构建方案上没有统一的规范,持续集成也存在多种方案
  4. 新同学上手成本高,开发不同产品线需要学习多种方案、多种新技术
  5. 随着Node.js的发展和ES2015规范的持续完善,社区倾向于在浏览器端和服务器端都使用CommonJS规范进行模块化开发,以便在不久的将来过渡到ES2015 Module规范。

随着业务的持续迭代和人员的变更,上面的这些问题也变得日益凸显。如何在持续进化的技术架构和代码的可维护性以及开发流程的规范性之间寻找一种平衡也自然成为亟待解决的问题。

2015年初,经过大量的技术方案调研和评估,结合我们自身的系统情况和业务特点,我们做了一次比较大的工程化架构升级,着重解决当下遇到的这些问题。涉及到几个方面的工作:

  1. 搭建私有NPM仓库。解决线上机器外网隔离、访问速度、私有包发布、以及NPM生态存在的依赖版本不稳定的问题;
  2. 设计开发统一的前端脚手架工具bizdp。规范前端开发流程,统一入口和开发体验;解初始化脚手架、安装启动、mock数据、热更新、测试、构建等一系列工程化问题。
  3. 更新Mock数据方案,开发bizmock组件。提供多级自适应的mock服务,实现更加丰富的mock数据要求以及线上调试的方案。
  4. 引入Webpack,切换模块化方案到CommonJS

3.3.1 搭建私有NPM仓库

官方的镜像源一直被国内开发者诟病,主要原因就是速度太慢,网络不稳定,经常会有延迟、卡顿、失败的情况出现。对开发效率和体验的影响比较明显,虽然国内像淘宝很早就开源出国内的npm镜像源供国内开发者使用,一定程度上解决了速度的问题。但因为另外一个重要的原因,让我们必须自己搭建自己的私有NPM仓库:我们内网用于构建、部署上线的机器出于安全的原因都无法直接访问外网。

私有的仓库还可依提供更强大私有包发布功能:

{
    "name": "@bizfe/biz-dp"
}

可以把内部的公有模块全部抽离出来,统一按照规范发布到私服上。我们申请了两台硬盘比较大的机器,用于主备容灾,并制定了定期+按需镜像的策略,一定程度上可以避免类似leftpad事件这种问题的影响。

此外,我们还在上线时遇到过一个和semver机制相关的问题,我们某个项目的依赖中间接依赖了yeoman这个包:

"yeoman": "^1.0.1"

在某个时间点发布了一个bugfix版本,比如1.0.2,这个版本存在一些bug会导致项目构建失败,而仓库很及时地把1.0.2镜像过来了,正巧赶上项目已经测试通过等待上线的这个时间点,结果就遇到了上线构建失败的问题,经过反复的排查才定位到是因为semver机制和开源的人为不稳定因素导致的。虽然是小概率事件,但也反映出了npm的这个语义化版本依赖的机制所存在的潜在风险。

在如今看来,解决方案用 yarn 对依赖进行锁定再合适不过了,但在当时 npm-shrinkwrap 方案并不成熟,所以我们重新设定了私有仓库的镜像方案,延长了镜像周期,设置合理的镜像时间点避开上线高峰期,并确保测试构建和上线构建时仓库没有任何的变化。

3.3.2 开发统一脚手架工具bizdp

每一个前端团队为了提高开发效率,保障开发质量和可维护性都或多或少会指定一些规范,并开发一些工具来辅助落实这些规范。bizdp就是这样一个角色,bizdp的设计目标是前端开发人员只需要使用这一个cli工具就可以完成前端开发从0到上线的所有流程。

这中间包含了很多功能和需求:

  1. 常用的脚手架生成服务
  2. 前后端分离的项目安装启动服务
  3. 热更新服务
  4. mock数据服务
  5. 构建及测试、静态检查等功能
  6. 部署

bizdp的设计**是:“不重复造轮子,只是优雅地使用轮子”。因此任何技术架构的系统都可以很方便地接入bizdp作为团队的统一的开发接口,目前商业产品数十个前端系统都是基于bizdp进行前端开发的。

bizdp的脚手架生成服务提供了多种宿主环境(pc、移动浏览器、移动os)下数十种前端脚手架的生成服务,这些脚手架项目不仅提供了经过线上环境验证的最佳实践,而且具备统一的UI设计风格,内部项目可以基于这些脚手架实现快速的项目搭建。各团队也可以基于自己团队的架构提供适合自己的脚手架集成。

bizdp目前还在持续的迭代演进,未来将提供更加简洁优雅的使用接口,隐藏更多的非业务相关配置和功能,让开发人员只需关注开发本身即可。

3.3.3 更强大的mock组件

业界有非常丰富的前后端分离实践,自然也产出了很多mock数据的解决方案。总结起来,无非就是三大类:

  1. 静态文件

    通过node server 拦截ajax接口请求,并根据规则读取本地静态接口数据文件响应给客户端

  2. 动态模板

    通过node server拦截ajax接口请求,根据规则读取接口对应的模板文件,使用工具生成和模板对应的动态数据。

  3. 真实环境

    通过模拟登陆,实现拉取线上、QA、后端开发环境的数据返回给客户端。

这三种方案各有优缺点,也各有适用的场景:

方案 优点 缺点
静态文件 简单、灵活、高效;不依赖后端环境; 数据缺乏逻辑性,很多需要有数据联动的逻辑很难测试;接口变更需要修改文件;不方便模拟边界值情况
动态模板 模拟真实的数据场景,对边界值问题很容易模拟;不依赖环境; 编写模板有一定成本,接口变动也需要相应修改模板文件;缺乏数据逻辑性
真实环境 数据有逻辑性,易于发现问题;可以方便线上调试 环境通常滞后于开发,新接口环境通常只有在开发完成的时候才可以提供;

基于实际的mock需求,我们开发了biz-mock组件来实现这三种方案的整合:

3.3.4 webpack构建升级

起初我们只是想把模块化方案从amd和cmd迁移到commonJS,像browserify这样的包就可以解决这个需求,使用grunt+browserify理论上是个可行的方案。但鉴于grunt配置文件难以阅读和维护,其本身也仅仅是一个task runner的实现,在处理很多诸如图片缓存、模板、以及一些特殊的静态资源时不得不自己去社区寻找解决方案,而且方案的质量也参差不齐。相比之下,facebook推出的webpack的**显得技高一筹:webpack万物皆模块的**,加上强大的依赖分析和code split功能,再配上webpack-dev-server和HMR技术,无疑是复杂web前端系统的最佳选择。

因此在总结完最佳实践后,我们把所有的项目统一由grunt切到了webpack。

4. 组件化时代

每一种技术、**、框架的流行必定有其所在时代的合理性。模块化时代的洗礼让我们习惯性去思考模块之间的边界,在那个时代,无疑为我们解决了很多不必要的麻烦。然而随着业务的持续迭代,当模块数量上升到数百个甚至上千个时,而且模块之间的各种依赖变得错综复杂,行为之间的联动难以预测时,每迭代一次都如履薄冰,经常出现一些难以预料的bug。

我们尝试用 pub/sub 的设计模式来解决模块之间耦合和通信,尝试通过一些 AOP 的方案来实现复杂的监控埋点需求,尝试通过一些组件化的方案来进行界面的拼装。情况得到相当的好转,不过在实际开发过程中我们发现,约定式的规范在人员变更时、培训不充分时通常会遇到执行困境。

如今Web前端已然进入组件化时代,各种组件化的方案也都有了大量的成功实践,综合来看,React+Redux给我们提供了非常理想的解决思路,其本质仍然是pub/sub模式,只是redux更进一步,在**层面进行角色(action、reducer)和职责的拆分,使用单一对象作为整个应用的state,并在数据流转上通过单一的模式进行控制。而最关键的是,这些行为通常是在框架层面做了约束和控制的,开发人员不得不去这么做,否则应用便运行不起来,这无疑很好地解决了我们遇到的问题。

目前我们已经在部分系统中完成了react+redux的组件化升级改造,也积累了一些最佳实践,有机会再另开篇详聊吧。

这个时期,我们也在工程化方面做了一些升级:

  1. 引入babel和polyfill全面支持ES2015+stage2的语法和API
  2. 引入css-module和资源就近依赖的组件管理规范
  3. 改造了前端代码部署的流程,支持后端不停服务的纯前端上线
  4. webpack配置管理和并行构建性能优化

4.1 前后端分离部署

前后端分离部署是我们在去年开始的一个基础架构改造的项目,主要目标是为了解耦前后端工程的依赖以及上线部署的依赖问题。之前一直是和后端Java工程一期打包成war包上线,改造之后,通过热更新的机制,我们可以实现后端无需重启服务的纯前端热更新上线。改造之后,前端人员自己管理自己的代码权限,不再受限于后端的版本管理和时间安排,真正做到了“想上就能上”。架构图如下所示:

5. 后记

聊到这里,已经介绍了我们从2009年一路走来所做的一些工作,我笼统地把它们都归为前端工程化的范畴,但并未对前端工程化这个词语下个明确的定义。社区对于这个词语的定义也莫衷一是,没有一个权威的定义,因此不妨聊一聊我个人对这个词的理解:

工程化中的工程应该来源于软件工程,这个工程包含了软件的生命周期所需要经历的过程,通俗点就是指代下面这个过程:

需求分析 - 设计 - 编码 - 测试 - 交付 - 维护

而对于开发而言,更多的是指:

编码 - 测试 - 交付 - 维护

工程化是个动词,指代的是把原来刀耕火种的工作纳入正规的软件工程管理的行为。而软件工程本身要解决的本质问题是:

  1. 保障软件产品的质量、可靠性和可交付性
  2. 提高软件产品的生产效率
  3. 降低软件产品的维护成本

对应到前端开发便是一切可以提高开发效率、降低维护成本、提高开发质量以及产品使用体验的事物和行为。比如:模块化、组件化、构建编译、性能优化、编码规范、开发流程优化、工具、测试部署发布等等。

根据上述分析,斗胆下个定义:

根据具体的业务特点,将前端GUI软件的开发交付流程、以及这个过程中所涉及到的技术、工具、经验等规范化、标准化、自动化的过程就是前端工程化。其目标是让前端开发能够自成体系,最大程度地提高前端工程师的开发效率和体验,提高开发质量和产品交付效果,并降低维护成本。

循着定义可知:工程化并不是一蹴而就的工作,而是随着前端行业的不断发展需要持续进化的工作。在如今一日三变的前端文艺复兴时代,各种**、标准、框架、工具层出不穷,如同这夜晚浩瀚的星空,我们唯有低头自省,深刻认识团队所处的环境、所解决的业务特点、成员的能力结构等等才能在仰望时找到东方那颗指路明星,在前端行业的斗转星移间走出一条属于我们自己的道路。

commented

good

commented

慢慢的回忆,点赞

总结的很好

2018 我为前辈点赞

cool

commented

mark,虽然已经2021