babel plugin开发思考
babel 定义
babel 就是把ecma较新的js语法翻译成浏览器可以识别的解释器,具体详见 babel官网
babel plugin
-
关于 plugin 的设计结构
plugin 是一个很常见的设计结构了,往前看 jquery的时期,jquery 暴露了一个extend 方法,把插件都挂$.extend 下
之后像webpack这样的plugin,就是注册了webpack的生命周期钩子,然后到了生命周期触发
-
关于 plugin 的设计理念
一般都是某个工具提供一个core,做最核心的运算,其他部分功能开放给第三方,jquery webpack babel 都是这样的。
babel 实践
基础知识
如果你时间很充裕的话,可以先看一下 官方的handbook,知识点很全,知识点很多,可能消化要一点时间,但是还是建议读一下。
提炼一些比较基础,重要的知识点
babel-core会把代码转成ast树,ast 是一个个节点信息,例如
源码: export default class {} 对应的 ast 节点就是
{
type: ExportDefaultDeclaration
start: 501,
end: 2128
declaration: {....}
}
ast 节点一般是不建议直接操作的,原因很简单ast的node 信息比较独立,babel 把这些独立的 node 通过一些描述信息拼接成了一个program(整体),这些描述信息的最小单元就是path,path 还暴露了对节点添加 删除 移动的方法,比直接修改ast 也方便很多
path 和 ast 用一个比较官方的说法是reactive的,简单说就是ast 变了, path 也变了,path 变了,ast 也变了 是个双向绑定
实践
-
一个最基础的例子 对单文件transform 一般例子如下
var babel = require('babel-core'); var t = require('babel-types'); const code = `import {uniq, extend, flatten, cloneDeep } from "lodash"`; const visitor = { Identifier(path){ console.log(path.node.name) } } const result = babel.transform(code, { plugins: [{ visitor: visitor }] })
声明 Identifier 类型的节点,parse 到 Identifier 时 输出node 名字 输出: uniq extend flatten cloneDeep
- 稍微复杂的例子
现状:项目中样式存在污染问题,一般代码组织 一个jsx 对应一个scss文件,但是项目中的部分scss文件存在这样的写法
input{
}
textarea{
}
.form{
}
对应的 jsx 代码
render () {
return (<div>
....
</div>)
目标:会parse jsx,如果最外层元素没有设置classname,就手动加上classname = {$__dirname}_container
这样, 然后把这个 class 包到scss文件最外面,对应的scss文件
.$__dirname}_container{
input{
}
textarea{
}
.form{
}
}
对应的 jsx 代码
render () {
return (<div className =`{$__dirname}_container`>
....
</div>)
分析问题
项目中的文件上千个,肯定不能通过手动babel一个一个文件执行,肯定要结合webpack,因为webpack自动会帮我们收集依赖,有优势。
基本写法,bable.config.js 写下如下代码
module.exports = {
"presets": [
"@babel/preset-env",
"@babel/preset-react"
],
"ignore": [
"node_modules/**",
"dist"
],
"env": {
"test": {
"plugins": ["@babel/plugin-transform-runtime"]
}
},
"plugins": [
["founder-transform", {test: 'fz'}], //测试插件
["syntax-dynamic-import"],
["@babel/plugin-proposal-export-default-from"],
["@babel/plugin-proposal-export-namespace-from"],
["@babel/plugin-proposal-decorators", { "legacy": true }],
["@babel/plugin-proposal-class-properties", { "loose" : true }],
["import", {
"libraryName": "mtui-d",
"libraryDirectory": "lib",
"style":true
}
]
]
}
这里有个注意点,我们自定义的插件一定要写在plugin最前面,关于plugin的执行顺序,引用官网的话
- 插件在 Presets 前运行。
- 插件顺序从前往后排列。
- Preset 顺序是颠倒的(从后往前)。
大致就是。plugin: founder-transform ...... import -> presets: @babel/preset-react @babel/preset-env
preset 也是一系列插件啦,所以你可以想象时一排插件对ast进行改写
#####开始实践
- 提取jsx文件,捕获 import 'xxxx.scss' 记录scss 文件路径
- 提取到 export default class 里 render 最外层元素, 如果没有classname, 就加上classname,然后去对应的scss 文件封上一层 classname
- 如果外层有classname,但是对应的scss文件没有出现classname 就把path记录下来,手动确认是否有污染
是不是很简单?
主流程就两步
- 在 render 的jsx 最外层绑个classname
- 然后去 把classname 封到最外层
第二步就不说了,两行代码解决
let content = fs.readFileSync(path);
fs.writeFileSync(path, 'utf-8')
第一步也很简单,哗哗写下如下代码,截取关键代码吧
-
提取jsx文件,捕获 import 'xxxx.scss' 记录scss 文件路
ImportDeclaration(path, _ref = {opts:{}}){ co(function *(){ const specifiers = path.node.specifiers; const {value} = path.node.source; if(specifiers.length === 0 && value.slice(-5) === ".scss" && _ref.filename.indexOf('/container/') > -1){ //只对container目录下文件改造 const cssPath = paths.join(_ref.filename, '..' , value); let exist = yield statPromise(cssPath); let source = exist && fs.readFileSync(cssPath).toString() if(!containerJSCSS[_ref.filename]){ containerJSCSS[_ref.filename] = {} } if(!containerJSCSS[_ref.filename].cssPath){ containerJSCSS[_ref.filename].cssPath = [] } containerJSCSS[_ref.filename].filename = _ref.filename; containerJSCSS[_ref.filename].cssPath.push(cssPath); containerJSCSS[_ref.filename].cssSource = source; } })
},
-
在 render 的jsx 最外层绑个classname
ExportDefaultDeclaration(path, _ref = {opts: {}}){ const declaration = path.node.declaration; let code = _ref.file.code; let ast = _ref.file.ast; //class if(_ref.filename.indexOf('/container/') > -1 && declaration.type === "ClassDeclaration"){ if(declaration && declaration.body){ // render return() let {body: declarationBody} = declaration.body; let render = declarationBody && declarationBody.find(_ => { return _.key.name === "render" && _.type === "ClassMethod" }) if(render && render.body){ let {body: renderBody} = render.body; let returnStatement = renderBody.find(_ => _.type === "ReturnStatement") || {}; let {argument} = returnStatement; if(argument && argument.type === "JSXElement"){ // render return (<> </>) let {openingElement: {attributes, name}} = argument; if(name.type === "JSXIdentifier"){ if(!containerJSCSS[_ref.filename]){ containerJSCSS[_ref.filename] = {} } containerJSCSS[_ref.filename]['wrapElement'] = name.name } let attributesClass = attributes.find(_ => { return _.name && _.name.name !== "className" }) containerJSCSS[_ref.filename]['wrapElementClass'] = !!attributesClass containerJSCSS[_ref.filename]['filename'] = _ref.filename; attributes.push(t.jSXAttribute(t.JSXIdentifier('className'), t.stringLiteral(_ref.filename))); //绑classname const output = generate(ast, {}, code); output.code += `/** ${_ref.filename} **/\n`; fs.writeFile('./data.jsx', output.code, { flag: 'a' }, (err) => { if (err) throw err; }) } } } } }
代码很简单,就是一步一步获取export class 下的 render return( ) A元素罢了
但是当代码跑起来的时候,出现了两个问题
- const output = generate(ast, {}, code); 发现code 不是源文件,里面出现了es5的代码
- 我们在babel plugin 声明的 containerJSCSS 怎么传出去呢?我需要最终的结果,最后还需要一些批量的操作
第一个问题很简单,拆分一下
-
babel plugin 调用的时机?
webpack 本身的设计是管道的,一个文件处理完,下一个文件进来,拿到第一个jsx 过一遍babel-loader, 然后一次执行 plugin 到 preset的插件
-
babel-loader 做了啥?
关键代码 result = yield transform(source, options);
不看babel-loader其他优化,最基础的操作就是transform了,source是源文件,option是参数,babel-loader传的参数
-
既然我们写的插件是第一个执行的,为啥code 不是源码呢,还出现了es5的代码 ?
猜想:babel 对每一个语句类型 例如 ImportDeclaration 就是import 语句 当作一个钩子,当捕获到了import 的时候,会按照插件的顺序依次下发,出现了es5的代码,很有可能是其他的插件有比ImportDeclaration更前的钩子先修改了ast树 验证:
Program: { enter(path, _ref = {opts:{}}) { if( _ref.filename.indexOf('/container/') > -1 ){ let _ast = _ref.file.ast; const output = generate(_ast, {}, _ref.file.code); output.code += `/** ${_ref.filename} **/\n`; fs.writeFile('./data.jsx', output.code, { flag: 'a' }, (err) => { if (err) throw err; }) } } }
我们在最插件多增加了一个钩子,这个钩子是最先执行的,先于ImportDeclaration 的,打印出来就是源码,哈哈哈哈
解决思路:
在 Program enter里缓存住 ast 树,在基于这个ast树 生成代码就好了,但是成本是巨大的,因为ast 是引用类型,你需要做一次深拷贝,不然还是会被修改
第二个问题
- babel plugin 里的变量 在webpack 任务结束后怎么传递出去?
因为babel 本身是基于管道的,他并不知道进入这个管道的文件是不是最后一个文件,我们直接把这个变量挂在global 下,然后在 webpack compilation done的钩子做后续的批量操作,代码示例
const fs = require('fs');
class ConsoleLogOnBuildWebpackPlugin {
apply(compiler) {
compiler.hooks.done.tap('done', compilation => {
fs.writeFile('./data.txt', JSON.stringify(global.containerJSCSS), 'utf-8', (err) => {
if (err) throw err;
})
});
}
}
module.exports.default = ConsoleLogOnBuildWebpackPlugin
总结
如果不是特殊需求 别在babel里做状态翻转的操作,例如之前我做的这样
其他plugin 的 钩子已经把ast 改写了,你的钩子执行的逻辑又依赖的之前的ast,这样很不好