React 是一个用来构建用户界面的JavaScript库,React 起源于 Facebook 的内部项目,由于 React 的设计思想极其独特,属于革命性创新,性能出众,代码逻辑却非常简单。所以,越来越多的人开始关注和使用,认为它可能是将来Web开发的主流工具。
首先,先跟大家描述下 React 最特别的部分:JSX 和 虚拟DOM。
然后是React的核心内容:组件、Props、State和单向数据流。
最后是React能够给我们实际带来的价值:性能、多终端和测试。
JSX
之前流行的是 Script 标签模板:
近两年构建工具的流行,很多团队已经在使用Grunt等构建工具来预编译模板,从而简化模板开发,提高运行效率,减少维护成本。
JSX使用的也是预编译模板,React 提供了一个预编译工具,叫 react-tools,可以通过 npm 命令安装,一般是在开发时期运行,运行后它会启动一个监听程序,实时监听 JSX 源码的修改,然后自动编译为 JS 代码。JSX 会被编译成 React.createElement(),目的是为了实现虚拟 DOM。除此之外,JSX 还支持运行时编译。
虚拟DOM
传统 Web App 和 DOM 直接交互,由 App 来控制 DOM 的构建和渲染、元素属性的读写、事件的注册和销毁等。
虚拟 DOM 则是在 DOM 的基础上建立了一个抽象层,对数据和状态所做的任何改动,都会被自动且高效的同步到虚拟 DOM,最后再批量同步到 DOM 中。虚拟 DOM 会使得 App 只关心数据和组件的执行结果,中间产生的 DOM 操作不需要 App 干预,而且通过虚拟 DOM 来生成 DOM,会有一项非常可观的收益 -- 性能。比如,渲染一个空的 div,浏览器需要为这个 div 生成几百个属性,而虚拟DOM 只有6个。
React会在内存中维护一个虚拟DOM树,当我们对这个树进行读或写的时候,实际上是对虚拟DOM进行的。当数据变化时,然后React会自动更新虚拟DOM,然后拿新的虚拟DOM和旧的虚拟DOM进行对比,找到有变更的部分,得出一个Patch,然后将这个Patch放到一个队列里,最终批量更新这些Patch到DOM中。这样的机制可以保证即便是根节点数据的变化,最终表现在DOM上的修改也只是受这个数据影响的部分,这样可以保证非常高效的渲染。缺陷是首次渲染大量DOM时因为多了一层虚拟DOM的计算,会比innerHTML插入方式慢。
一个最基本的 React 组件由数据和 JSX 两个主要部分构成。props 主要作用是提供数据来源,可以简单地理解为 props 就是构造函数的参数。state 的作用是控制组件的表现,用来存放会随着交互变化的状态,比如开关状态等。JSX 是根据 state 和 props 中的值,结合一些视图层面的逻辑,输出对应的 DOM 结构。
但单个组件肯定不能满足实际需求,我们需要做的是将这些独立的组件进行组装,同时找出共性的部分即最细粒度的组件进行复用。
单向数据流和Props
在React中,数据流是自上而下单向地从父节点传递到子节点,只需要从父节点提供的 props 中获取数据并渲染即可。如果顶层组件的某个 prop 改变了,React会递归地向下遍历整棵组件树,重新渲染所有使用这个属性的组件。
竹笕,是中日传统禅文化中常见的庭院装饰品,它的构造可简单可复杂,但原理很简单,比如这个竹笕,水从竹笕顶部入口流入内部,并按照固定的顺序从上向下依次流入各个小竹筒,然后驱动水轮转动。
props 是组件唯一的数据来源,对于组件来说:props 永远是只读的。React 提供了一套非常简单好用的属性校验机制——React有一个PropTypes属性校验工具,经过简单的配置即可。当使用者传入的参数不满足校验规则时,React会给出非常详细的警告。
PropTypes 包含的校验类型包括基本类型、数组、对象、实例、枚举以及对象类型的深入验证等。如果内置的验证类型不满足需求,还可以通过自定义规则来验证。如果某个属性是必须的,在类型后面加上 isRequired。
State
每一个组件都看成是一个状态机,组件内部通过state来维护组件状态的变化。
React通过将事件处理器绑定到组件上来处理事件。React事件本质上和原生JS一样,鼠标事件用来处理点击操作,表单事件用于表单元素变化等,React事件的命名、行为和原生JS差不多,不一样的地方是React事件名区分大小写。若事件的处理器需要由组件的使用者来提供,这时可以通过props将事件处理器传进来。
React 组件实现组件可交互所需的流程如上图,render() 输出虚拟 DOM,虚拟 DOM 转为 DOM,再在 DOM 上注册事件,事件触发 setState() 修改数据,在每次调用 setState 方法时,React 会自动执行render方法来更新虚拟 DOM,如果组件已经被渲染,那么还会更新到 DOM 中去。
React 支持的不完全事件列表如上。
初始化、更新和销毁
React 的组件拥有一套清晰完整而且非常容易理解的生命周期机制,大体可以分为三个过程:初始化、更新和销毁,在组件生命周期中,随着组件的 props 或者 state 发生改变,它的虚拟 DOM和DOM 表现发生相应的变化。
组件类在声明时,会先调用 getDefaultProps() 方法来获取默认 props 值,这个方法会且只会在声明组件类时调用一次,这一点需要注意,它返回的默认 props 由所有实例共享。在组件被实例化之前,会先调用一次实例方法 getInitialState() 方法,用于获取这个组件的初始 state。
实例化后是渲染准备,componentWillMount 方法会在生成虚拟 DOM 之前被调用,可以在此对组件的渲染做一些准备工作,比如计算目标容器尺寸然后修改组件自身的尺寸以适应目标容器等。
接下来是渲染工作,在这里会创建一个虚拟 DOM 用来表示组件的结构。对于一个组件来说,render是唯一必须的方法。render方法需要满足这几点:
- 只能通过 this.props 或 this.state 访问数据。
- 只能出现一个顶级组件。
- 可以返回 null、false 或任何 React 组件。
- 不能对 props、state 或 DOM 进行修改。
- 需要注意的是,render 方法返回的是虚拟DOM。
渲染完成后,可能需要对DOM做一些操作,比如截屏、上报日志,或者初始化 iScroll 等第三方非React 插件,可以在 componentDidMount() 方法中实现。
修改组件的属性时,组件的 componentWillReceiveProps() 方法会被调用,在这里,你可以对外部传入的数据进行一些预处理,比如从 props 中读取数据写入 state。
默认情况下,组件调用者修改组件属性时,React会遍历这个组件的所有子组件,进行“灌水”,将props 从上到下一层一层传下去,并逐个执行更新操作,可以使用shouldComponentUpdate() 方法里 return false 来终止部分更新。
组件在更新前,React会执行 componentWillUpdate() 方法,这个方法类似于componentWillMount()方法,唯一不同在于组件是已经渲染过的。需要注意的是,不可以在这个方法中修改 props 或 state,如果要修改,应当在componentWillReceiveProps() 中修改。
然后是渲染,React会拿这次返回的虚拟 DOM 和缓存中的虚拟 DOM 进行对比,找出【最小修改点】,然后替换。
更新完成后,React会调用组件的 componentDidUpdate 方法,这个方法类似于componentDidMount 方法,同样可以在此通过 this.getDOMNode() 方法取得最终的DOM节点。
在这个方法中销毁非React组件注册的事件、插入的节点,或者一些定时器之类。
结合 Node.js 实现服务端渲染
因为有虚拟DOM的存在,React 可以很容易地将虚拟 DOM 转换为字符串,使得只写一份UI代码,可以同时运行在 node 和浏览器中。
在node里将组件渲染为一段HTML只需要:
const html = React.renderToString(el);
结合Node.js,服务端渲染流程为:
- 获取数据(这里是on=true)
- 引入要渲染的React组件(Switching)
- 调用React.renderToString()方法来生成HTML
- 最后同时发送HTML和JSON数据给浏览器
需要注意的是这里的JSON字符串中可能出现结尾标签或HTML注释,可能会导致语法错误,这里需要进行转义。
然后:
- 将前面在action里生成的HTML写到#container元素里(服务端运行)
- 将服务端的JSON数据输出到全局变量gDataForClient中(服务端运行)
- 引入必须的JS文件(服务端运行)
- 获取全局变量 gDataForClient(客户端运行)
- 尝试重新渲染组件(客户端运行)
如此,组件的代码前后端都可以复用。
原生渲染
React-Native 能够用一套代码同时运行在浏览器和node里,而且能够以原生App的姿势运行在iOS和Android系统中,既拥有Web迭代迅速的特性,又拥有原生App的体验。iOS开发,苹果对应用上架的流程长和审核效率低,上一个版本还没审核结束,下一个版本就已经做好了。而React-Native支持从网络拉取JS,这样iOS应用也能够像Web一样实现快速迭代,绕过苹果的审核。React-Native 使 React 的价值最大化。
单元测试
单元测试是对各个模块进行最小范围的测试。以往对前端的UI进行单元测试,都需要依靠phantomjs、casperjs等沙盒浏览器内核,模拟用户操作,并检查操作后的 DOM 状态,比较繁琐,维护成本高。因为虚拟 DOM 的存在,使得测试 react 的代码变得容易了许多。
React 十分的灵活, 但也有缺点,比如首次渲染大量DOM会慢,不支持双向绑定,以及需要造更多轮子等。
Hooks
在计算机科学中,函数副作用指当调用函数时,除了返回函数值之外,还对主调用函数产生附加的影响。例如修改全局变量(函数外的变量),修改参数或改变外部存储。
典型的副作用:
- 发送一个http请求
- new Date() / Math.random();
- console.log / IO
- DOM查询 (外部数据)
由于函数组件有着纯函数的特点,本身不负责数据存储和副作用处理。在 JavaScript 中,解决数据存储主要有以下几个方案。
- class 成员变量(类组件采用)
- 全局状态
- Dom
- localStorage 等本地存储方案
- 闭包(函数组件 hooks 采用)
useState 数据存储
从使用上看,useState 是返回一个 state 数据字段和一个更新 state 的 dispatch:
function Demo () {
const [count, setCount] = useState(0)
return <div onClick={() => { setCount(count++); }}>{count}<div>
}
根据闭包定义:
var useState = (initState) => {
let data = initState;
const dispatch = (newData) => {
data = newData;
}
return [data, dispatch];
}
如此在初始化阶段能很好的运行,但是每次更新渲染的时候重新调用函数组件而重新初始化,显然不能达到要求,所以需要一个数据结构对每次执行的 state 进行存储,同时还需要区分初始化和更新状态的不同执行方式,继续优化定义:
type Queue {
last: Update,
dispatch: any,
lastRenderedState: any
}
type Update {
action: any,
next: Update
}
type Hook {
memoizedState: any, // 上一次完整更新之后的最终状态值
queue: UpdateQueue<any, any> | null, // 更新队列
next: Hook // 下一个 hook
}
var useState = (initState) => {
// 生命周期: mounted
if (mounted) {
mountedState(initState);
}
// 生命周期:updated
if (updated) {
updatedState(initState);
}
}
function mountedState(initState) {
const hook = mountWorkInProgressHook();
hook.memorizedState = initState;
const queue = (hook.queue = {
last: null,
dispatch: null,
lastRenderedState: null
});
// 闭包绑定 queue,实现共享
const dispatch = dispatchAction.bind(null, queue);
queue.dispatch = dispatch;
return [hook.memorizedState, dispatch]
}
function dispatchAction(queue, action) {
// 使用数据结构存储所有的更新行为,以便在 rerender 流程中计算最新的状态值
const update = {
action,
next: null,
};
// 处理队列更新
let last = queue.last;
if (last === null) {
update.next = update;
} else {
// ... 更新循环链表
}
// 执行 fiber 的渲染
scheduleWork();
}
function updateState(initialState){
// 获取当前正在工作中的 hook
const hook = updateWorkInProgressHook();
// 根据 dispatchAction 中存储的更新行为计算出新的状态值,并返回给组件
(function doReducerWork(){
let newState = null;
do{
// 循环链表,执行每一次更新
}while(...)
hook.memoizedState = newState;
})();
return [hook.memoizedState, hook.queue.dispatch];
}
在调用 dispatch 更新的时候,并不是直接进行更新逻辑,而是将其存储,在 update 时统一的调度更新,根据执行的有序性,采用队列存储一个 hook 的多次调用。
需要在一个组件内使用不同Hook,因此需要记录所有使用的 Hook 信息。 可采用链表形式进行存储。
整个链表在 mounted 的时候构建,在 update 时按照顺序执行。因此不能在条件循环等场景下使用。
useEffect 副作用处理
在 mount 时创建一个 hook 对象,新建一个 effectQueue,以单向链表的方式存储每一个 effect,将 effectQueue 绑定在 fiberNode 上,并在完成渲染之后依次执行该队列中存储的 effect 函数。与 useState 不同的一点是,useEffect 拥有一个 deps 依赖数组。当依赖数组变更的时候,一个新的副作用函数会被追加至链尾。
type EffectQueue = {
lastEffect: Effect
}
type FiberNode = {
memoizedState: any, // 用来存放某个组件内所有的 Hook 状态
updateQueue: EffectQueue
}
type Effect = {
create: any;
destory: any;
deps: Array;
next: any;
}
function useEffect(fn, dependencies) {
if (mounted) {
mounteEffect(fn, dependencies)
}
if (updated) {
updateEffect(fn, dependencies)
}
}
function mountEffect(fn, deps) {
const hook = mountWorkInProgressHook();
hook.memorizedState = pushEffect(xxxTag, fn, deps)
}
function updateEffect(fn, deps) {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
// 依赖改变则触发销毁重置
if (currentHook !== null) {
const prevEffect = currentHook.memoizedState;
const destroy = prevEffect.destroy;
if (nextDeps!== null){
if(areHookInputsEqual(deps, prevEffect.deps)){
pushEffect(xxxTag, create, destroy, deps);
return;
}
}
}
hook.memoizedState = pushEffect(xxxTag, create, deps);
}
function pushEffect(tag, create, destroy, deps) {
const effect = {
create,
destory,
deps,
next: null
};
// 构建 effect 队列
const updateQueue = fiberNode.updateQueue = fiberNode.updateQueue || newUpdateQueue();
if (updateQueue.lastEffect) {
const firstEffect = lastEffect.next;
lastEffect.next = effect;
effect.next = firstEffect;
updateQueue.lastEffect = effect;
} else {
updateQueue.lastEffect = effect.next = effect;
}
return effect;
}
在 useEffect 阶段,实际并没有对 effect 进行执行,仅仅是构建一条 effect 执行存储链表,而真正 create 和 destroy 的执行,在于 commit 阶段。
SWR 的 hooks 设计
在传统的请求模型里,一个请求的完整流程是这样的:
- 用户点击,触发请求流程
- 设置相关视图的状态为 loading
- 发起请求
- 处理响应结果,关闭视图的 loading 状态
而现代 hooks 请求库有以下几个特点:
- Effect 状态封装:只需要关心 hooks 中暴露出的 data 和 error 以及 loading 状态;
-
cacheKey 机制:在需要重新拉取数据时,先读取缓存数据,再发起数据请求;
-
基于缓存的分页预加载:设置一个隐藏的 dom,请求当前页的下一页,达到秒开;
-
Refetching 机制:包括聚焦重新请求、定期重新请求和重连的重新请求;
-
mutate 机制:通过 mutate 优先更改本地数据,然后将本地数据发送到服务端,最后从服务端拉取“验证”结果
React 实践总结
取消请求
正在发起请求的组件如果从页面卸载,是需要取消请求的,利用 useEffect 的返回 和 fetch 的 AbortController API:
export function useFetch = (config, deps) => {
const abortController = new AbortController();
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<ResultType | null>(null);
useEffect(() => {
setLoading(true);
fetch({
...config,
signal: abortController.signal
})
.then((res) => setResult(res);)
.finally(() => setLoading(false););
}, deps)
useEffect(() => {
return () => abortController.abort();
}, [])
return { result, loading }
}
深比较依赖
对于 useEffect 对依赖 deps 的比较是浅比较(通过Object.is
),问题是,如果需要为这些依赖项之一提供一个对象,并且该对象在每次渲染时都是新的,那么即使没有任何属性发生更改,effect 仍然会被调用。
用社区提供的方案解决:useDeepCompareEffect
,它选用深比较策略。
以 URL 为数据仓库
分享功能和URL可以说是强相关,将页面状态更改自动同步到 URL的query形成数据仓库:
/**
* 封装 url query 和 页面状态
*/
export function useQuery() {
const history = useHistory();
const { search, pathname } = useLocation();
/** 保存query状态 */
const queryState = useRef(qs.parse(search));
/** 设置query */
const setQuery = handler => {
const nextQuery = handler(queryState.current);
queryState.current = nextQuery;
/** replace会使组件重新渲染 */
history.replace({
pathname: pathname,
search: qs.stringify(nextQuery),
});
};
return [queryState.current, setQuery];
}
/**
* how to use?
*/
const [query, setQuery] = useQuery();
/** 接口请求依赖 page 和 size */
useEffect(() => {
api.getUsers();
}, [query.page, query, size]);
useEffect(() => {
onPageChange(page);
}, [page]);
/** 分页改变时,触发接口重新请求 */
const onPageChange = page => {
setQuery(prevQuery => ({
...prevQuery,
page,
}));
};
AST 对 React 做国际化
国际化中的核心是替换代码中的文本,转为 i18n.t(key)
国际化方法调用,这一步可以交给 Babel AST 去完成。扫描出代码中需要替换文本的位置,修改 AST 把它转为方法调用即可,比如最重要的 traverse
部分:
// 遍历ast
traverse(ast, {
Program(path) {
// i18n的import导入 一般第一项一定是import React 所以直接插入在后面就可以
path.get("body.0").insertAfter(makeImportDeclaration(I18_HOOK, I18_LIB))
},
// 通过找到第一个jsxElement 来向上寻找Component函数并且插入i18n的hook函数
JSXElement(path) {
const functionParent = path.getFunctionParent()
const functionBody = functionParent.node.body.body
if (!this.hasInsertUseI18n) {
functionBody.unshift(
buildDestructFunction({
VALUE: t.identifier(I18_FUNC),
SOURCE: t.callExpression(t.identifier(I18_HOOK), []),
}),
)
this.hasInsertUseI18n = true
}
},
// jsx中的文字 直接替换成{t(key)}的形式
JSXText(path) {
const { node } = path
const i18nKey = findI18nKey(node.value)
if (i18nKey) {
node.value = `{${I18_FUNC}("${i18nKey}")}`
}
},
// Literal找到的可能是函数中调用参数的文字 也可能是jsx属性中的文字
Literal(path) {
const { node } = path
const i18nKey = findI18nKey(node.value)
if (i18nKey) {
if (path.parent.type === "JSXAttribute") {
path.replaceWith(
t.jsxExpressionContainer(makeCallExpression(I18_FUNC, i18nKey)),
)
} else {
if (t.isStringLiteral(node)) {
path.replaceWith(makeCallExpression(I18_FUNC, i18nKey))
}
}
}
},
})
React 动态渲染组件
对于长页面,为了更好的用户体验,需要考虑在滚动到下一屏时才渲染下一屏的组件。
const data = [...allData];
const [compList, setCompList] = useState([]); // 渲染的组件数据
const bottomDomRef = useRef<HTMLDivElement>(null);
/** 三种状态组件 */
/** 1. 子屏组件 */
<div>
{compList.map((item, index) => (
<div className="groups" key={index}>
// 根据不同的子屏区域渲染不同的子屏区域组件
{renderAreaConfig(item)}
</div>
))}
</div>
/** 2. loading 组件 */
<div ref={bottomDomRef} className='bottom-loading'>
<Icon name="loading" />
</div>
/** 3. completed 组件 */
<div className="bottom-completed">
<p>已经到底啦</p>
</div>
两个关注点:
1. 渲染下一屏组件的时机:当 loading 组件的位置滚动到视图中并且还有未渲染的组件时,调用 Intersection Observer API 进行判断 loading 组件是否滚动到视图中;将 n 个组件分割成每 x 个 1 组,对每组依次进行渲染,并用 compGroups 保存分割的组,同时使用 groupIdx 指针来指向下一个需要渲染的组序列;当 groupIdx 小于 groupCount,更新 compList 和 groupIdx 来渲染下一组;
2. 数据反复更新的过程中,避免重复发起数据请求;在对应的分组组件中,将组件用 React.memo 进行包裹,并对比它们的唯一标识 uuid,避免重复渲染。 React.memo——如果不传 areEqual 则对 props 进行浅比较。若传入,则需要返回具体的比较结果 true(不更新)& false(更新) ,这与 shouldComponentUpdate() 刚好相反—— true (更新)& false(不更新)。
import React, { memo } from 'react';
import { useInView } from 'react-intersection-observer';
const groupSize = 3;
const splitGroups = (data: any[], pageSize: number): any[] => {
const groupsTemp = [];
for (let i = 0; i < data.length; i += pageSize) {
groupsTemp.push(allDataList.slice(i, i + pageSize));
}
return groupsTemp;
};
/** 防抖 hook */
function useDebounce<T extends(...args: any[]) => any>(
func: T,
delay: number,
deps: DependencyList = [],
): [T, () => void] {
const timer = useRef<number>();
const cancel = useCallback(() => {
if (timer.current) {
clearTimeout(timer.current);
}
}, []);
const run = useCallback((...args) => {
cancel();
timer.current = window.setTimeout(() => {
func(...args);
}, delay);
}, deps);
return [run as T, cancel];
}
type GoodsRecommedProps = {
...,
goodsQuery:{
uuid: '...'
}
}
const isEqual = (prevProps: GoodsRecommedProps, nextProps: GoodsRecommedProps): boolean => {
if (prevProps.goodsQuery.uuid !== nextProps.goodsQuery.uuid) {
return false;
}
return true;
};
const GoodsRecommed: React.FC<GoodsRecommedProps> = (props) => {
const [bottomDomRef, bottomDomInView] = useInView({
threshold: 0,
});
const compGroups = useMemo(() => splitGroups(data, groupSize), [data]);
const groupCount = compGroups.length;
const [groupIdx, setGroupIdx] = useState(0);
const [scrollRenderHandler] = useDebounce(
() => {
if (inView && groupIdx < groupCount) {
setCompList(compList.concat(compGroups[groupIdx]));
setGroupIdx(groupIdx + 1);
}
}, 300, [compGroups, compList, groupIdx, inView],
);
useEffect(() => {
document.addEventListener('scroll', scrollRenderHandler);
return () => {
document.removeEventListener('scroll', scrollRenderHandler);
};
}, [scrollRenderHandler]);
return (
)
}
export default memo(GoodsRecommed, isEqual);