前景回顾
客户端视角下的babel(理论篇)介绍了AST(抽象语法树)的概念,以及Babel作为JavaScript编译器的运作原理。我们深入讲解了Babel的各个阶段,详细介绍了它们所具备的基础概念及相应的功能。最后,我们阐述了Babel插件如何对AST节点进行操作和转换的。与上篇理论相比,本文将围绕TN项目中的一些具体案例,重点介绍Babel在实践应用中的功能和用途。
总览
在正式讲述具体项目前,我们还是按照惯例先提几个问题(带着问题感触更深:)
-
TN从Source Code到AST经历了哪些阶段?
-
这些阶段TN处理了哪些事情,遇到了哪些难题,并如何解决的?
问题1:
image.pngTN会在编译期间通过Babel将Source Code转成AST。其中核心的两个阶段:parse、transform
细节的内容这边就不一一展开了,客户端视角下的babel(理论篇)讲解的非常详细了
问题2:
TN任务的处理主要在transform阶段。在该阶段我们具体处理了哪些事项呢?
-
支持高级语法:比如Enum(JavaScript中是没有枚举类型的)
-
支持语法降级:比如foreach降级到for
-
移除换行符多出来的JSXText节点
-
多文件依赖导入
-
变量名冲突
-
函数声明提升到作用域的顶部
-
...
针对不同种类的事项,我们可以创建不同的业务插件,来对节点进行添加、更新、移除等操作,从而达到我们的诉求。
当然,期间也遇到一些难点:比如多文件依赖如何有序导入、变量名冲突如何解决;对于换行符多出来的JSXText节点,我们如何处理,并保证处理的结果和SolidJS是一致的等等。
下面我们将讲述具体的几个事项,来帮助我们更好的了解TN的处理机制,以及难点问题的解决方式。
实践
前景声明
实践项目中会以SolidJS的表现作为依据去处理节点。为何是SolidJS而非React等其他前端框架呢?归根结底取决于TN的愿景:极致的渲染速度+开发体验效率。
下面会简单说明为何使用SolidJS作为首选的前端开发框架,更加详细的内容可参考
开发体验和效率
逻辑表达式 VS React
语法与React相似,实现与Composition API相似
useState -> createSignal
useMemo -> createMemo
useEffect -> createEffect
useLayoutEffect -> createRenderEffect
PS:Composition API 是 Vue 3 新增的 API,旨在解决 Vue 2 中大型组件的可读性、代码重用和类型安全等问题。Composition API 的核心思想是将组件的逻辑按照功能划分为不同的逻辑部分(composition),然后通过组合这些逻辑部分来创建复杂的组件逻辑。
依赖追踪 VS Vue3
Solid和Vue3相似,定义响应式数据,添加副作用函数,只不过Solid是读写分离的。
Solid 是一款基于 JavaScript 的 UI 库,它的读写分离是指对 UI 组件状态进行分离,将其分为可读状态和可写状态。具体来说,Solid 将各个 UI 组件的状态分解为两部分:
- 可读状态:指 UI 组件的非响应式数据和方法,即只能被访问而不能进行修改的状态。这些状态的访问不会导致 UI 重新渲染。Solid 通过将这些状态管理在普通 JavaScript 对象中,来实现它们的可访问性。
- 可写状态:指 UI 组件的响应式数据和方法,即在修改状态时会导致 UI 重新渲染的状态。Solid 通过将这些状态管理在响应式数据流中,来实现它们的响应式。
基于编译的运行时优化
在React与Vue中存在一层「虚拟DOM」(React中叫Fiber树)。
每当发生更新,「虚拟DOM」会进行比较(Diff算法),比较的结果会执行不同的DOM操作(增、删、改)。
而SolidJS在发生更新时,可以直接调用编译好的DOM操作方法,省去了「虚拟DOM比较」这一步所消耗的时间。
enum转换
enum 是 TypeScript 中的一个特性,它是一种枚举类型,用于定义具有固定值的常量集合。而在 JavaScript 中,目前并没有原生的 enum 类型。
当你定义了一个枚举
enum Fruit {
Apple = 0,
Banner = 1
}
其被编译成 JavaScript 后如下所示:
var Fruit = /*#__PURE__*/function (Fruit) {
Fruit[Fruit["Apple"] = 0] = "Apple";
Fruit[Fruit["Banner"] = 1] = "Banner";
return Fruit;
}(Fruit || {});
对应的AST如下:
{
"type":"VariableDeclaration",
"kind":"var",
"declarations":[
{
"type":"VariableDeclarator",
"id":{
...
"name":"Fruit"
},
"init":{
"type":"CallExpression",
"callee":{
"type":"FunctionExpression",
...
"body":{
"type":"BlockStatement",
"body":[
{
"type":"ExpressionStatement",
"expression":{
"type":"AssignmentExpression",
"operator":"=",
"left":{
"type":"MemberExpression",
...
"property":{
"type":"AssignmentExpression",
"operator":"=",
"left":{
"type":"MemberExpression",
...
"property":{
"type":"StringLiteral",
"value":"Apple"
},
},
"right":{
"type":"NumericLiteral",
"value":0
}
},
}
}
},
{
"type":"ExpressionStatement",
"expression":{
"type":"AssignmentExpression",
"operator":"=",
"left":{
"type":"MemberExpression",
...
},
"property":{
"type":"AssignmentExpression",
"operator":"=",
"left":{
"type":"MemberExpression",
...
"property":{
"type":"StringLiteral",
"value":"Banner"
},
},
"right":{
"type":"NumericLiteral",
"value":1
}
},
}
}
},
...
]
},
...
},
...
}
}
],
...
}
拿到上述AST后,我们发现一个简单的枚举被paser后变得极其复杂,而且可读性极差。那么是否有办法可以将其处理的精简并且可读化呢?答案是肯定的:通过构建TSEnumDeclaration的转换插件就可以完成目标
visitor: {
VariableDeclaration(path) {
if (path.node.type === 'VariableDeclaration' && (path.node.kind === 'var' || path.node.kind === 'let') && path.node.declarations.length === 1) {
const declarationNode = path.node.declarations[0];
if (declarationNode.type === 'VariableDeclarator' && declarationNode.init?.type === 'CallExpression' && declarationNode.init?.callee?.type === 'FunctionExpression') {
const enumName = declarationNode.id.name;
const entries = [];
declarationNode.init.callee?.body?.body.filter(function(propertyNode) {
return propertyNode.type === 'ExpressionStatement';
}).forEach(propertyNode => {
// enum Fruit {
// Apple = "apple",
// Banana = "banana",
// Orange = "orange"
// }
// enum Fruit {
// Apple,
// Banana,
// Orange
// }
// enum构建
const memberName = propertyNode.expression.left.property.left?.property.value || propertyNode.expression.left.property.value;
// initial value 处理
const rightValue = propertyNode.expression.right.value;
const leftValue = propertyNode.expression.left.property.right?.value;
const memberValue = typeof leftValue === 'undefined' || leftValue === null || leftValue === '' ? rightValue : leftValue;
entries.push(
tsEnumMember(identifier(memberName), typeof memberValue === 'string' ? stringLiteral(memberValue) : numericLiteral(memberValue))
);
});
// TSEnumDeclaration
const tsEnumDeclWithComment = tsEnumDeclaration(identifier(enumName), entries);
...
}
}
}
}
PS: 节点类型的判断可以根据type值判断,也可以根据@babel/types定义的isX进行判断,两者等价
上述AST节点经转换后,我们就得到了预期的结果:
{
type: 'TSEnumDeclaration',
id: { type: 'Identifier', name: 'Fruit' },
members: [
{ type: 'TSEnumMember', id: [Object], initializer: [Object] },
{ type: 'TSEnumMember', id: [Object], initializer: [Object] }
]
}
{"type":"TSEnumDeclaration","id":{"type":"Identifier","name":"Fruit"},"members":[{"type":"TSEnumMember","id":{"type":"Identifier","name":"Apple"},"initializer":{"type":"NumericLiteral","value":0}},{"type":"TSEnumMember","id":{"type":"Identifier","name":"Banner"},"initializer":{"type":"NumericLiteral","value":1}}]}
换行符处理
我们通过几个例子看下Babel对于换行符是如何处理的(不添加插件默认输出的结果)
- Case1:文本标签换行,换行符会包含在文本内容中(eg:文本数据前后包含上下的换行符及前置的空格符)
<text>
文本数据
</text>
"children": [
{
"type": "JSXText",
"value": "\n 文本数据\n",
"raw": "\n 文本数据\n"
}
]
- Case2:标签换行会生产多余的JSXText节点(eg:view标签换行)
<view>
<text>
文本数据1
</text>
</view>
"children": [
{
"type": "JSXText",
"value": "\n\t",
"raw": "\n\t"
},
{
...
"children": [
{
"type": "JSXText",
"value": "\n \t\t文本数据1\n\t",
"raw": "\n \t\t文本数据1\n\t"
}
]
},
{
"type": "JSXText",
"value": "\n",
"raw": "\n"
}
],
通过上述case,我们大概发现了一个结论:如果在原始的JSX代码中有多余的换行符,则Babel将这些换行符解释为一个JSXText元素,这样可能会导致最终渲染出来的DOM树中多出一些空行节点。
那我们看下React是如何处理空行符的?
image.png通过上述demo我们非常清晰看到,首先标签换行并不会产生多余的换行符;文本内容如果需要换行则需要{'\n'}表示,否则换行效果只是多一个空格;同一行文本内部有多个空格,则显示多个空格,同一行文本的首尾空格不会显示。
看完React,我们了解下SolidJS是怎么样的一个现象
image.pngSolidJS和React处理类似,区别点在于:两个文本间多个空格符会合并成单个;换行符会转换成单空格。
基于SolidJS对空行符的处理机制,处理JSX空行符的业务插件具体实现就很明确了
visitor: {
JSXText(path) {
// 获取当前节点在父节点中的前一个相邻节点
const prevSibling = path.getSibling(path.key - 1)
// 获取当前节点在父节点中的下一个相邻节点
const nextSibling = path.getSibling(path.key + 1)
// 判断该节点是否只包含空白字符或换行符
if (path.node.value.trim() === '') {
if (!prevSibling.node) {
path.remove()
} else if (prevSibling.node.type === "JSXElement") {
path.remove()
} else if (!nextSibling.node) { // 末节点
path.remove()
} else if (prevSibling.node.type === 'JSXExpressionContainer') { // {count()}
const text = path.node.value.replace(/\s+/g, " ") // 前置后置中间空格>=1替换为一个空格
path.node.value = text
}
} else { // 文本中存在多个空格、换行,变成单个空格
var text = path.node.value
if (!prevSibling.node && !nextSibling.node) { // 单一节点
text = text.trim() // 去除首尾空格
text = text.replace(/\s+/g, " ") // 前置后置中间空格>=1替换为一个空格
} else if (!prevSibling.node) { // 首节点
text = text.trimStart() // 去除前置空格
text = text.replace(/\s+/g, " ") // 前置后置中间空格>=1替换为一个空格
} else if (!nextSibling.node) { // 末节点
text = text.replace(/\s+/g, " ") // 前置后置中间空格>=1替换为一个空格
text = text.trimEnd() // 去除后置空格
} else {
text = text.replace(/\s+/g, " ") // 前置后置中间空格>=1替换为一个空格
}
path.node.value = text
}
}
}
基于上述插件,Case1的输出结果:
{
"type":"JSXElement",
...
"openingElement":Object{...},
"closingElement":Object{...},
"children":[
{
"type":"JSXText",
...
"value":"文本数据"
}]
}
Case2的输出结果:
{
"type":"JSXElement",
...
"openingElement":Object{...},
"closingElement":Object{...},
"children":[
{
"type":"JSXElement",
...
"openingElement":Object{...},
"closingElement":Object{...},
"children":[
{
"type":"JSXText",
...
"value":"文本数据1"
}
]
}]
}
多import导入解决变量名冲突
TN会将所有模块打包成一个单一的Bundle文件,然后通过包管理下发到Native侧。所以如何将import到的外部依赖文件导入进来,并处理好变量名冲突问题,就成了关键。
说到打包,我们肯定会想到webpack。那么webpack是如何导入并解决变量名冲突问题的呢?
input:
// index.js
import {targetVersionCompare} from './util.js'
targetVersionCompare('11')
// util.js
import { testValue} from "./utilA.js";
const isEmptyString = (str) =>
typeof str === 'undefined' || str == null || str === '';
const a = "1111"
export const targetVersionCompare = (target) => {
console.log(testValue)
if (isEmptyString(target)) {
return false
}
console.log(a)
return false
}
// utilA.js
export const testValue = 'A'
const a = "xxxx"
console.log(a)
webpack dist:
/******/ (() => { // webpackBootstrap
/******/ "use strict";
var __webpack_exports__ = {};
;// CONCATENATED MODULE: ./utilA.js
const testValue = 'A'
const a = "xxxx"
console.log(a)
;// CONCATENATED MODULE: ./util.js
const isEmptyString = (str) =>
typeof str === 'undefined' || str == null || str === '';
const util_a = "1111"
const targetVersionCompare = (target) => {
console.log(testValue)
if (isEmptyString(target)) {
return false
}
console.log(util_a)
return false
}
;// CONCATENATED MODULE: ./index.jsx
targetVersionCompare('11')
/******/ })()
;
从上述源码输入到最后的产物对比,我们大概了解了webpack处理多文件导入的机制。
-
如果导入的文件某个变量名和当前文件变量名冲突,则添加前缀:当前文件名变量名序列
-
变量名a冲突:a->util_a
-
变量名a冲突,存在变量名util_a:a->util_a_0
-
变量名a冲突,存在变量名util_a,util_a_0:a->util_a_1
-
export导出标识会被remove
-
加载依赖文件的顺序:递归导入。当A依赖B、A依赖C,B依赖D,则导入的顺序依次为D-B-C-A
方案设计
webpack的处理机制我们大概了解了,整体的大致流程是怎么样的呢?
简要流程图
image.png流程概述
-
创建remove-export-name-plugin:移除导出标识
-
创建import-declaration-plugin
-
递归读取依赖的文件
-
生成唯一标识 By 完整文件路径
-
链表记录前后依赖&AST、export数据绑定
-
import导入数据处理
-
反转链表
-
diff
-
重命名冲突处理
-
节点合并
方案实现
移除export
visitor: {
ExportNamedDeclaration(path) {
const { node } = path
const { declaration, specifiers } = node
if (declaration) {
path.replaceWith(declaration);
}
},
ExportDefaultDeclaration(path) {
const { node } = path
const { declaration } = node
if (declaration) {
path.replaceWith(declaration);
} else {
path.remove();
}
}
}
import-declaration-plugin
- 递归读取依赖的文件
当我们输入import { testValue} from "./utilA.js"语句的时候,AST会生成节点:ImportDeclaration,其导入的文件相对路径也会在source字段下展示。如下所示:
image.png那么如何根据import的相对路径去构建完成的文件路径呢?
首先我们需要明白一点:导入文件的相对路径只有对于当前文件是透明的,而文件导入依赖是递归的,所以我们需要做的就是用一个栈记录当前import file路径依赖,导入文件的时候压栈,离开文件的时候出栈。
PS:另外JS在导入文件的时候,可以选择性的不写文件后缀名,需要单独处理
const dependFilePathStack = [] // 栈记录当前import file路径依赖
const constructFile = (importModulePath) => {
const curFilePath = dependFilePathStack.slice(-1)[0] || source // 获取栈顶元素
var filePath = path.resolve(path.dirname(curFilePath), importModulePath)
const dirPath = path.resolve(filePath, '..'); // 获取上一级目录
if (path.extname(filePath)) { // 当前文件路径存在后缀
return filePath
}
// 获取目录下所有文件
const fileNames = fs.readdirSync(dirPath)
// 获取文件名
const basename = path.basename(filePath);
// 查找指定文件名的后缀名(兼容.d.ts声明文件)
const targetFileName = fileNames.find(fullFileName => {
return path.basename(fullFileName, path.extname(fullFileName)) === basename || path.basename(fullFileName, '.d.ts') === basename;
})
// 组合文件路径和后缀名
return targetFileName ? `${dirPath}/${targetFileName}` : ''
}
- 生成唯一标识 By 完整文件路径
tn处理重命名冲突规则和webpack稍微有点差异。差异点:webpack可以理解为在"运行时"去处理重命名冲突,即后置判断;而tn为了简化逻辑,会在“编译时”即前置阶段直接生成一个唯一标识,当命名冲突的时候,直接使用唯一标识去替换。
- 链表记录前后依赖&AST、export数据绑定
import文件导入是递归读取的,我们可以使用链表来存储文件之间的依赖关系。节点的定义如下:next 表示当前文件导入的下一个依赖文件,nameSpace 表示当前文件的唯一标识,nodes 表示当前文件的 AST节点集合,exported 表示当前文件导出的集合。
class ImportNode {
constructor(next, nameSpace, nodes, exported) {
this.next = next
this.nameSpace = nameSpace
this.nodes = nodes
this.exported = exported
}
}
递归读取文件的同时,记录对应ImportNode的AST及exported相关数据
// 记录import依赖
const lasted = findLastedNode(path)
if (!lasted) {
// 绑定Head Node
const headNode = new ImportNode(null, nameSpace, null, null)
importHeadNode = headNode
} else {
lasted.next = new ImportNode(null, nameSpace, null, null)
}
// 获取ast节点集合
const importModuleAst = processedFilePaths[fullFilePath] || generateToAst(fullFilePath)
processedFilePaths[fullFilePath] = importModuleAst
const astBody = importModuleAst.program.body
// 绑定nodes
const target = findTargetNode(nameSpace)
target.nodes = astBody
// 绑定exported
target.exported = collectExportAs(astBody)
import-declaration-plugin组件大致实现如下:
var importHeadNode = null
const dependFilePathStack = [] // 栈记录当前import file路径依赖
...
visitor: {
ImportDeclaration(path) {
// 1.获取当前import module path
// 2.获取导入模块的代码并解析成 AST
const importModulePath = path.get('source').node.value
const fullFilePath = constructFile(importModulePath)
if (!fullFilePath || processedFilePaths[fullFilePath]) {
return;
}
// 构建namespace
const nameSpace = constructNameSpace(fullFilePath)
// 记录import依赖
const lasted = findLastedNode(path)
if (!lasted) {
// 绑定Head Node
const headNode = new ImportNode(null, nameSpace, null, null)
importHeadNode = headNode
...
} else {
lasted.next = new ImportNode(null, nameSpace, null, null)
}
dependFilePathStack.push(fullFilePath)
// traverseImportHead(importHeadNode)
const importModuleAst = processedFilePaths[fullFilePath] || generateToAst(fullFilePath)
processedFilePaths[fullFilePath] = importModuleAst
dependFilePathStack.pop()
// 导入文件的body
const astBody = importModuleAst.program.body
// 绑定nodes
const target = findTargetNode(nameSpace)
target.nodes = astBody
// 绑定exported
target.exported = collectExportAs(astBody)
}
}
import导入数据处理
- 反转链表
- tn和webpack对于依赖的文件导入顺序是一致的,比如A依赖B、A依赖C,B依赖D,则导入的顺序依次为D-B-C-A。因此我们需要反转链表,从链表的尾部节点倒序读入
const reverseLinkList = (importHeadNode) => {
const reverseList = []
var head = importHeadNode
while (head) {
reverseList.push(head)
head = head.next
}
return reverseList.reverse()
}
- diff
diff是重命名冲突的核心函数。当import文件导入时,有可能导入的Identifier与当前文件存在冲突。大致处理的规则如下:
-
获取当前import文件内容中声明的所有names
-
当前node与导入的前几个文件diff
-
读取下一个节点,重复1,2两个步骤
// 1\. 获取当前import文件内容中声明的所有names 2\. 当前node与导入的前几个文件diff
const dealImportDependies = (reverseImportList) => {
var importNodes = []
for (let index = 0; index < reverseImportList.length; index++) {
const node = reverseImportList[index]
// diff前 (原始声明)
const beforeDiffDeclarationNames = collectNodeDeclarationName(node)
// diff
diffImportFileDeclaration(importDeclarationNames(), node)
importNodes = [...importNodes, ...node.nodes]
// console.log(JSON.stringify(node))
// diff后(替换后的声明):部分声明diff后会重新替换
const afterDeclarationNames = collectNodeDeclarationName(node)
// 根据前后diff生成对照表
const nameUnion = declarationNameSpaceUnion(beforeDiffDeclarationNames, afterDeclarationNames)
declarationNameTable[node.nameSpace] = nameUnion
// 绑定export as
exportedAsDeclarationTable[node.nameSpace] = isNonEmptyObj(node.exported) ? node.exported : null
}
return importNodes
}
后续的node与前面import过的声明集合进行diff
/**
* dfs 节点下的叶子节点声明
* @param {*} input node
* @param {*} baseUnion 前置导入的file declaration name 集合
* @param {*} nameSpace 当前文件的namespace
* @param {*} importDeclarationNames 当前文件导入的import声明 import {A,B} from 'xxxx'
* @returns
*/
function dfsNodeDeclaration(input, baseUnion, nameSpace, importDeclarationNames) {
if(!input){
return
}
if(Array.isArray(input)){
for(let i = 0; i < input.length; i++){
dfsNodeDeclaration(input[i], baseUnion, nameSpace, importDeclarationNames)
}
}
if(input.type === 'FunctionDeclaration'){ // done
dfsNodeDeclaration(input.id, baseUnion, nameSpace, importDeclarationNames)
dfsNodeDeclaration(input.body, baseUnion, nameSpace, importDeclarationNames)
}
...
else if(input.type === 'Identifier' || input.type === 'JSXIdentifier'){
dealIdentifier(input, baseUnion, nameSpace, importDeclarationNames)
} else if (input.type === 'JSXExpressionContainer') {
dfsNodeDeclaration(input.expression, baseUnion, nameSpace, importDeclarationNames)
}
...
else {
return
}
}
- 重命名冲突处理
首先我们梳理下可能存在的几种情况。
比如文件
// utilB
// utilB:utilB_Test
function utilB() {
}
export { utilB as utilB_Test }
// util
// utilB_Test:testB
import { utilB_Test as testB} from "./utilB.js";
testB()
// 最终映射链
testB()->testB->utilB_Test->utilB->declarationNameTable查找
为何还需declarationNameTable查找,因为utilB可能因为命名冲突已改成了utilB_utilB
/**
* 重名的变量使用内部的还是外部的
* 具体规则:如果命中重命名冲突,1.使用的Identifier在import中是否声明,若声明则使用该声明的 2.使用当前文件的namespace去更换,避免重命名
* @param {*} node
* @param {*} baseUnion
* @param {*} namespace
* @param {*} importDeclarationNames
*/
const dealIdentifier = (node, baseUnion, nameSpace, importDeclarationNames) => {
const name = node.name
// find -> replace
var declarationName = findImportedDeclarationName(importDeclarationNames, name)
if (declarationName) { // 使用外部声明
// find export as别名处理
const exportedAsName = findExportedAsDeclarationName(name)
if (exportedAsName) {
declarationName = exportedAsName
}
const updatedDeclarationName = exportDeclarationName(declarationName)
node.name = updatedDeclarationName
return
}
if (baseUnion.includes(name)) { // 其他文件声明与当前的node冲突
// 内部声明
node.name = nameSpace+'_'+name
}
}
总结
通过上述TN的一些实践项目,想必你对Babel的理解更加深刻了吧
总的来说,Babel对于现代前端开发来说是一个很重要的工具。通过Babel,我们可以使用最新的JavaScript特性,同时保证代码能够在较老的浏览器或运行环境中运行。在实践中,Babel的应用范围非常广泛,包括但不限于:
-
在Node.js和浏览器环境中使用;
-
在Webpack等打包工具中使用,将ES6/ES7代码转换为ES5代码;
-
在React.js和Vue.js等框架中使用,通过相应的插件和预设来优化编译结果;
-
实现一码多投,让应用程序能够在多个平台上运行。
网友评论