J2CL (Java to Closure Compiler) is a modern source-to-source transpiler that converts Java bytecode to optimized JavaScript, serving as the spiritual successor to Google Web Toolkit (GWT). This guide explores J2CL's architecture, features, and how it improves upon GWT.
J2CL Overview and Architecture
What is J2CL?
J2CL is a transpiler that converts Java bytecode to Closure-style JavaScript, which can then be optimized by the Closure Compiler for production deployment.
Key Architecture Differences
// GWT Approach: Java source → JavaScript // J2CL Approach: Java bytecode → Closure JS → Optimized JS
Setting Up J2CL
Maven Configuration
<!-- pom.xml --> <project> <properties> <j2cl.version>0.21.0</j2cl.version> </properties> <dependencies> <dependency> <groupId>com.google.j2cl</groupId> <artifactId>j2cl-base</artifactId> <version>${j2cl.version}</version> <scope>provided</scope> </dependency> <dependency> <groupId>com.google.elemental2</groupId> <artifactId>elemental2-core</artifactId> <version>1.1.0</version> </dependency> <dependency> <groupId>com.google.elemental2</groupId> <artifactId>elemental2-dom</artifactId> <version>1.1.0</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>com.google.j2cl</groupId> <artifactId>j2cl-maven-plugin</artifactId> <version>${j2cl.version}</version> <executions> <execution> <goals> <goal>compile</goal> </goals> </execution> </executions> </plugin> </plugins> </build> </project> Bazel Build Configuration
# BUILD.bazel load("@com_google_j2cl//build_defs:rules.bzl", "j2cl_library", "j2cl_application") j2cl_library( name = "myapp_lib", srcs = ["MyApp.java"], deps = [ "@com_google_elemental2//elemental2/core:core", "@com_google_elemental2//elemental2/dom:dom", ], ) j2cl_application( name = "myapp", entry_points = ["com.example.MyApp"], deps = [":myapp_lib"], ) Basic J2CL Application
Simple Web Application
// MyApp.java package com.example; import elemental2.dom.DomGlobal; import elemental2.dom.HTMLButtonElement; import elemental2.dom.HTMLDivElement; import elemental2.dom.HTMLElement; import elemental2.dom.Event; public class MyApp { public static void main(String[] args) { DomGlobal.document.body.innerHTML = ""; // Create UI elements HTMLDivElement container = (HTMLDivElement) DomGlobal.document.createElement("div"); HTMLButtonElement button = (HTMLButtonElement) DomGlobal.document.createElement("button"); HTMLDivElement output = (HTMLDivElement) DomGlobal.document.createElement("div"); // Configure elements button.textContent = "Click me!"; button.style.cssText = """ padding: 10px 20px; font-size: 16px; margin: 10px; """; output.style.cssText = """ padding: 10px; margin: 10px; border: 1px solid #ccc; """; // Add event listener button.addEventListener("click", (Event evt) -> { int count = (int) (Math.random() * 100); output.textContent = "Random number: " + count; DomGlobal.console.log("Button clicked, generated: " + count); }); // Build UI container.appendChild(button); container.appendChild(output); DomGlobal.document.body.appendChild(container); DomGlobal.console.log("J2CL application started successfully!"); } } DOM Manipulation with Elemental2
Working with Browser APIs
package com.example.dom; import elemental2.dom.*; import jsinterop.annotations.JsType; public class TodoApp { private final HTMLUListElement todoList; private final HTMLInputElement input; private int todoCount = 0; public TodoApp() { // Create main container HTMLDivElement container = (HTMLDivElement) DomGlobal.document.createElement("div"); container.className = "todo-container"; // Create input and button input = (HTMLInputElement) DomGlobal.document.createElement("input"); input.type = "text"; input.placeholder = "Enter a todo item..."; HTMLButtonElement addButton = (HTMLButtonElement) DomGlobal.document.createElement("button"); addButton.textContent = "Add Todo"; // Create todo list todoList = (HTMLUListElement) DomGlobal.document.createElement("ul"); todoList.className = "todo-list"; // Add event listeners addButton.addEventListener("click", evt -> addTodo()); input.addEventListener("keypress", evt -> { if ("Enter".equals(((KeyboardEvent) evt).key)) { addTodo(); } }); // Build UI container.appendChild(input); container.appendChild(addButton); container.appendChild(todoList); DomGlobal.document.body.appendChild(container); applyStyles(); } private void addTodo() { String text = input.value.trim(); if (!text.isEmpty()) { createTodoItem(text); input.value = ""; input.focus(); } } private void createTodoItem(String text) { HTMLLIElement li = (HTMLLIElement) DomGlobal.document.createElement("li"); li.className = "todo-item"; HTMLSpanElement textSpan = (HTMLSpanElement) DomGlobal.document.createElement("span"); textSpan.textContent = ++todoCount + ". " + text; HTMLButtonElement deleteBtn = (HTMLButtonElement) DomGlobal.document.createElement("button"); deleteBtn.textContent = "Delete"; deleteBtn.className = "delete-btn"; deleteBtn.addEventListener("click", evt -> { todoList.removeChild(li); DomGlobal.console.log("Removed todo: " + text); }); li.appendChild(textSpan); li.appendChild(deleteBtn); todoList.appendChild(li); } private void applyStyles() { HTMLElement style = (HTMLElement) DomGlobal.document.createElement("style"); style.textContent = """ .todo-container { max-width: 500px; margin: 20px auto; padding: 20px; font-family: Arial, sans-serif; } .todo-container input { width: 70%; padding: 8px; margin-right: 10px; } .todo-container button { padding: 8px 16px; background: #007cba; color: white; border: none; border-radius: 4px; cursor: pointer; } .todo-list { list-style: none; padding: 0; margin-top: 20px; } .todo-item { display: flex; justify-content: space-between; align-items: center; padding: 10px; margin: 5px 0; background: #f5f5f5; border-radius: 4px; } .delete-btn { background: #dc3545 !important; padding: 4px 8px !important; font-size: 12px; } """; DomGlobal.document.head.appendChild(style); } public static void main(String[] args) { new TodoApp(); } } JavaScript Interoperability
JsInterop Annotations
package com.example.interop; import jsinterop.annotations.JsConstructor; import jsinterop.annotations.JsMethod; import jsinterop.annotations.JsPackage; import jsinterop.annotations.JsProperty; import jsinterop.annotations.JsType; // Using existing JavaScript libraries @JsType(isNative = true, namespace = JsPackage.GLOBAL) public class JavaScriptIntegration { // Native JavaScript Math.random() public static native double random(); // Using window.console @JsMethod(namespace = JsPackage.GLOBAL) public static native void alert(String message); // Working with JSON @JsMethod(namespace = JsPackage.GLOBAL) public static native String JSONStringify(Object obj); @JsMethod(namespace = JsPackage.GLOBAL) public static native Object JSONParse(String json); } // Custom JavaScript-interoperable type @JsType public class UserData { @JsProperty public String name; @JsProperty public int age; @JsProperty public String email; @JsConstructor public UserData() {} @JsMethod public String toJson() { return JavaScriptIntegration.JSONStringify(this); } public static UserData fromJson(String json) { return (UserData) JavaScriptIntegration.JSONParse(json); } } // Using the interoperable types public class InteropExample { public static void main(String[] args) { // Call native JavaScript functions double randomValue = JavaScriptIntegration.random(); JavaScriptIntegration.alert("Random value: " + randomValue); // Work with JSON UserData user = new UserData(); user.name = "John Doe"; user.age = 30; user.email = "[email protected]"; String json = user.toJson(); DomGlobal.console.log("User JSON: " + json); UserData parsedUser = UserData.fromJson(json); DomGlobal.console.log("Parsed user: " + parsedUser.name); } } Advanced J2CL Features
Async/Await Support
package com.example.async; import elemental2.promise.Promise; import jsinterop.annotations.JsMethod; import jsinterop.annotations.JsPackage; import jsinterop.annotations.JsType; @JsType(isNative = true, namespace = JsPackage.GLOBAL) public class FetchApi { public static native Promise<Response> fetch(String url); } @JsType(isNative = true, namespace = JsPackage.GLOBAL) class Response { public native Promise<String> text(); public native Promise<JavaScriptObject> json(); } public class AsyncExample { public Promise<String> fetchData(String url) { return FetchApi.fetch(url) .then(response -> { if (!response.ok) { throw new RuntimeException("HTTP error: " + response.status); } return response.text(); }) .then(text -> { DomGlobal.console.log("Fetched data: " + text); return Promise.resolve(text); }) .catch_(error -> { DomGlobal.console.error("Fetch error: " + error); return Promise.reject(error); }); } public Promise<User[]> fetchUsers() { return FetchApi.fetch("https://jsonplaceholder.typicode.com/users") .then(Response::json) .then(json -> { // Process JSON data User[] users = processUserData(json); return Promise.resolve(users); }); } private native User[] processUserData(JavaScriptObject json); public static void main(String[] args) { AsyncExample example = new AsyncExample(); example.fetchData("https://api.example.com/data") .then(data -> { DomGlobal.console.log("Data received: " + data); return null; }); } } // User record for data binding record User(int id, String name, String email) {} Web Components with J2CL
package com.example.components; import elemental2.dom.*; import jsinterop.annotations.JsType; @JsType public class CustomButton extends HTMLElement { private HTMLButtonElement button; private int clickCount = 0; public CustomButton() { // Create shadow DOM ShadowRoot shadow = this.attachShadow(new ShadowRootInit.Builder() .setMode("open") .build()); // Create button element button = (HTMLButtonElement) DomGlobal.document.createElement("button"); button.style.cssText = """ padding: 12px 24px; background: linear-gradient(45deg, #667eea, #764ba2); color: white; border: none; border-radius: 25px; cursor: pointer; font-size: 16px; transition: transform 0.2s; """; updateButtonText(); // Add hover effects button.addEventListener("mouseenter", evt -> { button.style.transform = "scale(1.05)"; }); button.addEventListener("mouseleave", evt -> { button.style.transform = "scale(1)"; }); // Add click handler button.addEventListener("click", evt -> { clickCount++; updateButtonText(); // Dispatch custom event CustomEvent customEvent = new CustomEvent("buttonClicked"); customEvent.setDetail(new ClickDetail(clickCount, System.currentTimeMillis())); this.dispatchEvent(customEvent); }); shadow.appendChild(button); } private void updateButtonText() { button.textContent = "Clicked: " + clickCount + " times"; } // Attribute change handling public static void observedAttributes() { return new String[]{"color-scheme"}; } public void attributeChangedCallback(String name, String oldValue, String newValue) { if ("color-scheme".equals(name)) { applyColorScheme(newValue); } } private void applyColorScheme(String scheme) { switch (scheme) { case "dark": button.style.background = "linear-gradient(45deg, #2c3e50, #34495e)"; break; case "light": button.style.background = "linear-gradient(45deg, #667eea, #764ba2)"; break; } } // Custom event detail class @JsType public static class ClickDetail { public final int count; public final double timestamp; public ClickDetail(int count, double timestamp) { this.count = count; this.timestamp = timestamp; } } } // Component registry public class WebComponentRegistry { public static void registerComponents() { DomGlobal.customElements.define("custom-button", CustomButton.class); } public static void main(String[] args) { registerComponents(); // Create and use custom element CustomButton button = (CustomButton) DomGlobal.document.createElement("custom-button"); button.setAttribute("color-scheme", "dark"); button.addEventListener("buttonClicked", evt -> { CustomButton.ClickDetail detail = (CustomButton.ClickDetail) ((CustomEvent) evt).detail; DomGlobal.console.log("Button clicked " + detail.count + " times at " + detail.timestamp); }); DomGlobal.document.body.appendChild(button); } } State Management Patterns
Reactive State Management
package com.example.state; import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; public class Store<T> { private T state; private final List<Consumer<T>> listeners = new ArrayList<>(); public Store(T initialState) { this.state = initialState; } public T getState() { return state; } public void setState(T newState) { this.state = newState; notifyListeners(); } public void updateState(Consumer<T> updater) { updater.accept(state); notifyListeners(); } public void subscribe(Consumer<T> listener) { listeners.add(listener); } public void unsubscribe(Consumer<T> listener) { listeners.remove(listener); } private void notifyListeners() { for (Consumer<T> listener : listeners) { listener.accept(state); } } } // Application state record AppState(int counter, String message, boolean loading) { public AppState withCounter(int newCounter) { return new AppState(newCounter, this.message, this.loading); } public AppState withMessage(String newMessage) { return new AppState(this.counter, newMessage, this.loading); } public AppState withLoading(boolean newLoading) { return new AppState(this.counter, this.message, newLoading); } } // Stateful component public class CounterApp { private final Store<AppState> store; private final HTMLDivElement container; public CounterApp() { this.store = new Store<>(new AppState(0, "Ready", false)); this.container = (HTMLDivElement) DomGlobal.document.createElement("div"); initializeUI(); setupSubscriptions(); } private void initializeUI() { container.innerHTML = """ <div class="counter-app"> <h1>Counter: <span id="counter-value">0</span></h1> <p id="status-message">Ready</p> <button id="increment-btn">Increment</button> <button id="decrement-btn">Decrement</button> <button id="reset-btn">Reset</button> </div> """; // Add event listeners container.querySelector("#increment-btn") .addEventListener("click", evt -> increment()); container.querySelector("#decrement-btn") .addEventListener("click", evt -> decrement()); container.querySelector("#reset-btn") .addEventListener("click", evt -> reset()); DomGlobal.document.body.appendChild(container); } private void setupSubscriptions() { store.subscribe(state -> { updateCounterDisplay(state.counter()); updateStatusMessage(state.message()); }); } private void increment() { store.updateState(state -> { int newCounter = state.counter() + 1; String message = "Counter incremented to " + newCounter; return state.withCounter(newCounter).withMessage(message); }); } private void decrement() { store.updateState(state -> { int newCounter = state.counter() - 1; String message = "Counter decremented to " + newCounter; return state.withCounter(newCounter).withMessage(message); }); } private void reset() { store.setState(new AppState(0, "Counter reset", false)); } private void updateCounterDisplay(int value) { HTMLElement counterElement = (HTMLElement) container.querySelector("#counter-value"); counterElement.textContent = String.valueOf(value); } private void updateStatusMessage(String message) { HTMLElement messageElement = (HTMLElement) container.querySelector("#status-message"); messageElement.textContent = message; } public static void main(String[] args) { new CounterApp(); } } Testing J2CL Applications
Unit Testing Setup
// Test configuration for J2CL package com.example.test; import com.google.j2cl.junit.apt.J2clTestInput; import org.junit.Test; import org.junit.runner.RunWith; import static org.junit.Assert.*; @RunWith(com.google.j2cl.junit.J2clTestRunner.class) @J2clTestInput(TodoAppTest.class) public class TodoAppTest { @Test public void testTodoItemCreation() { // Test business logic without DOM dependencies TodoApp app = new TodoApp(); // Test methods that don't require browser environment } @Test public void testStateManagement() { Store<AppState> store = new Store<>(new AppState(0, "Test", false)); store.subscribe(state -> { assertEquals(0, state.counter()); }); store.setState(new AppState(1, "Updated", false)); } } Build and Deployment
Optimized Production Build
# With Bazel bazel build //src/main/java/com/example:myapp # With Maven mvn clean compile j2cl:compile # Output will be in target/j2cl-out/ for Maven # or bazel-bin/ for Bazel
Integration with Modern Bundlers
// webpack.config.js for J2CL output module.exports = { entry: './target/j2cl-out/myapp.js', output: { filename: 'app.bundle.js', path: __dirname + '/dist' }, optimization: { minimizer: [ new (require('closure-compiler'))({ compilation_level: 'ADVANCED', language_in: 'ECMASCRIPT_2019', language_out: 'ECMASCRIPT_2019' }) ] } }; Comparison: J2CL vs GWT
| Feature | GWT | J2CL |
|---|---|---|
| Input | Java source code | Java bytecode |
| Output | JavaScript | Closure-style JavaScript |
| Optimization | GWT compiler | Closure Compiler |
| Interop | JSNI | JsInterop |
| Build Tool | Custom | Bazel, Maven, Gradle |
| Size | Larger output | Highly optimized |
| Performance | Good | Excellent with Closure |
Conclusion
J2CL represents the modern evolution of Java-to-JavaScript compilation with significant advantages over GWT:
Key Benefits:
- Better optimization through Closure Compiler
- Superior interoperability with modern JavaScript
- Modern tooling integration with Bazel, Maven, and Gradle
- Smaller output sizes with advanced optimizations
- Type-safe JavaScript interop via JsInterop
Use Cases:
- Large-scale web applications requiring type safety
- Code sharing between server and client
- Teams with strong Java expertise building web apps
- Applications requiring advanced JavaScript optimization
Migration Path from GWT:
- Start with shared business logic
- Gradually replace UI components with Elemental2
- Convert JSNI to JsInterop
- Update build configuration
- Leverage modern JavaScript tooling
J2CL provides a robust, future-proof path for Java developers to build high-performance web applications while leveraging the entire Java ecosystem and tooling.