dev.js

写完这篇关于 npm scripts 的博客后,我又陆续花了两个半天,将这个定制脚本做到了能让自己满意的程度。功能和性能全面超越了原来的 gulp 脚本,投入的时间也很合理,因为之前无论 gulp 还是 webpack ,都至少花掉我一天时间用在配置上,却达不到理想的效果。

从功能上看,现在的脚本能监视目录、优化代码(主要痛点,市面上没有现成的方案)、检查代码、编译 riot 标签、打包文件,并为自动重载做好了准备。从性能上看,由于充分利用了内存来加速,每次文件变更后触发的流程(优化、检查、编译和打包)提速在十倍以上,从原来的平均超过 1000 ms 到现在的平均不到 100 ms。只是启动仍然需要 2 秒左右,但也比原来稍胜一筹,加上优化难度大、提升空间有限,且一般而言一天也就执行一次,就不强求了。

当前版本的完整代码如下:

// 依赖引入
const fs = require('fs');
const path = require('path');
const http = require('http');
const crypto = require('crypto');

const prettier = require('prettier');
const Linter = require('eslint').Linter;
const eslintrc = require('./.eslintrc.json');
const chokidar = require('chokidar');
const riot = require('riot');

const cacheTags = {};

// 各关键函数
// - 拆分 Pug 文件
const split = (tag) => {
    var pug, script, style;
    [pug, script] = tag.split(/\n    script.*\.\n/);
    [pug, style] = pug.split(/\n    style.*\.\n/);
    if (style) {
        return [pug, script, style]
    }
    [script, style] = script.split(/\n    style.*\.\n/);
    return [pug, script || '', style || ''];
};

// - prettier 处理 
const pretty = (pug, script, style) => {
    script = prettier.format(script, {tabWidth: 4, singleQuote: true, trailingComma: 'all', arrowParens: 'always'});
    style = prettier.format(style, {parser: 'css', tabWidth: 4});
    return [pug, script, style];
};

// - eslint 检查
const lint = (pug, script, filename) => {
    const line = pug.split('\n').length;
    const linter = new Linter();
    console.log(linter.verify(script, eslintrc, {filename: filename}));
};

// - 重新组装
const join = (pug, script, style) => {
    script = script
        ? '\n    script.' + ('\n' + script.trim()).replace(/\n/g, '\n        ') + '\n'
        : '';
    script = script.replace(/\n\s+\n/g, '\n\n');
    style = style
        ? '\n    style.' + ('\n' + style.trim()).replace(/\n/g, '\n        ') + '\n'
        : '';
    style = style.replace(/\n\s+\n/g, '\n\n');
    return pug + script + style;
};

// 编译 Riot 标签
const compile = (tag) => {
    return riot.compile(tag, {
        template: 'pug',
        type: 'es6',
        style: 'css',
    });
};

// 前缀时间,用于输出
const log = (str) => {
    const time = new Date().toString().match(/\d+:\d+:\d+/)[0];
    console.log(`[${time}] ${str}`);
};

// 单次处理的完整流程
const job = (tag, filename) => {
    var pug, script, style;
    [pug, script, style] = split(tag);
    [pug, script, style] = pretty(pug, script, style);
    lint(pug, script, filename);
    const newTag = join(pug, script, style);
    cacheTags[filename] = compile(newTag);
    return newTag;
};

// 处理工序
const watch = (subapp) => {
    const cwd = path.join(__dirname, 'riot-tags', subapp);
    const cache = {};
    chokidar
        .watch('*.tag', {cwd: cwd})
        .on('add', (filename) => {
            const fullname = path.join(cwd, filename);
            fs.readFile(fullname, {encoding: 'utf8'}, (err, tag) => {
                cacheTags[filename] = compile(tag);
            });
        }).on('change', (filename) => {
            const fullname = path.join(cwd, filename);
            fs.readFile(fullname, {encoding: 'utf8'}, (err, tag) => {
                if (cache[fullname] == tag) return;
                try {
                    const result = job(tag, filename);
                    if (result == tag) return;
                    log('文件已更新,等待重新加载')
                    cache[fullname] = result;
                    fs.writeFile(fullname, result, {encoding: 'utf8'});
                } catch (e) {
                    log('语法错误');
                    console.log(e.codeFrame);
                    return;
                }
            });
        });
    log('程序已启动');
};

watch(process.argv[2]);

http.createServer((req, res) => {
    const tags = Object.values(cacheTags).join('\n');
    res.setHeader('Content-Type', 'text/javascript');
    res.setHeader('Accept-Charset', 'utf-8');
    const hash = crypto.createHash('md5').update(tags).digest("hex");
    res.setHeader('ETag', hash);
    res.end(tags);
}).listen(process.env.PORT);

代码总计 121 行,比我预想的要简单多了。

代码分析

由于内容比较多,只能针对部分重点细讲,其他就一笔带过,读者可以自行阅读源码。

1-11 行,引入依赖。

功能函数

这批函数,各自完成一个独立功能。

17-26 行,split 函数,通过正则表达式拆解 riot 标签,将其分解成 pug、JavaScript、CSS。

29-33 行,pretty 函数,使用 Prettier 格式化代码。

36-40 行,lint 函数,使用 Eslint 对 Prettier 格式化后的代码,进行进一步的检查。检查结果中的行号不是根据原文件而是根据 JavaScript 片段来的,还有改进空间。

43-53 行,join 函数,将 pug、JavaScript、CSS 重新拼接回 riot 标签。

56-62 行,compile 函数,编译 riot 标签。

65-68 行,log 函数,在输出的内容前加上当前时间。

串联

71-79 行,job 函数,串联上述功能,并将 compile 函数的结果缓存到 cacheTags 对象中用于后续打包。其中调用 lint 的步骤,不会对后面的 compile 产生影响,也不影响 job 函数的返回值,因此如果能改成开一个子进程来执行,就可以更有效地利用 CPU 来提高效率。

监视、初始化与渐进

82-110 行,watch 函数,完成了初始化并通过监视文件变化渐进式的改进结果。

这里使用 chokidar 来监视指定目录,on('add', ...) 在开启监视时被触发,针对每个被监视的文件执行一次,因此可以读取所有 riot 标签文件,并将编译结果保存到 cacheTagss对象中,on('change', ...) 在文件发生变化时触发,每当用户保存文件时,针对该文件调用 job 函数将分解、优化、检查、拼接、编译的流程走上一遍。优化的结果会写回原文件,用户在编辑器中重新加载该文件(可以通过配置编辑器实现自动重载),就会看到优化后的代码。编译的结果也会保存在 cacheTags 变量中。

由于回写文件会再次触发 change 事件,这里用 cache 变量保存了每个文件上次优化后的内容,通过比较文件内容来避免链式触发。

112 行,用命令行中指定的参数作为目录名,调用 watch 函数,因此,在命令行执行:

node dev.js app

即可调用此脚本监视项目目录下的 riot-tags/app 目录。

文件打包

原来的 gulp 版本会将最终结果打包成一个物理文件,作为静态文件供网页调用。而我在学习 webpack 的时候发现它能开启一个 web 服务来提供打包结果,以内存计算代替文件读写,相比 gulp 要高效得多,因此我采用了这种方案。

114-121 使用了 node 原生的 http 来启动一个 web 服务器,无论你向它发送什么请求,都会将打包结果返回给你。

这里首先通过 Object.values 将 cacheTags 中的内容全部取出来,再合并成一个整体。Object.values 函数可能比较新,node 6.x 中不能使用,因此我用了 8.x 来执行这个脚本。当然这个功能也可以通过循环实现。

后面设置了几个 http header 来确保浏览器能正确的识别返回内容。特别是 ETag 的设置,这里将打包结果的 md5 码作为 ETag,保证每次文件变化时,ETag 都能相应变化,这样就可以通过 live.js 快速实现代码修改后,浏览器自动刷新的功能了。

最后 listen 中的参数,参考了 express 设置端口的方法。

Npm Scripts

最后,在 package.json 中,添加下列内容:

"scripts": {
    "dev-app": "PORT=16664 node dev.js app"
},

就可以通过 npm run dev-app 调用上述脚本,其中目录参数是 app,端口参数是 16664。

总结

目前看来,选择通过自制脚本来解决这个问题无疑是正确的。开发成本只是略高于 gulp 或 webpack 的学习和配置成本,但是灵活性要好得多,对于特殊需求,不再需要技巧性 hack,而是直接从根源解决,得到一个更满意的结果。面对飞速发展的 web 前端和一个又一个新冒出来的工具,也能更坦然了。

另外,实现上述工作的代码量远小于我的预期,最初畏惧调用原生库转而寻求各种 gulp 和 webpack 插件是完全没有必要的。很多看似高大上的东西,花心思去了解琢磨了,也没之前想象的那么复杂。

世上无难事,只怕有心人。

浙ICP备15043004号-1