mathclub是最近做的一个个人项目,帮助考SAT的同学通过在线做题、回顾、问答提高成绩。用户功能有:计次/计时做题、成绩单、错题分布、错题回顾、提问、汇总以及注册登录。管理后台主要是题库管理、学员管理、成绩单管理、问题回复。怎么看都像学校里的课设,的确项目本身并不出奇,开发上选用的一些方案或许更有意思。
整个项目一个人从产品需求、原型设计、前后端开发到部署历时2周左右。可以从截图上感受一下:
技术选型上服务端是Node.js,应用框架选了老牌的Express(4.x变化挺大不少中间件都废了),数据服务用的是MongoLab(MongoDB的云服务平台),图片上传用的是又拍云,程序部署在Nodejitsu上。模板引擎没选主流的Jade或ejs,而是用Express React Views它实现了在服务端渲染React组件。前端框架是用React,这次有意想追求前后端全部组件化的组织。之前是用Webpack实现CommonJS模块打包,这次用Browserify配置更简单,它有丰富的transform很赞,其中的reactify转换React的JSX很完美。CSS用Sass+autoprefixer让人省心。将这一切串起来的自动构建工具是Gulp。我其实崇尚用最精简的工具组合开发,上述组合在我看来比较精简了。(帖纸留念)
先看Express React Views,后面简称ERV。配合Express在服务端渲染React组件。最大的好处是服务端的模板也可以按照组件思路组织。
Express里的设置很简单:
var engineOptions = {
harmony: true
};
app.set('view engine', 'jsx');
app.engine('jsx', require('express-react-views').createEngine(engineOptions));
传统模板引擎都是扩展HTML。ERV则是JS,看过React的都知道,它支持的JSX看上去就是xhtml。比如登录页面的模板就是这样的:
/** @jsx React.DOM */
var React = require('react');
var Base = require('./base');
var config = require('../config');
module.exports = React.createClass({
render: function() {
return (
<Base pageTitle={ 'Welcome to ' + config.siteName }
cssfile="/webapp/build/css/accounts.css">
<div id="app-login" className="login-panel" data-type={this.props.type}></div>
<script src="/webapp/build/js/accounts.js"></script>
</Base>
);
}
});
render是渲染部分。首次看到的人也很容易理解吧。
下面从它和传统模板的比较中体会一下所言的组件思路:
ejs: 用<%…%>做标记,JS语法。只有简单的判断、循环。ejs的view helper很牵强。ejs算是早期JS模板的典型。
<ul>
<% for(var i=0; i<items.length; i++) {%>
<li><%= items[i] %></li>
<% } %>
</ul>
Jade: 算是node模板引擎里的翘楚。和早期的相比Jade进步很多,后面会用ERV分别比较它的几个高级特性:继承、组合(include、mixins)、过滤器(filter)。它设计的全新语法,看上去非常简洁。(但看着就醉了)
ul
each val in items
li= val
ERV的模板不是html而是支持内嵌JSX的JS,而且和客户端React用的完全一致。它只渲染内容,不会生成JS,React里的事件、生命周期在Server端都是用不上的。同样实现一个循环是这样的:
/** @jsx React.DOM */
var React = require('react');
module.exports = React.createClass({
render: function() {
return: (
<ul>
{items.map(e) {
return <li>e</li>
}}
</ul>
);
}
});
在组件思路下只用一个组件组合就替代了所有其它概念,请看:
Jade的extends:继承layout,overwrite其中定义的block。
extends ./layout.jade
block title
title Article Title
block content
h1 My Article
React模板没有继承、没有block。Layout是一个组件。项目中的实例layout.jsx。
<Layout title=“Article Title”>
<h1>My Article</h1>
</Layout>
Jade的include不支持传参,只是把一块内容放进来。比如:
include ./includes/header.jade
React的组件像html标签随意引用,甚至可以做为属性的值传递。
<Header />
Jade的mixins可以传参。这样用:
mixin article(title)
.article
.article-wrapper
h1= title
if block
block
else
p No content provided
+article('Hello world’)
p This is my
p Amazing article
同样,Jade设计的Mixins Block,用React组件轻松实现:
<Article title=“Hello world”>
<p>This is my</p>
<p>Amazing article</p>
</Article>
Jade还有Mixin Attributes:感觉是为满足需求强加上去的。
mixin link(href, name)
a(class!=attributes.class, href=href)= name
+link('/foo', 'foo')(class="btn")
同样,用组件实现,无需那么啰嗦:
<Link href=“/foo”>foo</Link>
Jade还有filters,转换字符串。本意不就是想给模板赋予更强的处理能力吗。
.content
:markdown
_This_ is a **Markdown** snippet
React模板本身就是JS,可以任意require:
var markdown = require( "markdown" ).markdown;
…
<p>
{ markdown('_This_ is a **Markdown** snippet') }
</p>
再说Jade没有的组件的好处。组件的组合非常灵活,比如是页头嵌入一个计时器:
<Header options={Timer} />
Jade实现起来就会很勉强,Mixin的参数只能是字符串。Extends多了,block名字会冲突和混乱。
传统模板view和controller是分开的。React的view-controller模板和逻辑在一起会不会混乱?不会,组件架构下都是一个一个的小组件,单个组件一旦变复杂就会被拆解,所以不会混乱。
小tip: 如果用vim编写代码,大多数人都会用emmet(原zencoding),它的snippets有助进一步提高效率。装过emmet后,.vimrc里写:
let g:user_emmet_settings = webapi#json#decode(join(readfile(expand('~/.snippets.json')), "\n"))
~/.snippets.json里扩展两个针对写react的片断: http://pastebin.com/aFqm9GTE
ok,梳理一下模板的发展经历了早期的html字符串的占位符替换、组合到注入、再到完全由JS生成但JSX看上去又那么像html,这似乎是一种合理的发展趋势。
模板比较完毕。再看看Server和Client模板一致的好处:在我做首页时,开始是整个页面在server端渲染,里面有一个登录。做到后面时,登录和注册有个切换的交互,需要把登录转成客户端的组件,直接把JSX的部分移过去就ok了,样式不用重写很方便。
继续说说客户端React的迷人之处。分享两个项目中的用例:一个是表单验证,一个是多组件间的通讯。Dijkstra曰:编程的艺术就是处理复杂性的艺术。设法把代码写的精简从来就不是什么境界。代码架构内部的秩序能有效适应需求变化和化解业务的复杂度易于扩展和维护才是要追求的境界。React把重点放在可预测性和数据单向同步上,我在做过几个项目后觉得它对控制复杂度很有效。
表单验证是个normal case,不复杂但很繁琐。每个表单项有多个校验规则,多个表单项之间还有关联,提示信息错误信息的呈现。比如邮箱至少有非空、格式、是否被占用三个规则,表单提交后有错误还要原地显示出错信息,偷懒的做法是把所有报错集中显示。用React写的思路是,把表单抽象为一个数据结构,和UI呈现同步。输入变化时更新这个数据结构,提交时不访问DOM只检查并更新这个数据结构,UI自动同步更新。这就是React的状态(state)机制。可以先扫一遍完整的代码:http://pastebin.com/knZCEUQa
可预测性体现在Render里,每个表单项可能出现报错信息(需要哪些校验)一目了然,而不是夹杂在几百行代码里。
<div className="field">
<input placeholder="Email" value={this.state.userInfo.mail} onChange={this.handleInputMail} />
</div>
<div className="field-error">
{ this.state.formError.mail.isBlank ? ERRORS.MAIL_BLANK : ''}
{ this.state.formError.mail.isInvalidFormat ? ERRORS.MAIL_INVAILD : ''}
{ this.state.formError.mail.isUsed ? ERRORS.MAIL_USED : ''}
</div>
每个表单项绑定一个onChange事件,这是React的合成事件,监听值变化,更新state,自动更新UI,这种单向循环的规律体现了React的一种哲学(歪歪一句国内的开发者们往往以开发强大的工具为荣耀,背后欠缺理念)。可以注意到在这个注册表单组件的代码里完全没有用到DOM。当状态发生变化时,整个UI不会重新渲染,React内部高效的diff算法会算出具体哪个地方需要更新。
这个特性我在做答题功能时体会更深。一个试卷有若干道题,一道一道题出现,都是选择题,在规定时间内可以任意前后回顾做过的题。我做了一个Question组件,题的数据从它的data属性传入,这样当前后切换题时,会自动更新Question的data属性,题目就变了。这样做没问题,但当我选了某一项,再点下一题,题目变了,默认选项还是上一题的。因为React在做diff的时候只会算state变化的部分,这时需要加个key属性并赋予一个唯一值,这样就表示是一个新对象了。
顺便说一下,React里处理input:radio单选控件很别致,它的onChange事件加在它的父层上即可:
<ul key={this.props.data._id} onChange={this.handleOptionChange}>
{ this.props.data.questionOptions.map(function(e, i) {
var labelValue = optionLabel[i].toLowerCase();
return (
<li key={i}>
<input type="radio"
value={ labelValue }
name={ 'quest-option-' + this.props.data._id }
defaultChecked={ labelValue == anwser ? 'checked' : ''}
id={ 'radio-quest-' + i } />
<label htmlFor={ 'radio-quest-' + i }>
<i>{ optionLabel[i] }</i>
<span dangerouslySetInnerHTML={{__html: e }} />
</label>
</li>
);
}.bind(this)) }
</ul>
在React里可以说基本不用直接访问DOM。完全用不上事件代理。这些都挺颠覆传统前端开发思维的。前面提到的数据单向循环的规律是React所提倡的,明显体现在Flux里。大型项目中数据交互复杂,秩序是非常重要的。那么在小型项目中呢?当我看了Fluxxor之后,发现写一个简单的就够用了。接下来介绍React里多个组件的通讯。
场景分析:答题是一个单页应用,组件的构成是:
<Timer beginTime="60" />
<Testing data={paper} />
Testing组件由Question、前后翻页、提示等组件构成。Timer要把时间的变化传给Testing,到达规定时间需要提示。Question要把questionId和用户选项传给Testing保存。前后切换题目时,Testing把题目和之前选过的传给Question。Timer和Testing同级,Question是Testing子组件,这就是三个组件之间的关系。思考一下有哪些实现思路?
Callback是一种方案。当用户选择后,回调Testing将数据回传。Callback的致命问题是有层级局限,上下级之间可以,如果再隔一级或是平级就不灵了,就要设法拿到对象实例。当需求发生变化时,Callback需要修改或增加便会引起混乱。另外一种方案是把Testing的实例传给子组件,在子组件里操作父组。这种做法比Callback还烂,一是子组件变得不可复用,二是业务逻辑渗透进通用组件里让代码变混乱,三是产生依赖后依赖组件接囗变更会导致不可预测的后果,最后代码变得不可维护。在React里这么干会报错。
前面也提到React里处处体现单向循环的秩序。不要考虑任何形式的逆向操作。解决思路是:扩展EventEmitter写一个状态管理器,任何组件都可以将要传递的数据暂存到里面并同时能监听变化事件。这段代码很短,请看http://pastebin.com/J4qVZV3g。在这个用例中,Testing只需要监听公共状态的变化即可,计时到了弹提示,或是更新用户答案...当需求有变更,比如时间到了冻结选项,实现起来也非常容易。在应用的时候,React组件的mixins机制,可以避免重复写加监听和卸监听的代码,具体请看代码http://pastebin.com/xEL3KEDj。在Question组件里的用法:
module.exports = React.createClass({
mixins: [activitMixin],
...
handleOptionChange: function(e) {
// 传过去后省下的事就不用操心了
this.setActivitState('anwser', {
id: this.props.data._id,
result: e.target.value
});
}
...
});
Flux本质上就是这种思想。数据操作集中在Store里,View-Controller里只能调Actions和监听Store的变化。
上面主要分享两块:服务端应用React实现模板的组件化组织和React在客户端开发的特点。背后的理念让我受益匪浅,希望也能带给其他人启发。
@zwhu webpack,你值得拥有。