嘗試了幾天 React,覺得這東西真心不錯,打算逐步替換過去的前端架構,但跟接觸其他新框架、新技術一樣,都有各種坑等著去踩,當然大多是因為不夠了解和定勢思維導致的,在這裡做一個記錄整理。
依賴的環境:
"react": "^15.6.1",
"react-dom": "^15.6.1",
"react-router-dom": "^4.2.2",
"react-scripts": "1.0.13"
在此之前,雖說接觸了 JS 十幾年,但並不太瞭解 node.js,npm,vue,ES6 等“新潮”的技術,這方面算是個小白。所以為了系統的體驗一番,用的都是目前較新的 react 版本。
一. 如何從伺服器獲取資料
首先,在目前的實際應用中,頁面資料是來自於後端的 API,但是 React 元件是初始化後就開始 render,這個過程沒找到簡單的方法來打斷,那就先給一個空的或包含特定狀態(如載入中)的 state 讓 render 方法先返回一個再說,然後通過 AJAX 非同步從服務端取回資料,再次改變 state 觸發更新流程。同步通訊當然也可以,但是強烈不推薦,As of jQuery 1.8, the use of async: false with jqXHR ($.Deferred) is deprecated。
class XxxList extends Component {
constructor(props) {
super(props);
this.state = {};
this.componentWillReceiveProps(props);
};
componentWillReceiveProps =(props)=> {
// 顯示載入提示
this.setState({
ern : -1
});
// 非同步載入資料
this._loadData(props.params);
};
shouldComponentUpdate =()=> {
// 更新屬性請求資料時先不更新介面
return ! this._loading;
};
_loadData =(req)=> {
this._loading = true;
let dat = toFormData(req); // 將普通物件轉為 FormData, 這是自定義的方法
fetch(XXX_LOAD_URL, {
body: dat,
method: "POST",
credentials: "include"
})
.then(rsp => {
return rsp.json();
})
.then(rst => {
this._loading = false;
this.setState({
list: rst.list,
page: rst.page
});
});
};
render() {
if (this.state.ern == -1) {
return (<div>載入中...</div>);
}
// 組織列表
let listHtml = [];
for (let info of this.state.list) {
listHtml.push(
<li key={info.id}>{info.name}</li>
);
}
return (
<ul>
{listHtml}
</ul>
);
};
}
上面的非同步載入過程還好理解,兩次 render 嘛。但也許你看過關於 React 元件生命週期的文章後,可能會疑問為什麼要重寫 componentWillReceiveProps 方法而不直接在構造方法裡 _loadData 呢?後者當然是可以的,這裡有個“坑”,起初我理解每次 render 裡 <XxxComponent/> 都是在 new 一個元件,但經過除錯發現並不是,元件僅初始化了一次,之後再進入那個程式碼就是更新元件的 props 了。也許這就是為什麼在組織列表時要給個 key 了,不給就報 Warning(按 React 的介紹上是能自動用列表索引作為鍵)。
額外的,這裡 fetch 需要注意,如果服務端需要會話且依賴 Cookie 裡的會話 ID,務必加上 credentials: "include"
,否則 Cookie 不會傳遞,沒法正常工作。
2017/10/29 補充 fetch 需注意,首先取得的資料是一個 Response 物件,如果你在 Chrome 的控制檯網路裡看,響應資料是空的,這是因為這時候還沒有開始獲取響應的 body,只有在呼叫 .json()
或其他的資料解析、提取方法後,才會真正的讀取響應資料。所以看到很多例子都是第一個 then 裡 return xxx.json()
,然後在第二個 then 裡才開始正式對資料進行處理。
二. 下級元件如何與上級通訊
這個相對簡單,其實很多 React 的例子已經間接的給出方法了,比如:
<button onClick={this.onBtn1Click}>點我</button>
換位思考一下,把 button 換成我自定義的元件,在這個自定義元件裡產生某個事件或某狀態改變時,呼叫 props 裡注入進來的方法就能達到通知上級的目的了。以分頁為例:
class XxxDemo extends Component {
// 省略其他方法...
render() {
return (
<div>
{/*其他懶得寫了*/}
<Pager onGoto={this._loadData} params={this.props.params}/>
</div>
);
};
}
class Pager extends Component {
// 省略其他方法...
_gotoPage =(pn)=> {
let params = this.props.params || {};
params.pn = pn;
// 呼叫上級通過屬性傳遞過來的方法
this.props.onGoto(params);
};
render() {
let params = this.props.params || {};
let pn = params.pn ? parseInt(params.pn) : 1;
return (
<div>
<button onClick={this._gotoPage.bind(this, pn - 1)}>上一頁</button>
<button onClick={this._gotoPage.bind(this, pn + 1)}>下一頁</button>
</div>
);
};
};
上面程式碼寫得很不嚴謹,真實場景至少得判斷一下邊界。至於 params 相關的程式碼該放哪 Pager 級還是其父級,根據實際情況自行決定吧。
三. 上級元件如何與下級通訊
我嘗試了一些方法,比如在 render 裡把子元件賦給當前元件物件的一個變數,但發現沒有叫 setState 也沒有 setProps 的方法,貌似是個叫 ReactCompositeComponentWrapper 的物件。然後試了直接 new 對應的元件物件,放到 return 裡面後報錯 “Objects are not valid as a React child”。
後來,偶然發現 ref 這個屬性(抱歉,我很少仔細的讀文件,習慣自己一點點試著來)。上面說過在列表中對元件加 key 來避免 Warning,那麼這個 ref 就是另一個有特別意義的屬性,加上後,就可以利用 this.refs.XXX
來取得對應的子元件物件了,然後當你僅需要更新子元件的時候,就可以用 this.refs.XXX.setState
來更新狀態了。
這裡需要注意兩點,一是初始化流程未執行完 render 時 refs 裡是沒有子元件物件的,所以使用前務必判斷一下存不存在,不存在則走正常方式更新自己;二是並不存在 setProps 方法(至少我用的版本沒有),而且 props 物件也是隻讀的,只能通過 state 來更新。
四. 跨層級元件間通訊
在上一節中,實在沒招的時候我還嘗試過全域性和區域性“跳線”的方式,但全域性“跳線”是程式設計師的忌諱,會讓程式結構混亂不堪,就像一個長滿草的機箱。
但是一些例如全域性通知之類的公共元件,還是可以註冊到全域性環境的。這樣,只需在構造方法里加上 global.XXX = this
或 window.XXX = this
,就能在任意元件裡,輕鬆的用 XXX.setState 來使其更新了。
實際開發中,比較好的方式,一個是所有公共元件都是主元件的子元件,在主元件的 componentDidMount 中將 this.refs.xxx 加入全域性環境;另一方面,如果明確公共元件是唯一的且是自己可控的,也可以將公共元件作為主元件的同級,在構造方法種註冊到全域性環境。
當然了,你也許會說為什麼不逐層往下通過 props 傳遞給子元件呢?一個問題是首次 render 前在 refs 裡拿不到元件物件(倒是可以把頂層元件物件往下傳,但不推薦);二是全域性“跳線”只要合理利用就並非魔鬼,該是公共的何必藏著掖著呢。
那對於非全域性的跨元件間互通呢?利用上面提到的 props,refs 都行。我個人推薦涉及事件的總是把事件處理函式通過 props 向下傳遞,然後在上層事件處理函式裡利用 refs 通知另一個子元件變更狀態。這有點像傳統 DOM 的事件冒泡(擴散),你在外圍監聽到下級 A 擴散上來的事件,然後改變另一個下級 B。強烈不建議把上層元件物件直接傳下去,除非有什麼特殊情況。
五. React-Router
我用的 4.x 版,而網上搜到的文章多是針對之前版本的,包括搜尋很靠前的http://www.ruanyifeng.com/blo…裡介紹的。
4.x 版的 react-router 變化很大,首先,如果要在 web 環境用,依賴的包選 react-router-dom 即可;其次如果要使用瀏覽器歷史(路徑)來定義路由,應當使用 BrowserRouter 而不是在 Router 元件上設定 histroy={browserHistory}。精簡可用如下:
import { BrowserRouter as Router, Switch, Route } from `react-router-dom`;
// 省略 import 其他元件...
ReactDOM.render(
<Router>
<Switch>
<Route path="/xxx" component={Xxx}/>
<Route path="/xxx/:id" component={XxxXx}/>
</Switch>
</Router>,
document.getElementById("root")
);
六. ES6 bind
看到五花八門的物件方法寫法,還有各種 bind,比如在構造方法裡 bind 的,方法尾巴上加 bind 的。作為一個“強迫症患者”這是不能忍受的。發現 ES6 的 ()=>
這個 lambda 語法有個神奇功能,就是自動把當前 context 給 bind 上去,這太好了。那就統一寫成:
xxx =(arg1, arg2)=> {
// pass...
};
看上去整潔、漂亮,如丘位元之箭,哈哈。至於元件的 render,那就不必管了,反正自己是不會呼叫的,react 在呼叫的時候一定是 bind 好了的,就不操它的心了。
題外話,我找到一本《ES6 in Depth》的電子書,在 《Class》章節的例子裡明確的不需要 bind(this),我也不知道 React 這裡怎麼回事,有清楚這個的希望能告訴我一下。
七. 匯入模組的非 js 資源
匯入模組(JS)是 import `模組名`;
,那想匯入模組裡的非 JS 資源、比如 CSS 呢?比如 bootstrap 的 css,可以用 import `bootstrap/dist/css/bootstrap.css`;
,你可以簡單的理解為匯入路徑(類似 PHP 的 INCLUDE_PATH 或 Java 的 CLASS_PATH)會包含當前專案的 node_modules 目錄,而用非 ./
,../
等(如模組名稱)開頭的路徑均到匯入路徑中去搜尋。
八. 與非 node 的服務端優雅地通訊
在開發階段,一個方法是你每次 AJAX 的 URL 總是帶上完整的域名和埠,使用這一的絕對 URL,只要確保你啟動的 node server 的域一致即可,避免了跨域問題。例如你的應用服務端是 8080 埠,node server 是 3000 埠,介面 URL 寫成 http://localhost:8080/path/to/resource 即可,你可以把 http://localhost:8080 部分定義為一個常量,在正式釋出時改為線上的域名。但是我不推薦這種方式。
我認為更好的方式是在 package.json 中增加 proxy: "http://localhost:8080"
,AJAX URL 路徑就正常的 /path/to/resource 即可。經實驗,proxy 還可以指向不同域,也就是說你可以愉快的指向你遠端的 API 開發(測試)伺服器,而不必在自己機器上安裝和啟動一個。
然後,可以設定 homepage: "/app/path"
這種,作用就相當於給當前應用一個路徑字首,這樣當你釋出到生產環境的 web 目錄下的 app/path 裡時,import 的額外資源(圖片等)路徑就不會有問題。但是,這個 homepage 並不會影響到你的路由路徑,如果最終部署的位置不在網站根目錄,你還得老老實實的給你的路由路徑加上字首;但好在 Route 設定可以巢狀,所以只需要在頂層設一個即可。
以上兩項設定後,build 時什麼也不用改。
另外,標準的 react-scripts build 後是到專案下的 build 目錄,如果想在執行 build 後直接釋出到本地服務端 web 目錄,可以在 build 命令末尾增加 && rm -rf ../app/path && mv -f build ../app/path
,這是針對 Mac OSX 和 Linux 的命令,Windows 應該是 && del /F ..\app\path && move build ..\app\path
(手頭沒 Windows 所以沒實驗)。
2017/10/29 補充 有時候服務端介面用到了會話,如果會話ID通過 Cookie 傳遞,而域名又沒法一致時(比如直接利用非本地的測試伺服器),可以在本地架設一個 nginx 或 apache 再配置一箇中間代理來作為跳板,將 cookie 傳遞過去。看到 node server 裡也有 http proxy 之類的模組,貌似這塊還挺完善,也可以考慮寫一個,有空了再研究。
九. 上非 node 服務端後重新整理 react-route 路徑出現 404 錯誤頁
其實這個很有意思,對服務端程式設計來說,單入口+路由 的模式已經很常見,導致有的工作時間不長的服務端程式設計師都沒理解為什麼會這樣,好像天然就如此一樣。所以當前端程式設計師發現上了伺服器後一重新整理就 404,去找服務端程式設計師要個說法,服務端程式設計師也一臉懵逼的樣子。
首先解釋一下服務端的單入口是什麼個情況。在很久很久以前(呵呵),比如 PHP 或 ASP 做的網站,頁面、增刪改查程式都是混合在一起的;後來搞 MVC,頁面歸到模板,與資料邏輯分離;再後來進入初級的前後端分離,服務的歸服務,頁面的歸頁面。後兩個階段,利用 apache 或 nginx 的 url rewrite 技術或 path-info 方法,後端程式的路徑就不再依賴於他在 web 目錄下的路徑,甚至完全跟對外的 web 不在一個目錄下,既清爽又安全。
好了,那麼要讓後端怎麼配置呢?這裡假定我有一個前端單頁應用在網站目錄的 static/app1 目錄。
apache 可以在 .htaccess 或對應的 <Directory> 中加入
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^static/app1/(.*)$ static/app1/ [L]
nginx 可以在網站對應的 conf 檔案的 location /
中加入
if (!-e $request_filename)
{
rewrite ^/static/app1/.*$ /static/app1/index.html last;
}
如果已經存在這個 if 塊,則在塊首加入這個 rewrite 規則即可。
如果服務端是 Java Servlet (Tomcat, Jetty 等),可以使用第三方的 URLWrite 元件或類似我的 https://github.com/ihongs/Hon… 這樣寫個簡單的路徑過濾器,來將某個路徑字首下的所有請求都交給該字首目錄下的 index.html;說得直白點,就是不管請求匹配到的哪個路徑,都輸出 index.html 的內容。
但需特別注意,如果服務端也採用這種路由方式,這個路徑字首一定要區分開,比如後端存在路徑 app1/resource1/ 那前端就不要使用 app1 這個路徑了。我的做法是所有前端靜態檔案都在 static 目錄下,而後端絕對不會使用 static 這個字首,也就不可能存在衝突了。
十. 附上前面提到的的 toFormData 函式
/* global FormData */
import jQuery from `jquery`;
export function toFormData (req) {
if (req instanceof FormData) {
return req;
}
if (req instanceof jQuery) {
return new FormData(req[0]);
}
if (req && req.elements) {
return new FormData(req);
}
let dat = new FormData();
if (jQuery.isPlainObject (req)) {
for (let k in req) {
dat.append(k, req[ k ]);
}
} else if (jQuery.isArray(req)) {
for (let o of req) {
dat.append(o.name, o.value);
}
} else if ( req !== undefined ) {
throw new Error("Can not conv `"+req+"` to FormData");
}
return dat;
}
暫時就這些,總結:React 讓前端程式碼結構性很強,資料繫結的做法非常棒。之後再發現其他“坑”再補充。