JavaScriptCore
Whether you love it or hate it, JavaScript has become the most important language for developers today. Yet despite any efforts we may take to change or replace it we’d be hard-pressed to deny its usefulness.
This week on NSHipster, we’ll discuss the JavaScriptCore framework, and how you can use it to set aside your core beliefs in type safety and type sanity and let JavaScript do some of the heavy lifting in your apps.
The JavaScriptCore framework provides direct access to WebKit’s JavaScript engine in your apps.
You can execute JavaScript code within a context
by calling the evaluate
method on a JSContext
object.
evaluate
returns a JSValue
object
containing the value of the last expression that was evaluated.
For example,
a JavaScript expression that adds the numbers 1, 2, and 3
results in the number value 6.
import Java Script Core
let context = JSContext()!
let result = context.evaluate Script("1 + 2 + 3")
result?.to Int32() // 6
You can cast JSValue
to a native Swift or Objective-C type
by calling the corresponding method found in the following table:
JavaScript Type |
JSValue method |
Objective-C Type | Swift Type |
---|---|---|---|
string | to |
NSString |
String! |
boolean | to |
BOOL |
Bool |
number |
to to to to
|
NSNumber double int32_t uint32_t
|
NSNumber! Double Int32 UInt32
|
Date | to |
NSDate |
Date? |
Array | to |
NSArray |
[Any]! |
Object | to |
NSDictionary |
[Any |
Class |
to to
|
custom type | custom type |
JavaScript evaluation isn’t limited to single statements. When you evaluate code that declares a function or variable, it’s saved into the context’s object space.
context.evaluate Script(#"""
function triple(number) {
return number * 3;
}
"""#)
context.evaluate Script("triple(5)")?
.to Int32() // 15
Handling Exceptions
The evaluate
method
doesn’t expose an NSError **
pointer
and isn’t imported by Swift as a method that throws
;
by default,
invalid scripts fail silently when evaluated within a context.
This is — you might say —
less than ideal.
To get notified when things break,
set the exception
property on JSContext
objects
before evaluation.
import Java Script Core
let context = JSContext()!
context.exception Handler = { context, exception in
print(exception!.to String())
}
context.evaluate Script("**INVALID**")
// Prints "Syntax Error: Unexpected token '**'"
Managing Multiple Virtual Machines and Contexts
Each JSContext
executes on a JSVirtual
that defines a shared object space.
You can execute multiple operations concurrently
across multiple virtual machines.
The default JSContext
initializer
creates its virtual machine implicitly.
You can initialize multiple JSContext
objects
to have a shared virtual machine.
A virtual machine performs deferred tasks, such as garbage collection and WebAssembly compilation, on the runloop on which it was initialized.
let queue = Dispatch Queue(label: "js")
let vm = queue.sync { JSVirtual Machine()! }
let context = JSContext(virtual Machine: vm)!
Getting JavaScript Context Values from Swift
You can access named values from a JSContext
by calling the object
method.
For example,
if you evaluate a script that declares the variable three
and sets it to the result of calling the triple()
function
(declared previously),
you can access the resulting value by variable name.
context.evaluate Script("var three Times Five = triple(5)")
context.object For Keyed Subscript("three Times Five")?
.to Int32() // 15
Setting Swift Values on a JavaScript Context
Conversely,
you can set Swift values as variables in a JSContext
by calling the set
method.
let three Times Two = 2 * 3
context.set Object(three Times Two,
for Keyed Subscript: "three Times Two" as NSString)
In this example,
we initialize a Swift constant three
to the product of 2 and 3,
and set that value to a variable in context
with the same name.
We can verify that the three
variable
is stored with the expected value
by performing an equality check in JavaScript.
context.evaluate Script("three Times Two === triple(2);")?
.to Bool() // true
Passing Functions between Swift and JavaScript
Functions are different from other values in JavaScript.
And though you can’t convert a function contained in a JSValue
directly to a native function type,
you can execute it within the JavaScript context
using the call(with
method.
let triple = context.object For Keyed Subscript("triple")
triple?.call(with Arguments: [9])?
.to Int32() // 27
In this example,
we access the triple
function from before by name
and call it —
passing 9 an argument —
to produce the value 27.
A similar limitation exists when you attempt to go the opposite direction,
from Swift to JavaScript:
JavaScriptCore is limited to passing Objective-C blocks
to JavaScript contexts.
In Swift,
you can use the @convention(block)
to create a compatible closure.
let quadruple: @convention(block) (Int) -> Int = { input in
return input * 4
}
context.set Object(quadruple,
for Keyed Subscript: "quadruple" as NSString)
In this example,
we define a block that multiplies an Int
by 4 and returns the resulting Int
,
and assign it to a function in the JavaScript context with the name quadruple
.
We can verify this assignment by either
calling the function directly in evaluated JavaScript
or by using object
to get the function in a JSValue
and call it with the call(with
method.
context.evaluate Script("quadruple(3)")?
.to Int32() // 12
context.object For Keyed Subscript("quadruple")?
.call(with Arguments: [3]) // 12
Passing Swift Objects between Swift and JavaScript
All of the conversion between Swift and Javascript we’ve seen so far
has involved manual conversion with intermediary JSValue
objects.
To improve interoperability between language contexts,
JavaScriptCore provides the JSExport
protocol,
which allows native classes to be mapped and initialized directly.
…though to call the process “streamlined” would be generous. As we’ll see, it takes quite a bit of setup to get this working in Swift, and may not be worth the extra effort in most cases.
But for the sake of completeness, let’s take a look at what all this entails:
Declaring the Exported JavaScript Interface
The first step is to declare a protocol that inherits JSExport
.
This protocol defines the interface exported to JavaScript:
the methods that can be called; the properties that can be set and gotten.
For example,
here’s the interface that might be exported for a Person
class
consisting of stored properties for first
, last
, and birth
:
import Foundation
import Java Script Core
// Protocol must be declared with `@objc`
@objc protocol Person JSExports: JSExport {
var first Name: String { get set }
var last Name: String { get set }
var birth Year: NSNumber? { get set }
var full Name: String { get }
// Imported as `Person.create With First Name Last Name(_:_:)`
static func create With(first Name: String, last Name: String) -> Person
}
JavaScriptCore uses the Objective-C runtime
to automatically convert values between the two languages,
hence the @objc
attribute here and in the corresponding class declaration.
Conforming to the Exported JavaScript Interface
Next, create a Person
class that adopts the Person
protocol
and makes itself Objective-C compatible with NSObject
inheritance
and an @objc
attribute for good measure.
// Class must inherit from `NSObject`
@objc public class Person : NSObject, Person JSExports {
// Properties must be declared with `dynamic`
dynamic var first Name: String
dynamic var last Name: String
dynamic var birth Year: NSNumber?
required init(first Name: String, last Name: String) {
self.first Name = first Name
self.last Name = last Name
}
var full Name: String {
return "\(first Name) \(last Name)"
}
class func create With(first Name: String, last Name: String) -> Person {
return Person(first Name: first Name, last Name: last Name)
}
}
Each stored property must be declared dynamic
to interoperate with the Objective-C runtime.
The init(first
initializer won’t be accessible from JavaScript,
because it isn’t part of the exported interface declared by Person
;
instead, a Person
object can be constructed through
a type method imported as Person.create
.
Registering the Class in the JavaScript Context
Finally,
register the class within the JSContext
by passing the type to set
.
context.set Object(Person.self,
for Keyed Subscript: "Person" as NSString)
Instantiating Swift Classes from JavaScript
With all of the setup out of the way, we can now experience the singular beauty of seamless(-ish) interoperability between Swift and JavaScript!
We’ll start by declaring a load
function in JavaScript,
which parses a JSON string and constructs imported Person
objects
using the JSON attributes.
context.evaluate Script(#"""
function load People(json) {
return JSON.parse(json)
.map((attributes) => {
let person = Person.create With First Name Last Name(
attributes.first,
attributes.last
);
person.birth Year = attributes.year;
return person;
});
}
"""#)
We can even flex our muscles by defining the JSON string in Swift
and then passing it as an argument to the load
function
(accessed by name using the object
method).
let json = """
[
{ "first": "Grace", "last": "Hopper", "year": 1906 },
{ "first": "Ada", "last": "Lovelace", "year": 1815 },
{ "first": "Margaret", "last": "Hamilton", "year": 1936 }
]
"""
let load People = context.object For Keyed Subscript("load People")!
let people = load People.call(with Arguments: [json])!.to Array()
Going back and forth between languages like this is neat and all, but doesn’t quite justify all of the effort it took to get to this point.
So let’s finish up with some NSHipster-brand pizazz, and see decorate these aforementioned pioneers of computer science with a touch of mustache.
Showing Off with Mustache
Mustache is a simple,
logic-less templating language,
with implementations in many languages,
including JavaScript.
We can load up mustache.js
into our JavaScript context
using the evaluate
to make it accessible for subsequent JS invocations.
guard let url = Bundle.main.url(for Resource: "mustache", with Extension: "js") else {
fatal Error("missing resource mustache.js")
}
context.evaluate Script(try String(contents Of: url),
with Source URL: url)
From here,
we can define a mustache template (in all of its curly-braced glory)
using a Swift multi-line string literal.
This template —
along with the array of people
from before in a keyed dictionary —
are passed as arguments to the render
method
found in the Mustache
object declared in context
after evaluating mustache.js
.
let template = """
{{#people}}
{{full Name}}, born {{birth Year}}
{{/people}}
"""
let result = context.object For Keyed Subscript("Mustache")
.object For Keyed Subscript("render")
.call(with Arguments: [template, ["people": people]])!
print(result)
// Prints:
// "Grace Hopper, born 1906"
// "Ada Lovelace, born 1815"
// "Margaret Hamilton, born 1936"
The JavaScriptCore framework provides a convenient way to leverage the entire JavaScript ecosystem.
Whether you use it to bootstrap new functionality, foster feature parity across different platforms, or extend functionality to users by way of a scripting interface, there’s no reason not to consider what role JavaScript can play in your apps.