能同步傳送微信公眾號訊息的部落格系統

海興發表於2016-07-28

用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

相關文章