Last week, my coworker Charlie asked what it would take to automatically detect and purge unused aphrodite styles in our codebase.
If asked 2 years ago, I probably would have gone with a regex and a string-munging python script, but I’d just spent the past few nights messing with babel plugins, and figured I could probably get pretty far with relatively little work. As it happened, I was impressed by how easy it was using the tools that babel provides.
As a bonus, it also works with React Native because they have the same API, and it could probably be extended to other libraries without too much work.
Here’s what we’re building
We’re making the guts of the stylecleanup tool, which finds & deletes unused styles in your JavaScript, and works with both aphrodite and React Native.
For example, here’s a React Native file
import {StyleSheet, View} from 'react-native'
import {Component} from 'react'
export default class MyComponent extends Component {
render() {
return <View style={styles.header} />
}
}
const styles = StyleSheet.create({
header: { backgroundColor: 'red' },
awesome: { fontSize: 20 },
})
The style awesome
is unused, and we’d like to automatically detect that.
Here’s how we do it
The rough steps are
- parse the JavaScript file
- find all the stylesheet declarations, e.g.
const myStyleSheet = StyleSheet.create({ .... })
- get a list of the styles within that stylesheet (e.g.
"header"
,"awesome"
) - go through and find references to that stylesheet, e.g.
myStyleSheet.someStyle
- from that, determine which styles are never referenced and can be safely deleted
Parse the JavaScript file
We’ll be using the libraries babylon and babel-traverse. The plugins section of @thejameskyle’s babel-handbook is also an excellent reference.
const babylon = require('babylon')
const text = fs.readFileSync(file, 'utf8')
const ast = babylon.parse(text, {
// this means that `import` and `export` are allowed
sourceType: 'module',
// we want to allow all the fancy syntax
plugins: ['jsx', 'flow', 'objectRestSpread', 'classProperties'],
})
Find the StyleSheet declarations
We’ll look for the form const myStyleSheet = StyleSheet.create({ .... })
We’re going to use babel-traverse
, which makes walking through the tree super easy.
It gives us a function, traverse
, that will walk through the whole AST, and call the visitors we specify corresponding to the type of a given node. To figure out what the types are (and what the shape of the AST is like), I lean on astexplorer.net and the Readme of babel-types.
const styleSheets = []
traverse(ast, {
CallExpression(path) {
if (isStyleSheetCreate(path)) {
const members = path.node.arguments[0].properties.filter(
// Not gonna try to figure out spreads
property => property.type === 'ObjectProperty'
)
const styleNames = members.map(member => member.key.name)
styleSheets.push({id: path.parent.id, styleNames})
}
},
})
So I go through every CallExpression
, which has the form something(arg1, arg2, ...)
, check if it looks like StyleSheet.create
, and then process it. The function isStyleSheetCreate
(that I’ll describe in a second) determines whether the current node looks like var myStyleSheet = StyleSheet.create({y: ... })
. I then grab all the members of the object that’s being passed to StyleSheet.create
, discarding any that happen to be ObjectMethod
s or computed
properties, and get the names that they’re being identified by. In our example file, styleNames
would end up being ["header", "awesome"]
.
path.parent.id
refers to the variable name that this stylesheet is being bound to – myStyleSheet
in the var myStyleSheet = StyleSheet.create({y: ...})
example.
So at the end of this I have a list of the stylesheets that got created and assigned to a variable, and the style names within each stylesheet.
Let’s look at isStyleSheetCreate
now.
const isStyleSheetCreate = ({node, parent}) => {
return node.callee.type === 'MemberExpression' &&
node.callee.object.type === 'Identifier' &&
node.callee.object.name === 'StyleSheet' &&
node.callee.property.name === 'create' &&
parent.type === 'VariableDeclarator' &&
node.arguments.length === 1 &&
node.arguments[0].type === 'ObjectExpression'
}
The argument I’m passing in is a NodePath
that has many attributes, but the ones we care about are node
and parent
, which are both nodes in our AST (again, refer to the babel-types Readme for more info).
These checks establish that
- The function being called is
StyleSheet.create
- It’s only being called with one argument, which is an object literal
- It’s being assigned to a variable (indicated by the parent being a
VariableDeclarator
Find all the references to styles
as in myStyleSheet.someStyle
babel-traverse
actually makes this super easy for us, because it already tracks all variable references. We can just hang on to the Binding
object that it uses to collect references, and then iterate through them after traversal.
const styleSheets = []
traverse(ast, {
CallExpression(path) {
if (isStyleSheetCreate(path)) {
const members = path.node.arguments[0].properties.filter(
property => property.type === 'ObjectProperty' // Not gonna try to figure out spreads
)
const styleNames = members.map(member => member.key.name)
styleSheets.push({
id: path.parent.id,
styleNames,
// hang on to the binding object for "myStyleSheet"
binding: path.scope.getBinding(path.parent.id.name)
})
}
},
})
Now we iterate through our stylesheets, and take a look at the references.
styleSheets.forEach(({id, styleNames, binding}) => {
const referencedNames = binding.referencePaths
.filter(ref => ref.parent.type === 'MemberExpression' && !ref.parent.computed)
.map(ref => ref.parent.property.name)
})
In the example reference <View style={styles.header} />
, the referencePath
just points to the styles
identifier, which is the reference to our stylesheet binding. We want to get the string "header"
, so we
- determine that we’re looking at a
MemberExpression
(e.g.styles.something
) - make sure it’s not computed (we can’t do much with
styles[someVariable]
) - get the property name (e.g.
"header"
)
Then use the list of references to determine which styles aren’t being used at all.
styleSheets.forEach(({id, styleNames, binding}) => {
const referencedNames = binding.referencePaths
.filter(ref => ref.parent.type === 'MemberExpression' && !ref.parent.computed)
.map(ref => ref.parent.property.name)
const unused = styleNames.filter(name => referencedNames.indexOf(name) === -1)
// 🎉
})
Just for kicks, we can also get a list of styles that are referenced, but never defined!
styleSheets.forEach(({id, styleNames, binding}) => {
const referencedNames = binding.referencePaths
.filter(ref => ref.parent.type === 'MemberExpression' && !ref.parent.computed)
.map(ref => ref.parent.property.name)
const unused = styleNames.filter(name => referencedNames.indexOf(name) === -1)
// too easy
const missing = referencedNames.filter(name => styleNames.indexOf(name) === -1)
})
And we’re done!
To see the code in context, look at analyzeFile.js
in the stylecleanup
project.
For a real tool, there’s some more bookkeeping involved in
- showing the lines of code where e.g. a missing style is referenced
- being conservative about what styles are definitely unused vs styles that might be unused – if the stylesheet variable is used in a way that we ignore (e.g. not in a
MemberExpression
, or with a computed lookup), then there could be references to styles that we can’t track.