前端构建工具的简易实现

虽然说之前用过gulp、webpack这种前端构建工具,但是自己也只是单纯的使用,从来没想过这些东西到底是怎样实现的。最近看了一节公开课,觉得很受启发,现在就说一下我的这个前端构建工具是如何实现的。

功能

开发环境

zst dev 实现保存代码自动刷新的功能。

生产环境

zst dist 这个实现的功能较多:
1. 自动生成dist目录
2. 合并压缩js、css添加时间戳输出到dist目录
3. 将favicon.ico文件复制到dist目录
4. 处理index.html文件,删除注释
5. 如果开发的是移动端页面,还会在index.html中插入移动端meta及处理rem的代码

用到的模块:

express、watch、command、fs、path、uglifyjs、uglifycss、cheerio、open、package、http、socket.io

开发

config

我们需要一个config对象来作为项目的配置

const config = {
    server: {
        ip: "http://localhost",
        port: 3000
    },
    input: "./src",
    output: "./dist",
    info: "INFO  ",
    isPhone: true, //是否是手机
};

server字段设置的是服务的ip跟端口,input字段是输入路径,output字段是输出路径,info字段是打印log的日志头,isPhone字段是是否是移动端。

开发环境

  1. 首先,我们需要创建一个http服务在3000端口,这里我们使用express这个框架来实现。
var app = require('express');
var server = require('http').createServer(app);
server.listen(config.server.port, function (req, res) {
    log('server start at '+config.server.ip+":"+config.server.port);
});
app.get('/', function (req, res) {
    res.send(getHtml(config.isPhone,config.input));
});

function getHtml(isPhone,path) {
    var path = path || './src';
    var devHtml = fs.readFileSync(path +'/index.html', 'utf-8') + fs.readFileSync('./socket.xml');
    if(isPhone) return fs.readFileSync('./isphone.xml') + devHtml;
    else return devHtml;
}

在这段代码中,server.listen是在config.server.port端口开启一个服务器,cb会在服务开启后执行。
app.get()是express的路由,当客户端访问到/时,返回给客户端什么内容。
getHtml方法最终返回的是加上处理自动刷新代码的index.html。
如果要开启服务器后自动打开客户端,可以使用open插件:

var open = require('open'); //自动打开浏览器
server.listen(config.server.port, function (req, res) {
    log('server start at '+config.server.ip+":"+config.server.port);
    open(config.server.ip+":"+config.server.port);
});
  1. 使用socket.io及watch
var io = require('socket.io').listen(server);
io.sockets.on('connection', function (socket) {
    watch.watchTree(config.input, function (f, curr, prev) {
        if (typeof f == "object" && prev === null && curr === null) {
            // Finished walking the tree
        } else { // f was changed
            socket.emit('file-change');
        }
    });
    log('client connected');
});

socket.io是websocket长链接通信的封装,之前在egret游戏中使用了websocket通信,但是没有使用socket.io。
这个就是当socket连接后,执行的操作,也就是使用watch插件进行文件的监控。使用方式都有注释,也可以从github上进行搜索。最后一个else中,socket.emit是自定义的事件名,文件改变。

在js中写了这个还需要在html中接收file-change事件。

/* socket.xml */
<script src="/socket.io/socket.io.js"></script>
<script>
    var socket = io.connect('ws://localhost:3000');
    socket.on('file-change', function (data) {
        location.reload();
    });
</script>

上面这段代码是单独放到一个xml文件中的,在上面的getHtml方法中,如果是开发模式的话,会在index.html中自动添加上这段代码。
如果要在手机中看的话,需要将socket变量改成本机的ip,mac电脑使用ifconfig查看,windows电脑使用ipconfig查看。
这里socket.on监听到file-change事件之后就刷新页面。

生产环境

生产环境的功能较多
1. 处理css

var uglifyCss = require('uglifycss'); //合并压缩css
var fs = require('fs'); //nodejs文件处理模块

function handleCss() { //处理css
    let uglifiedCss = uglifyCss.processFiles(getCssArr(), { maxLineLen: 500, expandVars: true}); // 合并压缩css
    var cssName = "./bundle." + new Date().getTime() + ".css"; //添加时间戳
    fs.writeFileSync(config.output+'/'+cssName.slice(1), uglifiedCss); //将合并的css文件写入bundle.css
    log('Create bundle.css succeed');
    $('head').append('<link href="'+cssName+'" rel="stylesheet"/>'); //在html中添加打包好的css、js文件
}

function getCssArr() { //获取css的href属性列表
    let cssArr = [];
    let link = $('link');
    let ico = $('link')[$('link').length - 1]; //找到favicon.ico的link标签
    // let ico = $('link').pop();
    ico.attribs.href = './favicon.ico';
    for(let i = 0, len = link.length - 1; i < len; i++) { //length-1是因为有favicon
        cssArr.push(config.input+link[i].attribs.href.slice(1));
    }
    link.remove(); //删除原来的link标签
    $('head').append(ico); //添加ico标签
    return cssArr;
}

这里用到了nodejs的filesystem跟uglifiycss插件。
这里需要先引入uglifycss,其中第一个参数是一个数组对象,存放的是所有要压缩合并的css文件。这个数组可以通过getCssArr方法得到。
nodejs的fs系统有个writeFileSync接受输出路径跟输出内容,会在指定路径生成文件,这里就配合new Date().getTime()输出了带有时间戳的css文件。
getCssArr方法中,因为我在index.html文件中写上了favicon.ico,所以这里需要将最后一个link标签也就是存放favicon.ico的标签单独处理。

  1. 处理js
    处理js跟处理css几乎是一模一样。
function handleJs() { //处理js
    let uglifiedJs = uglifyJS.minify(getJsArr(), { //合并压缩js
        compress: {
            dead_code: true,
            global_defs: {
                DEBUG: false
            }
        }
    });
    var jsName = "./bundle." + new Date().getTime() + ".js";
    fs.writeFileSync(config.output+'/'+jsName.slice(1), uglifiedJs.code);
    log('Create bundle.js succeed');
    $('body').append('<script src="'+jsName+'" /></script>');
}

function getJsArr() { //获取js的src属性列表
    let scriptArr = [];
    let script = $('script');
    for(let i = 0, len = script.length - 2; i < len; i++) { //length-2是因为会在html最后增添socket.xml中的script标签
        if(script[i].attribs.src) scriptArr.push(config.input+"/" + script[i].attribs.src); //如果有这个属性就添加到scriptArr中,之后会进行合并压缩
    }
    script.remove(); //删除原来的script标签
    return scriptArr;
}
  1. 复制icon
function handleIcon() { //处理favicon.ico
    let inputPath = config.input + '/img/favicon.ico';
    let outputPath = config.output + '/favicon.ico';
    let readStream = fs.createReadStream(inputPath);
    let writeStream = fs.createWriteStream(outputPath);
    readStream.pipe(writeStream);
    log('Create favicon.ico succeed');
}

这里用到的fs系统的读写文件流。

  1. 处理目录
function handleDir() { //删除原有目录,生成新目录
    let dirpath = config.output;
    if(fs.existsSync(dirpath)) { //监测目录是否存在
        delDir(dirpath); //存在就递归删除目录
        end = new Date().getMilliseconds();
        log('Delete '+dirpath.slice(2)+' dir succeed');
        fs.mkdirSync(dirpath); //生成目录
        end = new Date().getMilliseconds();
        log('Create '+dirpath.slice(2) + ' dir succeed');
    }else {
        fs.mkdirSync(dirpath); //生成目录
        end = new Date().getMilliseconds();
        log('Create '+dirpath.slice(2) + ' dir succeed');
    }
}

function delDir(path) { //递归删除生产环境输出目录
    var files = [];
    files = fs.readdirSync(path);
    files.forEach(function (file, index) {
        var curPath = path + '/' + file;
        if(fs.statSync(curPath).isDirectory()) {
            delDir(curPath);
        }else {
            fs.unlinkSync(curPath);
        }
    });
    fs.rmdirSync(path);
}

这个目录如果是不删除的话,每次使用zst dist命令都会重新生成带事件戳的css文件、js文件,会导致文件过的问题。所以最好是在生成文件前先删除目录,再添加目录,然后在将处理好的css、js文件等放入目录。
因为使用nodejs删除文件夹的话,如果文件夹内容不为空,没有办法删除,所以这里需要使用递归来删除文件夹。

  1. 处理index.html
function handleHtml() { //处理html
    let regex = /<!--[\s\S]*?-->/g; //删除html中的注释
    let html = $.html().replace(regex, '');

    regex = /\s{10,}/g; //删除多余的空行
    html = html.replace(regex, '');

    $('html').html(html); //重新赋值html文件
    log('Create index.html succeed');
}

通过使用正则来删除index.html中的注释跟多余的空行。

cli

我们肯定是希望插件是一个命令行插件,这样就可以通过执行上面说的两个命令zst devzst dist在进行开发环境跟生产环境的不同处理。
这里我们需要使用nodejs不内置的command模块。

#! /usr/bin/env node
var command = require('commander'); //nodejs命令模块

command.version(package.version); //版本号
command.command('dev').action(function () { develop(); });
command.command('dist').action(function () { bundle(); });
command.parse(process.argv); //开始解析用户输入的命令,这个不能跟上面的命令放到同一行

第一句的#! /usr/bin/env node是使用nodejs来执行这段脚本。
我们需要在package.json中添加一个字段:

"bin": {
  "zst": "./zst.config.js"
}

如果是在本地使用的话,运行npm link就可以将其打包为命令行工具。
如果要发布到npm上的话:
首先:在命令行中输入npm login登录自己的npm账户(如果没有请到https://www.npmjs.com自行注册。)
然后:输入npm publish就可以将自己写的插件作为一个npm包上传到npm服务器中。npm install zst -g下载下来就可以了。

到了这一步,我们通过上面的命令下载下来可以发现,这个没法生成项目目录,而是存在在本机的node环境中可以使用。所以我们还需要给这个构建工具加一个壳子。

zst-web

我们需要像vue-cli那样,运行命令直接生成项目模板,这就需要我们在这个zst外面加上一个壳子。
使用npm init 新建一个项目,这个需要依赖commander、bluebird还有fs-extra模块。

/* generateStructure.js */
var Promise = require("bluebird"),
    fs = Promise.promisifyAll(require('fs-extra'));

var root = __dirname.replace(/zst-web\/lib/,'zst-web/');

function generateStructure(project){
    return fs.copySync(root + 'structure', project,{clobber: true});
}

module.exports = generateStructure;

通过bluebird跟fs-extra我们就可以实现将一个目录复制到另一个目录。这里的root是为了防止找不到复制源头的bug。

/* zst.js */
var command = require('commander');
var gs = require('../lib/generaterStructure');

command
    .version(require('../package.json').version)
    .usage('[options] [project name]')
    .parse(process.argv);

var dir = command.args[0];
if(!dir) command.help();

gs(dir);

这个地方的dir就是通过commander模块来截取commander管道中传入的参数,就类似于我们经常使用的gulp server后面的server。这样我们就能实现输入命令zst-web [project name]创建之前的zst中的所有内容。

这样,这个前端构建工具就实现了,我们可以通过:
npm install -g zst-web zst来安装zst这个构建工具跟zst-web这个命令行构建工具。
zst-web [project name]生成项目。

完整代码在我的github中有,欢迎大家提意见。

发表评论

邮箱地址不会被公开。 必填项已用*标注