精读《React Conf 2019 - Day1》

6,300 阅读16分钟

1 引言

React Conf 2019 在今年 10 月份举办,内容质量还是一如既往的高,如果想进一步学习前端或者 React,这个大会一定不能错过。

希望前端精读成为你学习成长路上的布道者,所以本期精读就介绍 React Conf 2019 - Day1 的相关内容。

总的来看,React Conf 今年的内容视野更广了,不仅仅有技术内容,还有宣扬公益、拓展到移动端、后端,最后还有对 web 发展的总结与展望。

前端世界正变得越来越复杂,可以看到大家对未来都充满了希望,永不停歇的探索精神是这场大会的主旋律。

2 概述 & 精读

本期大会思想、设计上的内容较多,具体实现层内容较少,因为行业领导者需要引领规范,而真正技术价值在于思维模型与算法,理解了解题思路,实现它其实并不难。

开发者体验与用户体验

  • 开发者体验:DX(develop experience)
  • 用户体验:UX(user experience)

技术人解决的问题总是围绕 DX 与 UX,而一般来说,优化了 DX 往往会带来 UX 的提升,这是因为一个解决开发者体验的技术创新往往也会带来用户体验的升级,至少也能让开发者有更好的心情、更充足的时间做出好产品。

如何优化开发者体验呢?

易上手

React 确实致力于解决这个问题,因为 React 实际上是一个开发者桥梁,无论你开发 web、ios 还是单片机,都可以通过一套统一的语法去实现。React 是一个协议标准(读到 reactReconciler 章节会更有体感),React 像 HTML,但 React 不止能构建 HTML 应用,React 希望构建一切。

高效开发

React 解决调试、工具问题,让开发者更高效的完成工作,这也是开发者体验重要组成部分。

弹性

React 编写的程序拥有良好可维护性,包括数据驱动、模块化等等特征都是为了更好服务于不同规模的团队。

对于 UX 问题,React 也有 Concurrent mode、Suspense 等方案。

虽然 React 还不完美,但 React 致力于解决 DX 与 UX 的目标和效果都是我们有目共睹的,更好的 DX、UX 一定是前端技术未来发展的大趋势。

样式方案

Facebook 使用 css-in-js,而今年的 React conf 给出了一种技术方案,将 413 kb 的样式文件体积降低到 74kb!

一步步了解这个方案,从用法开始:

const styles = stylex.create({
  blue: { color: "blue" },
  red: { color: "red" }
});

function MyComponent(props) {
  return <span className={styles("blue", "red")}>I'm red now!</span>;
}

如上是这个方案的写法,通过 stylex.create 创建样式,通过 styles() 使用样式。

主题方案

如果使用 CSS 变量定义主题,那么换肤就可以由最外层 class 轻松决定了:

.old-school-theme {
  --link-text: blue;
}

.text-link {
  color: var(--link-text);
}

字体颜色具体的值由外层 class 决定,因此外层的 class 就可以控制所有子元素的样式:

<div class="old-school-theme">
  <a class="text-link" href="...">
    I'm blue!
  </a>
</div>

将其封装成 React 组件,也不需要用 context 等 JS 能力,而是包裹一层 class 即可。

function ThemeProvider({ children, theme }) {
  return <div className={themes[theme]}>{children}</div>;
}

图标方案

下面是设计师给出的 svg 代码:

<svg viewBox="0 0 100 100">
  <path d="M9 25C8 25 8..." />
</svg>

将其包装为 React 组件:

function SettingsIcon(props) {
  return (
    <SVGIcon viewBox="0 0 100 100" {...props}>
      <path d="M9 25C8 25 8..." />
    </SVGIcon>
  );
}

结合上面提到的主题方案,就可以控制 svg 的主题颜色。

const styles = stylex.create({
  primary: { fill: "var(--primary-icon)" },
  gighlight: { fill: "var(--highlight-icon)" }
});

function SVGIcon(color, ...props) {
  return (
    <svg>
      {...props}
      className={styles({
        primary: color === "primary",
        highlight: color === "highlight"
      })}
      {children}
    </svg>
  );
}

减少样式大小的秘密

const styles = stylex.create({
  blue: { color: "blue" },
  default: { color: "red", fontSize: 16 }
});

function MyComponent(props) {
  return <span className={styles("default", props.isBlue && "blue")} />;
}

对于上述样式文件代码,最终会编译成 c1c2c3 三个 class

.c1 {
  color: blue;
}
.c2 {
  color: red;
}
.c3 {
  font-size: 16px;
}

出乎意料的是,并没有根据 bluedefault 生成对应的 class,而是根据实际样式值生成 class,这样做有什么好处呢?

首先是加载顺序,class 生效的顺序与加载顺序有关,而按照样式值生成的 class 可以精确控制样式加载顺序,使其与书写顺序对应:

// 效果可能是 blue 而不是 red
<div className="blue red" />

// 效果一定是 red,因为 css-in-js 在最终编排 class 时,虽然两种样式都存在,但书写顺序导致最后一个优先级最高,
// 合并的时候就会舍弃失效的那个 class
<div className={styles('blue', 'red')} />

这么做永远不会出现头疼的样式覆盖问题。

更重要的是,随着样式文件的增多,class 总量会减少。这是因为新增的 class 涵盖的属性可能已经被其他 class 写到并生成了,此时会直接复用对应属性生成的 class 而不会生成新的:

<Component1 className=".class1"/>
<Component2 className=".class2"/>
.class1 {
  background-color: mediumseagreen;
  cursor: default;
  margin-left: 0px;
}
.class2 {
  background-color: thistle;
  cursor: default;
  justify-self: flex-start;
  margin-left: 0px;
}

正如这个 Demo 所示,正常情况的 class1class2 存在许多重复定义的属性,但换成 css-in-js 的方案,编译后的效果等价于将 class 复用并拆解了:

<Component1 classNames=".classA .classC .classD">

<Component2 classNames=".classA .classN .classD .classE">
.classA {
  cursor: default;
}
.classB {
  background-color: mediumseagreen;
}
.classC {
  background-color: thistle;
}
.classD {
  margin-left: 0px;
}
.classE {
  justify-self: flex-start;
}

这种方式不仅节省空间、还能自动计算样式优先级避免冲突,并将 413 kb 的样式文件体积降低到 74kb。

字体大小方案

rem 的好处是相对的字体大小,使用 rem 作为单位可以很方便实现网页字体大小的切换。

但问题是现在工业设计都习惯了以 px 作为单位,所以一种全新的编译方案产生了:在编译阶段将 px 自动转换成 rem

这等于让以 px 为单位的字体大小可以跟随根节点字体大小随意缩放。

代码检测

静态检测类型错误、拼写错误、浏览器兼容问题。

在线检测 dom 节点元素问题,比如是否有可访问性,比如替代文案 aria-label。

提升加载速度

普通网页的加载流程是这样的:

先加载代码,然后会渲染页面,在渲染的同时发取数请求,等取数完成后才能渲染出真实数据。

那么如何改善这个情况呢?首先是预取数,提前解析出请求并在脚本加载的同时取数,可以节省大量时间:

那么下载的代码可以再拆分吗?注意到并不是所有代码都作用于 UI 渲染,我们可以将模块分为 ImportForDisplayimportForAfterDisplay

这样就可以优先加载与 UI 相关的代码,其余逻辑代码在页面展示出之后再加载:

这样可以实现源码分段加载,并分段渲染:

对取数来说也是如此,并不是所有取数都是初始化渲染阶段必须用上的。可以通过 relay 的特性 @defer 标记出可以延迟加载的数据:

fragment ProfileData on User {
  classNameprofile_picture { ... }

  ...AdditionalData @defer
}

这下取数也可以分段了,首屏的数据会优先加载:

利用 relay 还可以以数据驱动方式结合代码拆分:

... on Post {
  ... on PhotoPost {
    @module('PhotoComponent.js')
    photo_data
  }

  ... on VideoPost {
    @module('VideoComponent.js')
    video_data
  }

  ... on SongPost {
    @module('SongComponent.js')
    song_data
  }
}

这样首屏数据中也只会按需加载用到的部分,请求时间可以再次缩短:

可以看到,与 relay 结合可以进一步优化加载性能。

加载体验

可以 React.SuspenseReact.lazy 动态加载组件。通过 fallback 指定元素的占位图可以提升加载体验:

<React.Suspense fallback={<MyPlaceholder />}>
  <Post>
    <Header />
    <Body />
    <Reactions />
    <Comments />
  </Post>
</React.Suspense>

Suspense 可以被嵌套,资源会按嵌套顺序加载,保证一个自然的视觉连贯性。

智能文档

通过解析 Markdown 自动生成文档大家已经很熟悉了,也有很多现成的工具可以用,但这次分享的文档系统有意思之处在于,可以动态修改源码并实时生效。

不仅如此,还利用了 Typescript + MonacoEditor 在网页上做语法检测与 API 自动提示,这种文档体验上升了一个档次。

虽然没有透露技术实现细节,但从热更新的操作来看像是把编译工作放在了浏览器 web worker 中,如果是这种实现方式,原理与 CodeSandbox 实现原理 类似。

GraphQL and Stuff

这一段在安利利用接口自动生成 Typescript 代码提升前后端联调效率的工具,比如 go2dts。

我们团队也开源了基于 swagger 的 Typescript 接口自动生成工具 pont,欢迎使用。

React Reconciler

这是知识密度最大的一节,介绍了如何使用 React Reconclier。

React Reconclier 可以创建基于任何平台的 React 渲染器,也可以理解为通过 React Reconclier 可以创建自定义的 ReactDOM。

比如下面的例子,我们尝试用自定义函数 ReactDOMMini 渲染 React 组件:

import React from "react";
import logo from "./logo.svg";
import ReactDOMMini from "./react-dom-mini";
import "./App.css";

function App() {
  const [showLogo, setShowLogo] = React.useState(true);

  let [color, setColor] = React.useState("red");
  React.useEffect(() => {
    let colors = ["red", "green", "blue"];
    let i = 0;
    let interval = setInterval(() => {
      i++;
      setColor(colors[i % 3]);
    }, 1000);

    return () => clearInterval(interval);
  });

  return (
    <div
      className="App"
      onClick={() => {
        setShowLogo(show => !show);
      }}
    >
      <header className="App-header">
        {showLogo && <img src={logo} className="App-logo" alt="logo /" />}
        // 自创语法
        <p bgColor={color}>
          Edit <code>src/App.js</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React{" "}
        </a>
      </header>
    </div>
  );
}

ReactDOMMini.render(<App />, codument.getElementById("root"));

ReactDOMMini 是利用 ReactReconciler 生成的自定义组件渲染函数,下面是完整的代码:

import ReactReconciler from "react-reconciler";

const reconciler = ReactReconciler({
  createInstance(
    type,
    props,
    rootContainerInstance,
    hostContext,
    internalInstanceHandle
  ) {
    const el = document.createElement(type);

    ["alt", "className", "href", "rel", "src", "target"].forEach(key => {
      if (props[key]) {
        el[key] = props[key];
      }
    });

    // React 事件代理
    if (props.onClick) {
      el.addEventListener("click", props.onClick);
    }

    // 自创 api bgColor
    if (props.bgColor) {
      el.style.backgroundColor = props.bgColor;
    }

    return el;
  },

  createTextInstance(
    text,
    rootContainerInstance,
    hostContext,
    internalInstanceHandle
  ) {
    return document.createTextNode(text);
  },

  appendChildToContainer(container, child) {
    container.appendChild(child);
  },
  appendChild(parent, child) {
    parent.appendChild(child);
  },
  appendInitialChild(parent, child) {
    parent.appendChild(child);
  },

  removeChildFromContainer(container, child) {
    container.removeChild(child);
  },
  removeChild(parent, child) {
    parent.removeChild(child);
  },
  insertInContainerBefore(container, child, before) {
    container.insertBefore(child, before);
  },
  insertBefore(parent, child, before) {
    parent.insertBefore(child, before);
  },

  prepareUpdate(
    instance,
    type,
    oldProps,
    newProps,
    rootContainerInstance,
    currentHostContext
  ) {
    let payload;
    if (oldProps.bgColor !== newProps.bgColor) {
      payload = { newBgCOlor: newProps.bgColor };
    }
    return payload;
  },
  commitUpdate(
    instance,
    updatePayload,
    type,
    oldProps,
    newProps,
    finishedWork
  ) {
    if (updatePayload.newBgColor) {
      instance.style.backgroundColor = updatePayload.newBgColor;
    }
  }
});

const ReactDOMMini = {
  render(wahtToRender, div) {
    const container = reconciler.createContainer(div, false, false);
    reconciler.updateContainer(whatToRender, container, null, null);
  }
};

export default ReactDOMMini;

笔者拆解一下说明:

React 之所以具备跨平台特性,是因为其渲染函数 ReactReconciler 只关心如何组织组件与组件间关系,而不关心具体实现,所以会暴露出一系列回调函数。

创建实例

由于 React 组件本质是一个描述,即 tag + 属性,所以 Reconciler 不关心元素是如何创建的,需要通过 createInstance 拿到组件基本属性,在 Web 平台利用 DOM API 实现:

createInstance(
    type,
    props,
    rootContainerInstance,
    hostContext,
    internalInstanceHandle
  ) {
    const el = document.createElement(type);

    ["alt", "className", "href", "rel", "src", "target"].forEach(key => {
      if (props[key]) {
        el[key] = props[key];
      }
    });

    // React 事件代理
    if (props.onClick) {
      el.addEventListener("click", props.onClick);
    }

    // 自创 api bgColor
    if (props.bgColor) {
      el.style.backgroundColor = props.bgColor;
    }

    return el;
  }

之所以说 React 对 DOM 事件都做了一层代理,是因为 JSX 的所有函数都没有真正透传给 DOM,而是通过类似 el.addEventListener("click", props.onClick) 的方式代理实现的。

而自定义这个函数,我们甚至能创建例如 bgColor 这种特殊语法,只要解析引擎实现了这个语法的 Handler。

除此之外,还有 创建、删除实例 的回调函数,我们都要利用 DOM 平台的 API 重新实现一遍,这样不仅可以实现对浏览器 API 的兼容,还可以对接到比如 react-native 等非 WEB 平台。

更新组件

实现了 prepareUpdatecommitUpdate 才能完成组件更新。

prepareUpdate 返回的 payloadcommitUpdate 函数接收到,并根据接收到的信息决定如何更新实例节点。这个实例节点就是 createInstance 回调函数返回的对象,所以如果在 WEB 环境返回的 instance 就是 DOMInstance,后续所有操作都使用 DOMAPI。

总结一下:react 主要用平台无关的语法生成具有业务含义的 AST,而利用 react-reconciler 生成的渲染函数可以解析这个 AST,并提供了一系列回调函数实现完整的 UI 渲染功能,react-dom 现在也是基于 react-reconciler 写的。

图标体积优化

Facebook 团队通过优化,将图标大小从 4046.05KB 降低到了 132.95kb,体积减少了惊人的 96.7%,减少体积占总包体积的 19.6%!

实现方式很简单,下面是原始图标使用的代码:

<FontAwesomeIcon icon="coffee" />
<Icon icon={["fab", "twitter"]} />
<Button leftIcon="user" />
<FeatureGroup.Item icon="info" />
<FeatureGroup.Item icon={["fail", "info"]} />

在编译期间通过 AST 分析,将所有字符串引用换成了图标实例的引用,利用 webpack 的 tree-shaking 功能实现按需加载,从而删除了没有使用到的图标。

import {faCoffee,faInfo,faUser} from "@fontawesome/free-solid-svg-icons"
import {faTwitter} from '@fontawesome/free-brands-svg-icons'
import {faInfo as faInfoFal} from '@fontawesome/pro-light-svg-icons'

<FontAwesomeIcon icon={faCoffee} />
<Icon icon={faTwitter} />
<Button leftIcon={faUser} />
<FeatureGroup.Item icon={faInfo} />
<FeatureGroup.Item icon={faInfoFal} />

替换工具 的链接放出来了,感兴趣的同学可以点进去了解更多。

这也从某种意义上说明了 iconFont 注定被淘汰,因为字体文件目前无法按需加载,只有全部使用 SVG 图标的项目才能使用这种优化。

Git & Github

这一节介绍了基本 Git 知识以及 Github 用法,笔者略过比较水的部分,直接列出两个可能你不知道的点:

干预 Github 项目主要语言检测

如果你提交的代码包含许多自动生成的文件,可能你实际使用的语言不会被 Github 解析为主要语言,这时候可以通过 .gitattributes 文件忽略指定文件夹的检测:

static/* linguist-vendored

这样语言文件占比统计就会忽略 static/ 文件夹。

Git hooks 的技巧

以下是几个比较具有启发的点,我们可以利用 Git hooks 做点什么:

  • 阻止提交到 master。
  • 在 commit 之前执行 prettier/eslint/jest 检测。
  • 检测代码规范、合并冲突、检测是否有大文件。
  • commit 成功后给出提示或记录到日志。

但 Git hooks 仍然有局限性:

  • 容易被绕过:--no-verifuy --no-merge --no-checkout ---force。
  • 本地 hooks 无法提交,导致项目开发规则可能不尽相同。
  • 无法替代 CI、服务端分支保护、Code Review。

可以畅想一下,在 WebIDE 环境可以通过自定义 git 命令禁止检测绕过,自然解决第二条环境不一致的问题。

GraphQL + Typescript

GraphQL 是没有类型支持的,如果要手动创建一遍类型文件是非常痛苦的:

interface GetArticleData {
  getArticle: {
    id: number;
    title: string;
  };
}

const query = graphql(gql`
  query getArticle {
    article {
      id
      title
    }
  }
`);

apolloClient.query<GetArticleData>(query);

同样的代码分散在两处维护一定会带来问题,我们可以利用比如 typed-graphqlify 这种库解决类型问题:

import { params, types, query } from "typed-graphqlify";

const getArticleQuery = {
  article: params({
    id: types.number,
    title: types.string
  })
};

const gqlString = query("getUser", getUserQuery);

只要一遍定义就可以自动生成 GQLString,并且拿到 Typescript 类型。

React 文档国际化

即便是谷歌翻译也不是很靠谱,国际化文档还是要靠人肉,Nat Alison 利用 Github 充分发动各国人民的力量,共同打造了一个个 reactjs group 下的国际化仓库。

国际化仓库命名规则是 reactjs/xx.reactjs.org,比如简体中文的国际化仓库是:github.com/reactjs/zh-…

从仓库的 readme 可以看到维护规则是这样的:

  • 请 fork 这个仓库。
  • 基于 fork 后的仓库中 master 分支拉取一个新的分支(名字自取)。
  • 翻译(校对)你所选择的文章,提交到新的分支。
  • 此时提交 Pull Request 到该仓库。
  • 会有专人 Review 该 Pull Request,当两人以上通过该 Pull Request 时,你的翻译将被合并到仓库中。
  • 删除你所创建的分支(如继续参与,参考同步流程)。

之后定期从 React 官方文档项目拉取最新代码即可保持文档的同步更新。

你需要 redux 吗?

关于数据流的话题目前没有什么新意,但这次 React Conf 关于数据流总结的算是比较真诚的,总结了以下几个点:

  1. 全局数据流现在不是必须的,比如 Redux,但也不能说完全不能用,至少在全局状态较为复杂时有必要使用。
  2. 不要只使用一种数据流方案,根据状态的作用域确定方案比较好。
  3. 工程技术与科学不同,工程世界没有最好的方案,只有更好的方案。
  4. 就算有了完美方案也不要停止学习的步伐,总会有新知识产生。

web 历史

很精彩的演讲,不过新鲜内容并不多,比较有感触一点是:以前的网页地址对应到的是服务器磁盘的某个具体文件,比如早期 php 应用,现在后端不再是文件化而是服务化了,这层抽象让服务端摆脱了对文件结构的依赖,可以构建更多复杂动态逻辑,也支持了前后端分离的技术方案。

3 总结

这届 React Conf 让我们看到前端更多的可能性,我们不仅要关注技术实现细节,更要关注行业标准以及团队愿景。

React 团队的愿景是让 React 包罗万象,提升全球开发者的开发体验、提升全球产品的用户体验,基于这个目标,React Conf 自然不能只包含 DOM Diff、Reconciler 等等技术细节,更需要展示 React 如何帮助全球开发者,如何让这些开发者帮助到用户,如何推动行业标准的演进,如何让 React 打破国界、语言的壁垒。

相比其他前端大会非常多的干货来说,React Conf 虽然显得主题比较杂,但这正是人文情怀的体现,我相信只有带着更高的使命愿景,真诚帮助他人的技术团队才可以走得更远。

讨论地址是:精读《React Conf 2019 - Day1》 · Issue #214 · dt-fe/weekly

如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。

关注 前端精读微信公众号

版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证