纵观世界历史,生产工具的进步一次次地推动着人类社会的发展。在Web领域,最近几年也出现了一种新的生产工具栈,MEAN(MongoDB,Express,AngularJS,NodeJS),虽然其无法完全取代LAMP(Linux,Apache,MySql,PHP)/LNMP(Linux,Nginx,MySql,PHP),但确实是一种更先进的生产方式。

在MEAN技术栈中,NoSql优等生MongoDB取代了传统的关系型数据库劳模MySql。MongoDB官网上有其与Mysql的功能对比。相比MySql,MongoDB的唯一缺点是没法支持复杂的事务,在现实的互联网应用场景中,我们面对更多的是高并发、非事务性的业务;而对于一些简单事务场景,MongoDB也是可以应对的,请参考关于mongo原子操作的探讨。MongoDB天生是分布式的,支持自动分片(Auto-Sharding)、复制集(Replication Set),使得其可以很好的满足大数据时代背景下,超高并发、自动容错的存储要求。

传统WEB技术(LAMP/LNMP)中的PHP,被NodeJS取代。本质上讲,PHP并不算后端语言,其主要目的是为了从各个数据源获取数据,拼接处理完成后,返回给页面,因此我们也经常将PHP称为“胶水语言”。PHP语言特性决定了其只能串行地从各个数据源获取数据,因此其响应时间为各个数据源的响应时间之和。而NodeJS的异步特性,使得其可以并发的请求多个数据源,其响应时间为各个数据源的响应时间之中最长的。相比PHP,NodeJS更适合作为“胶水语言”

MEAN中的Express,AngularJS分别为后端WEB框架和前端SPA(Single Page Application)开发框架。

现代化的WEB生产方式需要有清晰的架构、高效的调试方法、规范的测试方法、以及工程化的构建方法。下面,我们就从这些方面了解基于MEAN开发的WEB开发方式,本文要求读者有传统LAMP/LNMP开发经验,同时熟悉NodeJS,并了解MongoDB。

1.MEAN基本代码框架

NodeJS是一个强社区驱动的技术方案,除了核心模块外,大部分功能需要第三方模块支持,这就导致一个WEB应用可能有几十个模块,对于初学者,有较高的学习门槛,而一个标准的项目模板是一个比较好的入门方式。《MEAN WEB开发》一书从无到有构建了一个规范的MEAN项目,非常适合作为种子项目,该书的源代码请从github上下载,本文也可以看作该书的读书笔记。让我们先从代码组织架构入手。

(1). 服务端MVC模型

NodeJS实现了CommonJS规范,其天生就支持现代化的模块化开发理念;而基于Express的中间件的架构,我们可以很方便地将常用功能(比如用户权限验证、日志模块)抽象为中间件模块。我们将服务端的代码组织为:

app/
    controllers/
    models/
    views/
    routes/
    config/
    tests/

这里使用ejs作为view层的模板引擎,使用Mongoose作为Model层的ORM(对象关系映射)框架,每一个controller对应的独立express路由文件放在routes文件夹中,config文件夹存放各个中间件(passport、Mongoose)的配置,服务端单元测试脚本在tests文件夹中。

(2). 客户端MVVM模型

客户端的基本框架是AngularJS,其本身也是将模块化理念作为其设计之本。我们将客户端代码组织如下:

public/
    articles/
        aritcle.client.modules.js
        controllers/
        views/
        config/
        services/
        test/
            unit/
            e2e/
    users/
    lib/
    application.js

所有前端代码存储在public中,对应服务端的每一个controller,客户端都有一个相应的模块,每个模块放在单独的文件夹中,比如articles和users文件夹。以articles模块为例,其中包含模块定义文件aritcle.client.modules.js、控制器controllers、视图模板views、路由文件config、以及需要提供给其他模块使用的service,其中test文件夹用来存放单元测试脚本和端到端测试脚本。application.js是前端文件的入口,用于加载各个模块(比如articles和users)。

2.高效的调试

只有高效的调试方式才能适应互联网web产品的快速迭代的需求。下面我们分别介绍基于MEAN开发WEB应用过程中的前端和后端调试方法。

(1). 使用node-inspector调试服务端代码

借助于node-inspector,我们可以像调试浏览器端JS代码一样调试服务端代码。首先安装node-inspector。

npm install -g node-inspector

然后启动node-inspecor,并在启动WEB应用时使用--debug参数

$: node-inspector
Visit http://127.0.0.1:8080/?port=5858 to start debugging.
$: node --debug server

这里的端口是可以修改的。使用chrome打开http://127.0.0.1:8080/?port=5858,我们就可以像调试常规前端JS文件一样调试服务端代码了。

(2). 使用Batarang调试AngularJS

在调试前端JS代码时,我们经常需要对AngularJS内部进行调试,而由于AngularJS的过度封装,对其内部调试往往很棘手。为此,AngularJS团队开发了一款名为Batarang的Chrome插件。安装完Batarang插件后,用chrome打开基于AngularJS框架的页面时,使用开发者工具面板,就会出现了一个新的AngularJS标签页,使用该标签可以结构化的预览AngularJS各个Scope的变量,并可以查看AngularJS的性能。

chrome-angular.png

3.规范的测试

规范化的测试是项目持续健康发展的根基,这里我们主要介绍服务端单元测试。对于服务端,我们采用Mocha测试框架,Mocha提供了简洁的接口,可以方便的实现单元测试:

describle(description,callback); //描述和封装测试集,callback用于封装测试逻辑
it(description,callback); //描述测试指标,callback用于封装测试逻辑
before、after、beforeEach、afterEach等4个钩子函数用来在测试的不同阶段执行

其中describle用于描述一个单元测试,it用于测试具体的逻辑,一个describle测试单元可以包含多个it测试,只有当所有被包含的it测试都通过时,describle描述的测试单元才能通过。此外,Mocah框架还提供了四个钩子函数,用于在测试前中做一些初始化的工作和在测试后做一些清理工作。

Mocha测试框架并没有提供断言库,因此缺少对测试结果进行逻辑判断的功能。通过使用Should.js提供的语义化接口,我们可以方便对验证代码逻辑的结果:

user.should.be.an.Object.and.have.property('name');

Should.js可以非常方便的测试对象,但却无法对HTTP结果进行测试,我们需要SuperTest断言库来测试HTTP请求的结果。SuperTest断言库也提供了语义化的接口:

request(app).get('/user')
    .set('Accept','application/json')
    .expect('Content-type',/json/)
    .expect(200,done);

通过使用Mocha、Should.js和SuperTest组成的工具集,我们可以方便的实现服务端的单元测试,一个单元测试的例子如下:

//创建Article Model的测试集
describe('Article Model Unit Tests:', function() {  
    //每次测试前执行的勾子函数
    beforeEach(function(done) {
        //创建测试过程中所需要的数据   
        done();
    });

    //测试article是否能够存储成功
    describe('Testing the save method', function() {
        //测试正常文章存储逻辑
        it('Should be able to save without problems', function() {
            article.save(function(err) {
                should.not.exist(err);
            });
        });

        //测试异常文章存储逻辑
        it('Should not be able to save an article without a title', function() {
            article.title = '';
            article.save(function(err) {
                should.exist(err);
            });
        });
    });

    // 每次测试后执行的勾子函数
    afterEach(function(done) {
        //清除测试过程中产生的数据
        done();
    });

运行测试用例:

mocha --reporter spec app/tests

正常情况下,该测试的结果是:

 Article Model Unit Tests:
    Testing the save method
      √ Should be able to save without problems
      √ Should not be able to save an article without a title
 2 passing (135ms)

对于前端,主要采用我们熟悉的AngularJS单元测试和e2e测试,请参考这篇博客

4.自动化的项目流程

前端工作的特点决定了其工程问题多于技术问题,因此在开始实际的项目代码开发之前,应该首先做好项目流程的自动化。前端项目构建工具很多,包括Grunt、Gulp、WebPack以及国内的fis等。一个完善的自动化项目流程主要包括:

自动化构建:js、css代码检查,前端js、css合并、md5戳生成、雪碧图生成、上传的CDN等重复性操作
自动化调试:通过添加特定的参数,比如--debug,可以方便的调试应用
自动化测试:每次运行应用运用之前,将单元测试集运行一遍,确保功能正常
自动化运行:以上三个步骤完成后,就可以运行应用了

何为高性能动画?让人感觉流程顺滑即可。24fps的电影就能让人感觉到流畅,但是游戏却要60fps以上才能让人感觉到流畅。分析原因,我们得出如下结论:

(1)视频的每一帧记录的是一段时间段(1/24s)的信息,而游戏的每一帧都由显卡绘制,它只能生成一个时间点的信息;
(2)视频的帧率是稳定的,而在系统负载不平稳时,显卡很难保证游戏帧率的稳定性;

前端动画与游戏的原理类似,我们设计高性能动画的基本思路就是提高帧率稳定帧率。让我们首先一起了解一下浏览器渲染页面的基本过程。

1.理解浏览器渲染流水线

渲染的基本流程是:扫描HTML文档结构、计算对应的CSS样式并生成RenderTree,然后根据RenderTree进行布局和绘制,基本过程示意图如下:

webkit flow

为了更简单的分析和定位渲染性能问题,我们将渲染流程抽象为五大步骤:

render flow

(1).Recalculate Style: 流水线中的第一步通常是使用javascript计算出需要如何操作DOM结构、并计算节点的最终样式规则

(2).layout:第二步通常是根据节点的css规则,来计算节点在屏幕上位置和尺寸。由于页面是按着文档流从上到下、从左到有布局地,一个节点的布局发生变化,可能使得多个节点重新布局

(3).update layer tree:一个页面可能有多个渲染层,layer tree用来维护各个渲染层的顺序

(4).paint:绘制本质上就是填充像素的过程。包括绘制文字、颜色、图像、边框和阴影等,由此确定一个DOM元素所有的可视效果。绘制一般是在多个层(layer)上同时进行。

(5).composite: 在多个层上分别完成绘制后,浏览器会按各个绘制层的正确顺序(layer tree中维持了各个图层的顺序)拼合成一个图层,最终显示在屏幕上。

理论上每一帧都要经过渲染流水线的处理,但渲染流水线中的有些步骤是可以跳过的。我们只修改节点的不影响布局的属性(背景图片、阴影等)时,就不需要重新layout了:

render-flow-exlucde-layout

如果修改不触发绘制(直接在GPU中完成)的样式,比如transform、opacity等,甚至连paint都不需要了:

render-flow-exlucde-layout-and-paint

2.监控动画性能

(1) 使用chrome开发者工具

我们必须首先学会如何对动画的性能指标(帧率数、帧率稳定性)进行监控,才能有针对性的提高动画的性能。chrome开发者工具中的timeline是绝佳的工具,我们可以查看每一帧都经过渲染流水线的哪些步骤:

chrome-timeline

上图中,我选中了其中一帧,可以从最底部的Event Log中看到这一帧没有经过渲染流水线中的layout和paint阶段。

(2) 通过时间戳计算帧率

chrome开发者工具中的timeline最大的问题就是其本身比较消耗资源,在开启timeline后,动画的帧率下降明显,因此其数据可能无法反映动画的正常运行情况。如果只是需要统计帧率,可以通过记录绘制每个帧消耗的时间来计算,第三方库stats.js帮我们做了这些事情。下面是一个可视化的例子:

JS Bin on jsbin.com

3.提高动画性能指标

上文提到过,动画的性能指标有两个,帧率数和帧率稳定性。我们分别从动画实现,节点的处理,属性的选择等方面讨论如何提高这两个动画性能指标。

(1).选择稳定的实现方式

css3动画使用起来非常简单,目前的浏览器支持率也不错,足以应对一般的交互需求,我们应该优先使用它。当浏览器不支持css3时,或动画场景过于复杂而仅凭css3无能为力时,就需要引入js来帮忙了。我们最常想到的js动画的实现方式,就是固定时间间隔修改元素的样式:

setInterval(function(){
    var anmationNode = document.getElementById('animation-node'); 
    //定期修改节点的样式
}, 100)

但这是一种非常粗暴的方式,其弱点是很明显的。浏览器的timer的触发时间点是不固定的,如果遇到比较长的同步任务,其触发时间点就会推迟,显然也就保证不了动画帧率的平稳性。HTML5为创建逐帧动画提供了一个新的API:RequestAnimationFrame,该方法在每次浏览器渲染时触发,其触发频率为60fps,我们可以通过这个函数来实现动画,而当动画中某些帧计算量太大无法在1/60s完成时,浏览器会将刷新评论降低到30fps,以保证帧率的稳定性。

function step(){
    //修改节点样式
    RequestAnimationFrame(step);
}
RequestAnimationFrame(step);

但是由于RequestAnimationFrame支持程度还不高(手机浏览器普遍不支持),我们可以结合RequestAnimationFramesetInterval实现一套逐渐增强和优雅降级的方案,下面是兼容各个浏览器的终极版本:

function getAnimationFrame() {
    if (window.requestAnimationFrame) { //较新浏览器
        return {
            request: requestAnimationFrame,
            cancel: cancelAnimationFrame,
        }
    } else if (window.mozRequestAnimationFrame && window.mozCancelAnimationFrame) { //firfox浏览器
        return {
            request: mozRequestAnimationFrame,
            cancel: mozCancelAnimationFrame
        }
    } else if (window.webkitRequestAnimationFrame && webkitRequestAnimationFrame(String)) {
        return {
            request: function(callback) {
                return: window.webkitRequestAnimationFrame(function() {
                    return callback(new Date - 0); //修正部分webkit版本下没有给callback传time参数的bug
                });
            },
            cancel: window.webkitCancelAnimationFrame || window.webkitCancelRequestAnimationFrame
        }
    } else { //用setInterval模拟requestAnimationFrame
        var millisec = 25; //40fps;
        var callbacks = [];
        var id = 0,
            cursor = 0;
        var timerId = null;

        function playAll() {
            var cloned = callbacks.slice(0);
            cursor += callbacks.length;
            callbacks.length = 0;
            var hits = 0;
            for (var i = 0, callback; callback = cloned[i++];) {
                if (callback !== 'cancelled') {
                    callback(new Data - 0);
                    hits++;
                }
            };
            if (hits == cloned.length) {
                clearInterval(timerId);
            }
        }

        timerId = window.setInterval(playAll, millisec);
        return {
            request: function(handler) {
                callbacks.push(handler);
                return id++;
            },
            cancel: function() {
                callbacks[id - cursor] = 'cancelled';
            }
        }
    }
}

(2).为动画节点创建新的渲染层

通过将动画节点与文档中的其他节点隔离开来,可以有效的减少重新布局(relayout)和重新绘制(repaint)的面积,从而提高页面的整体性能。隔离动画节点与文档中的其他节点方法通常是为动画节点创建新的渲染层(render layer)。下面是创建渲染层的常用方法:

<1> 使用3D变换

大家一定经常看到网上的文章说使用transform: translate3d(0, 0, 0)/translateZ(0)可以开启GPU加速,亲自试验以后发现其的确可以提高页面的渲染速度,我就曾经用它解决了一些低端机的闪烁问题。 那么其原理是什么呢?这种方式并非一定能够开启GPU加速。

W3C标准是这么说的。

Three-dimensional transforms can result in transformation matrices with a non-zero Z component (where the Z axis projects out of the plane of the screen). This can result in an element rendering on a different plane than that of its containing block. This may affect the front-to-back rendering order of that element relative to other elements, as well as causing it to intersect with other elements.

其主要意思就是3D变换会创建新的渲染层,而不是与其父节点在同一个渲染层中。在新的渲染层中修改节点不会干扰到其他节点,防止了对其他节点的重新布局(relayout)和重新绘制(repaint),自然也就加快了页面的渲染速度。除了transform: translate3d(0, 0, 0)/translateZ(0),我们还可以使用will-change

<2> 使用will-change

我们可以使用will-change让浏览器提前了解预期的元素变换,它允许浏览器提前做好适当的优化,使之最后能够快速和流畅的渲染。will-change: transform同样也会为节点创建新的渲染层。 .animation-element{ will-change: transform; }

我们可以通过chrome的开发者工具中timeline的layers标签,看到当前帧的渲染层。如下图:

chrome-layers

上图中右侧有对创建layer原因的描述: has a will-change hint。但是管理渲染层是有成本的,过多的渲染层可能会降低页面的渲染速度,因此我们应该避免滥用渲染层。

(3).选择高效的动画属性

修改节点的大部分属性都会引起重新绘制,甚至是重新布局。而理想情况下,我们应避免重新绘制和重新布局。幸运的当仅仅修改transfrom属性或opacity属性,可以做到不重新绘制。具体的思路是:为需要创建动画的节点创建新的渲染层,并且在新渲染层中只修改transformopacity属性。只有做到以上两点才可以避免重新布局和重新绘制,真正使用GPU加速。

(4).避免引起多余的渲染

我们在实现动画的过程中,经常需要获取某个元素的属性,然后对该属性做出修改:

function step(){
    var animationNode = doucment.getElementById('animation-node');
    for(var i = 1; i <= 20 ; i++){
        animationNode.width = animationNode.width + 1
    }
}

上述的for循环语句将导致浏览器进行20次多余的渲染,严重影响页面性能。通常来讲JS对页面样式的多次修改只会在页面下次刷新时渲染一次,而通过DOM API获取样式时,会强制页面完成一次渲染以体现最新修改后的值。上述例子就是这样导致浏览器多次渲染的。而正确的写法应该是读写分离。

var animationNode = doucment.getElementById('animation-node');
var initialWidth = animationNode.style.width;
for(var i = 1; i <= 20 ; i++){
    initialWidth+=1
}
animationNode.style.width = initialWidth;

当我们在复杂页面上实现动画是,常常由于疏忽导致页面多余的渲染。这是我们可以借助fastdom来隔离对真实DOM的操作,fastdom将对节点样式的读写批量缓存、一次执行,防止多余的渲染。


参考资料

http://taligarsiel.com/Projects/howbrowserswork1.htm

http://matrix.h5jun.com/slide/show?id=117#/

http://melonh.com/sharing/slides.html?file=high_performance_animation#/

http://www.infoq.com/cn/articles/javascript-high-performance-animation-and-page-rendering

https://developers.google.com/web/fundamentals/performance/?hl=zh-cn

https://github.com/wilsonpage/fastdom

https://github.com/mrdoob/stats.js