最陌生的熟悉人--React

最陌生的熟悉人?
嗯,你没看错。

何谓『最陌生』?
截止2018春节前,我从来没有在正式项目里用过react。

何谓『熟悉人』?
自打我第一次接触React(2015年初),以及随之而来的ES6, Webpack, Flux等众多新奇工具和概念让我措手不及。然后开始疯狂阅读文档,甚至默写例子(现在想来,着实有点crazy)。虽然在很长的时间里没有正式启用react,但它从未在我的世界里消失,每隔三差五就会出现在我的视野里,比如Angular,React,Vue经常同台出现。

2018年春节后,公司的脚手架(基于Vue)等基础设施也基本稳定了,我终于决定真刀真枪的来一次React实战了。基于这么几个原因:

  1. Vue的核心思想以及用法,以及较复杂场景也经历过一些了,官方继续迭代基本就是加入一些新的API,继续使用Vue栈的话,视野可能不够
  2. 近大半年来,我开始看好ts,之前用ts写过几个node模块,感觉还不错,然后想进一步体验用ts开发前端,但Vue对ts支持力度不够(虽然已经有一些方案),但tsx更成熟
  3. 我觉着react的开发方式更加贴近javascript自身,而Vue更加DSL化(主要指模板),我想深入体验一下react,看看能不能结合两者爆出什么火花(包括工程化方面)。

首先,全面复习了一下react官方文档,温故而知新,发现确实比3年前完善了许多,尤其提到了第三方的优秀周边。
在看文档的同时,借助create-react-app, create-react-app-typescript,next + typescript插件,敲了蛮多demo。
另外,还记录了一些比较入门级别的笔记:

  1. 对于多个类似表单项的change事件,可绑定同一handler,然后通过name属性来区分不同表单项
  2. jsx的自定义组件名必须大写开头,HTML内置组件必须小写开头
  3. import React from 'react' 必须写在组件模块顶部, 可借助babel-plugin-react-require 简化
  4. 可使用 transform-class-properties 使用es6 class的静态属性定义defaultProps:
    static defaultProps = {name: 'stranger'}, defaultProps先生效,然后再校验propTypes
  5. propTypes使用prop-types这个包
  6. 使用ref的场景
    • Managing focus, text selection, or media playback.
    • Triggering imperative animations.
    • Integrating with third-party DOM libraries.
    • 附件上传
  7. ref callbacks are invoked before componentDidMount or componentDidUpdate lifecycle hooks.
  8. ref使用方式(字符串式ref="textInput"不被推荐):
    ref={input => this.textInput = input}. input为DOM或组件实例
  9. 不能对函数式组件使用ref,因为它没有实例
  10. 当组件更新时,ref的callback会执行两次
  11. React Virtualized 是一个大列表虚拟化的优秀组件
  12. 如果能确保组件的更新只受props和state影响,可以选择继承React.PureComponent
  13. React.PureComponent 会跳过子节点的prop更新,所以要确保其子节点也都是pureComponent
  14. 调用setState, 如需访问前一状态时,应传入一个函数方式来调用this.setState(prevState => ({ words: [...prevState.words, 'marker']})
  15. 使用高阶组件代替mixins:https://reactjs.org/docs/higher-order-components.html,http://egorsmirnov.me/2015/09/30/react-and-es6-part4.html
  16. <Component {...this.props} /> 这个...很棒
  17. 慎用contextTypes,它只是用于把祖先的props传递给子孙
  18. React.Fragment 是个好东西,有了它,render函数就不需要返回数组了
  19. ReactDOM.createPortal可用于渲染组件至任意DOM内的场景,比如对话框,而且事件可以冒泡到父组件
  20. render prop是一种模式,就是把『render』作为一个prop传给子组件,由子组件去调用,有点像vue的作用域插槽,『反向渲染』
  21. render prop还可以这样用:
  22. render prop 对于React.PureComponent, 需要注意:传给render这个prop的函数,需要是<SomeComponent render={this.renderProp}/>这种形式,以保证shouldComponentUpdate不会总是返回true
  23. Keeping render() pure makes components easier to think about. 确保render方法是纯函数
  24. componentWillMount 是唯一一个在服务端渲染时被执行的生命周期方法
  25. 在componentDidMount中发起数据请求
  26. 可以在componentWillReceiveProps中调用setState,但是有可能触发此方法时props并没有改变,组件mounting过程并不会触发此方法
  27. We do not recommend doing deep equality checks or using JSON.stringify() in shouldComponentUpdate(). It is very inefficient and will harm performance.
  28. 不可以在componentWillUpdate中调用setState
  29. setState(updater[, callback]) callback执行时,re-render已经完毕,updater使用函数方式,能够保证prevState和props是最新的
  30. 仅在state和props之外的变化因素导致需要重新渲染时调用forceUpdate
  31. defaultProps用于undefined的情况, 而非null
  32. shouldComponentUpdate逻辑:

    ————我是图片分割线——-

    这两句话,看似矛盾,其实不然。上一句是指对于顶层组件的这一次re-render而言,如果C2的SCU返回false,则其子孙节点并不需要继续检查SCU。而下面这句话表示,当子孙节点自己的state变化时父组件并不会阻止他们re-render。
  33. defaultValue和defaultChecked等是被非受控组件用的
  34. DOM事件都是React合成的,跨浏览器的,如果需要访问原始事件对象,可用event.nativeEvent. React event被在handler触发后将会被置空,如果需要在handler触发后,可以被继续访问,需要调用event.persist()
  35. componentWillReceiveProps被执行的前提是组件不能被销毁。

开战

首先要说一下我们这个项目的性质,都是展示型页面,是另一个老项目的功能补充,提交表单等相关业务已经由Vue实现。最初打算顺带把服务端渲染做了,于是优先考虑next.js,看了文档,对其路由的可扩展性表示堪忧,还有部署等方面与我司的基本情况有所不匹配。又考虑到我们这个项目是比较依赖用户端定位的,服务端渲染就打折扣了。于是放弃next.js, 又考虑了create-react-app-typescript, 发现其内置的配置虽然已经比较丰富,但仍然与我司的环境不匹配。

出于种种原因,最终我决定自己从头打造一套react的配置,好歹我折腾vue配置也折腾了一年多,移植过来应该不会花太多时间。

然而,我还是太乐观了,因为这次加入了ts,以及react的css方案,与之前vue的配置还是有蛮大的不同,主要时间都是花在这两方面。

接下来直接说结果吧, 整个工程目录是这样的(『客户端』均指『浏览器端』相关的代码):

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
├── client // 客户端代码,大部分代码在这里
│ ├── assets // 图片以及全局stylus/css
│ ├── components // 全局组件
│ ├── pages // 页面级的container组件
│ ├── store // redux相关
│ ├── types // typescript的全局类型定义,包括window扩展和业务实体类型定义
│ ├── utils // 全局工具
│ ├── config.tsx // 全局配置,比如axios拦截器,全局loading,以及为react原型添加log方法
│ ├── index.tsx // 客户端入口,渲染App到页面根容器等
│ └── tsconfig.json // 客户端tsconfig.json
├── server // node端代码
│ ├── middlewares // 中间件
│ ├── routes // 路由定义
│ ├── utils // 全局工具
│ ├── app.ts // node服务启动ts入口模块
│ ├── dev.js // 开发时专用的入口模块
│ ├── index.js // node服务入口,注意是js,这里使用了`ts-node`
│ ├── tsconfig.json // node端的tsconfig.json
│ └── webpack.dev.server.js // 本地开发时webpack-dev-server的启动入口
├── babel-presets.js // babel配置
├── dev-constants.js // 本地开发时的常量,比如后端同学本地服务地址
├── favicon.ico // 网站图标
├── index.template.ejs // 单页应用入口模板
├── nodemon.json // nodemon配置,用于本地开发,watch node代码自动重启node服务
├── package-lock.json // 不解释了
├── package.json // 不解释了
├── pm2.yml // 服务器上部署时,读取的pm2配置文件
├── postcss.config.js // postcss配置文件
├── server.config.example.js // 服务器配置文件的例子
├── server.config.js // 服务器配置文件,不同环境不一样,通过配置中心拉取
├── tslint.json // tslint配置,客户端服务端公用
├── upyun.config.js // CDN配置,这里是又拍云,可替换
├── webpack.base.conf.js // webpack基础配置
├── webpack.client.dev.conf.js // webpack 客户端开发配置
└── webpack.client.prod.conf.js // webpack 生产环境配置

着重说以下几点:

ts相关配置

由于浏览器端配置和node端配置,还是有一些区别的,所以建了两份tsconfig.json。比如lib字段,客户端 需要包含dom, 以及一些ES6垫片,而node端,v8.x对ES6的支持已经比较完善了。还有模块系统是不一样的。

客户端baseUrl字段与webpack别名

即便不用ts,我们对webpack别名也有强烈的需求,然而当我们想导入某个模块的时候(比如client/components/tab),编辑器通常不能给出准确的智能提示,通过ts我们可以实现。

baseUrl默认指向tsconfig.json所在的目录,在这里我们需要把它配置为../(即告诉ts编译器我们的根目录在整个工程的根目录), 然后当我们写下client/时,client下的所有目录都被智能提示出来了,继续遍历下去仍然会有提示(至少vscode和webstorm是能做到的)。然后,需要为webpack配置别名:

1
2
3
4
5
resolve: {
alias: {
client: path.join('/path/to/client'),
},
},

然后我们就可以在任何客户端模块通过client/这个前缀,导入其他客户端模块。

module字段

node目前还没有实现ES模块,所以node端的ts配置,module字段需要设置为commonjs
浏览器端把模块的转换交给webpack,module字段设置为esnext

target字段与jsx字段

对于node端,target设置为es5es6(node版本>=8.x), jsx字段无需设置。
重点是客户端,原本这俩字段不值得一说,targetes5, jsx设置为react,并且无需babel介入,就可以让工程跑起来。然而自打我研究react的css方案,这里就开始让我抓狂了。我们这里先说结论:『客户端』target设置为es6, jsx设置为preserve(或不设置,默认值就是preserve, 只是为了强调),即保留jsx语法不编译,下文会提到CSS方案的问题。

CSS方案

现代化的前端工程,大多数CSS方案都需要解决class名冲突的问题。
在react社区,css in js的方案有好多种,然而我个人并不喜欢这种与js/ts耦合的方案,css modules也比较难接受。回想起vue的CSS方案还是比较优雅的,尤其是直接支持scoped css。在研究next.js的时候,它内置的styled-jsx让我眼前一亮。

简单讲,它巧妙的使用style标签『骗过jsx编译器』,再通过babel插件导入它自己的组件并生成scoped class随机数,结合运行时,将css字符串随style标签插入文档头。

它同时具备了vue方案与css in js方案的优点:
既不与js/ts耦合,而且能够在运行时读取js/ts变量,动态创建样式。同时,它还支持less,sass,stylus预处理器插件与postcss插件。

遗憾的是,它需要借助babel使用。在我之前的概念里,有了ts,就不需要babel了。但是现在看来,如果要使用styled-jsx,我的ts工程就要被『污染』了,oh no…

后来又思想斗争了一下,发现awesome-typescript-loader 文档已经明确有babel选项,说明ts与babel混用并没有不合理,虽然直觉上两者有功能上的重复,但的确babel的扩展性强的多,于是第二天就打了鸡血般的把styled-jsx方案加上工程里了,蛮爽~

但在打包环节还是踩坑了,webpack打包执行的命令是webpack --progress --hide-modules --config webpack.client.prod.conf.js, 居然报了一个让人很摸不着头脑的错:

1
TypeError: Invalid PostCSS Plugin found: [0]

初步排查了一下,styled-jsx-postcss插件拿到的居然是webpack的插件,WTF?!
花了一下午慢慢断点调试,发现就是调用postcss-load-plugins时,少传了一个参数argv, 这个参数默认为true,即postcss会从命令行参数里读取配置文件的路径,这里显然应该为false。然后发现github上已经有人提出这个issue了,似乎那个guy也排查了很久这个bug。于是本着为开源世界做贡献的精神,果断开 PR。 作者好像很忙,大约半个月后才合并。

webpack与主服务解耦

在此之前,公司其他大多数项目webpack方案都是作为koa中间件存在的,需要通过环境变量来区分是否需要use,虽然能用,但代码组织上比较耦合。

这次来个小创新~
如果不耦合为中间件,我希望在主服务外或者是另一个进程启动webpack-dev-server。

首先要说一说我们单页应用的形态,并没有上服务端渲染,路由使用的是html5模式。在node端需要判断当前请求是否为『页面请求』,这个逻辑需要结合项目的路由设计,请求path,请求后缀以及请求头判断,这里不展开细节了。如果不是『页面请求』(应该就是接口请求了),就该干嘛干嘛。如果是『页面请求』,则需要返回那个单页应用的『页面骨架』,如果是生产环境模式,那么读取已经build出来的index.html就好了;如果是本地开发环境,我们需要从已经启动好的webppack-dev-server那读取。

约定好webpack-dev-server端口, 我突发奇想,实践出了以下方案:

负责输出单页应用骨架的koa中间件server/middlewares/spa.ts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
async function spa(ctx: Koa.Context, next: () => void) {
// ...通过种种逻辑判断确认需要输出单页应用骨架
ctx.set('Content-Type', 'text/html; charset=utf-8');
if (!isDev) {
ctx.body = indexHTML; // indexHTML为已经读取出来的build过的inde.html内容
return Promise.resolve();
}
logger.info('Request html from webpack dev server...');
// 从webpack-dev-server拉取index.html
// 端口的约定比较粗暴哈哈
return axios.get(`http://localhost:${serverConfig.port + 100}/public/index.html`)
.then((response) => {
ctx.body = response.data;
})
.catch((error) => {
ctx.body =
'Can not find the page.<br/>' +
'Maybe your app has not been compliled successfully. <br/>' +
'Please check your codebase, fix the error(s) and refresh the page. </br>' +
'<hr/>' +
'Error Details: </br>' +
error.stack.replace(/\n/g, '</br>');
});
};

另一关键部分server/dev.js(你没看错是js):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const { spawn } = require('child_process')
// 必须用异步spawn,否则等nodemon起来后,webpack-dev-server的代码就不能执行了
const runSpawn = (cmd, cmdArgs) => {
spawn(cmd, cmdArgs, {
stdio: 'inherit',
shell: true,
})
}
const { start: startWDS } = require('./webpack.dev.server');
// 通过promise防止上述中间件请求webpack-dev-server时出服务级别的错
startWDS()
.then(() => {
runSpawn('./node_modules/.bin/nodemon', ['server/index.js']);
})

首先启动webpack-dev-server, 然后通过spawn启动nodemon
最终实现client和server代码一有改动浏览器都会自动刷新(server代码改动后node服务自动重启,主服务重启会通过applescript刷新浏览器)。开发体验还是不错的~

本来打算把刚刚做完的这个项目整理一下,去除业务相关代码,发到github,发现环境相关的代码,工作量比较大,下次抽空发上来。

—–update: 2018年05月20日00:42:04
简化版工程已整理完毕,请移步 https://github.com/stoneChen/react-typescript-mobile-seed