2019,我的React技术之路

1,809 阅读12分钟
文章大纲:
  • react hooks在业务中的应用
  • react hooks与react class的使用区别
  • 使用tsx编写自己的组件
  • 用webpack+node将组件编译成es5
  • 使用lerna将项目改造为monorepo
  • 将组件发布到npm
  • 使用github actions实现gh-pages页面的自动化构建

react hooks在业务中的应用

react hooks出了有一段时间,hooks的各种用法也渐渐被大家所了解,在体验过hooks写法之后,对于react,又有了新的感受,那就是大道至简,react又一次从写法层面上革新了我们对于面向UI编程的理解。
于是我选择在保证业务稳定的情况下,将后台项目中用到的各个组件以及页面从class组件迁移到hooks组件。
hooks的用法我就不赘述了,网上有很多文章可以入门hooks,这里仅仅做一点拓展,分享一些解决方案,让大家能高效率地进行业务开发,并写出优雅的代码。
  • immutablejs 这是配合hooks编写高性能react代码的必备法宝,语雀 使用的就是react+immutable.
  • umi/hooks umi虽然是类nextjs的偏脚手架的“框架”,但这并不妨碍阿里的react developer在此基础上进行创新,相比于ant design,我更加偏爱umi/hooks这个开源项目,因为它从hooks的角度出发,写了大量hooks逻辑复用的范例,是一个具有开创性的项目,配合antd,它能快速构建应用,节省了许多写相关逻辑的时间,这个开源项目的官网用的是pwa,没有使用ssr,所以目前在百度搜索不到,点击链接访问或者自行谷歌。

react hooks与react class的使用区别

之前写angular以及react的时候,经常有一个疑惑飘荡在我的脑海里——为什么要使用class进行开发,而不是更加简洁的function。
hooks给出了答案,angular却相信class依然可以沿用下去。
事实上,对于java、C#这类语言,class是其逻辑复用的核心,后端代码,某种意义上并没有前端这么复杂,前端代码里面混杂着视图和数据相关的逻辑,而数据往往是异步的,这使得数据相关逻辑更加难以复用,所以前端代码复用一直是一个难题。
既然前端页面无法通过class进行复用,那class还有什么用呢?答案可能是,能更方便地实现生命周期。(虽然react提供了mixin的功能,但react并不推荐通过mixin进行逻辑复用,详情请看这里)
组件/页面有了生命周期之后,就可以精准控制其在什么时候加载,什么时候销毁,这对于spa应用而言,十分重要,虽然现在的硬件设备的性能都足够强,但开发者始终考虑的是,在保证功能的前提下,提升开发效率的同时,最大限度地节省硬件资源。
不过,对于绝大部分组件而言,其实并不需要生命周期,它们的作用仅仅用于展示,最常见的场景可能是等待数据请求完毕之后将数据填充到指定位置,像这样的功能,使用handlebars就能很简单地实现,但handlebars这内裤无法做到双向数据绑定,于是就有了emberjs,不过问题依然存在——组件化,随着浏览器的更新换代,在抛弃了ie这个大包袱之后,react应运而生,谷歌也在angularjs的基础上重造出了angular,两年之后,尤雨溪吸收了两者的思想,并融入了对于web开发的思考,开源了vue。
技术始终是服务于商业的。“互联网的下半场”,其实说的是,现在早已不是多学多用的阶段,如今知而不精等于没用,因为现在开发者要做到的不仅仅是在工期内把一个产品开发出来,还需要进行多个维度的构建和优化,交互层面、设计层面、代码层面、运营层面、用户心理层面、市场层面、技术层面...
现在需要的是综合实力强者,但这无法投机取巧,需要开发者在各个领域的天赋以及后期长时间的学习积累。
从某种意义上讲,框架即开发者,开发者即框架。所以“互联网的下半场”,框架同样得是综合实力强者。
于是开发者和框架开发者们开始思考,如何防止劣币驱逐良币的情况出现,面对抄袭者、跟风者,最优解就是降维打击——从创造力这个角度入手,不断地创造新的解决方案去解决当下的各种问题,不仅仅是造轮子,而且还要给给过去制造的马车装上发动机,让它变成汽车。
言归正传,react的“进化”其实代表了一种趋势——变者生,留者逝。
从代码层面上讲,class组件其实是有冗余的,不要小瞧这种冗余,当页面达到成百上千之后,它产生的影响可能会让人难以想象。从逻辑复用层面上讲,class组件很难进行逻辑复用,只能进行组件化。
虽然react提供了pureComponent这样的组件,但还是没有解决根本问题,无状态组件上面依然挂载了一堆没用的属性和方法。对于有代码洁癖的人来说,这是无法让人忍受的。
在使用了hooks之后,react代码能够做到最大程度的分割,过去可能一个复杂的页面或者组件需要700行,而且由于class的缘故,无法进行原子级别的代码分离,现在hooks将useState这类逻辑从组件中抽象出来,把代码粒度做到了最细,不仅让代码更加易读,更加容易追踪代码逻辑,而且在抛弃了生命周期之后,开发者只需要关注数据逻辑,hooks用一种非常functional的方式将视图逻辑“压缩”成了数据逻辑,这种变化,让开发者面对复杂度更低的代码,某种程度上讲,降低了react的门槛。

使用tsx编写自己的组件

npx create-react-app my-app --typescript
在编程领域,有一个概念叫做“防御型编程”:
防御性编程是一种细致、谨慎的编程方法。为了开发可靠的软件,我们要设计系统中的每个组件,以使其尽可能地“保护”自己。我们通过明确地在代码中对设想进行检查,击碎了未记录下来的设想。这是一种努力,防止(或至少是观察)我们的代码以将会展现错误行为的方式被调用。
而typescript提供给我们的,是一种被动形式的防御性编程,我将之成为“收敛模式”。它从语法层面规范编码人员需要按照它的规范来进行编码,不仅如此,它还提供更为高级的语法特性供开发者使用,这种在语言之上通过编译器构建另一种语言的模式近年来被kotlin以及typescript所采用。
与创造一门新的编程语言不同的是,依赖编译器的高级语言可以直接对接目标语言的生态和社区,并通过吸收社区的内容来壮大自身,这种模式远比创造一种新的语言来的有效。
如何在react中使用typescript,这里以我写的image组件为例:
Image.tsx
import React, { useState, useEffect, useRef } from 'react'
import styles from './Image.less'

interface IProps {
      width?: string,
      height?: string,
      borderRadius?: string,
      src: string,
      alt?: string,
      mode?: "scaleToFill" | "aspectFit" | "aspectFill" | "aspectFillWidth" | "aspectFillHeight" | "widthFix" | "top" | "bottom" | "center" | "left" | "right" | "topLeft" | "topRight" | "bottomLeft" | "bottomRight",
      lazy?: boolean,
}

const Index: React.FC<IProps> = (props) => {
      const {
            width = '300px',
            height = '225px',
            borderRadius = '0px',
            src = '',
            alt = 'image',
            mode = 'aspectFill',
            lazy = false
      } = props

      const [state_img_style, setStateImgStyle] = useState({})
      const [state_img_src, setStateImgSrc] = useState('')

      const container_ref = useRef<HTMLDivElement>(null)
      const img_ref = useRef<HTMLImageElement>(null)

      useEffect(() => {
            let _width: number
            let _height: number

            const { current } = img_ref

            if (width && height && container_ref.current) {
                  if (width === '100%') {
                        _width = container_ref.current.offsetWidth
                  } else {
                        _width = Number(width.split('').filter(item => Number.isSafeInteger(Number(item))).join(''))
                  }

                  if (height === '100%') {
                        throw Error('height can`t be 100%!')
                  } else {
                        _height = Number(height.split('').filter(item => Number.isSafeInteger(Number(item))).join(''))
                  }

                  if (current) {
                        if (lazy) {
                              let io = new IntersectionObserver((e) => {
                                    const _e = e[e.length - 1]

                                    if (_e.isIntersecting) {
                                          setStateImgSrc(src)

                                          io.unobserve(current)
                                    }
                              })

                              io.observe(current)
                        } else {
                              setStateImgSrc(src)
                        }

                        if (mode === 'aspectFill') {
                              current.onload = () => {
                                    const { naturalWidth, naturalHeight } = current
                                    const ratio_natural = naturalWidth / naturalHeight
                                    const ratio_preset = _width / _height

                                    if (ratio_natural > ratio_preset) {
                                          setStateImgStyle({
                                                maxHeight: '100%'
                                          })
                                    } else {
                                          setStateImgStyle({
                                                maxWidth: '100%'
                                          })
                                    }
                              }
                        }
                  }
            }
      }, [height, lazy, mode, src, width])

      const style: object = {
            width: width,
            height: mode === 'widthFix' ? 'auto' : height,
            borderRadius: borderRadius,
            overflow: 'hidden'
      }

      return (
            <div
                  ref={container_ref}
                  className={`${styles._local}`}
                  style={{
                        width: '100%'
                  }}
            >
                  <div
                        className={`${mode}`}
                        style={style}
                  >
                        <img
                              ref={img_ref}
                              className='img'
                              src={state_img_src}
                              alt={alt}
                              style={state_img_style}
                        />
                  </div>
            </div>
      )
}

export default React.memo(Index) 
使用typescript,让我最满意的一点就是它会根据类型判断当前值是否有可能为空,然后让你必须写判断,这里就避免出现存在undefine的值导致代码报错的情况。
还需要给react组件写类型定义文件:
index.d.ts
import React from "react";

interface IProps {
      width?: string,
      height?: string,
      borderRadius?: string,
      src: string,
      alt?: string,
      mode?: "scaleToFill" | "aspectFit" | "aspectFill" | "aspectFillWidth" | "aspectFillHeight" | "widthFix" | "top" | "bottom" | "center" | "left" | "right" | "topLeft" | "topRight" | "bottomLeft" | "bottomRight",
      lazy?: boolean
}

declare const Image: React.FC<IProps>;

export default Image;
并通过package.json的types字段指定路径:
_package.json
{
      "name": "rokit-image",
      "version": "1.2.0",
      "description": "image component of rokit,inspire by miniapp image component.",
      "main": "index.js",
      "types": "index.d.ts",
      "scripts": {
            "test": "echo \"Error: no test specified\" && exit 1"
      },
      "repository": {
            "type": "git",
            "url": "git+https://github.com/MatrixAge/rokit.git"
      },
      "keywords": [
            "react",
            "component",
            "js",
            "rokit",
            "miniapp",
            "wxapp"
      ],
      "author": "matrixage",
      "license": "MIT",
      "bugs": {
            "url": "https://github.com/MatrixAge/rokit/issues"
      },
      "homepage": "https://github.com/MatrixAge/rokit#readme"
}
学习typescript,中文文档请看这里,英文文档请看这里,建议使用谷歌翻译看英文文档,唯一有点让我感到不适的就是泛型,不过这东西一般也很少用到,当然,也请谨慎使用。

用webpack+node将组件编译成es5

组件写好了,还需要将其编译成es5,这样就能发布给其他react开发者使用了,我使用的是monorepo来管理组件,所以组件打包之后也得组件与组件之间进行分离。这里需要自行用node操作文件并执行webpack配置:
lib_build.js
const path = require('path')
const glob = require('glob')
const fs = require('fs-extra')
const webpack = require('webpack')
const config = require('../config/lib_webpack.config')
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages')
const { appComponents, appComponentsSource } = require('../config/paths')

const build = () => {
	fs.emptyDirSync(appComponents)

	console.log('building components...')

	const compiler = webpack(config)

	return new Promise((resolve, reject) => {
		compiler.run((err, stats) => {
			let messages

			if (err) {
				if (!err.message) {
					return reject(err)
				}

				messages = formatWebpackMessages({
					errors: [ err.message ],
					warnings: []
				})
			}
			else {
				messages = formatWebpackMessages(stats.toJson({ all: false, warnings: true, errors: true }))
			}

			if (messages.errors.length) {
				if (messages.errors.length > 1) {
					messages.errors.length = 1
				}
				return reject(new Error(messages.errors.join('\n\n')))
			}

			return resolve({
				stats,
				warnings: messages.warnings
			})
		})
	})
}

const copy = () => {
	const copyFiles = (name, target_name) => {
		const files = glob.sync(path.join(appComponentsSource, `/**/${name}`))

		files.map((item) => {
			const array_package_path = item.split('/')
			const package_name = array_package_path[array_package_path.length - 2]

			fs.copyFileSync(item, `${appComponents}/${package_name}/${target_name}`)
		})
	}

	copyFiles('index.d.ts', 'index.d.ts')
	copyFiles('_package.json', 'package.json')
	copyFiles('readme.md', 'readme.md')
}

build()
	.then((res) => {
		if (res) {
			copy()

			console.log('components building success!')
		}
	})
	.catch((err) => {
		console.log(err)
	})

这里用了一个glob库,用来批量操作文件,很好用,强烈推荐。然后是webpack配置:
lib_webpack.config.js
const path = require('path')
const glob = require('glob')
const isWsl = require('is-wsl')
const TerserPlugin = require('terser-webpack-plugin')
const safePostCssParser = require('postcss-safe-parser')
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin')
const { appComponents } = require('./paths')

process.env.BABEL_ENV = 'production'
process.env.NODE_ENV = 'production'

const getComponents = (path) => {
	const files = glob.sync(path)

	let entry = {}

	files.map((item) => {
		const array_package_path = item.split('/')
		const package_name = array_package_path[array_package_path.length - 2]

		entry[package_name] = item
	})

	return entry
}

module.exports = [
	{
		mode: 'production',
		entry: getComponents(path.resolve(__dirname, '../src/components/**/index.tsx')),
		target: 'web',
		module: {
			rules: [
				{
					test: /\.(ts|tsx)$/,
					use: [
						{
							loader: 'babel-loader'
						}
					],
					exclude: /node_modules/
				},
				{
					test: /\.(css|less)$/,
					use: [
						{ loader: 'style-loader' },
						{
							loader: 'css-loader',
							options: {
								modules: true
							}
						},
						{
							loader: 'less-loader',
							options: {
								modules: true
							}
						}
					]
				}
			]
		},
		resolve: {
			extensions: [ '.tsx', '.ts', '.js' ]
		},
		externals: {
			react: 'react'
		},
		output: {
			filename: '[name]/index.js',
			path: appComponents,
			libraryTarget: 'commonjs2'
		},
		optimization: {
			minimize: true,
			minimizer: [
				new TerserPlugin({
					terserOptions: {
						parse: {
							ecma: 8
						},
						compress: {
							ecma: 5,
							warnings: false,
							comparisons: false,
							inline: 2
						},
						mangle: {
							safari10: true
						},
						keep_classnames: false,
						keep_fnames: false,
						output: {
							ecma: 5,
							comments: false,
							ascii_only: true
						}
					},
					parallel: !isWsl,
					cache: true,
					sourceMap: false
				}),
				new OptimizeCSSAssetsPlugin({
					cssProcessorOptions: {
						parser: safePostCssParser,
						map: false
					}
				})
			]
		}
	}
]

这里有两个容易出问题的点:
其一就是output.target配置:目前模块化标准有commonjs、amd、cmd以及es6 modules,而webpack对上述模块化标准的导出方式有所不同,详情请看这里,我这里使用的是commonjs2。
其二就是externals配置:如果在组件中已经引入react而在打包时没有将react给排除,那么有可能会因为多次引入react而报错。

使用lerna将项目改造为monorepo

rokit组件库的目标是提供单个好用的组件,想用哪个就安装哪个,不需要像一些UI框架那样上一个全家桶,虽然webpack有tree-shaking,但全家桶还是心智负担太重了些,所以我使用lerna来管理组件的发布,与此同时,修改了之间webpack配置,将编译之后的组件导入到了packages文件夹。
无论是新项目,还是现有项目,执行lerna init,然后修改目录结构为lerna指定的结构即可享受lerna publish带来的爽快体验了。下面是lerna.json的配置:
{
      "packages": [
            "packages/*"
      ],
      "version": "independent",
      "npmClient": "yarn",
      "useWorkspaces": true,
      "command": {
            "publish": {
                  "ignoreChanges": [
                        "ignored-file",
                        "*.md"
                  ]
            },
            "bootstrap": {
                  "npmClientArgs": [
                        "--no-package-lock"
                  ]
            }
      }
} 
这是rokit的文件目录结构:

将组件发布到npm

百度搜索npm,到npm官网使用邮箱注册一个账号,账号名称要international一点,最好单词本身带有语义,比如matrixage,这样能够加强认知,不要取一些自以为是狗屁不通的英文名字,我之前就取过vertical(直男)、verts(vert.x)这些,后来自己都不记得为什么叫这个了,于是就弃用了。
注册完成之后,到项目目录,npm login,输入账号密码,然后使用lerna publish选择版号即可发布到npm了,或是到你要发布的组件的目录执行npm publish --access=publish.
哦,对了,最好写一个readme,介绍一下项目的目标以及用法。

使用github actions实现gh-pages页面的自动化构建

今年github开源了github actions,可以通过github直接进行构建部署,通过编写yml文件(类似于dockerfile),可以进行自动化构建和部署,同时还可以直接引用别人的action,并在此基础上进行扩展,也算是自动化的一种延伸吧。
下面说一下用法,在项目目录新建一个.github文件夹,在.github文件夹中新建一个workflows文件夹,在workflows文件夹下新建一个ci.yml文件,内容如下:
name: Deploy gh-pages
on:
  push:
    branches:
      - master
jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
    - name: Checkout
      uses: actions/checkout@master

    - name: Build
      run: npm install && npm run build:site

    - name: Deploy
      uses: peaceiris/actions-gh-pages@v2
      env:
        ACTIONS_DEPLOY_KEY: ${{ secrets.ACTIONS_DEPLOY_KEY }}
        PUBLISH_BRANCH: gh-pages
        PUBLISH_DIR: ./build
里面有一个ACTIONS_DEPLOY_KEY,如何使用请看这里。另外,阮一峰老师的Github Actions入门 里面的脚本有点问题,生成文件之后无法触发gh-pages的自动构建流程,于是一番google之后找到了解决方案,就是上述代码咯。
至此,一个组件的开发到构建发布以及网站部署的流程就走完了,后续有什么不懂的请看这里