This is a study note of Scala.js. Scala.js brings strong typing and code share to web development. It runs faster than hand-written JavaScript and has good interoperability with JavaScript libraries. It comes with excellent editor support. Due to the complexity of build, this note also comes with a lot of mill build code.

1 Hi

Create a build.sc file in the project root folder:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// for Bloop plugin to generate configuration
import $ivy.`com.lihaoyi::mill-contrib-bloop:$MILL_VERSION`

import mill.scalajslib.ScalaJSModule

object app extends ScalaJSModule {

  // basic setup for a scala.js application
  def scalaVersion = "2.13.1"
  def scalaJSVersion = "1.0.1"
}

Create a app/src/Hi.scala file:

1
2
3
4
5
object Hi {
  def main(args: Array[String]) = {
    println("Hi")
  }
}

In the project folder, run mill app.

Mill builds the Hi.scala to generate a out/app/fastOpt/dest/out.js that can be executed in either Node.js or a browser. To generate fully optimized JS code, run mill app.fullOpt. The size of the out/app/fullOpt/dest/out.js is 8KB, reduced from 278KB.

2 A Demo with Testing

This demo use scala-js-dom lib to manipulate DOM.

The test code runs inside Node.js and requires scalajs-env-jsdom-nodejs. It uses NPM package jsdom for Node.js, use npm install jsdom to install it.

The build.sc has the following content:

 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
/* build.sc */

// for Bloop plugin to generate configuration
import $ivy.`com.lihaoyi::mill-contrib-bloop:$MILL_VERSION`

import mill.{T, Agg}
import mill.scalalib.DepSyntax // implicit class for ivy"string"
import mill.scalajslib.ScalaJSModule

object app extends ScalaJSModule {

  // basic setup for a scala.js application
  def scalaVersion = "2.13.1"
  def scalaJSVersion = "1.0.1"

  // use browser dom api
  def ivyDeps = Agg(
    ivy"org.scala-js::scalajs-dom::1.0.0"
  )

  // setup test
  object test extends Tests {

    def testFrameworks = Seq("utest.runner.Framework")

    def ivyDeps = Agg(
      ivy"org.scala-js::scalajs-env-jsdom-nodejs:1.0.0",
      ivy"com.lihaoyi::utest::0.7.4"
    )

    // config to use Nodeje JsDom, default is Nodejs
    // need to run "npm install jsdom"
    import mill.scalajslib.api.JsEnvConfig
    def jsEnvConfig: T[JsEnvConfig] = T { JsEnvConfig.JsDom() }
  }
}

The app/src/App.scala file:

 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
import org.scalajs.dom
import org.scalajs.dom.document

object App {
  def main(args: Array[String]): Unit = {
    document.addEventListener("DOMContentLoaded", { (e: dom.Event) =>
      setupUI()
    })
  }

  def setupUI(): Unit = {
    val button = document.createElement("button")
    button.textContent = "Click me!"
    button.addEventListener("click", { (e: dom.MouseEvent) =>
      addClickedMessage()
    })
    document.body.appendChild(button)
    appendPar(document.body, "Hello World")

    def appendPar(targetNode: dom.Node, text: String): Unit = {
      val parNode = document.createElement("p")
      parNode.textContent = text
      targetNode.appendChild(parNode)
    }

    def addClickedMessage(): Unit = {
      appendPar(document.body, "You clicked the button!")
    }
  }
}

The app/test/src/AppTest.scala file:

 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
import utest.{test, TestSuite, Tests}
import scala.scalajs.js

import org.scalajs.dom
import org.scalajs.dom.document

// for extended NodeList methods such as .count
import org.scalajs.dom.ext.PimpedNodeList

object AppTest extends TestSuite {

  // Initialize App
  App.setupUI()

  def tests = Tests {
    test("Hello World") {
      assert(
        document
          .querySelectorAll("p")
          .count(_.textContent == "Hello World") == 1
      )
    }

    test("Button Click") {
      def messageCount =
        document
          .querySelectorAll("p")
          .count(_.textContent == "You clicked the button!")

      val button =
        document.querySelector("button").asInstanceOf[dom.html.Button]
      assert(button != null && button.textContent == "Click me!")
      assert(messageCount == 0)

      for (c <- 1 to 5) {
        button.click()
        assert(messageCount == c)
      }
    }
  }
}

After npm install jsdom, use mill app.test to run the test.

3 A Demo with Scala jQuery

3.1 build.sc

The build file is tricky for a couple of reasons:

  • The udash-jquery wraps jQuery API for Scalajs program. It requires jQuery lib to work properly. To avoid hard code, we use WebJars to manage the dependent js resources. The js resources are merged with genereated js file for fastOpt, fullOpt and fastOptTest.
  • scalatags lib is used in app and in test. It generates jsdom nodes.
  • When override a command and run the the super.cmd, the overriden command is excuted in a special dest.
  • You cannot pass non-constant parameter to T.task because it will ccreate circular dependencies. Otherwise, you will get error message like “Target#apply() call cannot use value outputFile defined within the T{…} block combineWebJars(outputFile, taskFile)()“.

     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
    
    // for Bloop plugin to generate configuration
    import $ivy.`com.lihaoyi::mill-contrib-bloop:$MILL_VERSION`
    
    import $ivy.`com.lihaoyi::mill-contrib-playlib:$MILL_VERSION`
    import mill.playlib.Static
    
    import mill.{T, Agg, Module}
    import mill.scalalib.{DepSyntax, Lib} // implicit class for ivy"string"
    import mill.scalajslib.ScalaJSModule
    
    // use playlib Static to extract resources from webjar
    object app extends ScalaJSModule with Static { outer =>
    
    // basic setup for a scala.js application
    def scalaVersion = "2.13.1"
    def scalaJSVersion = "1.0.1"
    
    def ivyDeps = Agg(
    ivy"com.lihaoyi::scalatags_sjs1:0.9.0", // scalatags for scalajs
    ivy"org.webjars:jquery:3.5.0",
    ivy"io.udash::udash-jquery_sjs1:3.0.4"
    )
    
    def combineWebJars(
      outputFile: os.Path,
      libPath: os.Path,
      taskFile: os.Path
    ) = {
    // matches filename with pattern: "anyword.min.js"
    val jsFiles =
      os.walk(libPath)
        .filter(path =>
          os.isFile(path) && path.last.matches("[^\\.]*\\.min.js$")
        )
    
    val allFiles = jsFiles :+ taskFile
    val allContent = allFiles.map(os.read).mkString(";")
    
    // throw an exception if an error occurs
    os.write.over(outputFile, allContent)
    }
    
    // in production, need to define fallOpt
    def fastOpt = T {
    val outputFile = T.dest / "out.js"
    val taskFile = super.fastOpt().path
    
    // that's the playlib Static resource
    val libPath = os.Path(assetsPath(), webJarResources().path) / "lib"
    
    combineWebJars(outputFile, libPath, taskFile)
    PathRef(outputFile)
    }
    
    object test extends Tests {
    
    def testFrameworks = Seq("utest.runner.Framework")
    
    import mill.scalajslib.api.JsEnvConfig
    def jsEnvConfig: T[JsEnvConfig] = T { JsEnvConfig.JsDom() }
    
    def ivyDeps = Agg(
      ivy"com.lihaoyi::scalatags_sjs1:0.9.0",
      ivy"org.scala-js::scalajs-dom::1.0.0",
      ivy"org.scala-js::scalajs-env-jsdom-nodejs:1.0.0",
      ivy"org.webjars:jquery:3.5.0",
      ivy"io.udash::udash-jquery_sjs1:3.0.4",
      ivy"com.lihaoyi::utest::0.7.4"
    )
    
    // combine all webJar js files with the scalajs out.js
    def fastOptTest = T {
      val taskFile = super.fastOptTest().path
      val outputFile = T.ctx.dest / "out.js"
      // that's the playlib Static resource
      val libPath = os.Path(assetsPath(), webJarResources().path) / "lib"
    
      outer.combineWebJars(outputFile, libPath, taskFile)
      PathRef(outputFile)
    }
    }
    }

3.2 The App Code

The index.html file has a link to the supported jQuery:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <script src="out/app/fastOpt/dest/out.js"></script>
  </head>
  <body></body>
</html>

The app/src/App.scala file:

 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
import io.udash.wrappers.jquery.jQ

object App {

  val ButtonText = "Click me!"
  val H1Text = "Hi All"

  def main(args: Array[String]): Unit = {
    val content = setupUI()
    jQ(() => jQ("body").append(content))
  }

  def setupUI() = {
    import scalatags.JsDom.all._

    def addClickedMessage(): Unit = {
      println("clicked")
      jQ("body").append(p("wonderful").render)
    }

    // val button = document.createElement("button")

    val btn = button(
      onclick := { () => addClickedMessage() }
    )(ButtonText)

    val hi = h1(H1Text)

    div(hi, btn).render
  }
}

Run mill app.fastOpt to create the JS file, then open index.html in a browser.

3.3 The Test Code

The app/test/src/AppTest.scala file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import io.udash.wrappers.jquery.jQ

import utest.{test, TestSuite, Tests}

object AppTest extends TestSuite {

  import scalatags.JsDom.all._

  // Initialize App
  val content = App.setupUI()
  val jqContent = jQ(content)

  def tests = Tests {
    test("Hello World") {
      assert(
        jqContent.children().length == 2
      )
    }
  }
}

Configuration

Use @JSExportTopLevel to emit to the global level. Use @js.natvie and @JSImport to import native JS stuff.

There are additional setting to use ES module.

The JS evnironment can be nodejs, jsdom, phantomjs or selenium.

Use cross building to compile for Scala.js and Scala JVM.