1 Introduction

Hybrid mobile development has become the mainstream choice for balancing multi-platform development efficiency with native experience. While WebView hosts Web UI, it needs access to native capabilities like camera, geolocation, etc. However, JavaScript and Native code run in separate runtimes, making direct mutual invocation impossible. JSBridge is the core bridge that solves this problem—it defines a reliable communication protocol that allows JavaScript to call native functions, and also allows Native to actively notify JavaScript of events.

This article explains the communication principles of JSBridge from scratch, and gradually implements a simple Bridge that supports bidirectional invocation, covering Android integration and common pitfalls. After reading this article, you will master the injection API implementation of JSBridge and be able to build or improve a Bridge solution independently in your project.

2 What is JSBridge? Why is it needed?

2.1 Core Concepts and Background

JSBridge is a communication middleware that runs in the WebView environment. It allows JavaScript to call methods of native code, and also allows native code to execute JavaScript functions. In hybrid development, web pages use HTML/CSS/JavaScript to build UI, offering advantages like rapid iteration and cross-platform reuse; but some system-level functions (e.g., camera, gyroscope, file selection) can only be accessed via native APIs.

Through JSBridge, web pages can use these capabilities as if they were calling local functions, while the native layer can also actively send data back after page loading is complete or when the user interacts.

The history of JSBridge can be traced back to desktop software embedding web UI into native functionality, and it became popular on mobile with the rise of Hybrid App frameworks like Cordova and PhoneGap. Today, not only traditional hybrid solutions rely on it, but also React Native, WeChat Mini Programs, etc., adopt similar bridging ideas, though the communication underlying layer is managed by different runtimes. Understanding JSBridge helps in mastering any cross-platform technology.

2.2 Basic Communication Principles

The communication modes of JSBridge are mainly divided into two types:

  • JavaScript calling Native: There are two mainstream implementation methods. The first is API injection, where the native side injects a native object into the JavaScript global object (window) through the WebView interface. The properties and methods of this object correspond to native functions. When JS calls them, native code is actually executed.

The second is URL Scheme interception: JS initiates a URL request with a custom protocol (e.g., jsbridge://openCamera?param=xx), and the native side parses the URL in the WebView’s shouldOverrideUrlLoading callback, matches the corresponding method, and executes it. The API injection method has better performance and more flexible data transfer, making it the current mainstream choice. This article uses this approach.

  • Native calling JavaScript: This is simpler than the former. The native side directly executes a JavaScript string via the WebView API. On Android, use webView.evaluateJavascript(script, callback); on iOS, use webView.evaluateJavaScript(script, completionHandler:).

This JS code can invoke predefined functions in the Bridge, thereby allowing Native to send messages to JS.

The combination of these two directions forms a bidirectional communication loop. The key lies in the unification of the message protocol and the handling of callbacks.

3 Hands-on Implementation: Write Your Own JSBridge in Three Steps

The following steps guide you through implementing a JSBridge from scratch that supports bidirectional calls and a callback mechanism. The code uses JavaScript and Android Native as examples; the iOS principle is similar.

3.1 Define Bridge Interface and Message Format

First, define a message format that both sides can correctly parse. It is recommended to use a JSON object with three core fields:

1
2
3
4
5
{
"bridgeName": "camera",
"data": { "quality": 0.8 },
"callbackId": "cb_1712345678"
}
  • bridgeName: The name of the native function to be called. The native side dispatches it to the corresponding business module based on this name.
  • data: Parameters passed to the native function, in JSON format, supporting complex structures.
  • callbackId: A unique ID for identifying a call’s callback. Generated by the JS side, the native side uses this ID to find the corresponding callback function and execute it after the logic is completed. Supports asynchronous callback scenarios.

Why is callbackId needed? Native functions are often asynchronous (e.g., taking a photo, getting location), and JS cannot block and wait. Therefore, a mechanism is needed: JS first sends a request, and after the native side completes, it sends back the result with the original callbackId, and the JS side retrieves the stored callback based on the ID and executes it.

3.2 JavaScript Side (Web Side) Implementation

The following is the JavaScript code that needs to be included in the browser or WebView. It mounts a JSBridge object on window, providing callNative and onNativeCall methods externally.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
/**
* JSBridge - JavaScript side implementation
* Must call JSBridge.init() after page load
*/
;(function() {
// Store functions waiting for callback, keyed by callbackId
var callbackMap = {};
// Callback ID counter
var callbackIdCounter = 0;

function generateCallbackId() {
return 'cb_' + (Date.now()) + '_' + (callbackIdCounter++);
}

window.JSBridge = {
/**
* Initialize: get the Native injected object, create placeholder if not present
*/
init: function() {
// Convention: the native injected object is named NativeBridge
if (!window.NativeBridge) {
console.warn('JSBridge: NativeBridge not found, using mock');
window.NativeBridge = {
postMessage: function(jsonStr) {
// Mock implementation for pure web debugging
console.log('mock callNative:', jsonStr);
// Simulate asynchronous callback
setTimeout(function() {
var msg = JSON.parse(jsonStr);
var fakeResult = { message: 'mock result for ' + msg.bridgeName };
JSBridge.onNativeCall(JSON.stringify({
callbackId: msg.callbackId,
data: fakeResult,
bridgeName: msg.bridgeName + '_callback'
}));
}, 500);
}
};
}
},

/**
* JavaScript calls native function
* @param {string} bridgeName - function name
* @param {object} data - parameters
* @param {function} callback - optional callback function (executed after native completes)
*/
callNative: function(bridgeName, data, callback) {
var callbackId = null;
if (typeof callback === 'function') {
callbackId = generateCallbackId();
callbackMap[callbackId] = callback;
}
var message = JSON.stringify({
bridgeName: bridgeName,
data: data || {},
callbackId: callbackId
});
// Send message via the postMessage method injected by Native
window.NativeBridge.postMessage(message);
},

/**
* Entry point for native calling JavaScript
* @param {string} jsonStr - JSON string passed from native
*/
onNativeCall: function(jsonStr) {
var message;
try {
message = JSON.parse(jsonStr);
} catch (e) {
console.error('JSBridge: invalid JSON from native', jsonStr);
return;
}
// If it is a callback response
if (message.callbackId && callbackMap[message.callbackId]) {
var cb = callbackMap[message.callbackId];
delete callbackMap[message.callbackId]; // Prevent memory leak
cb(message.data);
} else {
// Otherwise treat as a normal native notification, can be extended by business logic
console.log('JSBridge: received native notify', message);
// Can dispatch custom event for page listening
document.dispatchEvent(new CustomEvent('jsbridge_notify', { detail: message }));
}
}
};

// Auto-initialize
window.JSBridge.init();
})();

Key points:

  • NativeBridge.postMessage is the API injected by Native, used to pass JSON strings to Native.
  • callbackMap manages callbacks using closures, deleting immediately after execution to avoid memory leaks.
  • onNativeCall is the entry point for Native to call JS. The native side needs to call window.JSBridge.onNativeCall(jsonString) via evaluateJavascript.

3.3 Native Side: Android Example (WebView Injection + Message Reception)

On Android, expose methods to JavaScript using the @JavascriptInterface annotation. Below is a complete integration example:

Step 1: Create the injected Java class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class NativeBridge {
private WebView webView;
public NativeBridge(WebView webView) {
this.webView = webView;
}

@JavascriptInterface
public void postMessage(String jsonMessage) {
// This method runs on the WebView kernel thread, cannot directly operate UI
// Post to main thread for processing
MessageHandler handler = new MessageHandler(webView);
handler.handleMessage(jsonMessage);
}
}

Step 2: Message dispatch handler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public class MessageHandler {
private WebView webView;
public MessageHandler(WebView webView) { this.webView = webView; }

public void handleMessage(String jsonMessage) {
// Parse JSON
try {
JSONObject msg = new JSONObject(jsonMessage);
String bridgeName = msg.getString("bridgeName");
JSONObject data = msg.optJSONObject("data");
String callbackId = msg.optString("callbackId");

// Dispatch to specific function module based on bridgeName
Object result = null;
switch (bridgeName) {
case "showDialog":
result = showDialog(data);
break;
case "getLocation":
result = getLocation(); // Async example, need callback
break;
default:
result = "unknown bridge";
break;
}

// If there is a callbackId, send the result back to JS
if (callbackId != null && !callbackId.isEmpty()) {
JSONObject response = new JSONObject();
response.put("callbackId", callbackId);
response.put("data", result);
// Note: evaluateJavascript must be called on the main thread
webView.post(() -> {
webView.evaluateJavascript(
"javascript:JSBridge.onNativeCall('" + response.toString() + "')",
null
);
});
}
} catch (Exception e) {
e.printStackTrace();
}
}

private String showDialog(JSONObject data) {
// Simulate native dialog, actual should use AlertDialog
return "dialog shown";
}

private JSONObject getLocation() {
// Simulate location retrieval, actual should call LocationManager
// Return mock data here
JSONObject loc = new JSONObject();
try { loc.put("lat", 39.9); loc.put("lng", 116.3); } catch (Exception e) {}
return loc;
}
}

Step 3: Configure WebView in Activity

1
2
3
4
5
6
7
WebView webView = findViewById(R.id.webView);
webView.getSettings().setJavaScriptEnabled(true);
// Inject NativeBridge object
NativeBridge bridge = new NativeBridge(webView);
webView.addJavascriptInterface(bridge, "NativeBridge");
// Load page
webView.loadUrl("file:///android_asset/index.html");

iOS brief implementation: Use WKUserContentController‘s addScriptMessageHandler:name: to register a message handler named NativeBridge. Implement the userContentController:didReceiveScriptMessage: method on the native side to receive messages from JS.

To send back, use webView.evaluateJavaScript:completionHandler:. Note that addScriptMessageHandler in WKUserContentController can cause strong reference cycles; remove it at an appropriate time.

3.4 Simple Implementation of Native Calling JavaScript

Scenarios where Native actively calls JS include: after native location is completed, notify the page; incoming push messages, etc. Simply execute a piece of JS code:

1
2
3
// Android
String js = "javascript:JSBridge.onNativeCall('" + jsonMessage + "')";
webView.evaluateJavascript(js, null);
1
2
3
// iOS (WKWebView)
NSString *js = [NSString stringWithFormat:@"JSBridge.onNativeCall('%@')", jsonMessage];
[webView evaluateJavaScript:js completionHandler:nil];

Note: jsonMessage needs to have its single or double quotes escaped to prevent JS parsing errors. It is recommended to process it on the frontend using JSON.stringify, or have the native side construct a JSON string and parse it again using JSON.parse in JS, rather than directly concatenating objects.

Thus, a simple but functional JSBridge is complete. It supports synchronous/asynchronous calls, bidirectional communication, and maintains callback relationships through callbackId.

4 Quick Integration with Open Source Library: DSBridge Practice

While manually implementing Bridge helps understand the principles, using mature open source libraries in production can save a lot of time and avoid common mistakes. DSBridge is an excellent open source JSBridge library with good performance, supporting synchronous/asynchronous and type-safe calls (GitHub: wendux/DSBridge-Android / lzan13/DSBridge-iOS).

4.1 DSBridge Overview and Dispatch Mechanism

The core idea of DSBridge is: the developer only needs to define a class with the @DWebApi annotation on the native side, and all public methods are automatically exposed to JavaScript. On the JS side, call dsBridge.call("apiName", args, callback). The return value of the native method is the synchronous response; if the return type is void or requires asynchrony, use handler.complete(data) to send back.

It automatically handles callbackId mapping, thread switching, and JSON serialization, greatly simplifying development.

4.2 Integration Steps (Android Example)

  1. Add dependency (build.gradle)
    1
    implementation 'com.github.wendux:DSBridge-Android:3.0-SNAPSHOT'
  2. Create API class
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public class MyApi {
    @DWebApi
    public String getDeviceInfo(JSONObject args) {
    return Build.MODEL;
    }

    @DWebApi
    public void showToast(JSONObject args, Completer completer) {
    String msg = args.optString("msg");
    // Simulate async operation
    new Handler(Looper.getMainLooper()).postDelayed(() -> {
    completer.complete("toast shown");
    }, 1000);
    }
    }
  3. Register to WebView
    1
    2
    3
    4
    5
    6
    WebView webView = findViewById(R.id.webView);
    webView.getSettings().setJavaScriptEnabled(true);
    // DSBridge will handle message passing
    webView.addJavascriptInterface(new MyApi(), "dsBridge");
    // Load page, the page needs to include dsbridge.js
    webView.loadUrl("file:///android_asset/index.html");
  4. JS side call
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <script src="dsbridge.js"></script>
    <script>
    // Synchronous call
    var device = dsBridge.call('getDeviceInfo', {});
    console.log(device);
    // Asynchronous call
    dsBridge.call('showToast', {msg: 'Hello'}, function(response) {
    console.log('callback:', response);
    });
    </script>

Compared to manual implementation, DSBridge reduces code by about 70% and automatically handles threading issues (calls from JS to Native run on a background thread by default to avoid blocking UI). For most business scenarios, it is recommended to directly use this library, and only consider writing manually when deep customization or special security policies are needed.

5 Advanced Tips and Typical Pitfalls

Even when using open source libraries, understanding the underlying pitfalls helps with debugging and optimization. The following are common issues encountered in hybrid development.

5.1 Pitfall 1: URL Length Limit and Protocol Hijacking

If using the URL Scheme method (instead of API injection) for JSBridge, passing large data (e.g., base64 encoded images) may cause the URL to exceed the WebView’s character limit (usually around 2KB), resulting in truncation or complete failure. The API injection method avoids this problem because data is passed directly as a JSON string, not limited by URL length.

If URL scheme must be used, split the data into multiple fragments and reassemble through sequential requests, or switch to the postMessage method. In practice, API injection is recommended directly for better compatibility and performance.

5.2 Pitfall 2: Memory Leaks and Thread Safety

  • Unreleased callbacks: On the JavaScript side, if the native side does not call back for a long time (e.g., user cancels location), the corresponding callback will remain in callbackMap forever. Solution: set a timeout timer for each callbackId (e.g., 10s), automatically remove it after timeout and call back with an error. The JS code above did not include timeout logic; production environment needs to add it.

  • Strong reference on Native side: In Android, objects injected via addJavascriptInterface are held by the WebView’s internal JS engine with strong references. If that object also holds a reference to Activity, it prevents the Activity from being garbage collected. Common scenario: holding Activity context or WebView in the NativeBridge class.

It is recommended that the injected object only hold a WeakReference<WebView>, and manually remove the injection when the Activity is destroyed (webView.removeJavascriptInterface("NativeBridge")).

  • Thread issues: Methods annotated with @JavascriptInterface run on the WebView kernel thread and cannot directly operate UI.

Need to post to the main thread before executing. DSBridge automatically handles this conversion, but it is easy to overlook when writing manually.

5.3 Advanced Tips: Security and Debugging

  • Disable dangerous configurations: Besides the necessary setJavaScriptEnabled(true), disable permissions like setAllowFileAccess(true), setAllowContentAccess(true) to prevent XSS attacks from using Bridge to read local files. For sensitive operations (e.g., payment, private data), verify the call source on the native side (e.g., check if referer is a legitimate domain).

  • Debug communication logs: During development, enable WebView remote debugging. On Android, after calling WebView.setWebContentsDebuggingEnabled(true), you can view WebView console output via Chrome DevTools at chrome://inspect.

Add console.log statements in onNativeCall and callNative on the JS side to print message details, helping quickly locate communication failure causes.

  • Message integrity check: In production, it is recommended to sign or verify the message content to prevent man-in-the-middle attacks from tampering with Bridge messages. Especially in financial apps, verify whether bridgeName is in the whitelist.

6 Summary and Extensions

6.1 Core Recap of This Article

  • JSBridge is the bridge for communication between JavaScript and Native in hybrid development. The core principles are API injection and executing JavaScript strings.

  • Writing a JSBridge manually requires three things: designing a unified message protocol (bridgeName/data/callbackId), maintaining a callback queue on the JS side, and handling dispatch and result return on the Native side.

  • In production, it is recommended to use mature libraries like DSBridge to save development costs, but understanding the underlying mechanism helps troubleshoot complex issues.

  • Common pitfalls include URL length limits, memory leaks (unreleased callbacks or strong references from injected objects), thread safety, and security configurations.

6.2 Extension Learning Directions

  • Comparison with other cross-platform solutions: React Native uses a JavaScript engine (JSC/Hermes) as an intermediate layer, bridging through Native module registration and serialization, with asynchronous messages; Flutter communicates with native via Platform Channel, using binary serialization. Deeply comparing the performance differences of these solutions can help choose the right tech stack more precisely.

  • Packaging a Bridge SDK: Encapsulate the JS code and Native code from this article into an SDK module for business teams. Abstract an API registration interface so that business side only needs to focus on business logic, not communication details.

  • Special restrictions in Mini Programs or WKWebView: In WeChat Mini Programs, the view layer and logic layer are separate; JSBridge messages need to be forwarded through Native. In iOS WKWebView, due to inter-process communication (IPC) limitations, the frequency and size of evaluateJavascript calls have upper limits; large data may need to be split or use alternative solutions.

Through the above practices, you have mastered the core implementation and engineering key points of JSBridge. It is recommended to first assess the complexity of the project’s requirements: for simple scenarios, a few lines of manual code suffice; for complex scenarios, directly choose DSBridge, and focus on testing edge cases after integration (large data, rapid clicks, page closing, etc.). With a solid bridge, the road to hybrid development can be more stable.