阅读 499

校验es6语法的尝试

起因

之前做了一个node-cli工具@kaola/buildflow,用来提升node前后端混合工程的构建效率,实现了并行构建(多线程,自由组合流程)、按需安装、按需构建、导出构建结果等功能。

当构建完成之后,还准备做一些对构建结果的校验,比如校验js bundle中是否包含es6语法。避免有代码未经babel编译就打到包里,造成线上兼容问题。

而故事,就从这里开始了... ~(ˉ▽ ̄~)

Round One 🙂

Keep It Simple & Stupid

正则匹配

这种字符串操作,用正则匹配一下就好了。

先确定几种es6常见语法:constlet() =>,我认为不可能有不用这三种语法的es6模块 ╭(╯^╰)╮

很快就设计好正则(为了方便确定出错的位置,我向后多匹配了一些字符):

let match = /(?:^|[^a-zA-Z0-9_])const\s[^\n='"]{0,10}/.exec(str) ||
    /(?:^|[^a-zA-Z0-9_])let\s[^\n='"]{0,10}/.exec(str) ||
    /\)\s*=>[^\n'"]{0,20}/.exec(str);复制代码

Copy

排除特定场景

容易想到一些特殊场景,如果匹配到的字符出现在如下几种语法中,不应该被匹配。比如字符串啦,注释啦,正则啦。因此我们要排除:

  1. 单行字符串,3种情况:单引号('xxx')、双引号("xxx")、es6字符串模板(`xxx`)
  2. 多行字符串,3种情况:单引号加反斜杠、双引号加反斜杠、es6字符串模板
  3. 单行注释:// xxx
  4. 多行注释:/* xxx */
  5. 正则:/abc/
  6. letconst被定义为变量:var let = 1;

同样很容易(喵喵?)的设计了排除的正则,直接replace掉:

// 排除单行注释
data = data.replace(/\/\/[^\n]*(\n|$)/mg, '');
// 排除多行注释
data = data.replace(/\/\*([^*]|\*[^/])*\*\//g, '');
// 排除单行/多行的js字符串内容
data = data.replace(/(^|[^\\])"(?:[^"\n]|\\"|\\\n)*([^\\]"|")/mg, '$1\"\"');
data = data.replace(/(^|[^\\])'(?:[^'\n]|\\'|\\\n)*([^\\]'|')/mg, '$1\'\'');
data = data.replace(/(^|[^\\])`(?:[^`]|\\`)*([^\\]`|`)/mg, '$1\`\`');
// 排除 let const 被定义为变量的情况
//  - `var let = 1;` 转换为 `var xxx = 1;`
data = data.replace(/(^|[^a-zA-Z0-9_])var\s+(?:let|const)\s/g, '$1var xxx ');复制代码

Copy

同学们注意(敲黑板),这里有2个知识点:

  1. 使用正则表达式的 Multiline + GlobalMatch (/abc/mg)的效果:
  • 每行进行一次匹配
  • 每次匹配时,可以跨行
  • 如果进行了跨行匹配,下一次匹配,将以上一次游标的结尾作为新的开始
  1. 如何匹配*/连在一起(*/🙅‍♂️):/([^*]|\*[^/])/

eval

正准备发布第一个版本,突然想到一个新的问题:eval

如果有模块使用了eval类型的devtool进行打包,上面的正则会将eval当做字符串忽略掉 -_-||,所以我准备在第一步就解开eval语法。

继续上正则:

// 处理eval
//  - eval('const a = "\\1";') 转换为 const a = "\1";
if(checkEval) {
    data = data.replace(/eval\s*\(\s*"((?:[^"]|\\")*)"\s*\)/g, (match, p1) => {
        return p1.replace(/\\([\s\S])/g, '$1');
    });
    data = data.replace(/eval\s*\(\s*'((?:[^']|\\')*)'\s*\)/g, (match, p1) => {
        return p1.replace(/\\([\s\S])/g, '$1');
    });
    data = data.replace(/eval\s*\(\s*`((?:[^`]|\\`)*)`\s*\)/g, (match, p1) => {
        return p1.replace(/\\([\s\S])/g, '$1');
    });
}复制代码

Copy

妈妈再也不用担心我的正则了 ┭┮﹏┭┮

注意,这里又出现2个知识点:

  1. replace的第二个参数,是支持函数的,上面我们用这个特性,将eval内部的字符串中的反斜杠去掉
  2. 使用[\s\S]来替代.,因为.是无法匹配换行符的,且我们没有使用 Multiline

单元测试

要想成为一个成熟的包,少不了单测。选择了jest,并加到发包前的预检查里,完美!

"scripts": {
    "test": "jest",
    "prepublishOnly": "npm run test"
}复制代码

Copy

设计了各种单测场景,类似这样:

let tests = [
    ['测试 let', './files/let/let.js', [2,15]], // 2,15为匹配到es6的行列值
    ['测试 let + 注释', './files/let/let-cmt.js', [6,23]],
    ['测试 let + eval', './files/let/let-eval.js', [6,26]],
    ['测试 let + 字符串', './files/let/let-str.js', [6,1]],
    ['测试 let + 注释 + 字符串 + eval', './files/let/let-mix.js', [10,3]],
    ...
];
tests.forEach(([name, file, pos] = []) => {
    test(name, () => {
        const data = fs.readFileSync(
            require.resolve(file),
            { encoding: 'utf8' }
        );
        expect(getES6Tag(data).pos).toStrictEqual(pos);
    });
});复制代码

Copy

报错信息如下,包含出错数量、发生的文件、行列数、上下文:

@kaola/buildflow.check.es6: there were 4 files error:
----
 - file: xxx/buildflow.check.es6/test/files/arrow/arrow-cmt.js
 - row:6, col:33
 - eror position ↴
        r e = 'const f = 6'; var x = () => 'xxxxx'; // const d = 4;
                                       ^
----复制代码

Copy

一切是那么的美好,直到有一个单测失败了:

var a = 4; // asdf " asdf
var b = "asdf"; const c = 1;复制代码

Copy

观察和调试后发现,字符串与注释发生了嵌套,在删除字符串的时候,破坏了注释的结构。删除字符串后,上述代码变为了这样:

var a = 4; // asdf ""asdf"; const c = 1;复制代码

Copy

然后再删除掉注释,const就被清除了 (キ`゚Д゚´)!!

Round Two 😯

解一道题

因吹死挺!现在的问题,可以类比为一道算法题,题目大概是:

  1. 通过分析一个js文件,你可以得到两个二维数组(分别表示字符串和注释的区间)
  • js文件:'yy'xxxxxx'y//yyy'xxx
  • 字符串区间:[[0,3], [10,17]]
  • 注释区间:[[12,20]]
  1. 二维数组的每一项,都是2个数字组成的数组,数字的含义为js文件字符串的下标(从0开始),2个数字的含义为js文件字符串的一段区间
  2. 每个二维数组中,各个区间之间不会重叠。但两个二维数组之间,区间可能会重叠
  3. 当一段区间(a)的某个下标在另一段区间(b)内部时,这个下标失效,同时区间(a)也失效,需要删除失效的下标,然后重新计算这个失效区间所属的整个二维数组

尝试删除所有区间,得到一个新的字符串(区间可能会多重嵌套哦~)

代码如下,请忽略执行效率(大过年的)

// 解决字符串和注释互相嵌套的问题
// 步骤一:删除字符串内部存在的注释标记,如果该字符串又存在于另一个注释内,则删除字符串标记
const deal = () => {
    const strRange = getStrRange(data); // 获取字符串区间,二维数组 [[2,4], [30, 37], ...]
    const commentRange = getCommentRange(data); // 获取注释区间,二维数组 xxx
    const res = strRange.some(([s1, s2]) => {
        return commentRange.some((c) => {
            const type = c.type; // 注释的类型 1单行注释`//` 2多行注释`/**/`
            const [c1, c2] = c;
            if(s1 < c1 && c1 < s2) {
                // 考虑多重嵌套,即:注释嵌套在字符串中,字符串又嵌套在注释中
                const res = commentRange.some(([cc1, cc2]) => {
                    if(cc1 < s1 && s1 < cc2) {
                        // 删除
                        data = data.replace(new RegExp(`^([\\s\\S]{${s1}})[\\s\\S]{2}([\\s\\S]*)$`, 'g'), '$1  $2');
                        return true; // 结束多重循环,重新计算字符串区间和注释区间
                    }
                });
                if(!res) {
                    // 删除
                    data = data.replace(new RegExp(`^([\\s\\S]{${c1}})[\\s\\S]{2}([\\s\\S]*)$`, 'g'), '$1  $2');
                }
                return true; // 结束多重循环,重新计算字符串区间和注释区间
            }
            if(type === 2) { // 多行注释,还需要考虑结束标记`*/`是否被嵌套的情况,单行注释`//`没有这个问题
                if(s1 < c2 && c2 < s2) {
                    // 考虑多重嵌套,即:注释嵌套在字符串中,字符串又嵌套在注释中
                    const res = commentRange.some(([cc1, cc2]) => {
                        if(cc1 < s1 && s1 < cc2) {
                            // 删除
                            data = data.replace(new RegExp(`^([\\s\\S]{${s1}})[\\s\\S]{2}([\\s\\S]*)$`, 'g'), '$1  $2');
                            return true; // 结束多重循环,重新计算字符串区间和注释区间
                        }
                    });
                    if(!res) {
                        // 删除
                        data = data.replace(new RegExp(`^([\\s\\S]{${c2}})[\\s\\S]{2}([\\s\\S]*)$`, 'g'), '$1  $2');
                    }
                    return true; // 结束多重循环,重新计算字符串区间和注释区间
                }
            }
        });
    });
    res && deal(); // 如果发现嵌套,中断执行,继续递归调用
};
deal(); // 步骤一开始
// 步骤二:删除注释
// balabala
// 步骤三:删除字符串
// balabala复制代码

Copy

打完收功,看起来一切ok,设计的各种奇葩单测也全部通过了。

放弃正则

发包并在工程中引入,额,为什么我故意构造的es6语法没能检测粗来 (O_O)?

原来是正则内嵌套了字符串或者注释:var regexp = /"/;

心态爆炸💥 场景太多,正则有些首尾难顾,抛弃正则方案,思考其他出路。

其实还有其他问题,比如正则中只考虑了\s空格,没有考虑\ttab的情况,因此\s*应该改为[\\s\\t]*

Round Three 😳

看来只能分析语法了 ヾ(◍°∇°◍)ノ゙

有很多工具可以直接用,比如esprima,比如acorn,比如babylon

但我先自己解析一把试试看,当做一个有趣的练习。

词法分析

由于我只是想通过解析,排除字符串、注释、正则,这三种情况,剩余的部分依然准备使用正则匹配。

因此,只需要进行词法分析(tokenize),找到这3种情况的区间,然后在字符串中删掉就好了。

方案:逐个字节遍历js文件,假设遍历到位置i,通过遍历过程中最后的2位字符data[i]+data[i-1],分析是否匹配字符串、注释、正则

首先,定义类型常量

const CMT1 = Symbol('CMT1'); // 注释1:  /* xxx */
const CMT2 = Symbol('CMT2'); // 注释2:  // xxx
const STR1 = Symbol('STR1'); // 字符串1:'xxx' - 包含多行
const STR2 = Symbol('STR2'); // 字符串2:"xxx" - 包含多行
const STR3 = Symbol('STR3'); // 字符串3:`xxx` - 包含多行
const REG  = Symbol('REG');  // 正则:   `/xxx/`
const CMT_MAP = {
    '/*': CMT1,
    '//': CMT2
};
const STR_MAP = {
    '\'': STR1,
    '"': STR2,
    '`': STR3
};复制代码

Copy

然后开始遍历

let idx = 0; // 当前遍历到的位置
let ranges = []; // [[1,5,STR1], [10,20,CMT2], ...] 表示1~5是一个单引号字符串,10~20是一个单行注释 ...
let s = '', // 当前遍历到的位置,最后2位字符组成的字符串
    type, // 当前匹配到的类型 STR1 CMT1 ...
    oldType, // 上一次匹配到的类型
    begin; // 匹配到某个类型时,记录当前位置
// 开始遍历
while(idx < data.length) {
    // 取最后2位字符
    if(s.length >= 2) {
        s = s[s.length - 1] + data[idx];
    } else {
        s += data[idx];
    }
    // 匹配字符串过程中,出现双反斜杠,第二个反斜杠修改为空
    if(type && [STR1, STR2, STR3].includes(type) && s === '\\\\') {
        s = s[0] + ' ';
    }
    // 还未匹配到某个类型,尝试进行匹配
    if(!type) {
        // 匹配字符串
        if(idx === 0 && ~'\'"`'.indexOf(s)) {
            type = STR_MAP[s];
            begin = idx;
        // 继续匹配字符串,注意 /" 要被正则匹配,而非字符串,因此需要排除左斜杠
        } else if(/[^\\/]['"`]/.test(s)) {
            type = STR_MAP[s[s.length - 1]];
            begin = idx;
        // 匹配注释
        } else if(['/*', '//'].includes(s)) {
            type = CMT_MAP[s];
            begin = idx - 1;
        // 匹配正则
        } else if(/\/[^*/\n]/.test(s) && oldType !== CMT1 && oldType !== REG) {
            type = REG;
            begin = idx - 1;
        }
        oldType = null;
    // 已经匹配到某个类型,尝试匹配该类型的结束符
    } else {
        if(
            (type === STR1 && /[^\\]'/.test(s)) ||
            (type === STR2 && /[^\\]"/.test(s)) ||
            (type === STR3 && /[^\\]`/.test(s))
        ) {
            ranges.push([begin, idx, type]);
            oldType = type;
            type = null;
        } else if(type === CMT1 && s === '*/' && idx - begin > 2) {
            ranges.push([begin, idx, type]);
            oldType = type;
            type = null;
        } else if(type === CMT2 && /\n$/.test(s)) {
            ranges.push([begin, idx, type]);
            oldType = type;
            type = null;
        } else if(type === REG) {
            let oldIdx = idx, rs = data[idx - 1], cur, count = 0, errCount = 0;
            // 为了区分正则和除号,这里做了hack处理
            // 当向后寻找了300个字符(1),或出现20个斜杠(2),都没能匹配到正则,就认为是除号😓
            while(true) {
                if(++count > 300) { // 字符数大于300,认为是除法运算,不再继续匹配(1)
                    type = null;
                    idx = oldIdx;
                    break;
                }
                cur = data[idx];
                if(cur === '/') {
                    try {
                        // 验证是否是正则
                        new RegExp(rs);
                        // 验证成功
                        ranges.push([begin, idx, type]);
                        oldType = type;
                        type = null;
                        s = '/';
                        break;
                    } catch (e) {
                        // 1.非完整正则,继续执行
                        // 2.匹配到`/`但失败的次数大于20,认为是除法运算,不再继续匹配(2)
                        if(++errCount > 20) { // 匹配到 / 且失败,次数大于 20
                            type = null;
                            idx = oldIdx;
                            break;
                        }
                    }
                }
                rs += cur;
                idx ++;
            }
        }
    }
    idx ++;
}复制代码

Copy

终于写完了,本地运行,构建流程执行到这里,emmm... 怎么还没好?卡住了?递归有bug根本停不下来?终于执行完了~

执行时间太久了(对于一个较大的构建结果,时间长达30s+),我再各个执行各个节点统计时间,发现问题不是出在词法分析,而是之后的删除

词法分析完毕后,得到了ranges二维数组,里面包含了字符串、注释、正则的区间,我们要从js bundle中删掉这部分字符。

一开始的方案是这样的:

方案一:字符串slice + 拼接

某复杂文件耗时5000ms+

let deleted = 0; // 已删除的字符数
ranges.forEach(([begin, end, type]) => {
    let l = data.length;
    if([STR1, STR2, STR3].includes(type)) {
        data = data.slice(0, begin - deleted + 1) + data.slice(end - deleted, data.length);
        deleted += (end - begin - 1);
    } else if([CMT1, CMT2].includes(type)) {
        data = data.slice(0, begin - deleted) + data.slice(end - deleted + 1, data.length);
        deleted += (end - begin + 1);
    } else if(type === REG) {
        data = data.slice(0, begin - deleted) + data.slice(end - deleted + 1, data.length);
        deleted += (end - begin + 1);
    }
});复制代码

Copy

我又尝试了2种方案,并对比了他们的耗时。

方案二:字符串->字符数组 + 数组的splice

某复杂文件耗时2300ms+

知识点叒来了:

字符串->字符数组,虽然 "𨭎".split('') -> ["�", "�"],但依然使用 split(''),因为上面while循环做词法分析的时候,也没有考虑占2位的字符问题

let dataList = data.split('');
let deleted = 0; // 已删除的字符数
ranges.forEach(([begin, end, type]) => {
    let l = data.length;
    if([STR1, STR2, STR3].includes(type)) {
        dataList.splice(begin - deleted + 1, end - begin - 1);
        deleted += (end - begin - 1);
    } else if([CMT1, CMT2].includes(type)) {
        dataList.splice(begin - deleted, end - begin + 1);
        deleted += (end - begin + 1);
    } else if(type === REG) {
        dataList.splice(begin - deleted, end - begin + 1);
        deleted += (end - begin + 1);
    }
});
data = dataList.join('');复制代码

Copy

方案三:正则

某复杂文件耗时11000ms+

这种方法比较巧妙,但效果最差 -_-||

let dataList = data.split('');
let deleted = 0; // 已删除的字符数
ranges.forEach(([begin, end, type]) => {
    let l = data.length;
    if([STR1, STR2, STR3].includes(type)) {
        data = data.replace(new RegExp(`^([\\s\\S]{${begin - deleted + 1}})([\\s\\S]{${end - begin - 1}})([\\s\\S]*)$`), '$1$3');
        deleted += (end - begin - 1);
    } else if([CMT1, CMT2].includes(type)) {
        data = data.replace(new RegExp(`^([\\s\\S]{${begin - deleted}})([\\s\\S]{${end - begin + 1}})([\\s\\S]*)$`), '$1$3');
        deleted += (end - begin + 1);
    } else if(type === REG) {
        data = data.replace(new RegExp(`^([\\s\\S]{${begin - deleted}})([\\s\\S]{${end - begin + 1}})([\\s\\S]*)$`), '$1$3');
        deleted += (end - begin + 1);
    }
});复制代码

Copy

最终方案

三种方案效果都不理想(不过很有趣不是么 o( ̄ヘ ̄o#) ),但其实解决办法很简单,只要在词法分析过程中,直接匹配es6语法就好了~

...
let str = ''; // 在其中检测es6语法
let match; // 检测到的es6语法
...
// 开始遍历
while(idx < data.length) {
    // 添加到循环开头
    if(!type) { // 未匹配到字符串、注释、正则时,检测es6
        str += data[idx]; 
        // 检测es6语法(let const =>)
        match = /(?:^|[^a-zA-Z0-9_])const\s[^\n='"]{0,10}/.exec(str) ||
            /(?:^|[^a-zA-Z0-9_])let\s[^\n='"]{0,10}/.exec(str) ||
            /\)\s*=>[^\n'"]{0,20}/.exec(str);
        if(match && match[0]) {
            break; // 检测到退出循环
        }
    }
...复制代码

Copy

Round Four 😅

其实在正则这条路不太好走,准备通过分析语法来识别es6的时候,就已经有很多工具可以用了。

例如上面提到的esprimaacornbabylon等。

所以只要3行代码:

const esprima = require('esprima');
const token = esprima.tokenize('const answer = 42');
/* 
[ { type: 'Keyword', value: 'const' },
  { type: 'Identifier', value: 'answer' },
  { type: 'Punctuator', value: '=' },
  { type: 'Numeric', value: '42' } ]
*/
const hasConst = token.some(({type, value}) => type === 'Keyword' && value === 'const');复制代码

Copy

Keep It Simple & Stupid

[尴尬而不失礼貌的微笑.gif]

继续补充一个知识点吧:

在shell中执行node-cli命令时,参数如果带有 *,会自动转义为匹配到的目录。

如:node a.js **/*.js,node实际获取到的参数,并不是**/*.js,而是对应的文件/目录数组。

有2种解决办法:

  1. 加引号('**/*.js')
  2. 转义字符(**/*.js)

心得

  1. es-check这个包已经做了校验es6语法的工作,但我认为可能有2个小问题:
  • 它基于acorn做的语法解析,因此语法写的不严谨(如:多次定义变量),也会导致报错。虽然在浏览器中运行没有问题。
  • 不校验eval中的内容。
  1. 刚开始用正则解析的时候,总觉得差一点就搞定了。应当及时转换思路,尝试其他方案 ┭┮﹏┭┮
  2. 做功能性组件的时候,单测很重要,最好在初次发布就加上。比如这次改了这么多功能,换了这么多方案,内心却稳如 🐶