一直以来,前端同学们对于编译原理都存在着复杂的看法,大部分人都觉得自己写业务也用不到这么高深的理论知识,况且编译原理晦涩难懂,并不能提升自己在前端领域内的专业知识。我不觉得这种想法有什么错,况且我之前也是这么认为的。而在前端领域内,和编译原理强相关的框架与工具类库主要有这么几种:
?.
与 ??
对应的 babel-plugin-optional-chaining[1] 与 babel-plugin-nullish-coalescing-operator[2],这一类工具还有 ESBuild 、swc 等。类似的,还有 Scss、Less 这一类最终编译到 CSS 的“超集”。这一类工具的特点是转换前的代码与转换产物实际上是同一层级的,它们的目标是得到标准环境能够运行的产物。.vue``.svelte``.astro
这一类特殊的语法。这一类工具的特点是,转换后的代码可能会有多种产物,如 Vue 的 SFC 最终会构建出 HTML、CSS、JavaScript。.graphql
)、Prisma (.prisma
) 这一类工具库(还有更熟悉一些的,如 HTML、SQL、Lex、XML 等),其不需要被编译为 JavaScript,如 .graphql
文件直接由 GraphQL 各个语言自己实现的 Engine 来消费。无论是哪一种情况,似乎对于非科班前端的同学来说都是地狱难度,但其实社区一直有各种各样的方案,来尝试降低 AST 操作的成本,如 FB 的 jscodeshift[3],相对于 Babel 的 Visitor API,jscodeshift 提供了命令式 + 链式调用的 API,更符合前端同学的认知模式(因为就像 Lodash、RxJS 这样),看看它们是怎么用的:
示例来自于 神光[4] 老师的文章。由于本文的重点并不是 jscodeshift 与 gogocode,这里就直接使用现成的示例了。
// Babel
const { declare } = require("@babel/helper-plugin-utils");
const noFuncAssignLint = declare((api, options, dirname) => {
api.assertVersion(7);
return {
pre(file) {
file.set("errors", []);
},
visitor: {
AssignmentExpression(path, state) {
const errors = state.file.get("errors");
const assignTarget = path.get("left").toString();
const binding = path.scope.getBinding(assignTarget);
if (binding) {
if (
binding.path.isFunctionDeclaration() ||
binding.path.isFunctionExpression()
) {
const tmp = Error.stackTraceLimit;
Error.stackTraceLimit = 0;
errors.push(
path.buildCodeFrameError("can not reassign to function", Error)
);
Error.stackTraceLimit = tmp;
}
}
},
},
post(file) {
console.log(file.get("errors"));
},
};
});
module.exports = noFuncAssignLint;
// jscodeshift
module.exports = function (fileInfo, api) {
return api
.jscodeshift(fileInfo.source)
.findVariableDeclarators("foo")
.renameTo("bar")
.toSource();
};
虽然以上并不是同一类操作的对比,但还是能看出来二者 API 风格的差异。
以及 阿里妈妈 的 gogocode[5],它基于 Babel 封装了一层,得到了类似 jscodeshift 的命令式 + 链式 API,同时其 API 命名也能看出来主要面对的的是编译原理小白,jscodeshift 还有 findVariableDeclaration
这种方法,但 gogocode 就完全是 find
、replace
这种了:
$(code)
.find("var a = 1")
.attr("declarations.0.id.name", "c")
.root()
.generate();
看起来真的很简单,但这么做也可能会带来一定的问题,为什么 Babel 要采用 Visitor API?类似的,还有 GraphQL Tools[6] 中,对 GraphQL Schema 添加 Directive 时同样采用的是 Visitor API,如
import { SchemaDirectiveVisitor } from "graphql-tools";
export class DeprecatedDirective extends SchemaDirectiveVisitor {
visitSchema(schema: GraphQLSchema) {}
visitObject(object: GraphQLObjectType) {}
visitFieldDefinition(field: GraphQLField<any, any>) {}
visitArgumentDefinition(argument: GraphQLArgument) {}
visitInterface(iface: GraphQLInterfaceType) {}
visitInputObject(object: GraphQLInputObjectType) {}
visitInputFieldDefinition(field: GraphQLInputField) {}
visitScalar(scalar: GraphQLScalarType) {}
visitUnion(union: GraphQLUnionType) {}
visitEnum(type: GraphQLEnumType) {}
visitEnumValue(value: GraphQLEnumValue) {}
}
Visitor API 是声明式的,我们声明对哪一部分语句做哪些处理,比如我要把所有符合条件 If 语句的判断都加上一个新的条件,然后 Babel 在遍历 AST 时(@babel/traverse
),发现 If 语句被注册了这么一个操作,那就执行它。而 jscodeshift、gogocode 的 Chaining API 则是命令式(Imperative)的,我们需要先获取到 AST 节点,然后对这个节点使用其提供(封装)的 API,这就使得我们很可能遗漏掉一些边界情况而产生不符预期的结果。
而 TypeScript 的 API 呢?TypeScript 的 Compiler API 是绝大部分开放的,足够用于做一些 CodeMod、AST Checker 这一类的工具,如我们使用原生的 Compiler API ,来组装一个函数:
import * as ts from "typescript";
function makeFactorialFunction() {
const functionName = ts.factory.createIdentifier("factorial");
const paramName = ts.factory.createIdentifier("n");
const paramType = ts.factory.createKeywordTypeNode(
ts.SyntaxKind.NumberKeyword
);
const paramModifiers = ts.factory.createModifier(
ts.SyntaxKind.ReadonlyKeyword
);
const parameter = ts.factory.createParameterDeclaration(
undefined,
[paramModifiers],
undefined,
paramName,
undefined,
paramType
);
// n <= 1
const condition = ts.factory.createBinaryExpression(
paramName,
ts.SyntaxKind.LessThanEqualsToken,
ts.factory.createNumericLiteral(1)
);
const ifBody = ts.factory.createBlock(
[ts.factory.createReturnStatement(ts.factory.createNumericLiteral(1))],
true
);
const decrementedArg = ts.factory.createBinaryExpression(
paramName,
ts.SyntaxKind.MinusToken,
ts.factory.createNumericLiteral(1)
);
const recurse = ts.factory.createBinaryExpression(
paramName,
ts.SyntaxKind.AsteriskToken,
ts.factory.createCallExpression(functionName, undefined, [decrementedArg])
);
const statements = [
ts.factory.createIfStatement(condition, ifBody),
ts.factory.createReturnStatement(recurse),
];
return ts.factory.createFunctionDeclaration(
undefined,
[ts.factory.createToken(ts.SyntaxKind.ExportKeyword)],
undefined,
functionName,
undefined,
[parameter],
ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword),
ts.factory.createBlock(statements, true)
);
}
const resultFile = ts.createSourceFile(
"func.ts",
"",
ts.ScriptTarget.Latest,
false,
ts.ScriptKind.TS
);
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
const result = printer.printNode(
ts.EmitHint.Unspecified,
makeFactorialFunction(),
resultFile
);
console.log(result);
以上的代码将会创建这么一个函数:
export function factorial(readonly n: number): number {
if (n <= 1) {
return 1;
}
return n * factorial(n - 1);
}
可以看到,TypeScript Compiler API 属于命令式,但和 jscodeshift 不同,它的 API 不是链式的,更像是组合式的?我们从 identifier 开始创建,组装参数、if 语句的条件与代码块、函数的返回语句,最后通过 createFunctionDeclaration
完成组装。简单的看一眼就知道其使用成本不低,你需要对 Expression、Declaration、Statement 等相关的概念有比较清晰地了解,比如上面的 If 语句需要使用哪些 token 来组装,还需要了解 TypeScript 的 AST,如 interface、类型别名、装饰器等(你可以在 ts-ast-viewer[7] 实时的查看 TypeScript AST 结构)。
因此,在这种情况下 ts-morph[8] 诞生了(原 ts-simple-ast ),它在 TypeScript Compiler API 的基础上做了一层封装,大大降低了使用成本,如上面的例子转换为 ts-morph 是这样的:
import { Project } from "ts-morph";
const s = new Project().createSourceFile("./func.ts", "");
s.addFunction({
isExported: true,
name: "factorial",
returnType: "number",
parameters: [
{
name: "n",
isReadonly: true,
type: "number",
},
],
statements: (writer) => {
writer.write(`
if (n <=1) {
return 1;
}
return n * factorial(n - 1);
`);
},
}).addStatements([]);
s.saveSync();
console.log(s.getText());
是的,为了避免像 TypeScript Compiler API 那样组装的场景,ts-morph 没有提供创建 IfStatement 这一类语句的 API 或者是相关能力,最方便的方式是直接调用 writeFunction 来直接写入。
很明显,这样的操作是有利有弊的,我们能够在创建 Function、Class、Import 这一类声明时,直接传入其结构即可,但对于函数(类方法)内部的语句,ts-morph 目前的确只提供了这种最简单的能力,这在很多场景下可能确实降低了很多成本,但也注定了无法使用在过于复杂或是要求更严格的场景下。
我在写到这里时突然想到了一个特殊的例子:Vite[9],众所周知,Vite 会对依赖进行一次重写,将裸引入(Bare Import)转换为能实际链接到代码的正确导入,如 import consola from 'consola'
会被重写为 import consola from '/node_modules/consola/src/index.js'
(具体路径由 main 指定,对于 esm 模块则会由 module 指定) ,这一部分的逻辑里主要依赖了 magic-string
和 es-module-lexer
这两个库,通过 es-module-lexer
获取到导入语句的标识在整个文件内部的起始位置、结束位置,并通过 magic-string
将其替换为浏览器能够解析的相对导入(如 importAnalysisBuild.ts[10])。这也带来了一种新的启发:对于仅关注特定场景的代码转换,如导入语句之于 Vite,装饰器之于 Inversify、TypeDI 这样的场景,大动干戈的使用 AST 就属于杀鸡焉用牛刀了。同样的,在只是对粒度较粗的 AST 节点(如整个 Class 结构)做操作时,ts-morph 也有着奇效。
实际上可能还是有类似的场景:
fs
即为 import fs from 'fs'
的标识符,也即是 Module Specifier),哪些是具名导入(import { spawn } from 'child_process'
),哪些是仅类型导入 (import type { Options } from 'prettier'
),然后对应的做一些操作,ts-morph 的复杂度还是超出了我的预期。做了这么多铺垫,是时候迎来今天的主角了,@ts-morpher[11] 基于 ts-morph 之上又做了一层额外封装,如果说 TypeScript Compiler API 的复杂度是 10,那么 ts-morph 的复杂度大概是 4,而 @ts-morpher 的复杂度大概只有 1 不到了。作为一个非科班、没学过编译原理、没玩过 Babel 的前端仔,它是我在需要做 AST Checker、CodeMod 时产生的灵感。
我们知道,AST 操作通常可以很轻易的划分为多个单元(如果你之前不知道,恭喜你现在知道了),比如获取节点-检查节点-修改节点 1-修改节点 2-保存源文件,这其中的每一个部分都是可以独立拆分的,如果我们能像 Lodash 一样调用一个个职责明确的方法,或者像 RxJS 那样把一个个操作符串(pipe)起来,那么 AST 操作好像也没那么可怕了。可能会有同学说,为什么要套娃?一层封一层?那我只能说,管它套娃不套娃呢,好用就完事了,什么 Declaration、Statement、Assignment...,我直接统统摁死,比如像这样(更多示例请参考官网):
import { Project } from "ts-morph";
import path from "path";
import fs from "fs-extra";
import { createImportDeclaration } from "@ts-morpher/creator";
import { checkImportExistByModuleSpecifier } from "@ts-morpher/checker";
import { ImportType } from "@ts-morpher/types";
const sourceFilePath = path.join(__dirname, "./source.ts");
fs.rmSync(sourceFilePath);
fs.ensureFileSync(sourceFilePath);
const p = new Project();
const source = p.addSourceFileAtPath(sourceFilePath);
createImportDeclaration(source, "fs", "fs-extra", ImportType.DEFAULT_IMPORT);
createImportDeclaration(source, "path", "path", ImportType.NAMESPACE_IMPORT);
createImportDeclaration(
source,
["exec", "execSync", "spawn", "spawnSync"],
"child_process",
ImportType.NAMED_IMPORT
);
createImportDeclaration(
source,
// First item will be regarded as default import, and rest will be used as named imports.
["ts", "transpileModule", "CompilerOptions", "factory"],
"typescript",
ImportType.DEFAULT_WITH_NAMED_IMPORT
);
createImportDeclaration(
source,
["SourceFile", "VariableDeclarationKind"],
"ts-morph",
ImportType.NAMED_IMPORT,
true
);
这一连串的方法调用会创建:
import fs from "fs-extra";
import * as path from "path";
import { exec, execSync, spawn, spawnSync } from "child_process";
import ts, { transpileModule, CompilerOptions, factory } from "typescript";
import type { SourceFile, VariableDeclarationKind } from "ts-morph";
再看一个稍微复杂点的例子:
import { Project } from "ts-morph";
import path from "path";
import fs from "fs-extra";
import {
createBaseClass,
createBaseClassProp,
createBaseClassDecorator,
createBaseInterfaceExport,
createImportDeclaration,
} from "@ts-morpher/creator";
import { ImportType } from "@ts-morpher/types";
const sourceFilePath = path.join(__dirname, "./source.ts");
fs.rmSync(sourceFilePath);
fs.ensureFileSync(sourceFilePath);
const p = new Project();
const source = p.addSourceFileAtPath(sourceFilePath);
createImportDeclaration(
source,
["PrimaryGeneratedColumn", "Column", "BaseEntity", "Entity"],
"typeorm",
ImportType.NAMED_IMPORTS
);
createBaseInterfaceExport(
source,
"IUser",
[],
[],
[
{
name: "id",
type: "number",
},
{
name: "name",
type: "string",
},
]
);
createBaseClass(source, {
name: "User",
isDefaultExport: true,
extends: "BaseEntity",
implements: ["IUser"],
});
createBaseClassDecorator(source, "User", {
name: "Entity",
arguments: [],
});
createBaseClassProp(source, "User", {
name: "id",
type: "number",
decorators: [{ name: "PrimaryGeneratedColumn", arguments: [] }],
});
createBaseClassProp(source, "User", {
name: "name",
type: "string",
decorators: [{ name: "Column", arguments: [] }],
});
这些代码将会创建:
import { PrimaryGeneratedColumn, Column, BaseEntity, Entity } from "typeorm";
export interface IUser {
id: number;
name: string;
}
@Entity()
export default class User extends BaseEntity implements IUser {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
}
其实本质上没有什么复杂的地方,就是将 ts-morph 的链式 API 封装好了针对于常用语句类型的增删改查方法:
@ts-morpher/helper
中的方法均用于获取声明或声明 Identifier ,如你可以获取一个文件里所有的导入的 Module Specifier(fs
之于 import fsMod from 'fs'
),也可以获取所有导入的声明,但是你不用管这个声明长什么样,直接扔给 @ts-morpher/checker
,调用 checkImportType
,看看这是个啥类型导入。为什么我要搞这个东西?因为在我目前的项目中需要做一些源码级的约束,如我想要强制所有主应用与子应用的入口文件,都导入了某个新的 SDK,如 import 'foo-error-reporter'
,如果没有导入的话,那我就给你整一个!由于不是所有子应用、主应用都能纳入管控,因此就需要这么一个究极强制卡口来放到 CI 流水线上。如果这样的话,那么用 ts-morph 可能差不多够了,诶,不好意思,我就是觉得 AST 操作还可以更简单一点,干脆自己再搞一层好了。
它也有着 100% 的单测覆盖率和 100+ 方法,而是说它还没有达到理想状态,比如把 AST 操作的复杂度降到 0.5 以下,这一点我想可以通过提供可视化的 playground,让你点击按钮来调用方法,同时实时的预览转换结果,还可以在这之上组合一些常见的能力,如合并两个文件的导入语句,批量更改 JSX 组件等等。
这也是我从零折腾 AST 一个月来的些许收获,希望你能有所收获~
[1]babel-plugin-optional-chaining: https://github.com/babel/babel/blob/main/packages/babel-plugin-proposal-optional-chaining
[2]babel-plugin-nullish-coalescing-operator: https://github.com/babel/babel/blob/main/packages/babel-plugin-proposal-nullish-coalescing-operator
[3]jscodeshift: https://github.com/facebook/jscodeshift
[4]神光: https://www.zhihu.com/people/di-xu-guang-50
[5]gogocode: https://gogocode.io/
[6]GraphQL Tools: https://github.com/ardatan/graphql-tools
[7]ts-ast-viewer: https://ts-ast-viewer.com/#
[8]ts-morph: https://ts-morph.com/
[9]Vite: https://github.com/vitejs/vite
[10]importAnalysisBuild.ts: https://github.com/vitejs/vite/blob/545b1f13cec069bbae5f37c7540171128f439e7b/packages/vite/src/node/plugins/importAnalysisBuild.ts#L217
[11]@ts-morpher: https://ts-morpher-docs.vercel.app/
本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/Qku6-oSOhiveeo_WlSfMzQ
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。