深入浅出 React

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,服务端渲染流程为:

  1. 获取数据(这里是on=true)
  2. 引入要渲染的React组件(Switching)
  3. 调用React.renderToString()方法来生成HTML
  4. 最后同时发送HTML和JSON数据给浏览器

需要注意的是这里的JSON字符串中可能出现结尾标签或HTML注释,可能会导致语法错误,这里需要进行转义。

然后:

  1. 将前面在action里生成的HTML写到#container元素里(服务端运行)
  2. 将服务端的JSON数据输出到全局变量gDataForClient中(服务端运行)
  3. 引入必须的JS文件(服务端运行)
  4. 获取全局变量 gDataForClient(客户端运行)
  5. 尝试重新渲染组件(客户端运行)

如此,组件的代码前后端都可以复用。

原生渲染

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);
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

薛定谔的猫96

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值