Teaching the Closure Compiler About React #
tl;dr: react-closure-compiler is a project that contains a custom Closure Compiler pass that understands React concepts like components, elements and mixins. It allows you to get type-aware checks within your components and compile React itself alongside your code with full minification.
Late last year, Quip started a gradual migration to React for our web UI (incidentally the chat features that were launched recently represent the first major functionality to be done entirely using React). When I started my research into the feasibility of using React, one of my questions was “Does it work with the Closure Compiler?” At Quip we rely heavily on it not just for minification, but also for type annotations to make refactorings less scary and code more self-documenting¹, and for its many warnings to prevent other gotchas in JavaScript development. The tidbits that I found were encouraging, though a bit sparse:
- An externs file with type declarations for most of React's API²
- A Quora post by Pete Hunt (a React core contributor) describing React as “closure compiler compatible”
- React's documentation about refs mentions making sure to quote refs annotated via string attributes³
In general I got the impression that it was certainly possible to use React with the Closure Compiler, but that not a lot of people were, and thus I would be off the beaten path⁴.
My first attempt was to add react.js (the unminified version) as source input along with a simple “hello world” component⁵. The rationale behind doing it this way was that, if React was to be a core library, it should be included in the main JavaScript bundle that we serve to our users, instead of being a separate file. It also wouldn't need an externs file, since the compiler was aware of it. Finally, since it was going to be minified with the rest of our code, I could use the non-minified version as the input, and get better error messages. I was then greeted by hundreds of errors and warnings which broadly fell into three categories:
- “illegal use of unknown JSDoc tag
providesModule
” and similar warnings about JSDoc tags that the React source uses that the Closure Compiler didn't understand - “variable
React
is undeclared” indicating that the Closure compiler did not realize what symbolsreact.js
exported, most likely because the module wrapper that it uses is a bit convoluted, and thus it's not obvious that the exported symbols are in the global scope - “dangerous use of the global
this
object” within my component methods, since the Closure Compiler did not realize that the functions within the spec passed toReact.createClass
were going to be run as methods on the component instance.
Since I was still in a prototyping stage with React, I looked into the most minimal set of changes I could do to deal with these issues. For 2, adding the externs file to our list helped, since the compiler now knew that there was a React
symbol and its many properties. This did seem somewhat wrong, since the React source was not actually external, and it was in fact safe to (globally) rename createClass
and other methods, but it did quieten those errors. For 1 and 3 I wrote a small custom warnings guard that ignored all “errors” in the React source itself and the “dangerous use of global this
” warning in .jsx
files.
Once I did all that, the code compiled, and appeared to run fine with all the other warnings and optimizations that we had. However, a few days later, as I was working on a more complex component, I ran into another error. Given:
var Comp = React.createClass({ render: function() {...}, someComponentMethod: function() {...} }); var compInstance = React.render(React.createElement(Comp), ...); compInstance.someComponentMethod();
I was told that someComponentMethod
was not a known property on compInstance
(which was of type React.ReactComponent
— per the externs file). This once again boiled down to the compiler not understanding that the React.createClass
construct (i.e. that it defined a type). It looked like I had two options for dealing with this:
- Add a
@suppress {missingProperties}
annotation at the callsite, so that the compiler wouldn't complain about the property that it didn't know about - Add a
@lends {React.ReactComponent.prototype}
annotation to the class spec, so that the compiler would know thatsomeComponentMethod
was indeed a method on components (this seemed to be the approach taken by some other code I came across).
The main problem with 2 is that it then told the compiler that all component instances had a someComponentMethod
method, which was not true. However, it seemed like the best option, so I added it and kept writing more components.
After a few more weeks, when more engineers started to write React code, these limitations started to chafe a bit. There was both the problem of having to teach others about how to handle sometimes cryptic error messages (@lends
is not a frequently-encountered JSDoc tag), as well as genuine bugs that were missed because the compiler did not have a good enough understanding of the code patterns to flag them. Additionally, the externs file didn't quite match with the latest terminology (e.g. React.render
's signature had it both taking and returning a ReactComponent
). Finally, the use of an externs file meant that none of the React API calls were getting renamed, which was adding some bloat to our JavaScript.
After thinking about these limitations for a while, I began to explore the possibility of creating a custom Closure Compiler pass that would teach it about components, mixins, and other React concepts. It already had a custom pass that remapped goog.defineClass
calls to class definitions, so teaching it about React.createClass
didn't seem like too much of a stretch.
Fast forward a few weeks (and a baby) later, and react-closure-compiler is a GitHub project that implements this custom pass. It takes constructs of the form:
var Comp = React.createClass({ render: function() {...}, someComponentMethod: function() {...} });
And transforms it to (before any of the normal compiler checks or type information was extracted):
/** * @interface * @extends {ReactComponent} */ function CompInterface() {} CompInterface.prototype = { render: function() {}, otherMethod: function() {} }; /** @typedef {CompInterface} */ var Comp = React.createClass({ /** @this {Comp} */ render: function() {...}, /** @this {Comp} */ otherMethod: function() {...} }); /** @typedef {ReactElement.<Comp>} */ var CompElement;
Things of note in the transformed code:
- The
CompInterface
type is necessary in order to teach the compiler about all the methods that are present on the component. Having it as an@interface
means that no extra code ends up being generated (and the existing code is left untouched). The methods in the interface are just stubs — they have the same parameters (and JSDoc is copied over, if any), but the body is empty. - The
@typedef
is added to the component variable so that user-authored code can treat that as the type (the interface is an implementation detail). - The
@this
annotations that are automatically added to all component methods means that the compiler understands that those functions do not run in the global scope. - The
CompElement
@typedef
is designed to make adding types to elements for that component less verbose.
A bit more formally, these are the types that the compiler knows about given the Comp
definition:
ReactClass.<Comp>
, for the class definitionReactElement.<Comp>
for an element created from that definition (via JSX orReact.createElement()
)Comp
for rendered instances of this component (this is subclass ofReactComponent
).
This means that, for example, you can use {Comp}
to as a @return
, @param
or @type
annotation for functions that operate on rendered instances of Comp
. Additionally, React.render
invocations on JSX tags or explicit React.createElement
calls are automatically annotated with the correct type.
To teach the compiler about the React API, I ended up having a types.js
file with the full API definition (teaching the compiler how to parse the module boilerplate seemed too complex, and in any case the React code does not have type annotations for everything). For the actual type hierarchy, in addition to looking at the terminology in the React source itself, I also drew on the TypeScript and Flow type definitions for React. Note that this is not an externs file, it's injected into the React source itself (since it's inert, it does not result in any output changes). This means that all React API calls can be renamed (with the exception of React.createElement
, which cannot be renamed due to the collision with the createElement
DOM API that's in another externs file).
Having done the basics, I then turned to mixins (one of the reasons why we're not using ES6 class syntax for components). I ended up requiring that mixins be wrapped in a React.createMixin(...)
call, which was introduced with React 0.13 (though it's not documented). This means that it's possible to cheaply understand mixins: [SomeMixin]
declarations in the compiler pass without having to do more complex source analysis.
The README covers more of the uses and gotchas, but the summary is that Quip itself is using this compiler pass to pre-process all our client-side code. The process of converting our 400+ components (from the externs type annotations) took a couple of days (which included tweaks to the pass itself, as well as fixing a few bugs that the extra checks uncovered).
The nice thing about having custom code in the compiler is that it provides an easy point to inject more React-specific behavior. For example, we're heavy users of propTypes
, but they're only useful when using the non-minified version of React — propTypes
are not checked in minified production builds. The compiler pass can thus strip them if compiling with the minified version.
Flow was the obvious alternative to consider if we wanted static type checking that was React-aware. I also more recently came across Typed React. However, extending the Closure Compiler allows us to benefit from the hundreds of other (non-React) source files that have Closure Compiler type annotations. Additionally, the compiler is not just a checker, it is also a minifier, and some minification passes rely on type information, thus it is beneficial to have type information accessible to the compiler. One discovery that I made while working on this project is that the compiler has a pass that converts type expressions to JSDoc, and generally seems to have some understanding of type expressions that (at least superficially) resemble Flow's and TypeScript's. It would be nice to have one type annotated codebase that all three toolchains could be run on, but I think that's a significant undertaking at this point.
If you use React and the Closure Compiler together, please give the pass a try (it integrates with Plovr easily, and can otherwise be registered programatically) and let me know how it works out for you.
- I continue to find doing large-scale refactorings less scary in our client-side code than ones in our server-side Python code, despite better test coverage in the latter environment.
- I ended up contributing to it a bit, as we started to use less common React APIs.
- Spelunking through React's codebase that I did much later turned up
keyOf
and many other indicators that React was definitely developed with unquoted property renaming minification in mind. - Indeed the original creator of the React externs file has indicated that he's no longer using the combination of React/Closure Compiler.
- Which used JSX, but that was not of interest to the Closure Compiler: it was transformed to plain JavaScript before the compiler saw it.
Post a Comment