⬆️ ⬇️

Writing a Babel plugin

Modularity is firmly established in the javascript world. However, with all the advantages, writing the same imports in each file is tiring. And what if you remove the connection of frequently used modules to the collector, and in the code to use them as global variables? Looks like a babel plugin task. Well, let's write together such a plugin, incidentally figuring out how babel works.





Let's start with the "skeleton". A plugin is a function that returns an object with visitors (visitors). An argument with it is passed an object with modules from the babel-core . In the future, we need the babel-types module.



export default function({types: t}) { return { visitor: {} }; } 


The visitor is a method of the visitor object whose name corresponds to the type of the abstract syntax tree (ASD) node, for example, FunctionDeclaration or StringLiteral ( full list ), to which the path (path) to the node is passed. We are interested in nodes of type Identifier .

')

 export default function({types: t}) { return { visitor: { Identifier(path, {opts: options}) { } } }; } 


Also, the visitor has access to the plugin settings in the .opts property of the second argument. Through them we will pass the variable names and paths to the modules for which the import will be created. It will look like this:



.babelrc

 { plugins: [[ "babel-plugin-auto-import", { declarations: [{name: "React", path: "react"}] } ]] } 


Bypass ASD. Ways Knots



Babel takes as input a code (as a string), which is broken up into tokens, from which the ASD is built. Then the plugins change the ASD, and a new code is generated from it, which is fed to the output. For manipulations with ASD, plugins use paths. You can also check through the paths what type of node this path represents. For this there are methods of the format .["is" + ]() . For example, path.isIdentifier() . The path can be searched among child paths using the .find(callback) method, and among parent paths using the .findParent(callback) method. The .parentPath property stores a link to the parent path.



Let's start writing the plugin itself. First and foremost, you need to filter identifiers. The Identifier type is widely used in various types of nodes. We need only some of them. Suppose we have this code:



 React.Component 


ASD for this code looks like this:



 { type: "MemberExpression", object: { type: "Identifier", name: "React" }, property: { type: "Identifier", name: "Component" }, computed: false } 


A node is an object with the .type property and some other properties specific to each type. Consider the root node - MemberExpression . He has three properties. Object is the expression to the left of the point. In this case, it is an identifier. The computed property indicates whether there is an identifier or some expression on the right, for example, x["a" + b] . Property - actually, that to the right of a point.



If we start our plug-in framework now, the Identifier method will be called twice: for React and Component identifiers, respectively. The plugin should handle the React ID, but skip the Component ID. To do this, the identifier path must receive the parent path and, if it is a node of type MemberExpression , check whether the identifier is a .object property. We put the check into a separate function:



 export default function({types: t}) { return { visitor: { Identifier(path, {opts: options}) { if (!isCorrectIdentifier(path)) return; } } }; function isCorrectIdentifier(path) { let {parentPath} = path; if (parentPath.isMemberExpression() && parentPath.get("object") == path) return true; } } 


In the final version of such checks there will be many - for each case its own check. But they all work on the same principle.



Full list
 function isCorrectIdentifier(path) { let {parentPath} = path; if (parentPath.isArrayExpression()) return true; else if (parentPath.isArrowFunctionExpression()) return true; else if (parentPath.isAssignmentExpression() && parentPath.get("right") == path) return true; else if (parentPath.isAwaitExpression()) return true; else if (parentPath.isBinaryExpression()) return true; else if (parentPath.bindExpression && parentPath.bindExpression()) return true; else if (parentPath.isCallExpression()) return true; else if (parentPath.isClassDeclaration() && parentPath.get("superClass") == path) return true; else if (parentPath.isClassExpression() && parentPath.get("superClass") == path) return true; else if (parentPath.isConditionalExpression()) return true; else if (parentPath.isDecorator()) return true; else if (parentPath.isDoWhileStatement()) return true; else if (parentPath.isExpressionStatement()) return true; else if (parentPath.isExportDefaultDeclaration()) return true; else if (parentPath.isForInStatement()) return true; else if (parentPath.isForStatement()) return true; else if (parentPath.isIfStatement()) return true; else if (parentPath.isLogicalExpression()) return true; else if (parentPath.isMemberExpression() && parentPath.get("object") == path) return true; else if (parentPath.isNewExpression()) return true; else if (parentPath.isObjectProperty() && parentPath.get("value") == path) return !parentPath.node.shorthand; else if (parentPath.isReturnStatement()) return true; else if (parentPath.isSpreadElement()) return true; else if (parentPath.isSwitchStatement()) return true; else if (parentPath.isTaggedTemplateExpression()) return true; else if (parentPath.isThrowStatement()) return true; else if (parentPath.isUnaryExpression()) return true; else if (parentPath.isVariableDeclarator() && parentPath.get("init") == path) return true; return false; } 




Variable scope



The next step is to check whether our identifier is declared as a local variable or is global. For this, there is one useful property in the paths - scope . With it, we will iterate all scopes, starting with the current. The variables for the current scope are in the .bindings property. The link to the parent scope is in the .parent property. It remains to recursively go through all the variables of all scopes and check if our identifier is there.



 export default function({types: t}) { return { visitor: { Identifier(path, {opts: options}) { if (!isCorrectIdentifier(path)) return; let {node: identifier, scope} = path; if (isDefined(identifier, scope)) return; } } }; // ... function isDefined(identifier, {bindings, parent}) { let variables = Object.keys(bindings); if (variables.some(has, identifier)) return true; return parent ? isDefined(identifier, parent) : false; } function has(identifier) { let {name} = this; return identifier == name; } } 


Fine! Now we are sure that you can work with the identifier. Take options from the declaration of “global” variables and process them:



 let {declarations} = options; declarations.some(declaration => { if (declaration.name == identifier.name) { let program = path.findParent(path => path.isProgram()); insertImport(program, declaration); return true; } }); 


Modification of the ASD



And here we have come to change the SDA. But before we begin to insert new imports, we get all the existing ones. To do this, we use the .reduce method to get an array with paths like ImportDeclaration :



 function insertImport(program, { name, path }) { let programBody = program.get("body"); let currentImportDeclarations = programBody.reduce(currentPath => { if (currentPath.isImportDeclaration()) list.push(currentPath); return list; }, []); } 


Now let's check if our identifier is already connected:



 let importDidAppend = currentImportDeclarations.some(({node: importDeclaration}) => { if (importDeclaration.source.value == path) { return importDeclaration.specifiers.some(specifier => specifier.local.name == name); } }); 


If the module is not connected, create a new import node and insert it into the program.



To create nodes, use the babel-types module. The link to it is in the variable t . For each of the nodes has its own method. We need to create importDeclaration . We look at the documentation and see that creating import requires specifiers (that is, the names of the variables being imported) and the path to the module.



First create a qualifier. Our plugin connects modules as exported by default ( export default ... ). Then create a node with a path to the module. This is a simple StringLiteral .



 let specifier = t.importDefaultSpecifier(t.identifier(name)); let pathToModule = t.stringLiteral(path); 


Well, we have everything to create an import:



 let importDeclaration = t.importDeclaration([specifier], pathToModule); 


It remains to insert the node in the ASD. For this we need a way. The path can be replaced by a node using the .replaceWith(node) method, or an array of nodes using the .replaceWithMultiple([...nodes]) method .replaceWithMultiple([...nodes]) . Can be removed using the .remove() method. For insertion, the .insertBefore(node) and .insertAfter(node) methods are used to insert a node before or after the path, respectively.



In our case, the import must be inserted into the so-called container. The program node has a property .body , which contains an array of expressions representing the program. To insert nodes into such “container” arrays, paths have special methods pushContainer and unshiftContainer . We use the latter:



 program.unshiftContainer("body", importNode); 


Plugin ready. We got acquainted with the main API Babel, considered the principles of the device and the plug-ins. The plugin we made is a simplified version that does not work correctly. But with the knowledge gained you can easily read the full code of the plugin . I hope the article was interesting, and the experience gained was useful. So tnank you!

Source: https://habr.com/ru/post/330018/



All Articles