继 Rails 从入门到完全放弃 拥抱 Elixir + Phoenix + React + Redux  这篇文章被喷之后,笔者很长一段时候没有上社区逛了。现在 tkvern  又回归了,给大家带来React实践的一些经验,一些踩坑的经验。
Rails嘛,很好用,Laravel也好用。Phoenix也好用。都好,哪个方便用哪个。
还有关于Turbolinks 之争,不能单从页面渲染时间去对比,要综合考虑。
Why Dva? Dva是基于Redux做了一层封装,对于React的state管理,有很多方案,我选择了轻量、简单的Dva。至于Mobx,还没应用到项目中来。先等友军踩踩坑,再往里面跳。
顺便贴下Dva的特性:
易学易用 :仅有 5 个 api,对 redux 用户尤其友好elm 概念 :通过 reducers, effects 和 subscriptions 组织 model支持 mobile 和 react-native :跨平台 (react-native 例子 )支持 HMR :目前基于 babel-plugin-dva-hmr  支持 components 和 routes 的 HMR动态加载 Model 和路由 :按需加载加快访问速度 (例子 )插件机制 :比如 dva-loading  可以自动处理 loading 状态,不用一遍遍地写 showLoading 和 hideLoading**完善的语法分析库 dva-ast **:dva-cli  基于此实现了智能创建 model, router 等 支持 TypeScript :通过 d.ts (例子 )Why Ant Design? 做为传道士,这么好的UI设计语言,肯定不会藏着掖着啦。蚂蚁金服的东西,确实不错,除了Ant Design外,还有Ant Design Mobile、AntV、AntMotion、G2。
Why yarn? npm install 太慢,试试yarn 吧。建议用npm install yarn -g进行安装。
开发过程中的前后端分离 项目开始了,前端视图写完,要开始数据交互了,后端提供的API还没好。
那么问题来了,如何在不依靠后端提供API的情况下,实现数据交互?
使用Mock.js 可以解决这个问题。先对接好API数据格式,然后使用Mockjs拦截Ajax请求,模拟后端真实数据。
在Mockjs官方提供的API不够用的情况下,还可以使用正则产生模拟数据。
如何对模拟做数据持久化处理? 这里给出一个模拟用户数据并持久化的实例实例:mock/users.js 
代码摘要:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 'use strict' ;const  qs = require ('qs' );const  mockjs = require ('mockjs' );const  Random  = mockjs.Random ;let  tableListData = {};if  (!global .tableListData ) {  const  data = mockjs.mock ({     'data|100' : [{       'id|+1' : 1 ,       'name' : () =>  {         return  Random .cname ();       },       'mobile' : /1(3[0-9]|4[57]|5[0-35-9]|7[01678]|8[0-9])\d{8}/ ,       'avatar' : () =>  {         return  Random .image ('125x125' );       },       'status|1-2' : 1 ,       'email' : () =>  {         return  Random .email ('visiondk.com' );       },       'isadmin|0-1' : 1 ,       'created_at' : () =>  {         return  Random .datetime ('yyyy-MM-dd HH:mm:ss' );       },       'updated_at' : () =>  {         return  Random .datetime ('yyyy-MM-dd HH:mm:ss' );       },     }],     page : {       total : 100 ,       current : 1 ,     },   });   tableListData = data;   global .tableListData  = tableListData; } else  {   tableListData = global .tableListData ; } 
模拟API怎么写? 完成持久化处理后,就可以像操作数据库一样进行增、删、改、查
下面是一个删除用户的API
参见mock/users.js#L106 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 'DELETE /api/users'  (req, res) {    setTimeout (() =>  {       const  deleteItem = qs.parse (req.body );       tableListData.data  = tableListData.data .filter ((item ) =>  {         if  (item.id  === deleteItem.id ) {           return  false ;         }         return  true ;       });       tableListData.page .total  = tableListData.data .length ;       global .tableListData  = tableListData;       res.json ({         success : true ,         data : tableListData.data ,         page : tableListData.page ,       });     }, 200 );   }, 
还有一步 模拟数据和API写好了,还需要拦截Ajax请求
修改package.json
1 2 3 4 5 6 7 8 9 10 11 . . . "scripts" :  {   "start" :  "dora --plugins \"proxy,webpack,webpack-hmr\"" ,    "build" :  "atool-build -o ../../../public" ,    "test" :  "atool-test-mocha ./src/**/*-test.js"  } . . . 
如果与dora有端口冲突可修改dora的端口号
1 "start" :  "dora --port 8888 --plugins \"proxy,webpack,webpack-hmr\"" , 
完成这些基本工作就做好了
友情提示 在模拟数据环境,services下的模块这么写就好了,真实API则替换为真实API的地址。可将地址前缀写到统一配置中去。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 import  request from  '../utils/request' ;import  qs from  'qs' ;export  async  function  query (params ) {  return  request (`/api/users?${qs.stringify(params)} ` ); } export  async  function  create (params ) {  return  request ('/api/users' , {     method : 'post' ,     body : qs.stringify (params),   }); } export  async  function  remove (params ) {  return  request ('/api/users' , {     method : 'delete' ,     body : qs.stringify (params),   }); } export  async  function  update (params ) {  return  request ('/api/users' , {     method : 'put' ,     body : qs.stringify (params),   }); } 
真实API参考实例: src/services/users.js 
如何保持登录状态 在看dva的引导手册时,并没有介绍登录相关的内容。因为不同的项目,对于登录这块的实现会有所不同,并不是唯一的。通常我们会使用Cookie的方式保持登录状态,或者 Auth 2.0的技术。
这里介绍Cookie的方式。
登录成功之后服务器会设置一个当前域可以使用的Cookie,例如token啥的。然后在每次数据请求的时候在Request Headers中携带token,后端会基于这个token进行权限验证。思路清晰了,来看看具体实现吧。(注:在这次项目中使用了统一登录模块,通过Header中的Authorization进行验证,将只介绍拿到token之后的数据处理)
准备工作 对于操作Cookie的一些操作,建议先封装到工具类模块下。同时我把操作LocalStrage的一些操作也写进来了。
参见src/utils/helper.js 
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 . . . export  function  getCookie (name ) {  const  reg = new  RegExp ('(^| )'  + name + '=([^;]*)(;|$)' );   const  arr = document .cookie .match (reg);   if  (arr) {     return  decodeURIComponent (arr[2 ]);   } else  {     return  null ;   } } export  function  delCookie ({ name, domain, path } ) {  if  (getCookie (name)) {     document .cookie  = name + '=; expires=Thu, 01-Jan-70 00:00:01 GMT; path='  +                        path + '; domain='  +                        domain;   } } . . . 
Header的预处理我放在了src/utils/auth.js#L5 ,这里后端返回的数据都是JSON格式,所以在Header里面需要添加application/json进去,而Authorization是后端用来验证用户信息的。变量sso_token为了方便代码阅读就没有按照规范命名了。
1 2 3 4 5 6 7 8 9 export  function  getAuthHeader (sso_token ) {  return  ({     headers : {       'Accept' : 'application/json' ,       'Authorization' : 'Bearer '  + sso_token,       'Content-Type' : 'application/json' ,     },   }); } 
修改Request 这里没有使用自带的catch机制来处理请求错误,在开发过程中,最开始打算使用统一错误处理,但是发现请求失败后,不能在models层处理components,所以就换了一种方式处理,后面会讲到。
参见src/utils/request.js#L29 
1 2 3 4 5 6 7 8 9 export  default  function  request (url, options ) {  const  sso_token = getCookie ('sso_token' );   const  authHeader = getAuthHeader (sso_token);   return  fetch (url, { ...options, ...authHeader })     .then (checkStatus)     .then (parseJSON)     .then ((data ) =>  ({ data }));      } 
完成这些配置之后,每次向服务器发送的请求就都携带了用户token了。在token无效时,服务器会抛出401错误,这时就需要在中间件中处理401错误。
参见src/utils/request.js#L10 
redirectLogin是工具类src/utils/auth.js 中的重定向登录方法。
1 2 3 4 5 6 7 8 9 10 11 function  checkStatus (response ) {  if  (response && response.status  === 401 ) {     redirectLogin ();   }   if  (response.status  >= 200  && response.status  < 500 ) {     return  response;   }   const  error = new  Error (response.statusText );   error.response  = response;   throw  error; } 
到此为止,登录状态的配置基本完成。
Router 我们的应用中会有多个页面,而且有的需要登录才可见,那么如何控制呢?
React的路由控制是比较灵活的,来看看下面这个例子:
src/router.jsx 
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import  React  from  'react' ;import  { Router , Route  } from  'dva/router' ;import  { authenticated } from  './utils/auth' ;import  Dashboard  from  './routes/Dashboard' ;import  Users  from  './routes/Users' ;import  User  from  './routes/User' ;import  Password  from  './routes/Password' ;import  Roles  from  './routes/Roles' ;import  Permissions  from  './routes/Permissions' ;export  default  function  ({ history } ) {  return  (     <Router  history ={history} >        <Route  path ="/"  component ={Dashboard}  onEnter ={authenticated}  />        <Route  path ="/user"  component ={User}  onEnter ={authenticated}  />        <Route  path ="/password"  component ={Password}  onEnter ={authenticated}  />        <Route  path ="/users"  component ={Users}  onEnter ={authenticated}  />        <Route  path ="/roles"  component ={Roles}  onEnter ={authenticated}  />        <Route  path ="/permissions"  component ={Permissions}  onEnter ={authenticated}  />      </Router >    ); } 
对于路由的验证配置在onEnter属性中,authenticated方法可统一进行路由验证,要注意每一个Route节点的验证都需要配置相应的onEnter属性。如果权限较为复杂需对每一个Route单独验证。其实这种基于客户端渲染的应用,如果页面限制有遗漏也关系不太,后端提供的API会对数据进行验证,即使前端访问到没有权限的页面,也同样不用担心,做好客户端错误处理即可。
数据缓存 对于一个React应用来说,缓存是很重要的一步。前后端分离后,频繁的Ajax请求会消耗大量的服务器资源,如果一些不长变动的持久化数据不做缓存的话,会浪费许多资源。所以,比较常见的方法就是将数据缓存在LocalStorage中。针对一些敏感信息可适当进行加密混淆处理,我这里就不介绍了。
什么时候做数据缓存? 例:用户信息缓存
参见src/models/auth.js#L64 
在subscriptions中配置了setup检测LocalStorage中的user是否存在。不存在时会去query用户信息,然后保存到user中,如果存在就将user中的数据添加到state的user: {}中。当然在进行请求时,已经在src/utils/auth.js验证用户信息是否正确,同时做了相应的限制src/utils/auth.js#L20 
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 import  { parse } from  'qs' ;import  { message } from  'antd' ;import  { query, update, password } from  '../services/auth' ;import  { getLocalStorage, setLocalStorage } from  '../utils/helper' ;export  default  {  namespace : 'auth' ,   state : {     user : {},     isLogined : false ,     currentMenu : [],   },   reducers : {     querySuccess (state, action ) {       return  { ...state, ...action.payload , isLogined : true  };     },   },   effects : {     *query ({ payload }, { call, put } ) {       const  { data } = yield  call (query, parse (payload));       if  (data && data.err_msg  === 'SUCCESS' ) {         setLocalStorage ('user' , data.data );         yield  put ({           type : 'querySuccess' ,           payload : {             user : data.data ,           },         });       }     },   }   subscriptions : {     setup ({ dispatch } ) {       const  data = getLocalStorage ('user' );       if  (!data) {         dispatch ({           type : 'query' ,           payload : {},         });       } else  {         dispatch ({           type : 'querySuccess' ,           payload : {             user : data,           },         });       }     },   }, } 
简单来说,就是没有缓存的时候缓存。
什么时候更新数据缓存? 例如,roles中添加和修改功能都需要用到permissions的数据,哪我怎么拿到最新的permissions数据呢。首先,我在加载roles列表页面时就需要将permissions的数据缓存,这样,在每次点添加或修改功能时就不需要再去拉取已缓存的数据了。
参见src/models/roles.js#L166 
在监听路由到roles时查询permissions是否缓存,将其更新到缓存中去。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 . . .   subscriptions : {     setup ({ dispatch, history } ) {       history.listen ((location ) =>  {         const  match = pathToRegexp ('/roles' ).exec (location.pathname );         if  (match) {           const  data = getLocalStorage ('permissions' );           if  (!data) {             dispatch ({               type : 'permissions/updateCache' ,             });           }           dispatch ({             type : 'query' ,             payload : location.query ,           });         }       });     },   }, . . . 
什么时候删除数据缓存? 删除缓存的配置是比较灵活的,这里的业务场景并不复杂所以,我用了比较简单的处理方式。
参见src/models/permissions.js#L112 
在执行新增或更新操作成功后,将本地原有的缓存删除。加上数据联动的特性,当再次回到roles操作时,缓存已经更新了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 . . .   *update ({ payload }, { select, call, put } ) {       yield  put ({ type : 'hideModal'  });       yield  put ({ type : 'showLoading'  });       const  id = yield  select (({ permissions } ) =>  permissions.currentItem .id );       const  newRole = { ...payload, id };       const  { data } = yield  call (update, newRole);       if  (data && data.err_msg  === 'SUCCESS' ) {         yield  put ({           type : 'updateSuccess' ,           payload : newRole,         });         localStorage .removeItem ('permissions' );         message.success ('更新成功!' );       }     }, . . . 
State的临时缓存 state的中的数据是变化的,刷新页面之后会重置掉,也可以将部分models中的state存到Localstorage中,让state的数据从Localstorage读取,但不是必要的。而list数据的更新,是直接操作state中的数据的。
如下(这样就不用更新整个list的数据了)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 . . .     grantSuccess (state, action ) {       const  grantUser = action.payload ;       const  newList = state.list .map ((user ) =>  {         if  (user.id  === grantUser.id ) {           user.roles  = grantUser.roles ;           return  { ...user };         }         return  user;       });       return  { ...state, ...newList, loading : false  };     }, . . . 
视图组件运用 Ant 提供的组件非常多,但用起来还是需要一些学习成本的,同时多个组件组合使用时也需要有很多地方注意的。
Modal注意事项 在使用Modal组件时,难免会出现一个页面多个Modal的情况,首先要注意的就是Modal的命名,在多Modal情况下,命名不注意很容易出现分不清用的是哪个Modal。建议命名时能望名知意。然后就是Modal需要用到别的Models的数据时,如果在弹窗时通过Ajax获取需要的数据再显示Modal,这样就会出现Modal延迟,而且Modal的动画也无法加载出来。所以,我的处理方式是,在进入这一级Route的时候就将需要的数据预缓存,这样调用时就可随用随取,不会出现延迟了。
参见src/components/user/UserModalGrant.jsx#L33 
Ant的form组件很完善,需要注意的就是表单的多条件查询。如果单单是一个条件查询的处理比较简单,将查询关键词设成string类型存到相应的Models中的state即可,多条件的话,稍微麻烦一点,需存成Hash对象。灵活处理即可。
其他 官方文档的描述很清楚,我就不充大头了。注意写法规范即可,直接复制粘贴官方例子代码会很难看。
跨域问题 终于说到点子上了,前后端分离遇到跨域问题很正常,而这种基于RESTful API的前后端分离就更好弄了。我这以Fetch + PHP + Laravel为例,这种并不是最有解决方案!仅供参考!
在header中进行如下配置
Access-Control-Allow-Origin配置允许的域
Access-Control-Allow-Methods配置允许的请求方式
Access-Control-Allow-Headers配置允许的请求头
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 <?php use  Illuminate \Http \Request ;Route ::group (['middleware' => ['auth:api' ]], function() {    header ("Access-Control-Allow-Origin: *" );     header ("Access-Control-Allow-Methods: GET, HEAD, POST, PUT, PATCH, DELETE" );     header ("Access-Control-Allow-Headers: Access-Control-Allow-Headers, Origin, Accept, Authorization, X-Requested-With, Content-Type, Access-Control-Request-Method, Access-Control-Request-Headers" );     require  base_path ('routes/common.php' ); }); 
基于其他编程语言的处理类似。
结语 了解前端、熟悉前端、精通前端、熟悉前端、不懂前端
了解 X X 、熟悉 X X 、精通 X X 、熟悉 X X 、不懂 X X