用React、Redux和wechat-es搭建一个能同步发送微信公众号消息的博客系统,而且,要支持各种文章样式。

  1. git、npm、webpack和babel手拉手
  2. 集成一个爽到飞起的编辑器

    git、npm、webpack和babel手拉手

标题中这四个应该是开发Node.js应用的主流工具了吧?虽然babel是个过渡性工具,但估计这个过渡期会比较长。

我想有个库

经历过压缩文件、CVS和Subversion的人才能真正懂得git的好。

有些人生来就注定能领导几百万人,有些人生来就注定能写出翻天覆地的软件。但只有一个人两样都能做到:托瓦兹。

而且不止一次做到!(PS:强烈抗议输入法在我想输入托瓦兹时提示“脱袜子”!!!)

不知道你们怎样,总之我每次用GitHub时都会在内心深处向这个神一般的男人致敬:

enter image description here

顺便吹捧下自己,鄙人曾有幸跟该书的著名译者陈少芸先生合译过一本书;更荣幸的是,还有合影:

enter image description here

哦,右一是我。好吧,也不能算是合影,毕竟我是真身出镜。

还是聊项目吧。

首先,要在GitHub上创建一个代码库,名字就叫webchat-blog。GitHub看我什么也没说,很体贴地甩了几条命令出来。我乖乖复制下来,准备粘贴到终端中执行:

echo "# wechat-blog" >> README.md
git init
git add README.md
git commit -m "first commit"
git remote add origin git@github.com:wuhaixing/wechat-blog.git
git push -u origin master

当然要先创建项目目录:

 mkdir wechat-blog && cd wechat-blog

然后有选择地粘帖。由于还有很多工作要做,先不要commit,更不要push,把这两条命令放到一边备用。

接下来初始化npm项目:

npm init -y

然后打开package.json文件,加上"private": true,免得一不小心把它提交到npmjs上。npmjs是公共场合,我为了占名字把还没完成的wechat-es publish上去了,估计会被很多人骂。

配置webpack

webpack是个挺好用的构建工具,自带web服务器,还支持模块热切换,配置起来也不难。当然,别的构建工具也都是这么说的,自己喜欢哪个用哪个吧。反正这个项目就用它了,先安装:

npm i -D webpack webpack-dev-server

如果你想在终端中直接运行webpack,请加上全局安装的选项-g,不过我习惯在npm里调用,所以就省了。

然后创建webpack.config.js文件,并添加基本配置:

 module.exports = {
   context: __dirname + "/app",
   entry: "./app.js",

   output: {
     filename: "app.js",
     path: __dirname + "/dist",
   },
 }

这个配置是告诉webpack,我们的应用放在app目录下,入口文件是app.js;并且请webpack把它的编译结果放到dist目录下,文件名仍然用app.js。

这个基本配置只是把文件复制到dist目录下,而webpack真正强大之处在于它可以在复制之前用各种loader对文件进行处理。为了处理ES 2015和React中的JSX,需要安装babel-loader,以及它的小伙伴们:

npm i -D babel-core babel-loader babel-preset-es2015 babel-preset-react

看babel-core这名字就不用问了,core,不解释!两个preset的名字也很直白,分别是处理es2015和react的。装好之后在webpack.config.js中加个配置项:

 module: {
   loaders: [
     {
       test: /\.js$/,
       exclude: /node_modules/,
       loaders: ["babel-loader"],
     }
   ],
 },

loader可以有很多个,不过要放在module里,一个个的在loaders里排好。其中的test比较敏感,不过它不是你们想的那种test,应该叫match,文件名符合后面这个正则表达式的都要处理。exclude就是排除。loaders里的就是要对符合test条件的文件使用的loaders,首先是babel-loader。综上所述,这个配置是让webpack对所有.js文件(除了node_modules中的)应用babel-loader。

为了告诉babel-loader将es 6和jsx语句转换成es 5,还要在package.json中配置babel的preset:

 "babel": {
     "presets": [
       "es2015",
       "react"
     ]
   }

一个小确能的React应用

做好了基本配置,我们就可以开始写React组件了。当然,还是要从安装开始:

npm i -S react react-dom

先写一个组件摆摆样子:

import React from 'react'

class Greeting extends React.Component {
  render() {
    return  <div className="greeting">
              Hello, {this.props.name}!
            </div>
  }
}

export default Greeting

然后在app/app.js中把这个组件渲染到页面中:

import React from 'react'
import ReactDOM from 'react-dom'
import Greeting from "./greeting"

ReactDOM.render(
  <Greeting name="World"/>,
  document.getElementById("app")
)

接下来添加app/index.html:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Wechat Blog</title>
  </head>
  <body>
    <div id="app"></div>
  </body>
  <script src="app.js"></script>
</html>

这是个html文件,之前webpack的loader只处理js文件,所以还要为它再添加一个loader。

再添加一个loader

这个loader叫file-loader,安装:

npm i -D file-loader

然后在loaders中添加配置处理html文件:

{
  test: /\.html$/,
  loader: "file?name=[name].[ext]",
},

你可能注意到了,前面我们用的是loaders:[],这里直接loader。因为前面那个还要再添加一个loader,用来实现react的热加载。

再添加一个loader

这个loader是配合webpack-dev-server用的。有了它,我们每次修改了react组件后,不用去终端里执行编译命令,不用到浏览器上点刷新按钮,修改就自动体现到页面上了。

npm i -D react-hot-loader

找到第一个loader,把它加到数组中就可以了。就像这样:

{
  test: /\.js$/,
  exclude: /node_modules/,
  loaders: ["react-hot", "babel-loader"],
},

好了,所有的loader都到场了,我们可以开始了。在package.json中加上:

"scripts": {
  "start": "webpack-dev-server --hot --inline"
},

--hot --inline这两个选项就是告诉webpack-dev-server,我们要热!加!载!

在终端中运行npm start,在浏览器中打开http://localhost:8080/,如果能看到Hello,World!那说明我们的六公里慢跑成功地迈出了第一步!

想看代码请直接:

git clone https://github.com/wuhaixing/wechat-blog

参考文档

Setting Up Webpack for React and Hot Module Replacement

集成一个爽到飞起的编辑器

要做blog系统,即便是非常简单的,选一个好用的编辑器也是最起码的操守。所以我搜了一整天,终于找到了Alloy Editor。它在自己的demo页面上是这样介绍自己的:

enter image description here

在老牌编辑器CKEditor的基础上搭建起来的所见即所得编辑器,在页面上点一下就可以直接编辑。编辑内容爽到飞起。。。

参照它在文档Creating a React component中提到的alloyeditor-react-component,我把Alloy Editor集成到了我们的项目中。不过实现和这个例子有些不同:

  1. 没用gulp来增加构建系统的复杂性。而是在webpack添加了copy-webpack-plugin,以便将alloyeditor目录复制到dist中;
  2. 去掉了没什么用处的server.js
  3. 将editor和client改成了es 6语法

给webpack添加一个可以复制目录的插件

copy-webpack-plugin可以复制单独的文件或目录。

安装:

npm i -D copy-webpack-plugin

在webpack.config.js中引入:

var CopyWebpackPlugin = require('copy-webpack-plugin');

然后在plugins中创建一个新对象:

plugins: [
  new CopyWebpackPlugin([
      { from: '../node_modules/alloyeditor/dist' }
  ])
]    

其实它有很多可配置的参数,不过只有from是必须的,比较常用的是{ from: 'source', to: 'dest' },我们只需要用from指定alloyeditor所在的源目录,目标目录就是output中指定的dist。

将alloyeditor封装到React组件中

我没看懂alloyeditor-react-component为什么要创建一个server.js。我的实践也证明确实不需要,只需要创建一个editor.js:

import React from 'react'
import AlloyEditor from 'alloyeditor'

export default class Editor extends React.Component {
  componentDidMount() {
    this._editor = AlloyEditor.editable(  
                     this.props.container, 
                     this.props.alloyEditorConfig)
  }

  componentWillUnmount() {
        this._editor.destroy()
  }

  render() {
    return  <div id={this.props.container}>
                {this.props.content}
            </div>
  }
}

componentDidMount中初始化AlloyEditor,在componentWillUnmountdestroy它。然后render方法中返回交给它的内容就可以了。

这个组件的用法跟其它组件都一样,在app.js中:

const content = <div>
                  <h1>请点击页面编辑</h1>
                  <p>编辑本页内容</p>
                </div>
ReactDOM.render(
  <Editor container="editable" content={content}/>,
  document.getElementById("app")
)

告诉它可编辑区块的id和要编辑的内容就可以了。

改动比较大的是index.html,要引入alloyeditor的样式,并定义ALLOYEDITOR_BASEPATHCKEDITOR_BASEPATH两个全局变量:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Webpack + React</title>
    <link href="alloy-editor/assets/alloy-editor-ocean-min.css" rel="stylesheet">
      <style>
      #app {
        left: 100px;
        position: relative;
        top: 100px;
      }
    </style>
    <script>
      window.ALLOYEDITOR_BASEPATH = 'alloy-editor/';
      window.CKEDITOR_BASEPATH = 'alloy-editor/';
    </script>
  </head>
  <body>
    <div id="app"></div>
  </body>
  <script src="app.js"></script>
</html>

这里指定了alloy-editor/,是因为在webpack.config.js中,将/node_modules/alloyeditor/dist目录中的内容复制到了/dist目录下,如果复制的目标目录不同,这里也要做相应的修改。

在终端中运行npm start,打开http://localhost:8080/,就能看到一个点击就能编辑的页面了。

3R闯前端之react-router

第一个R是React,我们之前已经用它创建了一个组件。但React只是个创建前端组件的库,不是框架,所以光靠它不足以撑起整个前端。另外两个是react-router和redux,它们都是因React而起。虽然redux适用范围很广,但它们三个是最常见的组合。结合wechat-blog,我们来看一下如何用它们完成前端开发中的任务。

先说react-router。

react-router致力于解决单页应用中的两个问题:一个是页面布局,另一个是路由。

没有 VS 有

其实react-router本身只是一个React组件库,它所做的工作都可以通过自己编写组件的方式完成。下面这个例子来自react-router的Introduction

import React from 'react'
import { render } from 'react-dom'

const About = React.createClass({/*...*/})
const Inbox = React.createClass({/*...*/})
const Home = React.createClass({/*...*/})

const App = React.createClass({
  getInitialState() {
    return {
      route: window.location.hash.substr(1)
    }
  },

  componentDidMount() {
    window.addEventListener('hashchange', () => {
      this.setState({
        route: window.location.hash.substr(1)
      })
    })
  },

  render() {
    let Child
    switch (this.state.route) {
      case '/about': Child = About; break;
      case '/inbox': Child = Inbox; break;
      default:      Child = Home;
    }

    return (
      <div>
        <h1>App</h1>
        <ul>
          <li><a href="#/about">About</a></li>
          <li><a href="#/inbox">Inbox</a></li>
        </ul>
        <Child/>
      </div>
    )
  }
})

render(<App />, document.body)

这个组件的componentDidMount方法中定义了windowhashchange事件监听器,它会根据hash url的变化改变state中的route值;组件的render方法又会根据route的值来给Child赋值,从而改变页面的渲染结果。

react-router的Introduction紧接着又给出了使用react-router完成这一任务的例子:

import React from 'react'
import { render } from 'react-dom'

// 接下来出场的是react-router的主要成员...
import { Router, Route, IndexRoute, Link, hashHistory } from 'react-router'

// App一下子变得简单了
// <Link>取代了<a>,#/也不见了...
const App = React.createClass({
  render() {
    return (
      <div>
        <h1>App</h1>
        {/* change the <a>s to <Link>s */}
        <ul>
          <li><Link to="/about">About</Link></li>
          <li><Link to="/inbox">Inbox</Link></li>
        </ul>

        {/*
          `<Child>`变成了`this.props.children`
          router会帮我们找出应该让哪个child登场
        */}
        {this.props.children}
      </div>
    )
  }
})

// 最后,渲染的是带了一堆小<Route>的<Router>。
// 一切都由它们负责搞定
render((
  <Router history={hashHistory}>
    <Route path="/" component={App}>
      <IndexRoute component={Home} />
      <Route path="about" component={About} />
      <Route path="inbox" component={Inbox} />
    </Route>
  </Router>
), document.body)

这次render方法渲染的不是App了,换成了react-router提供的组件<Router><Router>之下又有一组子组件<Route>,每个组件<Route>都有pathcomponent两个属性;<Route>也可以有自己的子组件<Route>。哦,还有<IndexRoute>,它的component实际上对应上层<Route>组件指定的path。而上层组件的component就起到了决定页面布局的作用。

wechat-blog中,路由的定义放在app/router.js文件中。结构跟上面例子中的一样,只是Routerhistory换成了browserHistory,而作为页面布局的组件名称上直接定义为MainLayout

 <Router history={browserHistory}>
    <Route component={MainLayout}>
      <Route path="/" component={Home} />
        <Route path="posts">
          <Route component={SearchLayoutContainer}>
            <IndexRoute component={PostListContainer} />
          </Route>
          <Route path="new" component={PostFormContainer} />
          <Route path=":postId" component={PostContainer} />
        </Route>
        <Route path="users">
          <Route component={SearchLayoutContainer}>
            <IndexRoute component={UserListContainer} />
          </Route>
          <Route path=":userId" component={UserProfileContainer} />
        </Route>

        <Route path="widgets">
          <Route component={SearchLayoutContainer}>
            <IndexRoute component={WidgetListContainer} />
          </Route>
        </Route>

    </Route>
  </Router>

路径中的变量

此外还有一点需要注意的是参数的传递,也就是路由中的动态部分。在Route的定义中,动态参数是用冒号作前缀表示的:,比如<Route path=":postId" component={PostContainer} />

react-router会把url中的动态部分放到组件的props中,比如在app/components/containers/post-container.js中:

componentDidMount: function() {
    let postId = this.props.params.postId
    if(postId) {
      postApi.getPost(postId)
    }
  }

动态url的生成也很简单,在app/components/views/post-list.js中有:

<Link to={'/posts/' + post.id}>{post.title}</Link>

跳转

有时候我们需要跳转到不同的url来显示相应的界面,比如在保存了一个post之后,希望能够显示post列表。这也很容易实现,在app/components/containers/post-form-container.js中有个例子,关键是下面这行代码:

browserHistory.push('/posts')

关于react-router,基本上就是这些了。

3R闯前端之redux

第三个R是redux。react简化了数据显示组件的创建和管理问题,react-router解决了单页应用的页面模板和路由问题。虽然redux官方只说它解决的是状态管理问题,但实际上它还极大降低了react组件间的耦合性。

先看图:

enter image description here

redux提供了一个存储状态的store,外界可以通过调用store.dispatch传递一个代表状态变化的action给它。在上图右侧,react组件的状态有变化时就dispatch一个action,然后所有跟store绑定的组件的状态都会相应地发生变化。组件之间不需要相互传递数据,极大降低了组件之间的耦合性。

具体实现时,大体上是下图这样的关系及流程:

enter image description here

  1. 当React组件需要获取数据或提交数据时,会调用相应的业务处理逻辑函数,即上图中的API函数。
  2. 这些API函数一般会向服务器发送请求,然后在得到响应结果后调用storedispatch函数;store.dispatch的参数就是ActionCreator的返回结果,Redux称之为Action。
  3. redux会将这些Action传给reducer,reducer会根据Action的类型进行处理,然后返回新的状态,由redux统一更新到它的状态库中。
  4. redux的状态更新能够传递到React组件中,主要归功于redux-react。我们要在React组件中调用connect函数,并将其返回结果作为默认输出。

我们结合wechat-blog来看一下具体的实现,首先从React组件开始。

Provider与connect

Provider是redux-react提供的一个React组件。之前在介绍react-router时,Router取代App成为应用的顶层组件。现在Router要让位给Provider了,在应用的入口文件app/app.js中:

ReactDOM.render(
  <Provider store={store}>{router}</Provider>,
  document.getElementById('root')
);

Provider有个store属性,它的所有子组件,实际上也就是所有React组件,都可以通过它访问到这个store,从而得到redux状态库中的所有状态。这个任务是由connect完成的,比如在app/components/containers/post-container.js中,可以看到:

const mapStateToProps = function(store) {
  return {
    post: store.postState.post
  };
};

export default connect(mapStateToProps)(PostContainer);

函数mapStateToPropsstore.postState.post赋值给post,这个函数成了connect的参数,而PostContainerconnect返回结果的参数。

通过redux-react的努力,React组件就这样跟redux的状态库store连接起来了,接下来我们去看看这个神秘的store究竟长什么样。

store,reducers、Action与ActionCreator

store非常简单,在app/store.js中只有四行代码:

import { createStore } from 'redux';
import reducers from './reducers';

const store = createStore(reducers);
export default store;

只是用reducers作为参数,通过redux提供的createStore函数来创建它。

app/reducers/index.js中,最重要的就是redux提供的combineReducers,它的作用很简单,只是把多个分散的reducer合并到一起。想象一下,如果所有reducer的代码都只能放在一个文件里......

reducer的代码也很直白简单,以app/reducers/post-reducer.js为例,只是一个主体为switch语句的函数而已:

switch(action.type) {

    case types.GET_POSTS_SUCCESS:
      return Object.assign({}, state, { posts: action.posts });
    ....
}

不过有一点非常重要:绝对不要修改状态!,redux的文档说了:“修改状态的唯一途径是发出action(一个描述将要发生什么的对象)”。在上面的例子中用了Object.assign({}, state, { posts: action.posts });,这会合并state{ posts: action.posts }创建一个新对象。Object.assign是ES 6的新特性,IE目前还不支持。也可以用一些库来达到同样的目的,比如 Facebook的Immutable.js,此外还有seamless-immutableMori等。

另外,reducer必须是纯函数。所谓的纯函数,就是要符合下述条件:

  1. 不会调用外部资源,比如网络或数据库;
  2. 输出仅由输入决定,即只要参数的值相同,则输出结果一定相同;
  3. 输入的参数应该被当做不可变值,绝不能修改;

接下来要介绍的action和action creator就更简单了。前面说过了,action就是一个普通的对象,它的特别之处在于必须要有个属性指明其类型。为了安全起见,最好把所有action的类型都集中放在一个文件中。

对于某一项操作,一般会定义两种action,分别是XXX_SUCCESSXXX_FAILED。action creator相当于将组件要传递的数据map成action的简单函数,比如在app/actions/post-actions.js中:

export function getPostsSuccess(posts) {
  return {
    type: types.GET_POSTS_SUCCESS,
    posts
  };
}

这些都准备好之后,整幅拼图就剩下最后一块了,API。

API

虽然叫API,但实际上仍然是客户端的操作,只是把业务逻辑请求从React组件里剥离了出来而已。在这一层,要解决的是两个问题,一是跟服务端的交互;二是发送action。我们看一下app/api/post-api.js

export function getPosts() {
  return axios.get('http://localhost:3001/posts')
    .then(response => {
      store.dispatch(getPostsSuccess(response.data));
      return response;
    });
}

axios可以向服务器端发送请求,其返回结果是Promise,如果不知道Promise是个什么鬼,请参考有Promise,不会搞大肚子。然后在then中用store.dispatch发出事件,redux状态库中的数据就会相应地变化,而react组件的props是跟它绑在一起的,自然也会发生变化。

前端的路线就是这样。

参考文献

Leveling Up with React: Redux