尝试了几天 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 不会传递,没法正常工作。
二. 下级组件如何与上级通讯
这个相对简单,其实很多 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... 即可,你可以把 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 所以没实验)。
九. 上非 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 让前端代码结构性很强,数据绑定的做法非常棒。之后再发现其他“坑”再补充。