React 填“坑”记

尝试了几天 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 = thiswindow.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 让前端代码结构性很强,数据绑定的做法非常棒。之后再发现其他“坑”再补充。

    原文作者:黄弘
    原文地址: https://segmentfault.com/a/1190000011153124
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞