How to Use NPM Packages in Native iOS Apps

This article will show you how to use npm packages directly with JavaScriptCore, how to interact with them from Swift and why this can be very powerful if used correctly.

Johannes Fahrenkrug
The Smyth Group
12 min readJan 9, 2020

--

Updated January 2023: This article was first written in 2017 using UIKit. This is the updated version using SwiftUI, async/await, and Webpack 5.

CocoaPods — the de-facto standard in package managers for the Apple ecosystem until Swift Packages came along — currently offers over 36.000 packages. Not all of those are for iOS, some are macOS-only. The Node.js package manager npm offers over half a million packages. That’s a lot! And while not all of those are usable on iOS, a lot of them are. Of course quality is more important than quantity, but the JavaScript world and the packages it offers can be a rich resource for iOS developers to tap into. Before you stop reading: I’m not talking about creating an ugly Frankensteinian web-app-dressed-up-as-a-native app. This article will show you how to use npm packages directly with JavaScriptCore, how to interact with them from Swift and why this can be very powerful if used correctly.

We will build a small sample app that will analyze the sentiment of any English sentence you enter. It does that with the help of the sentiment npm package, some JavaScript, webpack and JavaScriptCore. We will create a native app with native UI — no web views!

Prerequisites

You should be comfortable with iOS development, Swift and at least a bit of JavaScript. Make sure you have the latest version of Xcode and an up-to-date version of Node.js installed (I’m using 18.12.1). I use nvm to install Node.js, but feel free to choose whatever you prefer to install it. OK, ready? Let’s build this app!

Introducing: Sentimentalist

The app we will build will use emojis to display the sentiment of whatever English sentence the user types into a textfield. So naturally “Sentimentalist” is a very fitting name. The actual sentiment analysis will happen in JavaScript, using the “sentiment” npm package. We will build a very minimal JavaScript class that exposes an analyze method. All our JavaScript code (including the npm package) will be bundled up into a single JS file with the help of webpack. In our iOS app we will use the JavaScriptCore framework to load and run that JS file and then we will call the analyze method from Swift and hand its return value from the JavaScript world back to Swift.

You can find the complete source code of the project here. All the different steps are tagged in the git repository, so you can easily check out the app every step of the way.

Let’s get started!

The Xcode Project

Let’s first create the project for our app. Create a new iOS “App” project in Xcode, call it “Sentimentalist”, choose “Swift UI” as the interface, “Swift” as the language, and finally make sure that “Include Unit Tests” is checked.

Now we just need to add a basic UI: Open ContentView.swift, add a very simple ViewModel that contains two published properties sentence and emoji. The sentence property will hold the current value of the text field and enable us to react to changes. The emoji property will contain an emoji for the detected sentiment that will be displayed in the center of the app.

Next we will simply add a SwiftUI TextField and a Text and some spacers.

import SwiftUI
import Combine

@MainActor
class ViewModel: ObservableObject {
/// The sentence to be analyzed
@Published var sentence = ""

/// The emoji representing the sentiment of the current sentence
@Published var emoji = "😐"
}

struct ContentView: View {
@ObservedObject var viewModel = ViewModel()

var body: some View {
VStack {
TextField("Sentence", text: $viewModel.sentence).font(.system(size: 34))
Spacer()
Text(viewModel.emoji).font(.system(size: 134))
Spacer()
}
.padding()
}
}

You can see what the app should look like by checking out the tag step1 from the repo.

Calling JavaScript From Swift

Next, we will add a new Swift class named SentimentAnalyzer. This class will be our gateway to the JavaScript code we want to interact with.

We will treat it as a singleton. That way the JavaScript VM and the JavaScript context that we will interact with only has to be created once and can be re-used throughout the lifetime of the app. A JSVirtualMachine is a “self-contained environment for JavaScript execution” (docs) that is among other things needed to manage the memory of objects that cross the border between the native and the JavaScript world. A JSContext is associated with a JSVirtualMachine and is used to actually evaluate and run JavaScript code.

Everything is being set up in the init method. A snippet of JavaScript is evaluated in the JSContext we’ve created, ready to be called later on. The snippet simply defines an analyze function that will return a random value between -5 and 5. We will later replace that with code that calls the actual sentiment npm package. That package uses a score to represent the sentiment: below zero is negative (the lower the worse) and above 0 is positive (the higher the better).

The analyze method is marked with the async keyword which means it will execute the JavaScript function asynchronously, usually on a background thread. As you can see, we don’t have to convert the Swift string in any way in order to use it as an argument for the JavaScript analyze method. Crossing the border between these two worlds is very convenient thanks to JavaScriptCore. A key player here is JSValue: It can represent any JavaScript value, for example numbers, strings, objects, arrays, or functions. A lot of those types can be converted automatically when passed from native to JavaScript. For values that are sent from JavaScript to native, however, a little unwrapping is necessary. That’s why we call toInt32 on the returned JSValue. We know that we are getting an integer back from the analyze function, so we can safely make that conversion. We then create an Intfrom that value because we don’t want to hand around a specifically sized 32-bit integer in our code.

Finally, we also have a small helper method that returns an emoji for a given sentiment score. The complete SentimentAnalyzer.swift class looks like this:

import Foundation
import JavaScriptCore

/// An analyzer of sentiments
class SentimentAnalyzer {
/// Singleton instance. Much more resource-friendly than creating multiple new instances.
static let shared = SentimentAnalyzer()
private let vm = JSVirtualMachine()
private let context: JSContext

private init() {
let jsCode = """
// From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random
function randomNumber(min, max) {
min = Math.ceil(min);
max = Math.floor(max);
//The maximum is inclusive and the minimum is inclusive
return Math.floor(Math.random() * (max - min + 1)) + min;
}

function analyze(sentence) {
return randomNumber(-5, 5);
}
"""

// Create a new JavaScript context that will contain the state of our evaluated JS code.
context = JSContext(virtualMachine: vm)

// Evaluate the JS code that defines the functions to be used later on.
context.evaluateScript(jsCode)
}

/// Analyze the sentiment of a given English sentence.
/// - Parameters:
/// - sentence: The sentence to analyze
/// - Returns : The sentiment score
func analyze(_ sentence: String) async -> Int {
if let result = context.globalObject.invokeMethod("analyze", withArguments: [sentence]) {
return Int(result.toInt32())
}

return 0
}

/// Return an emoji for the given sentiment score.
/// - Parameters:
/// - score: The sentiment score
/// - Returns: String with a single emoji character
func emoji(forScore score: Int) -> String {
switch score {
case 5...Int.max:
return "😍"
case 4:
return "😃"
case 3:
return "😊"
case 2, 1:
return "🙂"
case -1, -2:
return "🙁"
case -3:
return "☹️"
case -4:
return "😤"
case Int.min...(-5):
return "😡"
default:
return "😐"
}
}
}

Let’s hook it up to the ViewModel:

@MainActor
class ViewModel: ObservableObject {
/// The sentence to be analyzed
@Published var sentence = "" {
didSet {
if (sentence != oldValue) {
Task {
let score = await SentimentAnalyzer.shared.analyze(sentence)
emoji = SentimentAnalyzer.shared.emoji(forScore: score)
}
}
}
}

/// The emoji representing the sentiment of the current sentence
@Published var emoji = SentimentAnalyzer.shared.emoji(forScore: 0)
}

We use didSet on the sentence property to react to changes. For some reason SwiftUI sometimes sets the value twice, so we only do something when the value is actually different. Next we use a Task in order to asynchronously call our analyze method using the await keyword. Once we have a score, we set the emoji property to the matching value. Since the emoji is stored in a @Published var, the SwiftUI interface updates automatically whenever it changes.

Check out thestep2 tag to see what the app should look like at this point. You can go ahead and run it: Enter some text and watch the emoji change randomly. Congratulations! You are successfully talking to JavaScript from your native iOS app. But wait, there’s more!

The JavaScript App

Now to create our JavaScript application, we will create a new directory called “JS” right inside the “Sentimentalist” directory that Xcode has created for us (the one that contains the .xcodeproj).

Inside the JS directory we will run npm init --yes to create a package.json file with some default settings. Feel free to open it and edit the author, name and description, but it’s not really necessary. The important thing is that this file will contain the names and versions of the npm packages that our app will use. Let’s add those right now. Run npm install sentiment --save to install the sentiment package and to write that dependency to the package.json file. Next run npm install webpack webpack-cli --save-dev to install the webpack tool that we will use to pack up our little JavaScript app into a bundle that we can use inside of our iOS app. We use the --save-dev option here because we only need this package as a development and not as a runtime dependency. If you checkout your project on a different machine, all you have to do is run npm install inside of the “JS” directory to install the dependencies.

Now we are ready to write a tiny JavaScript app that will make use of the sentiment package. Create a index.js file with this content:

const Sentiment = require('sentiment')

export class Analyzer {
static analyze(phrase) {
let sentiment = new Sentiment()
let result = sentiment.analyze(phrase)
return result['score']
}
}

Finally we have to configure webpack to build and bundle the app. Webpack is an impressive tool that can analyze which files and packages your project imports and then bundle all of it up into a single file (or — if you prefer — multiple files). Create a new file called webpack.config.js right inside the “JS” directory with this content:

const path = require('path')

module.exports = {
mode: "production",
entry: { Sentimentalist: "./index.js" },
output: {
path: path.resolve(__dirname, "dist"),
filename: "[name].bundle.js",
library: "[name]",
libraryTarget: "var"
}
};

This is a minimal webpack configuration: The entry section can be compared to targets in Xcode. We have one target called Sentimentalist and the file that webpack should use as a starting point to find all the dependencies that need to be included in the final bundle is index.js.

The output section specifies that the bundle should be written to the dist directory, that is should be called Sentimentalist.bundle.js and that the bundle should be accessible as a global variable also named Sentimentalist. This is just the tip of the iceberg of what webpack can do, but it’s enough for our example. Be sure to checkout the documentation so you can master webpack.

To save us from having to type a few extra characters each time we want to build the application, add this line to the ”scripts” section of “package.json”:

"build": "webpack --progress --color"

Now let’s see if it all works. Run npm run build. You should see an output like this:

WebPack successfully built and bundled our tiny JavaScript app

Congratulations! It worked!

Check out the tag step3 to see what the app should look like at this point.

Now we just need to integrate the Sentimentalist.bundle.js into our iOS app.

Using the JavaScript App From Swift

In Xcode, right-click on the Sentimentalist group in the Navigator and select “Add Files to Sentimentalist…”. Navigate to the “JS/dist” directory and select “Sentimentalist.bundle.js”: That will add the file to the project and also make sure that it is copied to the app bundle as part of the build process.

Now all that’s left to do is to use this bundle in the SentimentAnalyzer class. Replace the let jsCode… lines with this one:

let jsCode = try? String.init(contentsOf: Bundle.main.url(forResource: "Sentimentalist.bundle", withExtension: "js")!)

Finally we need to dig a little deeper to get to our new analyze method in the JavaScript bundle: webpack puts our JS project into a global object called Sentimentalist. Within that object our class that contains the static analyze method is called Analyzer. This is what the Swift analyze method needs to look like:

/// Analyze the sentiment of a given English sentence.
/// - Parameters:
/// - sentence: The sentence to analyze
/// - Returns : The sentiment score
func analyze(_ sentence: String) async -> Int {
let jsModule = self.context.objectForKeyedSubscript("Sentimentalist")
let jsAnalyzer = jsModule?.objectForKeyedSubscript("Analyzer")
if let result = jsAnalyzer?.invokeMethod("analyze", withArguments: [sentence]) {
return Int(result.toInt32())
}

return 0
}

Run the app and try out a few sentences and watch the emoji change!

Trying out different sentences

Check out the tag step4 to see what the app should look like at this point.

Calling Swift From JavaScript

Using JavaScript in your iOS app is not a one-way street. You can also call native code right from JavaScript. Let’s add the ability to output log messages from JS.

In SwiftAnalyzer’s init method we simply declare a Swift closure with the name nativeLog. We then let the JSContext know about that method by setting it via setObject:forKeyedSubscript:. The fact that we need to add @convention(block) to the closure and cast the Swift string explicitly to NSString are a few inconvenient reminders that JavaScriptCore is not a 100% first class citizen in the Swift world quite yet. This is the new init method:

private init() {
let jsCode = try? String.init(contentsOf: Bundle.main.url(forResource: "Sentimentalist.bundle", withExtension: "js")!)

// The Swift closure needs @convention(block) because JSContext's setObject:forKeyedSubscript: method
// expects an Objective-C compatible block in this instance.
// For more information check out https://developer.apple.com/library/content/documentation/Swift/Conceptual/Swift_Programming_Language/Attributes.html#//apple_ref/doc/uid/TP40014097-CH35-ID350
let nativeLog: @convention(block) (String) -> Void = { message in
print("JS Log: \(message)")
}

// Create a new JavaScript context that will contain the state of our evaluated JS code.
self.context = JSContext(virtualMachine: self.vm)

// Register our native logging function in the JS context
self.context.setObject(nativeLog, forKeyedSubscript: "nativeLog" as NSString)

// Evaluate the JS code that defines the functions to be used later on.
self.context.evaluateScript(jsCode)
}

Let’s switch back to index.js and make use of the native logging function. To play it safe we check if the nativeLog function is defined and then we simply call it:

const Sentiment = require('sentiment')

export class Analyzer {
static analyze(phrase) {
// Make sure nativeLog is defined and is a function
if (typeof nativeLog === 'function') {
nativeLog(`Analyzing '${phrase}'`)
}

let sentiment = new Sentiment()
let result = sentiment.analyze(phrase)
return result['score']
}
}

Run npm run build again and then Build & Run in Xcode: As you type you will see the log messages show up in Xcode’s console!

Checkout the tag step5 in the git repo to see the app at this state.

Is it fast?

Yes. Well, it depends. It is very fast for probably all but the most performance critical operations. In step6 in the git repo you see a performance test being added to the Xcode project. When it is run on my old 2017 iPad (not a Pro) on iOS 16.2, it measures an average performance of around 3 milliseconds for a call to the analyze method until the score is returned. That’s pretty fast! When targeting a refresh rate of 60 frames per second, you have 16ms per frame before you start dropping frames. That means we could call our analyze method 5 times per frame and still achieve 60fps. Not bad!

So. . . what should I use this for?

JavaScript engines are getting faster and faster. Frameworks like React Native are using the same techniques that we talked about but on a bigger scale. JavaScript on iOS is very fast and very powerful. So if you have existing business logic written in JavaScript it’s worth exploring whether you could just bundle it up and use it in your iOS app instead of having to re-write it.

Code sharing is another area where this approach can shine: It’s entirely possible to write portions of your app in JavaScript and share them between iOS, Android, and Windows. This is especially true when it comes to more or less self-contained pieces of code: I stick something in and get something out. Those units of code are prime candidates for this approach.

And last but not least, there might be npm packages out there that do exactly what you need and you can’t find something comparable on CocoaPods. You might very well be able to easily use it as-is in your iOS app without any noticeable performance implications. JavaScript and iOS are a powerful team!

--

--