Binding.scala in Practice
开了这个题目之后,我久久不知道如何动笔。搁置了一周之后,我终于下定决心写好这篇文章。我就来写写,我这两星期 我
和 scala.js 以及 Binding.scala 浴血奋战的故事(You and Music and Dream)。
首先,创建一个最简单的 scala.js with Binding.scala 的项目。
mkdir simple-scala.js-Binding.scala cd simple-scala.js-Binding.scala mkdir -p js/src/{main,test}/scala mkdir project echo sbt.version=0.13.13 > project/build.properties echo 'resolvers += "Typesafe Releases" at "http://repo.typesafe.com/typesafe/releases/" addSbtPlugin("org.scala-js" % "sbt-scalajs" % "0.6.14") addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.3") ' > project/plugin.sbt touch build.sbt
然后在 build.sbt
中添加如下内容。
1: lazy val commonSettings = Seq( 2: version := "0.0.1", 3: scalaVersion := "2.11.8", 4: addCompilerPlugin("org.scalamacros" % "paradise" % "2.1.0" cross CrossVersion.full), 5: scalacOptions ++= Seq("-feature", "-language:implicitConversions") 6: ) 7: 8: lazy val BindingScalaVersion = "10.0.1" 9: 10: lazy val root = (project in file(".")). 11: settings(commonSettings: _*). 12: aggregate(js) 13: 14: lazy val js = (project in file("js")). 15: settings(commonSettings: _*). 16: settings( 17: mainClass in (Compile) := Some("Application"), 18: persistLauncher := true, 19: libraryDependencies ++= Seq( 20: "org.scala-js" %%% "scalajs-dom" % "0.9.1", 21: "com.thoughtworks.binding" %%% "dom" % BindingScalaVersion, 22: "com.thoughtworks.binding" %%% "route" % BindingScalaVersion 23: ) 24: ). 25: enablePlugins(ScalaJSPlugin)
然后写一个 Main
函数。
1: /* js/src/main/scala/Application.scala */ 2: import scala.scalajs.js.JSApp 3: 4: object Application extends JSApp { 5: def main(): Unit = println("Hello World!") 6: }
至此一个最简单的 scala.js
with Binding.scala
的工程已经搭建好了。
执行 sbt fastOptJS
$ ls js/target/scala-2.11 classes js-fastopt.js js-fastopt.js.map js-jsdeps.js js-launcher.js
在项目根目录下新建一个 index.html
1: <!doctype html> 2: <html> 3: <head> 4: <meta charset="utf-8"> 5: <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> 6: <meta name="description" content=""> 7: <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> 8: <title>Hello World!</title> 9: <script type="text/javascript" src="./js/target/scala-2.11/js-jsdeps.js"></script> 10: <script type="text/javascript" src="./js/target/scala-2.11/js-fastopt.js"></script> 11: </head> 12: <body> 13: <script type="text/javascript" src="./js/target/scala-2.11/js-launcher.js"></script> 14: </body> 15: </html>
在浏览器中打开,就能看到我们亲切的 Hello World!
了。
然而一个真正的前端项目,不可能一直使用本地文件来验证查看的,所以我需要一个server。=Play= 和 scalatra
在这个场景中有点杀鸡用牛刀的感觉,所以我选择了 Akka Http
做这个 webserver
。
中间又出了点变故,本来打算用 nginx
伺服所有的静态文件的,后来深感线上虚拟机上搞一个nginx,还要搞个发布流程甚是麻烦。索性打成一个可以执行的jar,来的方便,一股脑地就把配置写好吧。
1: lazy val commonSettings = Seq( 2: version := "0.0.1", 3: scalaVersion := "2.11.8", 4: addCompilerPlugin("org.scalamacros" % "paradise" % "2.1.0" cross CrossVersion.full), 5: scalacOptions ++= Seq("-feature", "-language:implicitConversions") 6: ) 7: 8: lazy val AkkaVersion = "2.4.16" 9: 10: lazy val AkkaHttpVersion = "10.0.1" 11: 12: lazy val BindingScalaVersion = "10.0.1" 13: 14: lazy val CirceVersion = "0.6.1" 15: 16: lazy val ScalazVersion = "7.2.8" 17: 18: lazy val compileCopyTask = taskKey[Unit]("compile and copy") 19: 20: 21: lazy val root = (project in file(".")). 22: settings(commonSettings: _*). 23: aggregate(server, js) 24: 25: lazy val server = (project in file("server")). 26: settings(commonSettings: _*). 27: settings( 28: cancelable in Global := true, 29: fork in run := true, 30: libraryDependencies ++= Seq( 31: "com.typesafe.akka" %% "akka-actor" % AkkaVersion, 32: "com.typesafe.akka" %% "akka-stream" % AkkaVersion, 33: "com.typesafe.akka" %% "akka-http" % AkkaHttpVersion, 34: "ch.qos.logback" % "logback-classic" % "1.1.7", 35: "com.typesafe.scala-logging" %% "scala-logging" % "3.5.0" 36: ) 37: ). 38: settings( 39: compileCopyTask := { 40: val mainVersion = scalaVersion.value.split("""\.""").take(2).mkString(".") 41: val to = target.value / ("scala-" + mainVersion) / "classes" / "static" / "js" 42: to.mkdirs() 43: val fastJs = (fastOptJS in Compile in js).value.data 44: val fastJsSourceMap = fastJs.getParentFile / (fastJs.getName + ".map") 45: val fastJsLauncher = (packageScalaJSLauncher in Compile in js).value.data 46: val fastJsDeps = (packageJSDependencies in Compile in js).value 47: val fullJs = (fullOptJS in Compile in js).value.data 48: val fullJsSourceMap = fullJs.getParentFile / (fullJs.getName + ".map") 49: val fullJsDeps = (packageMinifiedJSDependencies in Compile in js).value 50: 51: for(f <- Seq(fastJs, fastJsSourceMap, fastJsLauncher, fastJsDeps, fullJs, fullJsSourceMap, fullJsDeps)) { 52: IO.copyFile(f, to / f.getName) 53: } 54: } 55: ). 56: settings( 57: compile in Compile := { 58: compileCopyTask.value 59: (compile in Compile).value 60: } 61: ). 62: settings( 63: mainClass in assembly := Some("Server"), 64: assemblyJarName in assembly := "server.jar" 65: ) 66: 67: lazy val js = (project in file("js")). 68: settings(commonSettings: _*). 69: settings( 70: mainClass in (Compile) := Some("Application"), 71: persistLauncher := true, 72: libraryDependencies ++= Seq( 73: "org.scala-js" %%% "scalajs-dom" % "0.9.1", 74: "com.thoughtworks.binding" %%% "dom" % BindingScalaVersion, 75: "com.thoughtworks.binding" %%% "route" % BindingScalaVersion, 76: "io.circe" %%% "circe-core" % CirceVersion, 77: "io.circe" %%% "circe-parser" % CirceVersion, 78: "io.circe" %%% "circe-generic" % CirceVersion, 79: "org.scalaz" %%% "scalaz-core" % ScalazVersion 80: ) 81: ). 82: enablePlugins(ScalaJSPlugin) 83:
增加 server
submodule
mkdir -p server/src/{main,test}/{scala,resources}
增加 Server.scala
1: import java.util.concurrent.CountDownLatch 2: import akka.actor.ActorSystem 3: import akka.stream.ActorMaterializer 4: import akka.http.scaladsl.Http 5: import akka.http.scaladsl.server.Directives._ 6: import com.typesafe.config.ConfigFactory 7: import com.typesafe.scalalogging.Logger 8: import scala.concurrent.Await 9: import scala.concurrent.duration._ 10: 11: object Server extends App { 12: val logger = Logger(Server.getClass) 13: val conf = ConfigFactory.load().withFallback(ConfigFactory.load("default.conf")) 14: 15: implicit val actorSystem = ActorSystem() 16: implicit val materializer = ActorMaterializer() 17: implicit val executionContext = actorSystem.dispatcher 18: 19: val shutdownLatch = new CountDownLatch(1) 20: 21: val devRoute = pathSingleSlash { 22: parameter("fast") { _ => 23: getFromFile("./src/main/resources/static/index-fastopt.html") 24: } ~ 25: getFromFile("./src/main/resources/static/index-fullopt.html") 26: } ~ 27: encodeResponse { 28: getFromDirectory("./src/main/resources/static") ~ 29: pathPrefix("js") { 30: getFromDirectory("../js/target/scala-2.11") 31: } 32: } 33: 34: val productionRoute = pathSingleSlash { 35: parameter("fast") { _ => 36: getFromResource("static/index-fastopt.html") 37: } ~ 38: getFromResource("static/index-fullopt.html") 39: } ~ 40: encodeResponse { 41: getFromResourceDirectory("static") 42: } 43: 44: val host = conf.getString("server.host") 45: val port = conf.getInt("server.port") 46: val bindingFuture = Http().bindAndHandle(devRoute ~ productionRoute, host, port) 47: logger.info(s"Server online as http://${host}:${port}") 48: 49: Runtime.getRuntime().addShutdownHook(new Thread() { 50: override def run() = { 51: val f = bindingFuture.flatMap(_.unbind()) andThen { 52: case _ => actorSystem.terminate() 53: } 54: Await.ready(f, 1 minute) 55: logger.info("Goodbye!") 56: shutdownLatch.countDown() 57: } 58: }) 59: 60: shutdownLatch.await() 61: }
现在只要执行,=sbt server/run= ,然后在浏览器中访问 http://127.0.0.1:1234/?fast
或者 http://127.0.0.1:1234/
就能分别访问 fastOptJS
或者 fullOptJS
的js了。
好了这只是一个准备工作,接下来是 Binding.scala
的哲学时间。
Binding.scala
给我的第一印象不是 类似 react.js
的xml语法糖,而是 mvvm
中的经典js库 knockout 。=knockout= 的官方文档中开篇就介绍了,=knockout= 的特性。
- Elegant dependency tracking - automatically updates the right parts of your UI whenever your data model changes.
- Declarative bindings - a simple and obvious way to connect parts of your UI to your data model. You can construct a complex dynamic UIs easily using arbitrarily nested binding contexts.
- Trivially extensible - implement custom behaviors as new declarative bindings for easy reuse in just a few lines of code.
看一段 knockout
文档中的一小撮代码。
1: function AppViewModel() { 2: this.firstName = ko.observable('Bob'); 3: this.lastName = ko.observable('Smith'); 4: 5: this.fullName = ko.computed(function() { 6: return this.firstName() + " " + this.lastName(); 7: }, this); 8: }
这里在 AppViewModel
中定义了两个 observable
属性 firstName
和 lastName
,以及一个计算属性 fullName
, 换句话说,=fullName= 依赖于 firstName
和 lastName
。下面用 Binding.scala
来实现这个逻辑。
1: val firstName = Var("Shane") 2: val lastName = Var("Xu") 3: 4: val fullName = Binding { 5: s"${firstName.bind} ${lastName.bind}" 6: }
和 knockout
类似,=Binding.scala= 代码片段中的 fullName
也会随着 firstName
和 lastName
的变化而变化。
Binding.scala
第二印象,才是 react.js
。这主要归因于 Binding.scala
利用了 scala
原生的 xml
库,=scala= 是能直接在代码里面写 xml
的。然后利用宏来把 xml
转化成 Node
。
好吧回归主题, Binding.scala in Practice
,不知道Binding.scala的作者杨博,有没有在生产中使用过Binding.scala。为了写现在这篇文章我又翻出了杨博的 More than React 看了遍。其实有些观点,我不太认同。尤其是系列的第三篇 More than React(二)组件对复用性有害?
,其中有一句话:
Binding.scala 不发明“组件”之类的噱头,而以更轻巧的“方法”为最小复用单位,让编程体验更加顺畅,获得了更好的代码复用性。
有点“为赋新词”的味道。我不认为在基于html的前端编码中,“方法”作为最小的复用单位能提高效率。在目前为止我接触过的所有前端框架中,无论angular、react、knockout,最难解决的问题还是复用。我不知道项目越来越复杂,代码量越来越大杨博会如何组织代码。载我看来,按照组件去组织代码是最为清晰的。比如,在文章中举例的标签编辑器。原文中的代码如下:
1: @dom def tagPicker(tags: Vars[String]) = { 2: val input: Input = <input type="text"/> 3: val addHandler = { event: Event => 4: if (input.value != "" && !tags.get.contains(input.value)) { 5: tags.get += input.value 6: input.value = "" 7: } 8: } 9: <section> 10: <div>{ 11: for (tag <- tags) yield <q> 12: { tag } 13: <button onclick={ event: Event => tags.get -= tag }>x</button> 14: </q> 15: }</div> 16: <div>{ input } <button onclick={ addHandler }>Add</button></div> 17: </section> 18: }
我不知道,杨博是有意还是无意地一直在追求代码的行数。这段代码的确比DHTML和React的例子来的逻辑清晰。然而我想问的是,如果我某天想换一种展示方式,比如想把input控件换成select控件,又或者标签想用select来展示,那么上面的代码几乎就是废了。我曾经有幸接触过WPF(窃以为C#是我目前接触过的所有编程语言中,用户体验最好的语言,只可惜血统不好)。在WPF中小到一个Button都可以重写外观。比如,下面这个我随手摘来的栗子 https://www.tutorialspoint.com/wpf/wpf_templates.htm 。
1: <Window x:Class = "TemplateDemo.MainWindow" 2: xmlns = "http://schemas.microsoft.com/winfx/2006/xaml/presentation" 3: xmlns:x = "http://schemas.microsoft.com/winfx/2006/xaml" 4: Title = "MainWindow" Height = "350" Width = "604"> 5: 6: <Window.Resources> 7: <ControlTemplate x:Key= "ButtonTemplate" TargetType = "Button"> 8: 9: <Grid> 10: <Ellipse x:Name = "ButtonEllipse" Height = "100" Width = "150" > 11: <Ellipse.Fill> 12: <LinearGradientBrush StartPoint = "0,0.2" EndPoint = "0.2,1.4"> 13: <GradientStop Offset = "0" Color = "Red" /> 14: <GradientStop Offset = "1" Color = "Orange" /> 15: </LinearGradientBrush> 16: </Ellipse.Fill> 17: </Ellipse> 18: 19: <ContentPresenter Content = "{TemplateBinding Content}" 20: HorizontalAlignment = "Center" VerticalAlignment = "Center" /> 21: </Grid> 22: 23: <ControlTemplate.Triggers> 24: 25: <Trigger Property = "IsMouseOver" Value = "True"> 26: <Setter TargetName = "ButtonEllipse" Property = "Fill" > 27: <Setter.Value> 28: <LinearGradientBrush StartPoint = "0,0.2" EndPoint = "0.2,1.4"> 29: <GradientStop Offset = "0" Color = "YellowGreen" /> 30: <GradientStop Offset = "1" Color = "Gold" /> 31: </LinearGradientBrush> 32: </Setter.Value> 33: </Setter> 34: </Trigger> 35: 36: <Trigger Property = "IsPressed" Value = "True"> 37: <Setter Property = "RenderTransform"> 38: <Setter.Value> 39: <ScaleTransform ScaleX = "0.8" ScaleY = "0.8" 40: CenterX = "0" CenterY = "0" /> 41: </Setter.Value> 42: </Setter> 43: <Setter Property = "RenderTransformOrigin" Value = "0.5,0.5" /> 44: </Trigger> 45: 46: </ControlTemplate.Triggers> 47: 48: </ControlTemplate> 49: </Window.Resources> 50: 51: <StackPanel> 52: <Button Content = "Round Button!" 53: Template = "{StaticResource ButtonTemplate}" 54: Width = "150" Margin = "50" /> 55: <Button Content = "Default Button!" Height = "40" 56: Width = "150" Margin = "5" /> 57: </StackPanel> 58: 59: </Window>
Window.Resources标签中,定义了一个适用于Button的ControlTemplate,然后下面的StackPanel里面就塞了两个Button,上面那个是自定的,下面那个是正常的。而那个自定义的Button同样能够相应用户的点击事件,在用户看来,只是一个长得比较奇怪的Button。Button所有的内部逻辑,都和原先的Default的button一致。 也许你觉得这里,对一个Button,做了完全定义未免太小题大作了。然而举一个更有意思的例子,也许你就不会这么想了。
如果你所见的控件并不是你想象中的东西。
1: <LinearGradientBrush x:Key="TabItemHotBackground" EndPoint="0,1" StartPoint="0,0"> 2: <GradientStop Color="#EAF6FD" Offset="0.15"/> 3: <GradientStop Color="#D9F0FC" Offset=".5"/> 4: <GradientStop Color="#BEE6FD" Offset=".5"/> 5: <GradientStop Color="#A7D9F5" Offset="1"/> 6: </LinearGradientBrush> 7: <SolidColorBrush x:Key="TabItemSelectedBackground" Color="#F9F9F9"/> 8: <SolidColorBrush x:Key="TabItemHotBorderBrush" Color="#3C7FB1"/> 9: 10: <Style x:Key="RadioButtonStyle1" TargetType="{x:Type RadioButton}"> 11: <Setter Property="Template"> 12: <Setter.Value> 13: <ControlTemplate TargetType="RadioButton"> 14: <Border BorderBrush="Transparent" BorderThickness="0" Background="Transparent" CornerRadius="0"> 15: <TabItem x:Name="tabItem" IsSelected="{TemplateBinding IsChecked}" IsHitTestVisible="False"> 16: <TabItem.Header> 17: <ContentPresenter Margin="5"/> 18: </TabItem.Header> 19: </TabItem> 20: </Border> 21: <ControlTemplate.Triggers> 22: <Trigger Property="IsMouseOver" Value="true"> 23: <Setter Property="Background" TargetName="tabItem" Value="{StaticResource TabItemHotBackground}"/> 24: </Trigger> 25: <Trigger Property="IsChecked" Value="true"> 26: <Setter Property="Panel.ZIndex" Value="1"/> 27: <Setter Property="Background" TargetName="tabItem" Value="{StaticResource TabItemSelectedBackground}"/> 28: </Trigger> 29: <MultiTrigger> 30: <MultiTrigger.Conditions> 31: <Condition Property="IsChecked" Value="false"/> 32: <Condition Property="IsMouseOver" Value="true"/> 33: </MultiTrigger.Conditions> 34: <Setter Property="BorderBrush" TargetName="tabItem" Value="{StaticResource TabItemHotBorderBrush}"/> 35: </MultiTrigger> 36: </ControlTemplate.Triggers> 37: </ControlTemplate> 38: </Setter.Value> 39: </Setter> 40: </Style>
出处请看这里,http://stackoverflow.com/questions/4578665/is-there-a-way-to-template-a-radiobutton-so-it-should-tabitem-styled 。原谅我直接粘贴了代码。我已经好多年没有碰WPF了。但是WPF的思想一直深深地烙印在我的心里。 也许看到上面这坨东西,会令没有WPF经验的人很费解。其实这段代码实现了一个“伪装成Tabs的Radio Buttons”。提到Radio Button给人的第一映像便是一个空心圆形的控件,当选中时,空心圆形变成实心圆形;同一组的Radio Button只有一个能被选中。总结一下:
- Radio Button checked=true和checked=false两种状态
- 同一组Radio Button仅有一个checked=true
- 同一组Radio Button初始的时候可以一个都不被选中
然后再来看看标签页有什么特性:
- 每一个tab页有active=true和active=false两种状态
- 同一组tab页仅有一个active=true
- 同意组tab页初始的时候必须有一个被选中
这两种控件在行为上是如此相似。以至于可以通过给Radio Button写一个ControlTemplate就能,把一组Radio Button变成tabs。
以我浅薄的前端开发经验,我想给上面举得例子做个总结:
- 组件几乎很难在两个不同的项目里无缝的使用,除非你不在乎它的外观
- 然而其逻辑几乎可以完美的复用
基于上面的结论,我在 Binding.scala
的基础上定了一个简单的组件模型:
1: trait Component[T] { self: T => 2: @dom 3: def render(implicit template: T => Binding[Node]) = { 4: template(self).bind 5: } 6: }
就拿杨博文章中的TagPicker作为例子
1: import org.scalajs.dom.raw.{ Node, Event, HTMLInputElement } 2: import com.thoughtworks.binding.dom 3: import com.thoughtworks.binding.Binding 4: import com.thoughtworks.binding.Binding.{ Var, Vars } 5: 6: case class TagPicker(tags: Vars[String]) extends Component[TagPicker] { 7: val text = Var("") 8: 9: val textChangeHandler = { e: Event => 10: e.target match { 11: case i: HTMLInputElement => 12: text := i.value 13: case _ => 14: } 15: } 16: 17: val addHandler = { e: Event => 18: text.get match { 19: case "" | null => 20: case value if !tags.get.contains(value) => 21: tags.get += value 22: text := "" 23: } 24: } 25: 26: val removeHandler = { value: String => 27: { e: Event => 28: tags.get -= value 29: } 30: } 31: } 32: 33: object TagPicker { 34: 35: @dom 36: implicit def defaultTemplate(self: TagPicker): Binding[Node] = { 37: <section> 38: <div>{ 39: for (tag <- self.tags) yield { 40: <span> 41: { tag } 42: <button onclick={ self.removeHandler(tag) }>x</button> 43: </span> 44: } 45: }</div> 46: <div> 47: <input value={ self.text.bind } onchange={ self.textChangeHandler }/> 48: <button onclick={ self.addHandler }>Add</button> 49: </div> 50: </section> 51: } 52: }
完整的代码如下:
这里运用了几个scala代码的技巧。