Shane Xu's Home

Life is too short for so much sorrow.

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= 的特性。

看一段 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 属性 firstNamelastName ,以及一个计算属性 fullName , 换句话说,=fullName= 依赖于 firstNamelastName 。下面用 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 也会随着 firstNamelastName 的变化而变化。

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只有一个能被选中。总结一下:

  1. Radio Button checked=true和checked=false两种状态
  2. 同一组Radio Button仅有一个checked=true
  3. 同一组Radio Button初始的时候可以一个都不被选中

然后再来看看标签页有什么特性:

  1. 每一个tab页有active=true和active=false两种状态
  2. 同一组tab页仅有一个active=true
  3. 同意组tab页初始的时候必须有一个被选中

这两种控件在行为上是如此相似。以至于可以通过给Radio Button写一个ControlTemplate就能,把一组Radio Button变成tabs。

以我浅薄的前端开发经验,我想给上面举得例子做个总结:

  1. 组件几乎很难在两个不同的项目里无缝的使用,除非你不在乎它的外观
  2. 然而其逻辑几乎可以完美的复用

基于上面的结论,我在 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代码的技巧。

Comments

comments powered by Disqus