Skip to content

Instantly share code, notes, and snippets.

@kejun
Last active September 2, 2024 03:58
Show Gist options
  • Save kejun/3f4851c7f3b3e209fcbb to your computer and use it in GitHub Desktop.
Save kejun/3f4851c7f3b3e209fcbb to your computer and use it in GitHub Desktop.
最近一次项目的总结

mathclub是最近做的一个个人项目,帮助考SAT的同学通过在线做题、回顾、问答提高成绩。用户功能有:计次/计时做题、成绩单、错题分布、错题回顾、提问、汇总以及注册登录。管理后台主要是题库管理、学员管理、成绩单管理、问题回复。怎么看都像学校里的课设,的确项目本身并不出奇,开发上选用的一些方案或许更有意思。

整个项目一个人从产品需求、原型设计、前后端开发到部署历时2周左右。可以从截图上感受一下:

image

技术选型上服务端是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。我其实崇尚用最精简的工具组合开发,上述组合在我看来比较精简了。(帖纸留念)

image

先看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在客户端开发的特点。背后的理念让我受益匪浅,希望也能带给其他人启发。

@Burhh
Copy link

Burhh commented Sep 7, 2014

很赞的分享

@weger
Copy link

weger commented Sep 9, 2014

有seed工程么,求分享

@kejun
Copy link
Author

kejun commented Sep 10, 2014

@weger 抱歉,暂时还不能公开

@sdyxch
Copy link

sdyxch commented Sep 14, 2014

browserify + partialify(transform) + xcss(plugin) ...
sass + bourbon or autoprefixer ...
coffee
gulp

非常赞同 "用最精简的工具组合开发"

@zack-lin
Copy link

在踩了无数坑之后,最后采用了领域驱动模型的方式,来解决单向数据流的问题,但是最后势必会把所有的数据逻辑堆积在 store 领域模型层,最好的办法就是根据不同的领域拆分不同的 store,共用同一个内存对象,现在正在实践中,还不知道会发生什么问题

@kejun
Copy link
Author

kejun commented Sep 15, 2014

@zack-lin 靠谱,我尚未经历那么复杂的项目一个store还ok

@zack-lin
Copy link

@kejun 如果是单页面的前后端渲染,还要考虑的就是后端和前端怎么解决页面一致性的问题,比如 http://xxx/?page=detail#!/list

@gaowhen
Copy link

gaowhen commented Oct 22, 2014

粗略看起来,至少 React 调试起来要比 jade 方便。

另外,感觉 jade 的 mixin block 确实很方便,但是处理较为复杂的逻辑还是挺不开心。

@hax
Copy link

hax commented Oct 29, 2014

听上去 Store 跟事件总线差不多?

@norfish
Copy link

norfish commented Oct 30, 2014

最近也在用react做一些个人项目,不过是用在客户端,感谢分享

@haohcraft
Copy link

zan

@mefive
Copy link

mefive commented Dec 26, 2014

很好奇一件事。就是如何实现在Server和Client端的组件复用。
以我的理解,react在Server端输出的HTML,应该算是初始state,且不具备前端交互逻辑。
那么复用的仅仅是render的部分。
例如在Server端输出了一个select组件,里面存放了初始的list数据,前端是如何利用react接管这个组件state的呢?毕竟这个HTML不是前端react组件渲染出来的,它也没有能力去接管吧。
没有实践过react,想法纯属猜测。

@zwhu
Copy link

zwhu commented Jan 27, 2015

最近也在用 react

构建工具也是 gulp + Browserify , 但是有一个问题是,当我的 gulp.watch 报错之后,再修改文件就不能自动 watch 了。 不知道你是怎么解决?

gulp.task('browserify', function() {
    return browserify({
        entries: './www/views/app.jsx',
        transform: [reactify],
        debug : true
    }).bundle()
        .on('error', gutil.log.bind(gutil, 'Browserify Error'))
        //Pass desired output filename to vinyl-source-stream
        .pipe(source('bundle.js'))
        // Start piping stream to tasks!
        .pipe(gulp.dest('./public/build/'));
});

@LeezQ
Copy link

LeezQ commented Feb 16, 2015

@zwhu webpack,你值得拥有。

@morlay
Copy link

morlay commented Mar 20, 2015

@zwhu,因为 browserify 不用 gulp watch,他自己有 watchify, 可参考 https://github.com/morlay/gulp-starter/tree/master/gulp/tasks

@xu33
Copy link

xu33 commented Jun 5, 2015

@mefive 你把list的原始数据也输出到浏览器端,react再render一次就完成接管了。

@hugojing
Copy link

看完了,貌似不错。。但是。。。。把你的贴纸交出来!!!!
说真,贴纸哪里来的?

@xgz123
Copy link

xgz123 commented Jan 28, 2016

告诉我webpack也不错

@Z-starts
Copy link

贴纸+1

@soFlyMan
Copy link

soFlyMan commented Nov 2, 2016

贴纸!!!在哪可以搞到啊!!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment