200 行JS代码,带你实现代码编译器

开发 前端
对于前端同学来说,编译器可能适合神奇的魔盒,表面普通,但常常给我们惊喜。编译器,顾名思义,用来编译,编译什么呢?当然是编译代码咯。

一、前言

对于前端同学来说,编译器可能适合神奇的魔盒🎁,表面普通,但常常给我们惊喜。

编译器,顾名思义,用来编译,编译什么呢?当然是编译代码咯🌹。

[[320847]]

其实我们也经常接触到编译器的使用场景:

  •  React 中 JSX 转换成 JS 代码;
  •  通过 Babel 将 ES6 及以上规范的代码转换成 ES5 代码;
  •  通过各种 Loader 将 Less / Scss 代码转换成浏览器支持的 CSS 代码;
  •  将 TypeScript 转换为 JavaScript 代码。
  •  and so on...

使用场景非常之多,我的双手都数不过来了。😄

虽然现在社区已经有非常多工具能为我们完成上述工作,但了解一些编译原理是很有必要的。接下来进入本文主题:「200行JS代码,带你实现代码编译器」。

二、编译器介绍

2.1 程序运行方式

现代程序主要有两种编译模式:静态编译和动态解释。推荐一篇文章《Angular 2 JIT vs AOT》介绍得非常详细。

静态编译

简称 「AOT」(Ahead-Of-Time)即 「提前编译」 ,静态编译的程序会在执行前,会使用指定编译器,将全部代码编译成机器码。

(图片来自:https://segmentfault.com/a/1190000008739157)

在 Angular 的 AOT 编译模式开发流程如下:

  •  使用 TypeScript 开发 Angular 应用
  •  运行 ngc 编译应用程序
    •   使用 Angular Compiler 编译模板,一般输出 TypeScript 代码
    •   运行 tsc 编译 TypeScript 代码
  •  使用 Webpack 或 Gulp 等其他工具构建项目,如代码压缩、合并等
  •  部署应用

动态解释

简称 「JIT」(Just-In-Time)即 「即时编译」 ,动态解释的程序会使用指定解释器,一边编译一边执行程序。

(图片来自:https://segmentfault.com/a/1190000008739157[1])

在 Angular 的 JIT 编译模式开发流程如下:

  •  使用 TypeScript 开发 Angular 应用
  •  运行 tsc 编译 TypeScript 代码
  •  使用 Webpack 或 Gulp 等其他工具构建项目,如代码压缩、合并等
  •  部署应用

AOT vs JIT

AOT 编译流程:

(图片来自:https://segmentfault.com/a/1190000008739157)

JIT 编译流程:

(图片来自:https://segmentfault.com/a/1190000008739157)

特性 AOT JIT
编译平台 (Server) 服务器 (Browser) 浏览器
编译时机 Build (构建阶段) Runtime (运行时)
包大小 较小 较大
执行性能 更好 -
启动时间 更短 -

除此之外 AOT 还有以下优点:

  •  在客户端我们不需要导入体积庞大的 angular 编译器,这样可以减少我们 JS 脚本库的大小。
  •  使用 AOT 编译后的应用,不再包含任何 HTML 片段,取而代之的是编译生成的 TypeScript 代码,这样的话 TypeScript 编译器就能提前发现错误。总而言之,采用 AOT 编译模式,我们的模板是类型安全的。

2.2 现代编译器工作流程

摘抄维基百科中对 编译器[2]工作流程介绍:

    ❝    一个现代编译器的主要工作流程如下:源代码(source code)→ 预处理器(preprocessor)→ 编译器(compiler)→ 汇编程序(assembler)→ 目标代码(object code)→ 链接器(linker)→ 可执行文件(executables),最后打包好的文件就可以给电脑去判读运行了。    ❞

这里更强调了编译器的作用:「将原始程序作为输入,翻译产生目标语言的等价程序」。

编译器三个核心阶段.png

目前绝大多数现代编译器工作流程基本类似,包括三个核心阶段:

  1.  「解析(Parsing)」 :通过词法分析和语法分析,将原始代码字符串解析成「抽象语法树(Abstract Syntax Tree)」;
  2.  「转换(Transformation)」:对抽象语法树进行转换处理操作;
  3.  「生成代码(Code Generation)」:将转换之后的 AST 对象生成目标语言代码字符串。

三、编译器实现

本文将通过 「The Super Tiny Compiler[3]」 源码解读,学习如何实现一个轻量编译器,最终「实现将下面原始代码字符串(Lisp 风格的函数调用)编译成 JavaScript 可执行的代码」。

  Lisp 风格(编译前) JavaScript 风格(编译后)
2 + 2 (add 2 2) add(2, 2)
4 - 2 (subtract 4 2) subtract(4, 2)
2 + (4 - 2) (add 2 (subtract 4 2)) add(2, subtract(4, 2))

话说 The Super Tiny Compiler 号称「可能是有史以来最小的编译器」,并且其作者 James Kyle 也是 Babel 活跃维护者之一。

让我们开始吧~

3.1 The Super Tiny Compiler 工作流程

现在对照前面编译器的三个核心阶段,了解下 The Super Tiny Compiler  编译器核心工作流程:

图中详细流程如下:

  1.  执行「入口函数」,输入「原始代码字符串」作为参数; 
  1. // 原始代码字符串  
  2. (add 2 (subtract 42)) 

      2.  进入「解析阶段(Parsing)」,原始代码字符串通过「词法分析器(Tokenizer)」转换为「词法单元数组」,然后再通过 「词法分析器(Parser)」将「词法单元数组」转换为「抽象语法树(Abstract Syntax Tree 简称 AST)」,并返回;

   

   3.   进入「转换阶段(Transformation)」,将上一步生成的 「AST 对象」 导入「转换器(Transformer)」,通过「转换器」中的「遍历器(Traverser)」,将代码转换为我们所需的「新的 AST 对象」;

    4.   进入「代码生成阶段(Code Generation)」,将上一步返回的「新 AST 对象」通过「代码生成器(CodeGenerator)」,转换成 「JavaScript Code」;

    5.   「代码编译结束」,返回 「JavaScript Code」。

[[320850]]

上述流程看完后可能一脸懵逼,不过没事,请保持头脑清醒,先有个整个流程的印象,接下来我们开始阅读代码:

3.2 入口方法

首先定义一个入口方法 compiler ,接收原始代码字符串作为参数,返回最终 JavaScript Code: 

  1. // 编译器入口方法 参数:原始代码字符串 input  
  2. function compiler(input) {  
  3.   let tokens = tokenizer(input);  
  4.   let ast    = parser(tokens);  
  5.   let newAst = transformer(ast);  
  6.   let output = codeGenerator(newAst);  
  7.   return output;  

3.3 解析阶段

在解析阶段中,我们定义「词法分析器方法」 tokenizer  和「语法分析器方法」 parser 然后分别实现: 

  1. // 词法分析器 参数:原始代码字符串 input  
  2. function tokenizer(input) {};  
  3. // 语法分析器 参数:词法单元数组tokens  
  4. function parser(tokens) {}; 

词法分析器

「词法分析器方法」 tokenizer 的主要任务:遍历整个原始代码字符串,将原始代码字符串转换为「词法单元数组(tokens)」,并返回。

在遍历过程中,匹配每种字符并处理成「词法单元」压入「词法单元数组」,如当匹配到左括号( ( )时,将往「词法单元数组(tokens)「压入一个」词法单元对象」({type: 'paren', value:'('})。

 

  1. // 词法分析器 参数:原始代码字符串 input  
  2. function tokenizer(input) {  
  3.   let current = 0;  // 当前解析的字符索引,作为游标  
  4.   let tokens = [];  // 初始化词法单元数组  
  5.   // 循环遍历原始代码字符串,读取词法单元数组  
  6.   while (current < input.length) {  
  7.     let char = input[current];  
  8.     // 匹配左括号,匹配成功则压入对象 {type: 'paren', value:'('}  
  9.     if (char === '(') {  
  10.       tokens.push({  
  11.         type: 'paren',  
  12.         value: '('  
  13.       });  
  14.       current++;  
  15.       continue; // 自增current,完成本次循环,进入下一个循环  
  16.     }  
  17.     // 匹配右括号,匹配成功则压入对象 {type: 'paren', value:')'}  
  18.     if (char === ')') {  
  19.       tokens.push({  
  20.         type: 'paren',  
  21.         value: ')'  
  22.       });  
  23.       current++;  
  24.       continue;  
  25.     }   
  26.     // 匹配空白字符,匹配成功则跳过  
  27.     // 使用 \s 匹配,包括空格、制表符、换页符、换行符、垂直制表符等  
  28.     let WHITESPACE = /\s/;  
  29.     if (WHITESPACE.test(char)) {  
  30.       current++;  
  31.       continue; 
  32.      }  
  33.     // 匹配数字字符,使用 [0-9]:匹配  
  34.     // 匹配成功则压入{type: 'number', value: value}  
  35.     // 如 (add 123 456) 中 123 和 456 为两个数值词法单元  
  36.     let NUMBERS = /[0-9]/;  
  37.     if (NUMBERS.test(char)) {  
  38.       let value = '' 
  39.       // 匹配连续数字,作为数值 
  40.        while (NUMBERS.test(char)) {  
  41.         value += char;  
  42.         char = input[++current];  
  43.       }  
  44.       tokens.push({ type: 'number', value });  
  45.       continue;  
  46.     }  
  47.     // 匹配形双引号包围的字符串  
  48.     // 匹配成功则压入 { type: 'string', value: value }  
  49.     // 如 (concat "foo" "bar") 中 "foo" 和 "bar" 为两个字符串词法单元  
  50.     if (char === '"') {  
  51.       let value = '' 
  52.       char = input[++current]; // 跳过左双引号  
  53.       // 获取两个双引号之间所有字符  
  54.       while (char !== '"') {  
  55.         value += char;  
  56.         char = input[++current];  
  57.       }  
  58.       char = input[++current];// 跳过右双引号  
  59.       tokens.push({ type: 'string', value });  
  60.       continue;  
  61.     }  
  62.     // 匹配函数名,要求只含大小写字母,使用 [a-z] 匹配 i 模式  
  63.     // 匹配成功则压入 { type: 'name', value: value }  
  64.     // 如 (add 2 4) 中 add 为一个名称词法单元  
  65.     let LETTERS = /[a-z]/i;  
  66.     if (LETTERS.test(char)) {  
  67.       let value = '' 
  68.       // 获取连续字符  
  69.       while (LETTERS.test(char)) {  
  70.         value += char;  
  71.         char = input[++current];  
  72.       }  
  73.       tokens.push({ type: 'name', value });  
  74.       continue;  
  75.     }  
  76.     // 当遇到无法识别的字符,抛出错误提示,并退出  
  77.     thrownewTypeError('I dont know what this character is: ' + char);  
  78.   }  
  79.   // 词法分析器的最后返回词法单元数组  
  80.   return tokens;  

语法分析器

「语法分析器方法」 parser 的主要任务:将「词法分析器」返回的「词法单元数组」,转换为能够描述语法成分及其关系的中间形式(「抽象语法树 AST」)。

 

  1. // 语法分析器 参数:词法单元数组tokens  
  2. function parser(tokens) {  
  3.   let current = 0; // 设置当前解析的词法单元的索引,作为游标  
  4.   // 递归遍历(因为函数调用允许嵌套),将词法单元转成 LISP 的 AST 节点  
  5.   function walk() {  
  6.     // 获取当前索引下的词法单元 token  
  7.     let token = tokens[current];  
  8.     // 数值类型词法单元  
  9.     if (token.type === 'number') {  
  10.       current++; // 自增当前 current 值  
  11.       // 生成一个 AST节点 'NumberLiteral',表示数值字面量  
  12.       return {  
  13.         type: 'NumberLiteral',  
  14.         value: token.value,  
  15.       };  
  16.     }  
  17.     // 字符串类型词法单元  
  18.     if (token.type === 'string') {  
  19.       current++;  
  20.       // 生成一个 AST节点 'StringLiteral',表示字符串字面量  
  21.       return {  
  22.         type: 'StringLiteral',  
  23.         value: token.value,  
  24.       };  
  25.     }  
  26.     // 函数类型词法单元  
  27.     if (token.type === 'paren' && token.value === '(') {  
  28.       // 跳过左括号,获取下一个词法单元作为函数名  
  29.       token = tokens[++current];  
  30.       let node = {  
  31.         type: 'CallExpression',  
  32.         name: token.value,  
  33.         params: []  
  34.       }; 
  35.       // 再次自增 current 变量,获取参数词法单元  
  36.       token = tokens[++current];  
  37.       // 遍历每个词法单元,获取函数参数,直到出现右括号")"  
  38.       while ((token.type !== 'paren') || (token.type === 'paren' && token.value !== ')')) {  
  39.         node.params.push(walk());  
  40.         token = tokens[current];  
  41.       }  
  42.       current++; // 跳过右括号  
  43.       return node;  
  44.     }  
  45.     // 无法识别的字符,抛出错误提示  
  46.     thrownewTypeError(token.type);  
  47.   }  
  48.   // 初始化 AST 根节点  
  49.   let ast = {  
  50.     type: 'Program',  
  51.     body: [],  
  52.   };  
  53.   // 循环填充 ast.body  
  54.   while (current < tokens.length) {  
  55.     ast.body.push(walk());  
  56.   }  
  57.   // 最后返回ast  
  58.   return ast;  

3.4 转换阶段

在转换阶段中,定义了转换器 transformer 函数,使用词法分析器返回的 LISP 的 AST 对象作为参数,将 AST 对象转换成一个新的 AST 对象。

为了方便代码组织,我们定义一个遍历器 traverser 方法,用来处理每一个节点的操作。 

  1. // 遍历器 参数:ast 和 visitor  
  2. function traverser(ast, visitor) {  
  3.   // 定义方法 traverseArray  
  4.   // 用于遍历 AST节点数组,对数组中每个元素调用 traverseNode 方法。  
  5.   function traverseArray(array, parent) {  
  6.     array.forEach(child => {  
  7.       traverseNode(child, parent);  
  8.     });  
  9.   }  
  10.   // 定义方法 traverseNode  
  11.   // 用于处理每个 AST 节点,接受一个 node 和它的父节点 parent 作为参数  
  12.   function traverseNode(node, parent) {  
  13.     // 获取 visitor 上对应方法的对象  
  14.     let methods = visitor[node.type];  
  15.     // 获取 visitor 的 enter 方法,处理操作当前 node  
  16.     if (methods && methods.enter) {  
  17.       methods.enter(node, parent);  
  18.     }  
  19.     switch (node.type) {  
  20.       // 根节点  
  21.       case'Program':  
  22.         traverseArray(node.body, node);  
  23.         break; 
  24.       // 函数调用  
  25.       case'CallExpression':  
  26.         traverseArray(node.params, node);  
  27.         break;  
  28.       // 数值和字符串,忽略  
  29.       case'NumberLiteral':  
  30.       case'StringLiteral':  
  31.         break;  
  32.       // 当遇到无法识别的字符,抛出错误提示,并退出  
  33.       default:  
  34.         thrownewTypeError(node.type);  
  35.     }  
  36.     if (methods && methods.exit) {  
  37.       methods.exit(node, parent);  
  38.     }  
  39.   }  
  40.   // 首次执行,开始遍历  
  41.   traverseNode(ast, null);  

在看「遍历器」 traverser 方法时,建议结合下面介绍的「转换器」 transformer 方法阅读: 

  1. // 转化器,参数:ast  
  2. function transformer(ast) {  
  3.   // 创建 newAST,与之前 AST 类似,Program:作为新 AST 的根节点  
  4.   let newAst = {  
  5.     type: 'Program',  
  6.     body: [],  
  7.   };  
  8.   // 通过 _context 维护新旧 AST,注意 _context 是一个引用,从旧的 AST 到新的 AST。  
  9.   ast._context = newAst.body;  
  10.   // 通过遍历器遍历 处理旧的 AST  
  11.   traverser(ast, {  
  12.     // 数值,直接原样插入新AST,类型名称 NumberLiteral  
  13.     NumberLiteral: {  
  14.       enter(node, parent) {  
  15.         parent._context.push({  
  16.           type: 'NumberLiteral',  
  17.           value: node.value,  
  18.         });  
  19.       },  
  20.     },  
  21.     // 字符串,直接原样插入新AST,类型名称 StringLiteral  
  22.     StringLiteral: {  
  23.       enter(node, parent) {  
  24.         parent._context.push({  
  25.           type: 'StringLiteral',  
  26.           value: node.value,  
  27.         });  
  28.       },  
  29.     },  
  30.     // 函数调用  
  31.     CallExpression: {  
  32.       enter(node, parent) {  
  33.         // 创建不同的AST节点  
  34.         let expression = {  
  35.           type: 'CallExpression',  
  36.           callee: {  
  37.             type: 'Identifier',  
  38.             name: node.name,  
  39.           },  
  40.           arguments: [],  
  41.         };  
  42.         // 函数调用有子类,建立节点对应关系,供子节点使用  
  43.         node._context = expression.arguments;  
  44.         // 顶层函数调用算是语句,包装成特殊的AST节点  
  45.         if (parent.type !== 'CallExpression') {  
  46.           expression = {  
  47.             type: 'ExpressionStatement',  
  48.             expression: expression,  
  49.           };  
  50.         }  
  51.         parent._context.push(expression);  
  52.       },  
  53.     }  
  54.   });  
  55.   return newAst;  

重要一点,这里通过 _context 引用来「维护新旧 AST 对象」,管理方便,避免污染旧 AST 对象。

3.5 代码生成

接下来到了最后一步,我们定义「代码生成器」 codeGenerator 方法,通过递归,将新的 AST 对象代码转换成 JavaScript 可执行代码字符串。 

  1. // 代码生成器 参数:新 AST 对象  
  2. function codeGenerator(node) {  
  3.   switch (node.type) {  
  4.     // 遍历 body 属性中的节点,且递归调用 codeGenerator,按行输出结果  
  5.     case'Program':  
  6.       return node.body.map(codeGenerator)  
  7.         .join('\n');  
  8.     // 表达式,处理表达式内容,并用分号结尾  
  9.     case'ExpressionStatement':  
  10.       return (  
  11.         codeGenerator(node.expression) +  
  12.         ';'  
  13.       );  
  14.     // 函数调用,添加左右括号,参数用逗号隔开  
  15.     case'CallExpression':  
  16.       return (  
  17.         codeGenerator(node.callee) +  
  18.         '(' +  
  19.         node.arguments.map(codeGenerator)  
  20.           .join(', ') +  
  21.         ')'  
  22.       );  
  23.     // 标识符,返回其 name  
  24.     case'Identifier':  
  25.       return node.name;  
  26.     // 数值,返回其 value  
  27.     case'NumberLiteral':  
  28.       return node.value;  
  29.     // 字符串,用双引号包裹再输出  
  30.     case'StringLiteral':  
  31.       return'"' + node.value + '"';  
  32.     // 当遇到无法识别的字符,抛出错误提示,并退出  
  33.     default:  
  34.       thrownewTypeError(node.type);  
  35.   }  

3.6 编译器测试

截止上一步,我们完成简易编译器的代码开发。接下来通过前面原始需求的代码,测试编译器效果如何: 

  1. const add = (a, b) => a + b;  
  2. const subtract = (a, b) => a - b;  
  3. const source = "(add 2 (subtract 4 2))" 
  4. const target = compiler(source); // "add(2, (subtract(4, 2));"  
  5. const result = eval(target); // Ok result is 4 

3.7 工作流程小结

总结 The Super Tiny Compiler 编译器整个工作流程:

「1、input => tokenizer => tokens」

「2、tokens => parser => ast」

「3、ast => transformer => newAst」

「4、newAst => generator => output」

其实多数编译器的工作流程都大致相同:

四、手写 Webpack 编译器

根据之前介绍的 The Super Tiny Compiler编译器核心工作流程,再来手写 Webpack 的编译器,会让你有种众享丝滑的感觉~

[[320851]] 

话说,有些面试官喜欢问这个呢。当然,手写一遍能让我们更了解 Webpack 的构建流程,这个章节我们简要介绍一下。

4.1 Webpack 构建流程分析

从启动构建到输出结果一系列过程:

1.  「初始化参数」

解析 Webpack 配置参数,合并 Shell 传入和 webpack.config.js 文件配置的参数,形成最后的配置结果。

2.  「开始编译」

上一步得到的参数初始化 compiler 对象,注册所有配置的插件,插件监听 Webpack 构建生命周期的事件节点,做出相应的反应,执行对象的 run 方法开始执行编译。

3.  「确定入口」

从配置的 entry 入口,开始解析文件构建 AST 语法树,找出依赖,递归下去。

4.  「编译模块」

递归中根据「文件类型」和 「loader 配置」,调用所有配置的 loader 对文件进行转换,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理。

5  「完成模块编译并输出」

递归完事后,得到每个文件结果,包含每个模块以及他们之间的依赖关系,根据 entry 配置生成代码块 chunk 。

 6. 「输出完成」

输出所有的 chunk 到文件系统。

注意:在构建生命周期中有一系列插件在做合适的时机做合适事情,比如 UglifyPlugin 会在 loader 转换递归完对结果使用 UglifyJs 压缩「覆盖之前的结果」。

4.2 代码实现

手写 Webpack 需要实现以下三个核心方法:

  •  createAssets : 收集和处理文件的代码;
  •  createGraph :根据入口文件,返回所有文件依赖图;
  •  bundle : 根据依赖图整个代码并输出;

1. createAssets 

  1. function createAssets(filename){  
  2.     const content = fs.readFileSync(filename, "utf-8"); // 根据文件名读取文件内容  
  3.     // 将读取到的代码内容,转换为 AST  
  4.     const ast = parser.parse(content, {  
  5.         sourceType: "module"// 指定源码类型  
  6.     })  
  7.     const dependencies = []; // 用于收集文件依赖的路径  
  8.     // 通过 traverse 提供的操作 AST 的方法,获取每个节点的依赖路径  
  9.     traverse(ast, {  
  10.         ImportDeclaration: ({node}) => {  
  11.             dependencies.push(node.source.value);  
  12.         }  
  13.     });  
  14.     // 通过 AST 将 ES6 代码转换成 ES5 代码  
  15.     const { code } = babel.transformFromAstSync(ast, null, {  
  16.         presets: ["@babel/preset-env"]  
  17.     });  
  18.     let id = moduleId++;  
  19.     return {  
  20.         id,  
  21.         filename,  
  22.         code,  
  23.         dependencies  
  24.     }  

2. createGraph 

  1. function createGraph(entry) {  
  2.     const mainAsset = createAssets(entry); // 获取入口文件下的内容  
  3.     const queue = [mainAsset];  
  4.     for(const asset of queue){  
  5.         const dirname = path.dirname(asset.filename);  
  6.         asset.mapping = {};  
  7.         asset.dependencies.forEach(relativePath => {  
  8.             const absolutePath = path.join(dirname, relativePath); // 转换文件路径为绝对路径  
  9.             const child = createAssets(absolutePath);  
  10.             asset.mapping[relativePath] = child.id;  
  11.             queue.push(child); // 递归去遍历所有子节点的文件  
  12.         })  
  13.     }  
  14.     return queue;  

3. bunlde 

  1. function bundle(graph) {  
  2.     let modules = "" 
  3.     graph.forEach(item => {  
  4.         modules += `  
  5.             ${item.id}: [  
  6.                 function (require, module, exports){  
  7.                     ${item.code}  
  8.                 },  
  9.                 ${JSON.stringify(item.mapping)}  
  10.             ],  
  11.         `  
  12.     })  
  13.     return`  
  14.         (function(modules){  
  15.             function require(id){  
  16.                 const [fn, mapping] = modules[id];  
  17.                 function localRequire(relativePath){  
  18.                     return require(mapping[relativePath]);  
  19.                 }  
  20.                 const module = {  
  21.                     exports: {}  
  22.                 }  
  23.                 fn(localRequire, module, module.exports);  
  24.                 return module.exports;  
  25.             }  
  26.             require(0);  
  27.         })({${modules}})  
  28.     `  

五、总结

本文从编译器概念和基本工作流程开始介绍,然后通过 The Super Tiny Compiler 译器源码,详细介绍核心工作流程实现,包括「词法分析器」、「语法分析器」、「遍历器」和「转换器」的基本实现,最后通过「代码生成器」,将各个阶段代码结合起来,实现了这个号称「可能是有史以来最小的编译器。」

本文也简要介绍了「手写 Webpack 的实现」,需要读者自行完善和深入哟!

「是不是觉得很神奇~」

[[320852]]

当然通过本文学习,也仅仅是编译器相关知识的边山一脚,要学的知识还有非常多,不过好的开头,更能促进我们学习动力。加油!

最后,文中介绍到的代码,我存放在 Github 上:

  1.  [learning]the-super-tiny-compiler.js[4]
  2.  [writing]webpack-compiler.js[5]

 

 

责任编辑:庞桂玉 来源: 前端大全
相关推荐

2022-08-02 08:11:41

监控埋点埋点方式插桩

2018-01-05 14:48:03

前端JavaScript富文本编辑器

2012-04-05 09:13:17

C代码

2014-05-04 12:51:21

Javascript编译器

2023-11-15 17:58:58

C++代码

2023-04-14 10:40:45

工具编译器优化

2010-03-26 16:23:07

Visual Stud

2015-08-10 11:09:09

Python代码Python

2010-08-04 11:37:44

PHP NFS

2024-04-30 08:05:15

Rust代码计算

2022-03-28 10:25:27

前端文件编译器

2010-07-17 00:53:50

CMD Telnet

2022-02-28 12:07:56

RxJS函数式

2021-09-09 05:57:57

JS模块打包器前端

2020-02-25 11:15:46

代码开发AI

2019-09-29 10:29:55

Python 开发编程语言

2021-06-18 10:12:09

JS代码前端

2018-02-08 16:45:22

前端JS粘贴板

2021-04-26 09:04:13

Python 代码音乐

2020-03-12 10:16:45

代码Java多线程
点赞
收藏

51CTO技术栈公众号