使用Node.js打造前端自动化构建平台 node.jsGrunt构建平台自动化CI
1.前言
最近项目组打算从Apache+PHP环境迁移到Node上,正好刚看完入门资料,想借此练练手,也方便整合先前基于Grunt的压缩合并任务,于是,大幕拉开……
首先,说明下需要完成的任务,也即使用Node所能够带来的好处:
-
熟悉的JS操作,从函数的使用,到JSON的处理,以及事件、异步编程,乃是前端所擅长的,移植到Node后,可以减少对原有后端语言(PHP、Java等)的依赖,组内成员容易上手;
-
整合打包任务,也即项目构建,包括:Less编译CSS、合并、压缩、JSLint、打包等,后续还包括JSDoc、JSUnit等,借助Node的异步特性,可方便移植到Web平台上,实现自动化构建,从而无需由前端组专门构建;
-
结合WebSocket,除项目构建外,也可以打造消息平台,从IM客户端移植到Web上;
-
……
Node能实现的远超原先的想象,其所带来的性能也超乎想象,从易用性,到功能完整性,无不略胜一筹。在讲求敏捷开发的时代,不失为优秀的平台。
2.缘由
项目最早的构建是基于Ant,每次变更,都需要上传SVN后,登录SecureCRT手动执行命令更新到静态服务器上;然后后期有了压缩和合并,因为是基于SeaJS,整个合并的过程并没有那么简单,讨论的方案是使用Grunt进行模块的合并和压缩处理。于是,构建过程变成三步曲:执行合并压缩脚本、上传、部署脚本。烦不胜烦!而且在维护多个分支时,容易遗漏或忘记,在每次发测时已发生过不止一次版本不匹配情况。
3.开始
在确定移植到Node后,基于对Node的了解,突然想到Node的机制非常适合将构建过程移到Web上,并且可任务化、图形化、实时化,何乐而不为?做成了,对项目组乃至整个前端组都是大功一件!>_<
于是,立马动工……
入门的过程忽略,主要在于对各种Grunt模块的了解,以及Node的文件操作等。
3.1.导出SVN
因为有现成的工具和命令行可以直接执行SVN更新、导出等操作,所以首选命令行。首先采用spawn直接尝试运行输出,发现能运行ipconfig等命令,却不能执行cd等基本命令,各种搜索后,给出的答案是调用cmd,也因此而了解了调用子进程的四种方式之间的区别(spawn、exec、fork、execFile)。确认能实现后,搜索相关的Grunt模块,组员推荐的是grunt-shell,但有乱码问题,而且无法(至少目前没有找到)解决,然后找到grunt-shell-spawn,但出现无法输出的问题,对比grunt-shell源代码后,想起在grunt官网资料上看到的一段话:
Chances are this is happening because you have forgotten to call the this.async method to tell Grunt that your task is asynchronous. For simplicity's sake, Grunt uses a synchronous coding style, which can be switched to asynchronous by calling this.async() within the task body.
Note that passing false to the done() function tells Grunt that the task has failed.
于是查看源代码,才恍然大悟,问题出在async配置上。
解决输出问题后,同样出现乱码问题,但现象和直接采用spawn类似,便借鉴后者的解决方法移植到模块配置上,并修改了模块源代码。因此顺带了解了iconv-lite模块。
看到屏幕上的输出,甚是欢喜,万事开头难,解决了第一步,已然胜利了大半。
导出SVN任务:
shell: { exportSvn: { command: 'svn export "<%= config.svn.repoUrl %>" <%= config.path.tmp.svn %> --username <%= config.svn.user %> --password <%= config.svn.password %>', options: { async: false, stdout: function(data) { process.stdout.write(iconv.decode(data, 'gb2312')); }, stderr: function(data) { process.stderr.write(iconv.decode(data, 'gb2312')); } } } }
3.2.文件替换
因为项目原先采用的是PHP和SHTML的语法,需要对文件包含等语句进行替换,以符合ejs的语法(也可不替换,但和注释语法混淆),同时,因为Node本身有中间件支持Less的解析,因此可以去除页面上解析Less的js包含语句,于是,采用replace模块实现如下:
replace: { shtml: { src: ['<%= config.path.dest.views %>/*.shtml'], overwrite: true, replacements: [{ from: /<!--#include\s*file="([\w\-\.]+)"\s*-->/ig, to: '{{ include $1 }}' }] }, less: { src: ['<%= config.path.dest.views %>/*.php'], overwrite: true, replacements: [{ from: 'stylesheet/less', to: 'stylesheet' }, { from: /"less\/([\w\-]+)\.less"/ig, to: '"less/$1.css"' }, { from: /(<script\stype="text\/javascript"\ssrc="js\/lib\/less\/[\w\-\\\/\.]+\.js"><\/script>)/ig, to: '<!--$1-->' }] } }
3.3.文件复制
项目原结构为JS、LESS、Img、Mockup和SHTML文件平级,移植到Node后,前三者在public下,和mockup平级,shtml、php等文件在views下(含二级目录),因此需要分别复制,使用copy实现如下:
copy: { views: { files: [{ expand: true, cwd: '<%= config.path.tmp.svn %>', src: ['*.*', '<%= config.path.src.views %>'], dest: '<%= config.path.dest.views %>' }] }, public: { files: [{ expand: true, cwd: '<%= config.path.tmp.svn %>', src: ['<%= config.path.src.public %>'], dest: '<%= config.path.dest.public %>' }] }, mockup: { files: [{ expand: true, cwd: '<%= config.path.tmp.svn %>', src: ['<%= config.path.src.data %>'], dest: '<%= config.path.dest.data %>' }] } }
3.4.Less解析
项目采用Less编译生成css文件(个人觉得,方便编写、调试,但生成的目标文件太过庞大,也多少会影响性能),于是加入Less的编译任务(分前台和后台):
less: { front: { options: { compress: true, cleancss: true, report: 'gzip' }, files: [{ cwd: '<%= config.path.dest.public %>', expand: true, src: 'less/all.less', dest: '<%= config.path.dest.public %>', rename: function(dest, src) { return dest + src.replace('.less', '.css'); } }] }, admin: { options: { compress: true, cleancss: true, report: 'gzip' }, files: [{ cwd: '<%= config.path.dest.public %>', expand: true, src: 'less/all-admin.less', dest: '<%= config.path.dest.public %>', rename: function(dest, src) { return dest + src.replace('.less', '.css'); } }, { cwd: '<%= config.path.dest.public %>', expand: true, src: 'less/all-new-admin.less', dest: '<%= config.path.dest.public %>', rename: function(dest, src) { return dest + src.replace('.less', '.css'); } }] } }
3.5.合并压缩
改动较大,基于grunt-cmd-transport、grunt-cmd-concat、grunt-contrib-uglify改造而来,主要思想是递归合并require包含的模块文件,并提供配置以排除不需要合并的文件,以及提供压缩和非压缩之间的切换(URL+Cookie实现)。任务配置略,见完整Gruntfile.js配置。
3.6.JSLint
组员推荐JSHint,在于配置的灵活性,但个人认为,JSLint也可以通过声明等形式进行个性配置,更重要的在于已经有现成的可视化工具提供页面形式的输出,相比JSHint的控制台或文件输出,显然前者更为人性化,且可以结合Node,以Web形式访问,而且全组可查看校验结果。
任务配置如下:
shell: { jslint: { command: '<%= config.path.jslint.disk %>&cd <%= config.path.jslint.bin %>&run.bat', options: { async: false, stdout: function(data) { process.stdout.write(iconv.decode(data, 'gb2312')); }, stderr: function(data) { process.stderr.write(iconv.decode(data, 'gb2312')); } } } }
主要有个问题,因为JSLint的配置文件和JS源代码文件、结果输出文件不在同一目录,因此,在其结果输出文件名中便包含“..”,从而导致res.render和res.sendfile调用失败,返回404或403。网上查找资料,没有找到相关解决信息,于是显式地增加了两条路由,控制台调试输出显示路由匹配成功,但仍然返回404。于是继续尝试更改文件名的形式,增加了rename的任务,无奈rename同样失败,而且rename任务无法配置src和dest为正则形式。只好另寻其他方案,尝试调用fs.exists,返回true,说明file调用能够操作此文件,便试着通过readFile的形式输出,一举成功!兴奋!
3.7.压缩打包
此任务用于提供给后端进行部署,剔除页面文件,只包含js、图片、样式等,如下:
zip: { publish: { cwd: '<%= config.path.dest.public %>', src: ['<%= config.path.src.zipfiles %>'], dest: '<%= config.path.publish %>/<%= config.info.name + "-" + config.info.version + "-" + grunt.template.today("yyyymmddHHMMss") + ".zip" %>' } }
4.TODO
至此,预先制定的构建任务已全部完成,剩余的便是任务执行的定制化(以避免全部执行)、不同版本分支的构建、页面排版优化、消息机制引入(构建时通知全组)、构建结果邮件通知、代码量计算等等。
5.结束语
Grunt使用下来,相比Ant,确实前者更为容易配置、执行,而且结合丰富的各种模块和Web,几乎能实现各种功能。试想,若是用PHP或Java和Ant构建一套Web构建平台,不知要做多少工作?……
附1:完整构建任务配置
/*jslint es5:true*/ /*global process*/ /** * 构建任务配置 * @author Fuyun * @version 1.0.0(2014-04-06) * @since 1.0.0(2014-04-03) */ module.exports = function(grunt) { //@formatter:off 'use strict'; //@formatter:on var iconv = require('iconv-lite'); grunt.initConfig({ pkg: grunt.file.readJSON('package.json'), config: grunt.file.readJSON('config.json'), shell: { exportSvn: { command: 'svn export "<%= config.svn.repoUrl %>" <%= config.path.tmp.svn %> --username <%= config.svn.user %> --password <%= config.svn.password %>', options: { async: false, stdout: function(data) { process.stdout.write(iconv.decode(data, 'gb2312')); }, stderr: function(data) { process.stderr.write(iconv.decode(data, 'gb2312')); } } }, jslint: { command: '<%= config.path.jslint.disk %>&cd <%= config.path.jslint.bin %>&run.bat', options: { async: false, stdout: function(data) { process.stdout.write(iconv.decode(data, 'gb2312')); }, stderr: function(data) { process.stderr.write(iconv.decode(data, 'gb2312')); } } } }, replace: { shtml: { src: ['<%= config.path.dest.views %>/*.shtml'], overwrite: true, replacements: [{ from: /<!--#include\s*file="([\w\-\.]+)"\s*-->/ig, to: '{{ include $1 }}' }] }, less: { src: ['<%= config.path.dest.views %>/*.php'], overwrite: true, replacements: [{ from: 'stylesheet/less', to: 'stylesheet' }, { from: /"less\/([\w\-]+)\.less"/ig, to: '"less/$1.css"' }, { from: /(<script\stype="text\/javascript"\ssrc="js\/lib\/less\/[\w\-\\\/\.]+\.js"><\/script>)/ig, to: '<!--$1-->' }] } }, copy: { views: { files: [{ expand: true, cwd: '<%= config.path.tmp.svn %>', src: ['*.*', '<%= config.path.src.views %>'], dest: '<%= config.path.dest.views %>' }] }, public: { files: [{ expand: true, cwd: '<%= config.path.tmp.svn %>', src: ['<%= config.path.src.public %>'], dest: '<%= config.path.dest.public %>' }] }, mockup: { files: [{ expand: true, cwd: '<%= config.path.tmp.svn %>', src: ['<%= config.path.src.data %>'], dest: '<%= config.path.dest.data %>' }] } }, less: { front: { options: { compress: true, cleancss: true, report: 'gzip' }, files: [{ cwd: '<%= config.path.dest.public %>', expand: true, src: 'less/all.less', dest: '<%= config.path.dest.public %>', rename: function(dest, src) { return dest + src.replace('.less', '.css'); } }] }, admin: { options: { compress: true, cleancss: true, report: 'gzip' }, files: [{ cwd: '<%= config.path.dest.public %>', expand: true, src: 'less/all-admin.less', dest: '<%= config.path.dest.public %>', rename: function(dest, src) { return dest + src.replace('.less', '.css'); } }, { cwd: '<%= config.path.dest.public %>', expand: true, src: 'less/all-new-admin.less', dest: '<%= config.path.dest.public %>', rename: function(dest, src) { return dest + src.replace('.less', '.css'); } }] } }, zip: { publish: { cwd: '<%= config.path.dest.public %>', src: ['<%= config.path.src.zipfiles %>'], dest: '<%= config.path.publish %>/<%= config.info.name + "-" + config.info.version + "-" + grunt.template.today("yyyymmddHHMMss") + ".zip" %>' } }, clean: { beforeExport: ['<%= config.path.tmp.svn %>'], beforeTransport: ['../public/js/pages-min', '../public/js/pages-admin-min'], afterUglify: ['../public/.transport', '../public/.concat', '../public/js/pages-min/global.js', '../public/js/pages-admin-min/global.js'] }, transport: { options: { paths: ['../public/js'], debug: false, alias: grunt.file.readJSON('alias.json') }, // 前台页面模块的转换 frontPages: { files: [{ expand: true, cwd: '../public/js/pages', src: '*.js', dest: '../public/.transport/pages' }] }, // 后台页面模块的转换 adminPages: { files: [{ expand: true, cwd: '../public/js/pages-admin', src: '*.js', dest: '../public/.transport/pages-admin' }] }, // 辅助库模块的转换 lib: { files: [{ expand: true, cwd: '../public/js', src: 'lib/**/*.js', dest: '../public/.transport/' }] } }, concat: { // 前台页面模块的合并 frontPages: { options: { paths: '../public/.transport/', include: 'relative', // 把global模块合并到每个模块中 globalModule: 'pages/global.js', footer: 'seajs.use(["global"]);', logPath: 'concat.log' }, files: [{ expand: true, cwd: '../public/.transport/pages', src: '*.js', dest: '../public/.concat/pages' }] }, // 后台页面模块的合并 adminPages: { options: { paths: '../public/.transport/', include: 'relative', // 把global模块合并到每个模块中 globalModule: 'pages-admin/global.js', footer: 'seajs.use(["global"]);', logPath: 'concat.log' }, files: [{ expand: true, cwd: '../public/.transport/pages-admin', src: '*.js', dest: '../public/.concat/pages-admin' }] } }, uglify: { options: { banner: '/*! <%= pkg.name %> - v<%= pkg.version %> - <%= grunt.template.today("yyyy-mm-dd HH:MM:ss") %> */\r\n', }, frontPages: { files: [{ expand: true, cwd: '../public/.concat/pages', src: '*.js', dest: '../public/js/pages-min' }] }, adminPages: { files: [{ expand: true, cwd: '../public/.concat/pages-admin', src: '*.js', dest: '../public/js/pages-admin-min' }] } } }); grunt.loadNpmTasks('grunt-shell-spawn'); grunt.loadNpmTasks('grunt-text-replace'); grunt.loadNpmTasks('grunt-contrib-copy'); grunt.loadNpmTasks('grunt-contrib-clean'); grunt.loadNpmTasks('grunt-cmd-transport'); grunt.loadNpmTasks('grunt-cmd-concat'); grunt.loadNpmTasks('grunt-contrib-uglify'); grunt.loadNpmTasks('grunt-contrib-less'); grunt.loadNpmTasks('grunt-zip'); grunt.registerTask('default', ['clean:beforeExport', 'shell:exportSvn', 'copy', 'replace', 'shell:jslint', 'less', 'clean:beforeTransport', 'transport', 'concat', 'uglify', 'clean:afterUglify', 'zip']); };
附2:Socket.io脚本
$(function () { var socket = io.connect('http://127.0.0.1'), $log = $('#buildLog'); socket.on('ready', function(data){ $log.html($log.html() + data); }); socket.on('log', function(data){ data = data.replace(/\[\d{1,2}m/ig, ' '); data = data.replace(/\n/ig, '<br/>'); $log.html($log.html() + data); }); socket.on('error', function(data){ $log.html($log.html() + '<br/>Error: ' + data); }); socket.on('finish', function(data){ $log.html($log.html() + data); socket.disconnect(); }); $('#doBuild').click(function(e){ $log.html(''); socket.socket.reconnect(); socket.emit('doBuild', ''); }); });
附3:页面截图
首页:移植到Node后,根据文件名转为树状形式输出:
构建执行结果输出(不含更新SVN):
构建完成后,生成打包文件,提供页面供对历史打包归档文件的列表访问以及下载: