React.Component 损害了复用性?

HTML也可以静态编译?

2016/11/30 · HTML5 · 1
评论 ·
binding.scala,
React,
前端

本文作者: 伯乐在线 –
ThoughtWorks
。未经作者许可,禁止转载!
欢迎加入伯乐在线 专栏作者。

More than React系列文章:

《More than
React(一)为什么ReactJS不适合复杂的前端项目?》

《More than
React(二)React.Component损害了复用性?》

《More than React(三)虚拟DOM已死?》

《More than
React(四)HTML也可以静态编译?》


《More than
React》系列的上一篇文章《虚拟DOM已死?》比较了Binding.scala和其他框架的渲染机制。本篇文章中将介绍Binding.scala中的XHTML语法。

React.Component 损害了复用性?

2016/09/07 · 基础技术 ·
binding.scala,
data-binding,
React,
scala.js

本文作者: 伯乐在线 –
ThoughtWorks
。未经作者许可,禁止转载!
欢迎加入伯乐在线 专栏作者。

本系列的上一篇文章《为什么ReactJS不适合复杂的前端项目》列举了前端开发中的种种痛点。本篇文章中将详细探讨其中“复用性”痛点。我们将用原生
DHTML API 、 ReactJS 和 Binding.scala
实现同一个需要复用的标签编辑器,然后比较三个标签编辑器哪个实现难度更低,哪个更好用。

其他前端框架的问题

标签编辑器的功能需求

在InfoQ的许多文章都有标签。比如本文的标签是“binding.scala”、“data-binding”、“scala.js”。

假如你要开发一个博客系统,你也希望博客作者可以添加标签。所以你可能会提供标签编辑器供博客作者使用。

如图所示,标签编辑器在视觉上分为两行。

图片 1

第一行展示已经添加的所有标签,每个标签旁边有个“x”按钮可以删除标签。第二行是一个文本框和一个“Add”按钮可以把文本框的内容添加为新标签。每次点击“Add”按钮时,标签编辑器应该检查标签是否已经添加过,以免重复添加标签。而在成功添加标签后,还应清空文本框,以便用户输入新的标签。

除了用户界面以外,标签编辑器还应该提供 API 。标签编辑器所在的页面可以用
API 填入初始标签,也可以调用 API
随时增删查改标签。如果用户增删了标签,应该有某种机制通知页面的其他部分。

对HTML的残缺支持

以前我们使用其他前端框架,比如Cycle.js
、Widok、ScalaTags时,由于框架不支持
HTML语法,前端工程师被迫浪费大量时间,手动把HTML改写成代码,然后慢慢调试。

就算是支持HTML语法的框架,比如ReactJS,支持状况也很残缺不全。

比如,在ReactJS中,你不能这样写:

JavaScript

class BrokenReactComponent extends React.Component { render() { return (
<ol> <li class=”unsupported-class”>不支持 class
属性</li> <li style=”background-color: red”>不支持 style
属性</li> <li> <input type=”checkbox”
id=”unsupported-for”/> <label for=”unsupported-for”>不支持 for
属性</label> </li> </ol> ); } }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class BrokenReactComponent extends React.Component {
  render() {
    return (
      <ol>
        <li class="unsupported-class">不支持 class 属性</li>
        <li style="background-color: red">不支持 style 属性</li>
        <li>
          <input type="checkbox" id="unsupported-for"/>
          <label for="unsupported-for">不支持 for 属性</label>
        </li>
      </ol>
    );
  }
}

前端工程师必须手动把 classfor 属性替换成 className
htmlFor,还要把内联的 style
样式从CSS语法改成JSON语法,代码才能运行:

JavaScript

class WorkaroundReactComponent extends React.Component { render() {
return ( <ol> <li className=”workaround-class”>被迫把 class
改成 className</li> <li style={{ backgroundColor: “red”
}}>被迫把样式表改成 JSON</li> <li> <input
type=”checkbox” id=”workaround-for”/> <label
htmlFor=”workaround-for”>被迫把 for 改成 htmlFor</label>
</li> </ol> ); } }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class WorkaroundReactComponent extends React.Component {
  render() {
    return (
      <ol>
        <li className="workaround-class">被迫把 class 改成 className</li>
        <li style={{ backgroundColor: "red" }}>被迫把样式表改成 JSON</li>
        <li>
          <input type="checkbox" id="workaround-for"/>
          <label htmlFor="workaround-for">被迫把 for 改成 htmlFor</label>
        </li>
      </ol>
    );
  }
}

这种开发方式下,前端工程师虽然可以把HTML原型复制粘贴到代码中,但还需要大量改造才能实际运行。比Cycle.js、Widok或者ScalaTags省不了太多事。

原生 DHTML 版

首先,我试着不用任何前端框架,直接调用原生的 DHTML API
来实现标签编辑器,代码如下:

JavaScript

<!DOCTYPE html> <html> <head> <script> var tags
= []; function hasTag(tag) { for (var i = 0; i < tags.length; i++)
{ if (tags[i].tag == tag) { return true; } } return false; } function
removeTag(tag) { for (var i = 0; i < tags.length; i++) { if
(tags[i].tag == tag) {
document.getElementById(“tags-parent”).removeChild(tags[i].element);
tags.splice(i, 1); return; } } } function addTag(tag) { var element =
document.createElement(“q”); element.textContent = tag; var removeButton
= document.createElement(“button”); removeButton.textContent = “x”;
removeButton.onclick = function (event) { removeTag(tag); }
element.appendChild(removeButton);
document.getElementById(“tags-parent”).appendChild(element); tags.push({
tag: tag, element: element }); } function addHandler() { var tagInput =
document.getElementById(“tag-input”); var tag = tagInput.value; if (tag
&& !hasTag(tag)) { addTag(tag); tagInput.value = “”; } }
</script> </head> <body> <div
id=”tags-parent”></div> <div> <input id=”tag-input”
type=”text”/> <button onclick=”addHandler()”>Add</button>
</div> <script> addTag(“initial-tag-1”);
addTag(“initial-tag-2”); </script> </body> </html>

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
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
  &lt;script&gt;
    var tags = [];
 
    function hasTag(tag) {
      for (var i = 0; i &lt; tags.length; i++) {
        if (tags[i].tag == tag) {
          return true;
        }
      }
      return false;
    }
 
    function removeTag(tag) {
      for (var i = 0; i &lt; tags.length; i++) {
        if (tags[i].tag == tag) {
          document.getElementById("tags-parent").removeChild(tags[i].element);
          tags.splice(i, 1);
          return;
        }
      }
    }
 
    function addTag(tag) {
      var element = document.createElement("q");
      element.textContent = tag;
      var removeButton = document.createElement("button");
      removeButton.textContent = "x";
      removeButton.onclick = function (event) {
        removeTag(tag);
      }
      element.appendChild(removeButton);
      document.getElementById("tags-parent").appendChild(element);
      tags.push({
        tag: tag,
        element: element
      });
    }
 
    function addHandler() {
      var tagInput = document.getElementById("tag-input");
      var tag = tagInput.value;
      if (tag &amp;&amp; !hasTag(tag)) {
        addTag(tag);
        tagInput.value = "";
      }
    }
  &lt;/script&gt;
&lt;/head&gt;
&lt;body&gt;
  &lt;div id="tags-parent"&gt;&lt;/div&gt;
  &lt;div&gt;
    &lt;input id="tag-input" type="text"/&gt;
    &lt;button onclick="addHandler()"&gt;Add&lt;/button&gt;
  &lt;/div&gt;
  &lt;script&gt;
    addTag("initial-tag-1");
    addTag("initial-tag-2");
  &lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
 

为了实现标签编辑器的功能,我用了 45 行 JavaScript 代码来编写 UI
逻辑,外加若干的 HTML <div> 外加两行 JavaScript 代码填入初始化数据。

HTML 文件中硬编码了几个 <div>。这些<div>
本身并不是动态创建的,但可以作为容器,放置其他动态创建的元素。

代码中的函数来会把网页内容动态更新到这些 <div>
中。所以,如果要在同一个页面显示两个标签编辑器,id
就会冲突。因此,以上代码没有复用性。

就算用 jQuery 代替 DHTML
API,代码复用仍然很难。为了复用 UI ,jQuery
开发者通常必须额外增加代码,在 onload 时扫描整个网页,找出具有特定
class 属性的元素,然后对这些元素进行修改。对于复杂的网页,这些
onload 时运行的函数很容易就会冲突,比如一个函数修改了一个 HTML
元素,常常导致另一处代码受影响而内部状态错乱。

不兼容原生DOM操作

此外,ReactJS等一些前端框架,会生成虚拟DOM。虚拟DOM无法兼容浏览器原生的DOM
API
,导致和jQuery、D3等其他库协作时困难重重。比如ReactJS更新DOM对象时常常会破坏掉jQuery控件。

Reddit很多人讨论了这个问题。他们没有办法,只能弃用jQuery。我司的某客户在用了ReactJS后也被迫用ReactJS重写了大量jQeury控件。

ReactJS 实现的标签编辑器组件

ReactJS 提供了可以复用的组件,即 React.Component 。如果用 ReactJS
实现标签编辑器,大概可以这样写:

JavaScript

class TagPicker extends React.Component { static defaultProps = {
changeHandler: tags => {} } static propTypes = { tags:
React.PropTypes.arrayOf(React.PropTypes.string).isRequired,
changeHandler: React.PropTypes.func } state = { tags: this.props.tags }
addHandler = event => { const tag = this.refs.input.value; if (tag
&& this.state.tags.indexOf(tag) == -1) { this.refs.input.value =
“”; const newTags = this.state.tags.concat(tag); this.setState({ tags:
newTags }); this.props.changeHandler(newTags); } } render() { return (
<section> <div>{ this.state.tags.map(tag => <q key={
tag }> { tag } <button onClick={ event => { const newTags =
this.state.tags.filter(t => t != tag); this.setState({ tags: newTags
}); this.props.changeHandler(newTags); }}>x</button> </q>
) }</div> <div> <input type=”text” ref=”input”/>
<button onClick={ this.addHandler }>Add</button>
</div> </section> ); } }

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
class TagPicker extends React.Component {
 
  static defaultProps = {
    changeHandler: tags =&gt; {}
  }
 
  static propTypes = {
    tags: React.PropTypes.arrayOf(React.PropTypes.string).isRequired,
    changeHandler: React.PropTypes.func
  }
 
  state = {
    tags: this.props.tags
  }
 
  addHandler = event =&gt; {
    const tag = this.refs.input.value;
    if (tag &amp;&amp; this.state.tags.indexOf(tag) == -1) {
      this.refs.input.value = "";
      const newTags = this.state.tags.concat(tag);
      this.setState({
        tags: newTags
      });
      this.props.changeHandler(newTags);
    }
  }
 
  render() {
    return (
      &lt;section&gt;
        &lt;div&gt;{
          this.state.tags.map(tag =&gt;
            &lt;q key={ tag }&gt;
              { tag }
              &lt;button onClick={ event =&gt; {
                const newTags = this.state.tags.filter(t =&gt; t != tag);
                this.setState({ tags: newTags });
                this.props.changeHandler(newTags);
              }}&gt;x&lt;/button&gt;
            &lt;/q&gt;
          )
        }&lt;/div&gt;
        &lt;div&gt;
          &lt;input type="text" ref="input"/&gt;
          &lt;button onClick={ this.addHandler }&gt;Add&lt;/button&gt;
        &lt;/div&gt;
      &lt;/section&gt;
    );
  }
 
}
 

以上 51 行 ECMAScript 2015
代码实现了一个标签编辑器组件,即TagPicker。虽然代码量比 DHTML
版长了一点点,但复用性大大提升了。

如果你不用 ECMAScript 2015 的话,那么代码还会长一些,而且需要处理一些
JavaScript 的坑,比如在回调函数中用不了 this

ReactJS 开发者可以随时用 ReactDOM.render 函数把 TagPicker
渲染到任何空白元素内。此外,ReactJS 框架可以在 stateprops
改变时触发 render ,从而避免了手动修改现存的 DOM。

如果不考虑冗余的 key 属性,单个组件内的交互 ReactJS
还算差强人意。但是,复杂的网页结构往往需要多个组件层层嵌套,这种父子组件之间的交互,ReactJS
就很费劲了。

比如,假如需要在 TagPicker
之外显示所有的标签,每当用户增删标签,这些标签也要自动更新。要实现这个功能,需要给
TagPicker 传入 changeHandler 回调函数,代码如下:

JavaScript

class Page extends React.Component { state = { tags: [ “initial-tag-1”,
“initial-tag-2” ] }; changeHandler = tags => { this.setState({ tags
}); }; render() { return ( <div> <TagPicker tags={
this.state.tags } changeHandler={ this.changeHandler }/>
<h3>全部标签:</h3> <ol>{ this.state.tags.map(tag
=> <li>{ tag }</li> ) }</ol> </div> ); } }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Page extends React.Component {
 
  state = {
    tags: [ "initial-tag-1", "initial-tag-2" ]
  };
 
  changeHandler = tags =&gt; {
    this.setState({ tags });
  };
 
  render() {
    return (
      &lt;div&gt;
        &lt;TagPicker tags={ this.state.tags } changeHandler={ this.changeHandler }/&gt;
        &lt;h3&gt;全部标签:&lt;/h3&gt;
        &lt;ol&gt;{ this.state.tags.map(tag =&gt; &lt;li&gt;{ tag }&lt;/li&gt; ) }&lt;/ol&gt;
      &lt;/div&gt;
    );
  }
 
}
 

为了能触发页面其他部分更新,我被迫增加了一个 21 行代码的 Page 组件。

Page 组件必须实现 changeHandler 回调函数。每当回调函数触发,调用
Page 自己的 setState 来触发 Page 重绘。

从这个例子,我们可以看出, ReactJS
可以简单的解决简单的问题,但碰上层次复杂、交互频繁的网页,实现起来就很繁琐。使用
ReactJS 的前端项目充满了各种 xxxHandler
用来在组件中传递信息。我参与的某海外客户项目,平均每个组件大约需要传入五个回调函数。如果层次嵌套深,创建网页时,常常需要把回调函数从最顶层的组件一层层传入最底层的组件,而当事件触发时,又需要一层层把事件信息往外传。整个前端项目有超过一半代码都在这样绕圈子。

Binding.scala中的XHTML

现在有了Binding.scala ,可以在@dom方法中,直接编写XHTML。比如:

JavaScript

@dom def introductionDiv = { <div style=”font-size:0.8em”>
<h3>Binding.scala的优点</h3> <ul>
<li>简单</li> <li>概念少<br/>功能多</li>
</ul> </div> }

1
2
3
4
5
6
7
8
9
@dom def introductionDiv = {
  <div style="font-size:0.8em">
    <h3>Binding.scala的优点</h3>
    <ul>
      <li>简单</li>
      <li>概念少<br/>功能多</li>
    </ul>
  </div>
}

以上代码会被编译,直接创建真实的DOM对象,而没有虚拟DOM。

Binding.scala对浏览器原生DOM的支持很好,你可以在这些DOM对象上调用DOM
API,与 D3、jQuery等其他库交互也完全没有问题。

ReactJS对XHTML语法的残缺不全。相比之下,Binding.scala支持完整的XHTML语法,前端工程师可以直接把设计好的HTML原型复制粘贴到代码中,整个网站就可以运行了。

Binding.scala 的基本用法

在讲解 Binding.scala 如何实现标签编辑器以前,我先介绍一些 Binding.scala
的基础知识:

Binding.scala 中的最小复用单位是数据绑定表达式,即 @dom 方法。每个
@dom 方法是一段 HTML 模板。比如:

JavaScript

// 两个 HTML 换行符 @dom def twoBr = <br/><br/>

1
2
3
// 两个 HTML 换行符
@dom def twoBr = &lt;br/&gt;&lt;br/&gt;
 

JavaScript

// 一个 HTML 标题 @dom def myHeading(content: String) =
<h1>{content}</h1>

1
2
3
// 一个 HTML 标题
@dom def myHeading(content: String) = &lt;h1&gt;{content}&lt;/h1&gt;
 

每个模板还可以使用bind语法包含其他子模板,比如:

JavaScript

@dom def render = { <div> { myHeading(“Binding.scala的特点”).bind
} <p> 代码短 { twoBr.bind } 概念少 { twoBr.bind } 功能多
</p> </div> }

1
2
3
4
5
6
7
8
9
10
11
12
13
@dom def render = {
  &lt;div&gt;
    { myHeading("Binding.scala的特点").bind }
    &lt;p&gt;
      代码短
      { twoBr.bind }
      概念少
      { twoBr.bind }
      功能多
    &lt;/p&gt;
  &lt;/div&gt;
}
 

你可以参见附录:Binding.scala快速上手指南,学习上手Binding.scala开发的具体步骤。

此外,本系列第四篇文章《HTML也可以编译》还将列出Binding.scala所支持的完整HTML模板特性。

Binding.scala中XHTML的类型

@dom方法中XHTML对象的类型是Node的派生类。

比如,<div></div>
的类型就是HTMLDivElement,而
<button></button> 的类型就是
HTMLButtonElement。

此外, @dom
注解会修改整个方法的返回值,包装成一个Binding。

JavaScript

@dom def typedButton: Binding[HTMLButtonElement] = {
<button>按钮</button> }

1
2
3
@dom def typedButton: Binding[HTMLButtonElement] = {
  <button>按钮</button>
}

注意typedButton是个原生的HTMLButtonElement,所以可以直接对它调用 DOM
API。比如:

JavaScript

@dom val autoPrintln: Binding[Unit] = {
println(typedButton.bind.innerHTML) // 在控制台中打印按钮内部的 HTML }
autoPrintln.watch()

1
2
3
4
@dom val autoPrintln: Binding[Unit] = {
  println(typedButton.bind.innerHTML) // 在控制台中打印按钮内部的 HTML
}
autoPrintln.watch()

这段代码中,typedButton.bind.innerHTML 调用了 DOM API
HTMLButtonElement.innerHTML。通过autoPrintln.watch(),每当按钮发生更新,autoPrintln中的代码就会执行一次。

Binding.scala实现的标签编辑器模板

最后,下文将展示如何用Binding.scala实现标签编辑器。

标签编辑器要比刚才介绍的HTML模板复杂,因为它不只是静态模板,还包含交互。

JavaScript

@dom def tagPicker(tags: Vars[String]) = { val input: Input =
<input type=”text”/> val addHandler = { event: Event => if
(input.value != “” && !tags.get.contains(input.value)) {
tags.get += input.value input.value = “” } } <section>
<div>{ for (tag <- tags) yield <q> { tag } <button
onclick={ event: Event => tags.get -= tag }>x</button>
</q> }</div> <div>{ input } <button onclick={
addHandler }>Add</button></div> </section> }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@dom def tagPicker(tags: Vars[String]) = {
  val input: Input = &lt;input type="text"/&gt;
  val addHandler = { event: Event =&gt;
    if (input.value != "" &amp;&amp; !tags.get.contains(input.value)) {
      tags.get += input.value
      input.value = ""
    }
  }
  &lt;section&gt;
    &lt;div&gt;{
      for (tag &lt;- tags) yield &lt;q&gt;
        { tag }
        &lt;button onclick={ event: Event =&gt; tags.get -= tag }&gt;x&lt;/button&gt;
      &lt;/q&gt;
    }&lt;/div&gt;
    &lt;div&gt;{ input } &lt;button onclick={ addHandler }&gt;Add&lt;/button&gt;&lt;/div&gt;
  &lt;/section&gt;
}
 

这个标签编辑器的 HTML 模板一共用了 18 行代码就实现好了。

标签编辑器中需要显示当前所有标签,所以此处用tags: Vars[String]保存所有的标签数据,再用for/yield循环把tags中的每个标签渲染成UI元素。

Vars
是支持数据绑定的列表容器,每当容器中的数据发生改变,UI就会自动改变。所以,在x按钮中的onclick事件中删除tags中的数据时,页面上的标签就会自动随之消失。同样,在Add按钮的onclick中向tags中添加数据时,页面上也会自动产生对应的标签。

Binding.scala不但实现标签编辑器比 ReactJS 简单,而且用起来也比 ReactJS
简单:

JavaScript

@dom def render() = { val tags = Vars(“initial-tag-1”, “initial-tag-2”)
<div> { tagPicker(tags).bind } <h3>全部标签:</h3>
<ol>{ for (tag <- tags) yield <li>{ tag }</li>
}</ol> </div> }

1
2
3
4
5
6
7
8
9
@dom def render() = {
  val tags = Vars("initial-tag-1", "initial-tag-2")
  &lt;div&gt;
    { tagPicker(tags).bind }
    &lt;h3&gt;全部标签:&lt;/h3&gt;
    &lt;ol&gt;{ for (tag &lt;- tags) yield &lt;li&gt;{ tag }&lt;/li&gt; }&lt;/ol&gt;
  &lt;/div&gt;
}
 

只要用 9 行代码另写一个 HTML 模板,在模板中调用刚才实现好的 tagPicker
就行了。

完整的 DEMO 请访问

在 Binding.scala 不需要像 ReactJS 那样编写 changeHandler
之类的回调函数。每当用户在 tagPicker 输入新的标签时,tags
就会改变,网页也就会自动随之改变。

对比 ReactJS 和 Binding.scala 的代码,可以发现以下区别:

  • Binding.scala 的开发者可以用类似 tagPicker 这样的 @dom 方法表示
    HTML 模板,而不需要组件概念。
  • Binding.scala 的开发者可以在方法之间传递 tags 这样的参数,而不需要
    props 概念。
  • Binding.scala 的开发者可以在方法内定义局部变量表示状态,而不需要
    state 概念。

总的来说 Binding.scala 要比 ReactJS 精简不少。

如果你用过 ASP 、 PHP 、 JSP 之类的服务端网页模板语言, 你会发现和
Binding.scala 的 HTML 模板很像。

使用 Binding.scala 一点也不需要函数式编程知识,只要把设计工具中生成的
HTML 原型复制到代码中,然后把会变的部分用花括号代替、把重复的部分用
for / yield 代替,网页就做好了。

其他HTML节点

Binding.scala支持HTML注释:

JavaScript

@dom def comment = { <!– 你看不见我 –> }

1
2
3
@dom def comment = {
  <!– 你看不见我 –>
}

Binding.scala也支持CDATA块:

JavaScript

@dom def inlineStyle = { <section> <style><![CDATA[
.highlight { background-color:gold } ]]></style> <p
class=”highlight”>Binding.scala真好用!</p> </section> }

1
2
3
4
5
6
7
8
9
10
@dom def inlineStyle = {
  <section>
    <style><![CDATA[
      .highlight {
        background-color:gold
      }
    ]]></style>
    <p class="highlight">Binding.scala真好用!</p>
  </section>
}

结论

本文对比了不同技术栈中实现和使用可复用的标签编辑器的难度。

原生 HTML ReactJS Binding.scala
实现标签编辑器需要代码行数 45行 51行 17行
实现标签编辑器的难点 在代码中动态更新HTML页面太繁琐 实现组件的语法很笨重
使用标签编辑器并显示标签列表需要代码行数 难以复用 21行 8行
阻碍复用的难点 静态HTML元素难以模块化 交互组件之间层层传递回调函数过于复杂

Binding.scala
不发明“组件”之类的噱头,而以更轻巧的“方法”为最小复用单位,让编程体验更加顺畅,获得了更好的代码复用性。

本系列下一篇文章将比较 ReactJS 的虚拟 DOM 机制和 Binding.scala
的精确数据绑定机制,揭开 ReactJS 和 Binding.scala
相似用法背后隐藏的不同算法。

发表评论

电子邮件地址不会被公开。 必填项已用*标注