简单babel插件入门 [chatGPT]

84 阅读4分钟

背景

项目中APP端实现了一个预览功能,但是本地文件预览需要手动导入一个文件。而不能直接用webpack的import功能导入,比如要预览的文件位于 @shared/static/docs/xxx.pdf,代码需要这么写:

import '@shared/static/docs/xxx.pfd';
Bridge.Previewer.preview({
	isLocal: true,
	uri: 'shared/static/docs/xxx.pfd'
})

这里的 uri: 'xxx' 是给原生去寻找文件来预览,而上面的 import 是需要打包进app静态资源的。但是同事如果没留意,看到这个 import ‘xxx.pdf’ 会很疑惑,而且容易疏漏。 所以写了一个插件,当识别到有类似的调用时,就自动引入当前预览资源。

项目结构

├── babel.config.js
├── node_modules
├── package.json
├── src
└── test
// src/index.js
var babel = require("@babel/core");

const inputCode = `
Bridge.Priviewer.preview({
  isLocal: true,
  uri: 'uri'
})
`

const result = babel.transform(inputCode, {
  plugins: [
    function AutoImportPlugin(babel) {
			const { types: t } = babel
			return {
				visitor: {
					// ... 后续代码都在这里处理 ...
				}
			}
		}
  ]
})

console.log(result.code)

后面我们编写的代码都是在 AutoImportPlugin 函数体内部

基本的思路是:识别代码块 => 判断是否符合要求 => 提取 uri => 插入 import

具体实现

  1. 功能实现先需要识别 Bridge.Previewer.preview函数,可以先将代码丢到 Ast Explorer查看基本的AST结构

Untitled.png

会发现在 CallExpression节点内有个 callee属性,他下面object分别有 Bridge和Priviewer的标识,于是可以写一下代码,来判断调用的函数是

Bridge.Previewer.preview

CallExpression(path){
	const callee = path.get('callee')
	if (
			callee.get('object').matchesPattern('Bridge.Previewer')
			&& callee.get('property').isIdentifier({ name: 'preview' })
		) {
		// 逻辑操作
	}
}

这里一部分API可以在 github.com/jamiebuilds… 查找。

  1. 找到我们的目标函数调用之后,需要判断 isLocal 是否为 true,如果是false或者不传那么请求的事远程的文件,不需要我们手动导入。

继续在 AST Explorer网站找,能看到 CallExpression 下有个 arguments 的属性,存储着当前方法调用传入的参数。我们可以直接通过 filter 去找到key 为 isLocal 的属性,判断他的值是否为 true

// 操作逻辑
const { properties } = path.get('arguments')[0].node
const isLocalNode = properties.find(node => node.key.name === 'isLocal')
// 不为true则直接返回
if (isLocalNode?.value.value !== true) return;
// 其他逻辑...
  1. 如果都符合我们预设的目标,下一步就是提取 uri 并生成 import 节点
// 其他逻辑...
const uriValue = properties.find(node => node.key.name === 'uri').value.value
// 生成一个 import 的 Node
const importNode = t.importDeclaration([], t.stringLiteral(uriValue))
  1. 把生成的import节点丢到 body 中,可以先在 Program 找到 body 的节点暂存起来
bodyNode
visitor: {
	Program(path) {
	  bodyPaths = path.get('body')
	},
	CallExpression(path) {
		// ...
		bodyPaths[0].insertBefore(importNode)
	}
}

四个步骤就能简单实现识别特定代码自引入功能。

var babel = require("@babel/core");

const inputCode = `
Bridge.Previewer.preview({
  isLocal: true,
  uri: 'http:xxx'
})
`

const result = babel.transform(inputCode, {
  plugins: [
    function AutoImportPlugin(babel) {
      const { types: t } = babel

      let bodyPaths
      return {
        visitor: {
          Program(path) {
            bodyPaths = path.get('body')
          },

          CallExpression(path) {
            const callee = path.get('callee')
            if (
              callee.get('object').matchesPattern('Bridge.Previewer')
              && callee.get('property').isIdentifier({ name: 'preview' })
            ) {
              const { properties } = path.get('arguments')[0].node
              const isLocalNode = properties.find(node => node.key.name === 'isLocal')
              // 不为true则直接返回
              if (isLocalNode?.value.value !== true) return;
              const uriValue = properties.find(node => node.key.name === 'uri').value.value
              // 生成一个 import 的 Node
              const importNode = t.importDeclaration([], t.stringLiteral(uriValue))
              bodyPaths[0].insertBefore(importNode)
            }
          }
        }
      }
    }
  ]
})

console.log(result.code)
`
import "http:xxx"; // 这句就是生成的代码
Bridge.Previewer.preview({
  isLocal: true,
  uri: 'http:xxx'
});
`

但是现在还有几个问题:

  1. 如果uri不是直接传入一个string,而是通过一个变量传入,那么直接 uri.value.value 是取不到值的
  2. 有时候不会写完全的函数调用,而是直接 preview({ isLocal })
  3. 老代码可能有人已经手动引入过这个预览的资源,这时候我们就不需要重复引入(虽然也无碍)
  4. 可能引入资源与我们构建的路径规则不一样,比如 webpack 是 @shared 预览无需前面的 @ 符号

基于上面几个问题,我们来改造一下代码

问题一 非直接 string 值

我们可以用 babel 的 evaluate 来求值,这里 path.evaluate() 返回的值有一个 confident,来用表明当前的值是否是已确定的,如果是运行时才能确定的值,则 confident 为 false。

比如 const uri = Math.random() > .5 ? 'a': 'b' 那么我们直接抛个错让用户避免写这种代码即可。

// inputCode
const uri = '<https://xxx>'
Bridge.Previewer.preview({ isLocal: true, uri });

// plugins code
const arg = path.get('arguments')[0];
if (!arg?.isObjectExpression()) return

const argValue = arg.evaluate()
if (!argValue.confident) {
  const isLocalNode = arg.node.properties?.find?.(n => n.key.name === 'isLocal')
  const isLocalValue = isLocalNode?.value.value
  if (isLocalValue === true) {
    throw new Error('本地预览文件uri必须是一个固定值')
  }
  return
}
const { value: { isLocal, uri } } = argValue
if (!isLocal) return;
console.log(uri); // '<https://xxx>'

问题二 调用方法问题

我们可以在前面的判断加多一个用来判断调用方法callee.toString() === 'preview'

问题三 重复引入问题

这里可以在插入之前去检测一下当前的import节点是否包含了该 uri

const hasUriImport = bodyPath
	.filter(path => t.isImportDeclaration(path.node))
	.some(path => path.node.source.value === uri)

问题四 预览uri与构建规则不同

这里加个 options ,把转换规则交给用户。

function AutoImportPlugin(babel, options) {
	const transformUri = options.transformUri ?? (r => r)

	CallExpression(path) {
		// 生成之前,先调函数转换一下
		const uriValue = transformUri(uri);
		const importNode = t.importDeclaration([], t.stringLiteral(uriValue));
	}
}

babel.config.js
{
	plugins: [[AutoImportPlugin, { transformUri: r => `@${r}` }], ...otherPlugins]
}

具体代码可见: github.com/jsonz1993/b…

重点来了,标题为啥要加上 chatGPT ? 以上脚本7-80%的代码是通过 chatGPT 生成的,虽然生成的代码直接运行会报错,但是大体逻辑是对的。而且像 uri 传的是变量要怎么处理,这个我自己翻文档没找到,是问了 chatGPT 之后提供 demo 帮我解决的…

a.png

b.png