Open Source Adventures: Episode 62: Ruby2JS
There are three main ways to run some sort of Ruby in a browser, none of them terribly satisfying:
- WebAssembly - Ruby has limited support for it - you'll get good Ruby compatibility, and reasonable performance, but very poor JavaScript interoperability
- Opal Ruby - compiles Ruby to JavaScript, making some serious compromises in terms of Ruby compatibility and performance to achieve better JavaScript interoperability
- Ruby2JS - basically Ruby-like syntax for JavaScript, and not in any meaningful sense "Ruby" - minimal Ruby compatibility, but potentially good performance, and good JavaScript interoperability
Over previous few episodes we've taken a look at how Opal Ruby does things. So new I'll run all these examples in Ruby2JS.
Hello, World!
By default Ruby2JS targets obsolete JavaScript, but we can tell it to target modern platforms with some switches.
--es2022
goes a bit too far for me, using nasty JavaScript "private instance variables", which is not a feature we want, so I passed --underscored_private
to disable that.
We also need to specify -f functions
. Ruby2JS has a bunch of configurable "filters" to tweak code generation.
$ ruby2js --es2022 --underscored_private -f functions hello.rb >hello.js
puts "Hello, World!"
With default settings, it becomes:
puts("Hello, World!")
This is already highly problematic, as Ruby2JS by design doesn't have runtime, so there's no puts
. So by default, its level of compatibility with Ruby is so low, even Hello World will instantly crash.
Fortunately -f functions
rescues us here, generating the obvious code:
console.log("Hello, World!")
So we can at least run Hello, World. This matters a few more times, in all examples below I'll be using -f functions
.
Booleans and Nils
a = true
b = false
c = nil
Becomes:
let a = true;
let b = false;
let c = null
For true
and false
it's obvious. Translating nil
into null
changes semantics a lot, but that's the cost of JavaScript interoperability.
Numbers
a = -420
b = 6.9
c = a + b
d = 999_999_999_999_999_999
e = a.abs
Becomes:
let a = -420;
let b = 6.9;
let c = a + b;
let d = 999_999_999_999_999_999;
let e = Math.abs(a)
Just like Opal, Ruby Integer
and Float
both become JavaScript number
.
Ruby +
is translated into a JavaScript +
, not any kind of rb_plus
. That's a performance win of course, but that means you cannot +
arrays and such.
-f functions
again saves us, without it .abs
call is translated into nonsense.
Strings
a = "world"
b = :foo
c = "Hello, #{a}!"
Becomes:
let a = "world";
let b = "foo";
let c = `Hello, ${a}!`
So just like Opal Ruby, String
and Symbol
both become JavaScript string
.
RubyJS will use string interpolation if we choose appropriate target. This makes no difference semantically, but it results in more readable code. Then again, Opal really doesn't care about readability of code it generates.
Arrays
a = []
b = [10, 20, 30]
b[2] = 40
b[-1] = b[-1] + 5
c = b[0]
d = b[-1]
Becomes:
let a = [];
let b = [10, 20, 30];
b[2] = 40;
b[-1] = b.at(-1) + 5;
let c = b[0];
let d = b.at(-1)
Which is a terrible translation, as negative indexes are not supported in JavaScript, and they're used in Ruby all the time.
Given new ES target, -f functions
translates negative getters to .at
, but not negative setters, so we get something crazy inconsistent here. The b[-1] = b.at(-1) + 5;
line is just total nonsense, it's likely even worse than not supporting negative indexes at all.
Hashes
a = {}
b = { 10 => 20, 30 => 40 }
c = { hello: "world" }
Becomes:
let a = {};
let b = {[10]: 20, [30]: 40};
let c = {hello: "world"}
Translating Ruby Hash
es into JavaScript objects destroys most of their functionality, but it's more interoperable, and can be good enough for some very simple code.
Arguably ES6+ Map
would fit Ruby semantics better, and it's part of the platform, but ES6 Map
s have horrendously poor interoperability with any existing JavaScript code. For example JSON.stringify(new Map([["hello", "world"]]))
returns '{}'
, which is insane.
Simple Person class
class Person
def initialize(first_name, last_name)
@first_name = first_name
@last_name = last_name
end
def to_s
"#{@first_name} #{@last_name}"
end
end
person = Person.new("Alice", "Ruby")
puts "Hello, #{person}!"
Becomes:
class Person {
constructor(first_name, last_name) {
this._first_name = first_name;
this._last_name = last_name
};
get to_s() {
return `${this._first_name} ${this._last_name}`
}
};
let person = new Person("Alice", "Ruby");
console.log(`Hello, ${person}!`)
Which looks very nice, but of course it doesn't work, as to_s
means nothing in JavaScript, so it prints Hello, [object Object]!
.
To get it to actually work, we need to twist it into something like:
class Person
def initialize(first_name, last_name)
@first_name = first_name
@last_name = last_name
end
def toString()
return "#{@first_name} #{@last_name}"
end
end
person = Person.new("Alice", "Ruby")
puts "Hello, #{person}!"
Notice three changes:
to_s
becomestoString
- mandatory
()
aftertoString
- otherwise it's a getter not function, and that won't work - mandatory
return
(there's a filter for that, but I didn't check if it breaks anything else)
If you had any hopes that any nontrivial Ruby code will run in Ruby2JS, you should see by now that it's hopeless.
Inheritance
class Person
def initialize(first_name, last_name)
@first_name = first_name
@last_name = last_name
end
def toString()
return "#{@first_name} #{@last_name}"
end
end
class Cat < Person
def toString()
return "Your Majesty, Princess #{super}"
end
end
cat = Cat.new("Catherine", "Whiskers")
puts "Hello, #{cat}!"
Becomes:
class Person {
constructor(first_name, last_name) {
this._first_name = first_name;
this._last_name = last_name
};
toString() {
return `${this._first_name} ${this._last_name}`
}
};
class Cat extends Person {
toString() {
return `Your Majesty, Princess ${super.toString()}`
}
};
let cat = new Cat("Catherine", "Whiskers");
console.log(`Hello, ${cat}!`)
Story so far
Overall it's really unclear to me what are legitimate use cases for Ruby2JS. Its compatibility with Ruby is nearly nonexistent, you're about as likely to be able to run your Ruby code in Crystal or Elixir as in Ruby2JS. So at this point, why not just create a full Ruby-inspired programming language that compiles to JavaScript?
If all you want is better syntax, CoffeeScript 2 is one such attempt (which is unfortunately not Svelte-compatible, if it was, I'd consider it), and it's not hard to create another.
And it's not even possible to create any reusable Ruby2JS code, as different combinations of filters and target will completely change meaning of the code.
Coming next
In the next episode we'll go back to Opal Ruby.