背景
项目中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
具体实现
- 功能实现先需要识别 Bridge.Previewer.preview函数,可以先将代码丢到 Ast Explorer查看基本的AST结构
会发现在 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… 查找。
- 找到我们的目标函数调用之后,需要判断 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;
// 其他逻辑...
- 如果都符合我们预设的目标,下一步就是提取 uri 并生成 import 节点
// 其他逻辑...
const uriValue = properties.find(node => node.key.name === 'uri').value.value
// 生成一个 import 的 Node
const importNode = t.importDeclaration([], t.stringLiteral(uriValue))
- 把生成的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'
});
`
但是现在还有几个问题:
- 如果uri不是直接传入一个string,而是通过一个变量传入,那么直接 uri.value.value 是取不到值的
- 有时候不会写完全的函数调用,而是直接
preview({ isLocal }) - 老代码可能有人已经手动引入过这个预览的资源,这时候我们就不需要重复引入(虽然也无碍)
- 可能引入资源与我们构建的路径规则不一样,比如 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 帮我解决的…