babel plugin开发思考

1,965

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,知识点很全,知识点很多,可能消化要一点时间,但是还是建议读一下。

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进行改写

#####开始实践

  1. 提取jsx文件,捕获 import 'xxxx.scss' 记录scss 文件路径
  2. 提取到 export default class 里 render 最外层元素, 如果没有classname, 就加上classname,然后去对应的scss 文件封上一层 classname
  3. 如果外层有classname,但是对应的scss文件没有出现classname 就把path记录下来,手动确认是否有污染

是不是很简单?

主流程就两步

  1. 在 render 的jsx 最外层绑个classname
  2. 然后去 把classname 封到最外层

第二步就不说了,两行代码解决

let content = fs.readFileSync(path);

fs.writeFileSync(path, 'utf-8')

第一步也很简单,哗哗写下如下代码,截取关键代码吧

  1. 提取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;
             }
         })
    

    },

  2. 在 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,这样很不好