15be39cb7a00b93dc0893f6093c0562c.png

这个故事要从几年前,React 和 react-dom 离婚说起,先插一嘴,第三者不是张三(纯博人眼球)。

React 刚流行起来时,并没有 react-dom ,突然有一天,React 发达了,想学时间管理,然后阅人无数,就这样 React 成了王,而 react-dom 与之分开沦为了妾,React 可谓妻妾成群,我们随便举几个例子: React-Native 、Remax 等。为啥 React 如此无情?我摊牌了,编不下去了,就说好好写文章他不香吗?

正儿八经的,我们开始!

相信大家对于跨端这个概念不陌生,什么是跨端?就是让你感觉写一套代码可以做几个人的事,比如,我用 React 可以写Web 、可以写小程序 、可以写原生应用,这样能极大降低成本,但其实,你的工作交给 React 去做了,我们可以对应一下:

•web:react-dom•小程序:remax•ios、android:react-native

这样一捋是不是清晰了?我们再看一张图

63920bbef892bf9f85883c742ee3a979.png

到这里,你是否明白了当初 React 和 react-dom 分包的用意了?React 这个包本身代码量很少,他只做了规范和api定义,平台相关内容放在了与宿主相关的包,不同环境有对应的包面对,最终展现给用户的是单单用 React 就把很多事儿做了。

那按这样说,我们是不是也可以定义自己的React渲染器?当然可以,不然跟着这篇文章走,学完就会,会了还想学。

创建React项目

首先使用React脚手架创建一个demo项目

安装脚手架

npm i -g create-react-app

创建项目

create-react-app react-custom-renderer

运行项目

yarn start

现在我们可以在vs code中进行编码了

修改 App.js 文件源码

import React from "react";import "./App.css";class App extends React.Component {  constructor(props) {    super(props);    this.state = {      count: 0,    };  }  handleClick = () => {    this.setState(({ count }) => ({ count: count + 1 }));  };  render() {    const { handleClick } = this;    const { count } = this.state;    return (      <div className="App">        <header className="App-header" onClick={handleClick}>          <span>{count}span>        header>      div>    );  }}export default App;

打开浏览器,可以看到页面,我们点击页面试试,数字会逐渐增加。

1942c13a527f1a2f7c6cb62f4d2cd1e9.png

到这里,简单的React项目创建成功,接下来我们准备自定义渲染器。

初识渲染器

打开src/index.js,不出意外,一应该看到了这行代码:

import ReactDOM from 'react-dom';

还有这行

ReactDOM.render(  <App />,  document.getElementById('root'));

现在我们要使用自己的代码替换掉 react-dom,创建 MyRenderer.js,然后修改 index.js中的内容

import MyRenderer from './MyRenderer'MyRenderer.render(  <App />,  document.getElementById('root'))

然后打开浏览器,会看到报错信息,我们按照报错信息提示,完善 MyRenderer.js 的内容。首先文件中最基本的结构如下

import ReactReconciler from "react-reconciler";const rootHostContext = {};const childHostContext = {};const hostConfig = {  getRootHostContext: () => {    return rootHostContext;  },  getChildHostContext: () => {    return childHostContext;  },};const ReactReconcilerInst = ReactReconciler(hostConfig);export default {  render: (reactElement, domElement, callback) => {    if (!domElement._rootContainer) {      domElement._rootContainer = ReactReconcilerInst.createContainer(        domElement,        false      );    }    return ReactReconcilerInst.updateContainer(      reactElement,      domElement._rootContainer,      null,      callback    );  },};

react-reconciler 源码我们曾讲解过,我们可以把它当做一个调度器,负责创建与更新,而后在 scheduler中进行调度,我们导出一个对象,其中有一个方法 render ,参数与 react-dom的render方法一致,这里需要判断一下,如果传入的dom元素是根容器,则为创建操作,否则是更新的操作,创建操作调用 react-reconciler 实例的 createContainer 方法,更新操作调用 react-reconciler实例的 updateContainer 方法。我们再来看到更为重要的概念——hostConfig

Host宿主相关配置

Host——东家、宿主,见名知意,HostConfig是对于宿主相关的配置,这里所说的宿主就是运行环境,是web、小程序、还是原生APP。有了这个配置,react-reconciler在进行调度后,便能根据宿主环境,促成UI界面更新。

我们继续来到浏览器,跟随报错信息,完善我们hostConfig的内容,我将其中核心的方法列举如下,供大家参考学习。

•getRootHostContext•getChildHostContext•shouldSetTextContent•prepareForCommit•resetAfterCommit•createTextInstance•createInstance•appendInitialChild•appendChild•finalizeInitialChildren•appendChildToContainer•prepareUpdate•commitUpdate•commitTextUpdate•removeChild

看到这些方法不禁联想到DOM相关操作方法,都是语义化命名,这里不赘述各个方法的实际含义,一下我们修改相关方法,重新让项目跑起来,以助于大家理解渲染器的工作原理。

定义hostConfig

以上方法中,我们重点理解一下 createInstance 和 commitUpdate, 其他方法我在最后通过代码片段展示出来,供大家参考。(注:相关实现可能与实际使用有较大的差别,仅供借鉴学习)

createInstance

方法参数

•type•newProps•rootContainerInstance•_currentHostContext•workInProgress

返回值

根据传入type,创建dom元素,并处理props等,最终返回这个dom元素。本例我们只考虑一下几个props

•children•onClick•className•style•其他

代码实现

const hostConfig = {  createInstance: (    type,    newProps,    rootContainerInstance,    _currentHostContext,    workInProgress  ) => {    const domElement = document.createElement(type);    Object.keys(newProps).forEach((propName) => {      const propValue = newProps[propName];      if (propName === "children") {        if (typeof propValue === "string" || typeof propValue === "number") {          domElement.textContent = propValue;        }      } else if (propName === "onClick") {        domElement.addEventListener("click", propValue);      } else if (propName === "className") {        domElement.setAttribute("class", propValue);      } else if (propName === "style") {        const propValue = newProps[propName];        const propValueKeys = Object.keys(propValue)        const propValueStr = propValueKeys.map(k => `${k}: ${propValue[k]}`).join(';')        domElement.setAttribute(propName, propValueStr);      } else {        const propValue = newProps[propName];        domElement.setAttribute(propName, propValue);      }    });    return domElement;  },}

是不是很眼熟?谁说原生JavaScript不重要,我们可以看到在框架的内部,还是需要使用原生JavaScript去操作DOM,相关操作我们就不深入了。

commitUpdate

更新来自于哪里?很容易想到 setState ,当然还有 forceUpdate ,比如老生常谈的问题:兄嘚,setState 是同步还是异步啊?啥时候同步啊?这就涉及到 fiber的内容了,其实调度是通过计算的 expirationTime来确定的,将一定间隔内收到的更新请求入队并贴上相同时间,想想,如果其他条件都一样的情况下,那这几次更新都会等到同一个时间被执行,看似异步,实则将优先权让给了更需要的任务。

小小拓展了一下,我们回来,更新来自于 setState 、forceUpdate,更新在经过系列调度之后,最终会提交更新,这个操作就是在 commitUpdate方法完成。

方法参数

•domElement•updatePayload•type•oldProps•newProps

这里的操作其实与上面介绍的createInstance有类似之处,不同点在于,上面的方法需要创建实例,而此处更新操作是将已经创建好的实例进行更新,比如内容的更新,属性的更新等。

代码实现

const hostConfig = {  commitUpdate(domElement, updatePayload, type, oldProps, newProps) {    Object.keys(newProps).forEach((propName) => {      const propValue = newProps[propName];      if (propName === "children") {        if (typeof propValue === "string" || typeof propValue === "number") {          domElement.textContent = propValue;        }        // TODO 还要考虑数组的情况      } else if (propName === "style") {        const propValue = newProps[propName];        const propValueKeys = Object.keys(propValue)        const propValueStr = propValueKeys.map(k => `${k}: ${propValue[k]}`).join(';')        domElement.setAttribute(propName, propValueStr);      } else {        const propValue = newProps[propName];        domElement.setAttribute(propName, propValue);      }    });  },}

两个主要的方法介绍完了,你现在隐隐约约感受到了 react 跨平台的魅力了吗?我们可以想象一下,假设 MyRenderer.render 方法传入的第二个参数不是DOM对象,而是其他平台的 GUI 对象,那是不是在 createInstance 和 commitUpdate 方法中使用对应的GUI创建与更新api就可以了呢?没错!

完整配置

const hostConfig = {  getRootHostContext: () => {    return rootHostContext;  },  getChildHostContext: () => {    return childHostContext;  },  shouldSetTextContent: (type, props) => {    return (      typeof props.children === "string" || typeof props.children === "number"    );  },  prepareForCommit: () => {},  resetAfterCommit: () => {},  createTextInstance: (text) => {    return document.createTextNode(text);  },  createInstance: (    type,    newProps,    rootContainerInstance,    _currentHostContext,    workInProgress  ) => {    const domElement = document.createElement(type);    Object.keys(newProps).forEach((propName) => {      const propValue = newProps[propName];      if (propName === "children") {        if (typeof propValue === "string" || typeof propValue === "number") {          domElement.textContent = propValue;        }      } else if (propName === "onClick") {        domElement.addEventListener("click", propValue);      } else if (propName === "className") {        domElement.setAttribute("class", propValue);      } else if (propName === "style") {        const propValue = newProps[propName];        const propValueKeys = Object.keys(propValue)        const propValueStr = propValueKeys.map(k => `${k}: ${propValue[k]}`).join(';')        domElement.setAttribute(propName, propValueStr);      } else {        const propValue = newProps[propName];        domElement.setAttribute(propName, propValue);      }    });    return domElement;  },  appendInitialChild: (parent, child) => {    parent.appendChild(child);  },  appendChild(parent, child) {    parent.appendChild(child);  },  finalizeInitialChildren: (domElement, type, props) => {},  supportsMutation: true,  appendChildToContainer: (parent, child) => {    parent.appendChild(child);  },  prepareUpdate(domElement, oldProps, newProps) {    return true;  },  commitUpdate(domElement, updatePayload, type, oldProps, newProps) {    Object.keys(newProps).forEach((propName) => {      const propValue = newProps[propName];      if (propName === "children") {        if (typeof propValue === "string" || typeof propValue === "number") {          domElement.textContent = propValue;        }        // TODO 还要考虑数组的情况      } else if (propName === "style") {        const propValue = newProps[propName];        const propValueKeys = Object.keys(propValue)        const propValueStr = propValueKeys.map(k => `${k}: ${propValue[k]}`).join(';')        domElement.setAttribute(propName, propValueStr);      } else {        const propValue = newProps[propName];        domElement.setAttribute(propName, propValue);      }    });  },  commitTextUpdate(textInstance, oldText, newText) {    textInstance.text = newText;  },  removeChild(parentInstance, child) {    parentInstance.removeChild(child);  },};

来到浏览器,正常工作了,点击页面,计数增加。

以上就是本节的所有内容了,看罢你都明白了吗?如果想看其他框架原理,欢迎留言评论

•微信公众号 《JavaScript全栈》•掘金 《合一大师》•Bilibili 《合一大师》•微信:zxhy-heart

我是合一,英雄再会。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐