diff --git a/README-zh_CN.md b/README-zh_CN.md new file mode 100644 index 0000000..26fa4ef --- /dev/null +++ b/README-zh_CN.md @@ -0,0 +1,84 @@ +# 创建一个自定义 React 渲染器 + +[![Build Status](https://travis-ci.org/nitin42/Making-a-custom-React-renderer.svg?branch=master)](https://travis-ci.org/nitin42/Making-a-custom-React-renderer) + +[English](./README.md) | 简体中文 + +> 让我们创建一个自定义的 React 渲染器 😎 + +

+ +

+ +## 介绍 + +这是一个关于如何创建一个自定义 React 渲染器并将所有内容渲染到你的目标宿主环境中的教程。教程分为三部分 —— + +* **第一部分** - 创建一个 React 调度器(使用 [`react-reconciler`](https://github.com/facebook/react/tree/master/packages/react-reconciler) 包)。 + +* **第二部分** - 我们将创建一个用于调度器的公开方法。 + +* **第三部分** - 创建渲染方法来将所有创建的组件实例渲染到我们的目标宿主环境中。 + +## 概要 + +### [第一部分](./part-one-zh_CN.md) + +在第一部分,我们将使用 [`react-reconciler`](https://github.com/facebook/react/tree/master/packages/react-reconciler) 创建一个 React 调度器。我们将使用 Fiber 实现渲染器,因为它拥有优秀的用于创建自定义渲染器的 API。 + +### [第二部分](./part-two-zh_CN.md) + +在第二部分,我们将创建一个用于调度器的公开方法。我们将创建一个自定义 `createElement` 函数,还将为我们的示例构建组件 API。 + +### [第三部分](./part-three-zh_CN.md) + +在第三部分,我们将创建渲染方法,用于渲染我们创建的组件实例。 + +## 我们将创建什么? + +我们将创建一个自定义渲染器,将 React 组件渲染到 word 文档中。我已经做了一个。完整的源代码和文档在[这里](https://github.com/nitin42/redocx)。 + +我们将使用 [officegen](https://github.com/Ziv-Barber/officegen)。我将在这里解释一些基本概念。 + +Officegen 可以为 Microsoft Office 2007 及更高版本生成 Open Office XML 文件。它生成一个输出流而不是文件。它独立于任何输出工具。 + +**创建一个文档对象** + +```js +let doc = officegen('docx', { __someOptions__ }); +``` + +**生成输出流** + +```js +let output = fs.createWriteStream (__filePath__); + +doc.generate(output); +``` + +**事件** + +`finalize` - 在流生成成功之后触发。 + +`error` - 在发生异常时触发。 + +## 运行这个项目 + +```bash +git clone https://github.com/nitin42/Making-a-custom-React-renderer +cd Making-a-custom-React-renderer +yarn install +yarn example +``` + +运行 `yarn example` 后,会在 [demo](./demo) 文件夹下生成一个 docx 文件。 + +## 贡献 + +欢迎提出改进教程的建议😃。 + +**如果您已成功完成本教程,您可以 watch 或 star 此代码库或在 [twitter](https://twitter.com/NTulswani) 上关注我以获取最新的消息。** + + + Sponsor + diff --git a/README.md b/README.md index 48adb47..d0b7d56 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ [![Build Status](https://travis-ci.org/nitin42/Making-a-custom-React-renderer.svg?branch=master)](https://travis-ci.org/nitin42/Making-a-custom-React-renderer) +English | [简体中文](./README-zh_CN.md) + > Let's make a custom React renderer 😎

diff --git a/part-one-zh_CN.md b/part-one-zh_CN.md new file mode 100644 index 0000000..adfb739 --- /dev/null +++ b/part-one-zh_CN.md @@ -0,0 +1,281 @@ +# 第一部分 + +这是教程的第一部分,也是教程最重要的部分,你将在其中了解 Fiber 及其结构与属性的详细说明,为你的渲染器创建宿主环境配置并将渲染器注入开发工具中。 + +在这个部分,我们将使用 [`react-reconciler`](https://github.com/facebook/react/tree/master/packages/react-reconciler) 包创建一个 React 调度器。我们将使用 Fiber 实现渲染器。起初,React 使用 **栈渲染器**,它是在 JavaScript 的调用栈上实现。另一方面,Fiber 受代数效应和函数式编程思维的影响。它可以被认为是一个包含组件输入和输出的信息的 JavaScript 对象。 + +在我们继续之前,我建议你阅读由 [Andrew Clark](https://twitter.com/acdlite?lang=en) 写的 [这篇](https://github.com/acdlite/react-fiber-architecture) 文章。这会让你更容易理解本教程。 + +让我们开始! + +我们首先安装这些依赖。 + +```bash +npm install react-reconciler fbjs +``` + +让我们从 `react-reconciler` 引入 `Reconciler`,接着引入其它模块。 + +```js +import Reconciler from 'react-reconciler'; +import emptyObject from 'fbjs/lib/emptyObject'; + +import { createElement } from './utils/createElement'; +``` + +注意我们还引入了 `createElement` 函数。别担心,我们稍后会实现它。 + +我们传入 **host config** 对象到 `Reconciler` 中来创建一个它的实例。在这个对象中,我们将定义一些可以认为是渲染器生命周期的方法(更新、添加子组件、删除子组件、提交)。 React 将管理所有非宿主组件。 + +```js +const WordRenderer = Reconciler({ + appendInitialChild(parentInstance, child) { + if (parentInstance.appendChild) { + parentInstance.appendChild(child); + } else { + parentInstance.document = child; + } + }, + + createInstance(type, props, internalInstanceHandle) { + return createElement(type, props, internalInstanceHandle); + }, + + createTextInstance(text, rootContainerInstance, internalInstanceHandle) { + return text; + }, + + finalizeInitialChildren(wordElement, type, props) { + return false; + }, + + getPublicInstance(inst) { + return inst; + }, + + prepareForCommit() { + // noop + }, + + prepareUpdate(wordElement, type, oldProps, newProps) { + return true; + }, + + resetAfterCommit() { + // noop + }, + + resetTextContent(wordElement) { + // noop + }, + + getRootHostContext(rootInstance) { + // 你可以使用此 'rootInstance' 从根节点传递数据。 + }, + + getChildHostContext() { + return emptyObject; + }, + + shouldSetTextContent(type, props) { + return false; + }, + + now: () => {} + + supportsMutation: false +}); +``` + +让我们分析一下我们的宿主配置 - + +**`createInstance`** + +这个方法根据 `type`、`props` 和 `internalInstanceHandle` 创建组件实例。 + +例子 - 假设我们渲染如下内容, + +```js +Hello World +``` + +`createInstance` 方法中将获得元素的 `type` (' TEXT ')、`props` ( { children: 'Hello World' } )和根元素的实例(`WordDocument`)信息。 + +**Fiber** + +Fiber 是组件需要或已经完成的工作。最多,一个组件实例有两个 Fiber,已完成的 Fiber 和进行中的 Fiber。 + +`internalInstanceHandle` 包含的信息有 `tag`、`type`、`key`、`stateNode` 和工作完成后会退回去的 Fiber。这个对象(Fiber)包含的信息有 - + +* **`tag`** - Fiber 的类型。 +* **`key`** - 子节点的唯一标识。 +* **`type`** - 该 Fiber 关联的方法、类或模块。 +* **`stateNode`** - 该 Fiber 关联的本地状态。 + +* **`return`** - 当前 Fiber 执行完毕后将要回退回去的 Fiber(父 Fiber)。 +* **`child`** - `child`、`sibling` 和 `index` 表达 **单向列表数据结构**。 +* **`sibling`** +* **`index`** +* **`ref`** - ref 最终用于关联这个节点。 + +* **`pendingProps`** - 该属性用于标签发生重载时。 +* **`memoizedProps`** - 该属性用于创建输出。 +* **`updateQueue`** - 状态更新和回调的队列。 +* **`memoizedState`** - 该状态用于创建输出。 + +* **`subtreeFlags`** - 位标志。Fiber 使用位标志来保存有关 Fiber 及其子树的一些表示操作状态或某些中间状态。 +* **`nextEffect`** - 在单向链表中快速到下一个具有副作用的 Fiber。 +* **`firstEffect`** - 子树中有副作用的第一个(firstEffect)和最后一个(lastEffect)Fiber。重用此 Fiber 中已经完成的工作。 + +* **`expirationTime`** - 该属性表示工作在未来应该已经完成的时间点。 +* **`alternate`** - Fiber 的历史版本,包含随时可用的 Fiber,而非使用时分配。在计算机图形学中,这个概念被抽象为 **双缓冲** 模式。它使用更多内存,但我们可以清理它。 + +* `pendingWorkPriority` +* `progressedPriority` +* `progressedChild` +* `progressedFirstDeletion` +* `progressedLastDeletion` + +**`appendInitialChild`** + +它用于添加子节点。如果子节点包含在父节点中(例如:`Document`),那么我们将添加所有子节点到父节点中,否则我们将在父节点上创建一个名为 `document` 的属性后添加所有子节点。 + +例子 - + +```js +const data = document.render(); // 返回输出 +``` + +**`prepareUpdate`** + +它计算实例的差异。即使 Fiber 暂停或中止对树的部分渲染,也可以重用这项工作。 + +**`commitUpdate`** + +提交更新或将计算的差异应用到宿主环境的节点 (WordDocument)。 + +**`commitMount`** + +渲染器挂载宿主节点,但可能会在表单自动获得焦点之后进行一些工作。仅当没有当前或备用(alternate) Fiber 时才挂载宿主组件。 + +**`hostContext`** + +宿主上下文是一个内部对象,我们的渲染器可以根据树中的位置使用它。在 DOM 中,这个对象需要被正确调用,例如根据当前上下文在 html 或 MathMl 中创建元素。 + +**`getPublicInstance`** + +这用来关联标识,这意味着它始终返回用作其参数的相同值。它是为 TestRenderers 添加的。 + +**`resetTextContent`** + +在进行任何插入(将宿主节点插入到父节点)之前重置父节点的文本内容。这类似于 OpenGl 中的双缓冲技术,在向其写入新像素并执行光栅化之前先清除缓冲区。 + +**`commitTextUpdate`** + +与 `commitUpdate` 类似,但它为文本节点提交更新内容。 + +**`removeChild and removeChildFromContainer`** + +从树中移除节点。如果返回的 Fiber 是容器,那么我们使用 `removeChildFromContainer` 从容器中删除节点,否则我们使用 `removeChild`。 + +**`insertBefore`** + +在目标节点之前插入一个子节点。 + +**`appendChildToContainer`** + +如果 Fiber 的类型是 `HostRoot` 或 `HostPortal`,则将子节点添加到该容器中。 + +**`appendChild`** + +向目标节点添加子节点。 + +**`shouldSetTextContent`** + +如果它返回 false,重置文本内容。 + +**`getHostContext`** + +用于标记当前的宿主上下文(根元素实例),发送更新的内容,从而更新正在进行中的 Fiber 队列(可能表示存在变化)。 + +**`createTextInstance`** + +创建文本节点的实例。 + +**`supportsMutation`** + +`true` 为 **可变渲染器** 模式,其中宿主目标需要具有像 DOM 中的 `appendChild` 这样的可变 API。 + +### 注意 + +* 你 **不应该** 依赖 Fiber 的数据结构。将其属性视为私有。 +* 将 'internalInstanceHandle' 对象视为黑盒。 +* 使用宿主上下文方法从根节点获取数据。 + +> 在与 [Dan Abramov](https://twitter.com/dan_abramov) 讨论宿主配置方法和 Fiber 属性后,将上述要点添加到教程中。 + +## 将第三方渲染器注入开发工具中 + +你还可以将渲染器注入 react-devtools 以调试你环境中的宿主组件。早期,是无法适配第三方渲染器的,但现在可以使用返回的 `reconciler` 实例,将渲染器注入到 react-devtools。 + +**使用** + +安装 react-devtools 的独立应用程序。 + +```bash +yarn add --dev react-devtools +``` + +Run + +```bash +yarn react-devtools +``` + +或者使用 npm, + +```bash +npm install --save-dev react-devtools +``` + +然后运行它 + +```bash +npx react-devtools +``` + +```js +const Reconciler = require('react-reconciler'); + +let hostConfig = { + // 根据上面的解释在这里添加方法 +}; + +const CustomRenderer = Reconciler(hostConfig); + +module.exports = CustomRenderer; +``` + +然后在你的 `render` 方法中, + +```js +const CustomRenderer = require('./reconciler') + +function render(element, target, callback) { + ... // 在这里,使用 CustomRenderer.updateContainer() 进行从最顶层开始的更新,有关更多详细信息,请参阅 Part-IV。 + CustomRenderer.injectIntoDevTools({ + bundleType: 1, // 0 for PROD, 1 for DEV + version: '0.1.0', // version for your renderer + rendererPackageName: 'custom-renderer', // package name + findHostInstanceByFiber: CustomRenderer.findHostInstance // host instance (root) + })) +} +``` + +我们完成了教程的第一部分。我知道其中的某些概念仅通过阅读代码是很难理解的。开始的时候感觉很混沌,但请继续尝试,最终它会逐渐变得清晰。刚开始学习 Fiber 架构的时候,我什么都不懂。我感到极为沮丧,但我在上述代码的每一部分都使用了 `console.log()` 并试图理解它们,然后在某一时刻“芜湖起飞”,最终帮助我构建了 [redocx](https://github.com/nitin42/redocx)。它是有点难理解,但你终将会搞懂。 + +如果你仍然有任何疑问,我在 Twitter 上 [@NTulswani](https://twitter.com/NTulswani)。 + +[更多渲染器的实际示例](https://github.com/facebook/react/tree/master/packages/react-reconciler#practical-examples) + +[继续第二部分](./part-two-zh_CN.md) diff --git a/part-three-zh_CN.md b/part-three-zh_CN.md new file mode 100644 index 0000000..9a21863 --- /dev/null +++ b/part-three-zh_CN.md @@ -0,0 +1,72 @@ +# 第三部分 + +这是我们教程的最后一部分。我们已经完成了所有繁重的工作,创建了一个 React 调度器,为我们的调度器创建了一个公开方法,设计了组件的 API。 + +现在我们只需要创建一个 `render` 方法来将所有内容渲染到宿主环境中。 + +## render + +```js + +import fs from 'fs'; +import { createElement } from '../utils/createElement'; +import { WordRenderer } from './renderer'; + +// 渲染组件 +async function render(element, filePath) { + const container = createElement('ROOT'); + + const node = WordRenderer.createContainer(container); + + WordRenderer.updateContainer(element, node, null); + + const stream = fs.createWriteStream(filePath); + + await new Promise((resolve, reject) => { + container.doc.generate(stream, Events(filePath, resolve, reject)); + }); +} + +function Events(filePath, resolve, reject) { + return { + finalize: () => { + console.log(`✨ Word document created at ${path.resolve(filePath)}.`); + resolve(); + }, + error: () => { + console.log('An error occurred while generating the document.'); + reject(); + }, + }; +} + +export default render; + +``` + +让我们看看这里发生了什么! + +**`container`** + +这是根元素实例(还记得我们调度器中的 `rootContainerInstance` 吗?)。 + +**`WordRenderer.createContainer`** + +这个方法接受一个 `root` 容器并返回当前的 Fiber(已完成的 Fiber)。记住 Fiber 是一个包含相关组件输入和输出信息的 JavaScript 对象。 + +**`WordRenderer.updateContainer`** + +这个函数接收元素、根容器、父组件、回调函数并触发一次从最顶层开始的更新。 +这是根据当前 Fiber 和优先级(取决于上下文)来调度更新实现的。 + +最后,我们渲染所有子节点并通过创建写入流来生成 word 文档。 + +仍有疑惑?查看 [常见问题](./faq.md)。 + +恭喜!你已成功完成本教程。本教程的完整源代码在此代码库 ([src](./src)) 中提供。如果你想阅读整个源代码,请按照以下顺序 - + +[`reconciler`](./src/reconciler/index.js) => [`components`](./src/components/) => [`createElement`](./src/utils/createElement.js) => [`render method`](./src/render/index.js) + +如果你喜欢阅读本教程,请 watch 或 star 这个代码库,并在 [Twitter](http://twitter.com/NTulswani) 上关注我以获取最新的消息。 + +感谢你阅读本教程! diff --git a/part-two-zh_CN.md b/part-two-zh_CN.md new file mode 100644 index 0000000..62efeaa --- /dev/null +++ b/part-two-zh_CN.md @@ -0,0 +1,169 @@ +# 第二部分 + +在上一节中,我们创建了一个 React 调度器,并了解了它如何管理渲染器的生命周期。 + +在第二部分,我们将创建一个用于调度器的公开方法。我们将设计我们组件的 API,然后将创建一个自定义版本的 `createElement` 方法。 + +## 组件 + +对于我们的例子,将只实现一个 `Text` 组件。`Text` 组件用于向文档添加文本。 + +> Text 组件并不创建特殊的文本节点。与 DOM API 相比,它具有不同的语义。 + +我们将首先为我们的组件创建一个根容器(还记得调度器中的 `rootContainerInstance` 吗?)它负责用 [officegen](https://github.com/Ziv-Barber/officegen) 创建一个文档实例。 + +**`WordDocument.js`** + +```js +import officegen from 'officegen' + +// 用于创建文档实例 +class WordDocument { + constructor() { + this.doc = officegen('docx') + } +} + +export default WordDocument +``` + +现在,让我们创建我们的 `Text` 组件。 + +**`Text.js`** + +```js +class Text { + constructor(root, props) { + this.root = root; + this.props = props; + + this.adder = this.root.doc.createP(); + } + + appendChild(child) { + // 用于添加子节点的 API + // 注意:这在不同的宿主环境中会有所不同。例如:在浏览器中,你可以使用 document.appendChild(child) + if (typeof child === 'string') { + // 添加字符串并渲染文本节点 + this.adder.addText(child); + } + } +} + +export default Text; + +``` + +让我们看看这里发生了什么! + +**`constructor()`** + +在 `constructor` 中,我们初始化 `root` 实例和 `props`。我们还对之前在 `WordDocument.js` 中创建的 `doc` 实例创建了一个引用。使用此引用添加文本节点来创建段落。 + +例子 - + +```js +this.adder.addText(__someText__) +``` + +**`appendChild`** + +此方法使用 `docx` 的特定平台方法(即 `appendChild`)添加子节点。请记住,我们在调度器的 `appendInitialChild` 方法中检查父实例是否存在 `appendChild` 方法!? + +```js +appendInitialChild(parentInstance, child) { + if (parentInstance.appendChild) { + parentInstance.appendChild(child); + } else { + parentInstance.document = child; + } +} +``` + +除了 `appendChild` 方法,你还可以添加 `removeChild` 方法来删​​除子节点。由于我们的宿主目标不提供用于删除子节点的可变 API,因此我们没有使用此方法。 + +> 在本教程,`Text` 组件不允许嵌套其它的组件。在更实际的示例中,你可能需要组件的嵌套。 + +#### 注意 + +- 不要在类组件 API 中使用数组来追踪子组件。相反,直接使用特定宿主的 API 添加它们,因为 React 提供了相关子节点(已删除或添加)所有有价值的信息。 + +这是正确的 + +```js +class MyComponent { + constructor(rootInstance, props) { + this.props = props + this.root = rootInstance + } + + appendChild(child) { + some_platform_api.add(child) + // 在浏览器中,我们可能会使用 document.appendChild(child) + } +} +``` + +这是错误的 + +```js +class MyComponent { + children = [] + + constructor(rootInstance, props) { + this.props = props + this.root = rootInstance + } + + appendChild(child) { + this.children.push(child) + } + + renderChildren() { + for (let i = 0; i < this.children.length; i++) { + // 对 this.children[i] 进行一些操作 + } + } + + render() { + this.renderChildren() + } +} +``` + +- 如果你的渲染目标没有提供像 `appendChild` 这样的可变方法,而是只允许你每次都替换整个“场景”,你可能需要使用“持久(persistent)”渲染器模式。这是一个[持久渲染器宿主配置的示例](https://github.com/facebook/react/blob/master/packages/react-native-renderer/src/ReactFabricHostConfig.js)。 + +## createElement + +这类似于将 DOM 作为目标的 `React.createElement()`。 + +**`createElement.js`** + +```js +import { Text, WordDocument } from '../components/index' + +/** + * 为文档创建一个元素 + * @param {string} type 元素类型 + * @param {Object} props 组件属性 + * @param {Object} root 根节点实例 + */ +function createElement(type, props, root) { + const COMPONENTS = { + ROOT: () => new WordDocument(), + TEXT: () => new Text(root, props), + default: undefined + } + + return COMPONENTS[type]() || COMPONENTS.default +} + +export { createElement } +``` +我认为你可以很容易地理解在 `createElement` 方法中发生了什么。它需要传入元素类型、组件属性和根节点实例。 + +根据元素类型,我们返回它的实例,或返回 `undefined`。 + +我们完成了教程的第二部分。我们为我们的两个组件(`Document` 和 `Text`)创建了 API,并创建了一个用于创建元素的 `createElement` 方法。在下一部分中,我们将构建一个 `render` 方法来将所有内容渲染到宿主环境中。 + +[继续第三部分](./part-three-zh_CN.md)