实现一个简单的react框架 -- (无Fiber架构)

583 阅读9分钟

简介

本文教你如何实现一个类 react 15 的框架,在实现的过程中了解 react 的生命周期函数,异步setState 这些是如何实现的。

JSX

JSX 是一种 JavaScript 的语法扩展,运用于React架构中。在 reactjsx 会被转换为虚拟DOM
什么是虚拟DOM呢。简单理解就是一个格式固定了的对象

const title = <div className="name">111</div>;

比如这段代码在 react 编译的时候,会转换为一个方法,这个方法的返回值就是虚拟DOM。

const title = React.createElement("div", attrs:{className:"name"}, "111");
// 虚拟dom
const DOM = {
    tag:'div',
    attrs:{
        className:"name"
    },
    children:["111"]
}

搭建项目

-- 目录 --
│  dist                          #打包后的文件
│  node_modules                  #npm下载的包文件
│  src                           #开发代码
│  babel.config.json             #babel 插件功能配置
│  package.json                  #初始化项目文件
│  webpack.dev.config.js         #webpack开发配置文件

我们需要在编译时把 jsx 自动转为虚拟DOM,要使用@babel/plugin-transform-react-jsx这个插件。
使用时,只需要在 babel 配置中把它放出来。

// babel.config.json
{
  "presets": ["@babel/preset-env"],
  "plugins": [
    [
      "@babel/plugin-transform-react-jsx",
      {
      	// 配置 遇到 jsx 类型的数据 自动转换为 React.createElement
        "pragma": "React.createElement"
      }
    ]
  ]
}
// package.json 初始化项目 
{
  "name": "react-simulation-15",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "webpack-dev-server --config webpack.dev.config.js"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@babel/core": "^7.12.10",
    "@babel/preset-env": "^7.12.11",
    "@babel/plugin-transform-react-jsx": "^7.12.12",
    "babel-loader": "^8.2.2",
    "webpack": "^5.11.0",
    "webpack-cli": "^3.3.12",
    "webpack-dev-server": "^3.11.0"
  },
  "dependencies": {}
}
// webpack.dev.config.js 启动配置
const path = require("path");

module.exports = {
  // 模式 开始模式 会自动预设安装插件 模式不同安装插件不同
  // 可以使用 node 自带的 process.env.NODE_ENV 来获取所在的环境
  mode: 'development',// production 生产模式  development 开发模式

  /* 入口 打包开始的文件*/
  entry:  path.join(__dirname, "src/index.js"),

  /* 输出到dist目录,输出文件名字为dist.js */
  output: {
    path: path.join(__dirname, "dist"),
    filename: 'dist.js',
  },

  /* cacheDirectory是用来缓存编译结果,下次编译加速 */
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        use: ["babel-loader?cacheDirectory=true"],
        include: path.join(__dirname, "src"),
      },
    ]
  },

  // webpack-dev-server
  devServer: {
    contentBase: path.join(__dirname, "dist"),
    compress: true, // gzip压缩
    host: "0.0.0.0", // 允许ip访问
    hot: true, // 热更新
    historyApiFallback: true, // 解决启动后刷新404
    port: 8111, // 端口
  },
};

根据插件的要求我们需要自定义一个 React.createElement ,在 src/index.js 中编写代码。

  • 编译时插件会直接调用该方法并传入多个参数,可以理解为使用了回调函数。
const React = {};
// tag DOM节点的标签名 
// attrs 节点上的所有属性
// children 子节点
React.createElement = function(tag, attrs, ...children) {
  return {
    tag,
    attrs,
    children
  };
};
console.log(<div>111</div>)

然后在dist文件夹下添加index.html。启动项目,console.log 打印成功,表示我们项目初始化成功。

<!doctype html>
<html lang="en">
<head><meta charset="UTF-8"><title>Document</title></head>
<body><div id="root"></div><script src="dist.js"></script></body>
</html>

ReactDOM.render

ReactDOM.renderreact 的入口方法。接下来就是实现这个入口函数。

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

从这段代码可以看出,第一个参数是一个jsx( 虚拟DOM ),第二个参数是一个真实DOM。我们通过虚拟DOM渲染成的真实DOM都将放入第二个参数中(简称容器)。

那么 render 这个方法,第一步要做的就是把虚拟DOM转换为真实DOM节点然后放入容器中。

const ReactDOM = {};
/**
 *
 * @param {*} vDom 虚拟DOM
 * @param {*} container 容器
 */
ReactDOM.render = function (vDom, container) {
  container.innerHTML = ""; // 清空容器
  // 放入容器中
  return container.appendChild(initComponent(vDom));
};

/**
 * 创建真实节点
 * @param {*} vDom 虚拟DOM
 * @param {*} container 容器
 */
function initComponent(vDom) {
  // 错误节点 修改为空
  if (vDom === null || vDom === undefined || typeof vDom === "boolean") vDom = "";
  // 文本返回文本节点
  if (typeof vDom === "number" || typeof vDom === "string") {
    vDom = String(vDom);
    let textNode = document.createTextNode(vDom);
    return textNode;
  }

  // 虚拟DOM 生成真实节点
  const dom = document.createElement(vDom.tag);
  // 添加属性
  setAttr(dom, vDom.attrs);
  if (vDom.children) {
    // 有子节点 重复操作
    vDom.children.forEach((child) => dom.appendChild(initComponent(child)));
  }
  return dom;
}

当然只是创建真实DOM节点是不够的,还要在节点上为它添加上属性。

/**
 * 修改属性
 * @param {*} dom 真实节点
 * @param {*} attrs 属性 数组对象
 */
function setAttr(dom, attrs) {
  Object.keys(attrs || []).forEach((key) => {
    const value = attrs[key];
    if (key === "style") {
      // 样式
      if (value && typeof value === "object") {
        for (let name in value) {
          dom.style[name] = value[name];
        }
      } else {
        dom.removeAttribute(key);
      }
    } else if (/on\w+/.test(key)) {
      // 事件处理 直接赋值
      key = key.toLowerCase();
      dom[key] = value || "";
      if (!value) {
        dom.removeAttribute(key);
      }
    } else {
      // 当值为空 删除属性
      if (value) {
        dom.setAttribute(key, value);
      } else {
        dom.removeAttribute(key);
      }
    }
  });
}

// ----------------- 使用 -----------------
ReactDOM.render(
    <div name="111">111</div>,
    document.getElementById('root')
);

启动项目页面展示 111 ,到这我们实现了浏览器基础节点展示,下面开始加入组件的转换。

加入组件和实现生命周期函数

JSX 编写组件后,@babel/plugin-transform-react-jsx 会帮我们把第一个参数tag变成function这个字符串,用于我们区分是否是组件。
有状态的组件都有一个基类 React.Component ,主要用用初始化 state & props的数据 和 setState的功能(执行后更新组件)。这里只是实现一个简单的功能后续会修改。

// 基类
React.Component = class Component {
  constructor(props = {}) {
    this.state = {};
    this.props = props;
  }
  setState(stateChange) {
    // 保存上一次的状态
    this.oldState = JSON.parse(JSON.stringify(this.state));
    // 合并 state
    Object.assign(this.state, stateChange);
    // 更新组件状态
    renderComponent(this);
  }

  render() {
    throw "组件无渲染!!!";
  }
}

react 中组件分为两种:继承了基类的 和 未继承的无状态组件。
添加 createComponent 公用方法。用于处理传入组件,实例化一个新组件出来。

/**
 * 创建组件
 * @param {*} component 函数组件
 * @param {*} props 属性值
 */
function createComponent(component, props) {
  let comp;
  // 根据原型判断 是否是 继承基类的组件
  if (component.prototype && component.prototype.render) {
    // 返回实例化组件
    comp = new component(props);
  } else {
    comp = new React.Component(props);
    // 修改构造函数 取消默认的 state --取消组件的状态
    comp.constructor = component;
    // 当执行 render 默认执行函数 并获取 return 中的 jsx
    comp.render = function () {
      return this.constructor(props);
    };
  }

  return comp;
}

组件的渲染过程中是有声明周期函数的,当 props 修改后我们需要实现这个生命周期。
添加 setComponentProps 用于修改组件的 props

/**
 * 修改组件属性值
 * @param {*} component 函数组件
 * @param {*} props 属性值
 */
function setComponentProps(component, props) {
  // 是否保存 真实DOM 后面生成节点时 创建
  if (!component.DOM) {
    // 声明周期函数 初始化  第一次加载组件执行
    if (component.willMount) component.willMount();
  } else if (component.base && component.receiveProps) {
    // 后续修改 props 执行
    component.receiveProps(props);
  }

  // 修改保存 props
  component.props = props;

  // 生成对应真实DOM
  renderComponent(component);
}

除了修改 props 的生命周期函数,在生成真实DOM前后也有其他生命周期函数。 添加 renderComponent 创建组件真实DOM 替换 旧DOM,并调用不同阶段的声明周期函数。

/**
 * 生成对应真实DOM 并替换 旧DOM
 * @param {*} component 函数组件
 */
function renderComponent(component) {
  let DOM;

  // 获取 组件的虚拟DOM
  const vDom = component.render();

  // 生命周期函数 修改 真实节点创建前的 什么周期函数
  if (component.DOM && component.willUpdate) component.willUpdate();

  // 判断组件 是否继续进行 更新操作的函数
  if (component.DOM && component.shouldUpdate) {
    // 如果组件经过了初次渲染,是更新阶段,那么可以根据这个生命周期判断是否更新
    let result = true;
    // 根据 组件返回状态判断 是否终止
    result =
      component.shouldUpdate &&
      component.shouldUpdate(component.props, component.state);
    if (!result) {
      // 终止更新 不修改对应的 state
      component.state = JSON.parse(JSON.stringify(component.oldState));
      component.prevState = JSON.parse(JSON.stringify(component.oldState));
      return;
    }
  }

  // 得到真实DOM
  DOM = initComponent(vDom);
  // DOM = diffNode(component.DOM, vDom);

  if (component.DOM) {
    // 真实DOM 生成后 执行
    if (component.didUpdate) component.didUpdate();
  } else if (component.didMount) {
    // 第一次 DOM加载完后执行
    component.didMount();
  }

  // 当存在真实DOM节点 新真实DOM 替换 旧的DOM
  if (component.DOM && component.DOM.parentNode) {
    component.DOM.parentNode.replaceChild(DOM, component.DOM);
  }
  // 绑定真实DOM
  component.DOM = DOM;
  // DOM绑定 本次组件
  DOM._component = component;
}

最后修改 initComponent 添加组件函数的对比渲染。

/**
 * 创建真实节点
 * @param {*} vDom 虚拟DOM
 * @param {*} container 容器
 */
function initComponent(vDom) {
  // 错误节点 修改为空
  if (vDom === null || vDom === undefined || typeof vDom === "boolean")
    vDom = "";
  // 文本返回文本节点
  if (typeof vDom === "number" || typeof vDom === "string") {
    vDom = String(vDom);
    let textNode = document.createTextNode(vDom);
    return textNode;
  }
  // 组件DOM
  if (typeof vDom === "object" && typeof vDom.tag === "function") {
    //先创建组件
    const component = createComponent(vDom.tag, vDom.attrs);
    // 设置属性
    setComponentProps(component, vDom.attrs);
    //返回的是真实dom对象
    return component.DOM;
  }

  // 默认节点
  if (typeof vDom === "object" && typeof vDom.tag === "string") {
    // 虚拟DOM 生成真实节点
    const dom = document.createElement(vDom.tag);
    // 添加属性
    setAttr(dom, vDom.attrs);
    if (vDom.children) {
      // 有子节点 重复操作
      vDom.children.forEach((child) => dom.appendChild(initComponent(child)));
    }
    return dom;
  }
}

修改使用框架的代码,启动项目就可以测试了。

// ----------------- 使用 -----------------
class Home extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      num: 0,
    };
  }
  // shouldUpdate(po, st) {
  //   console.log("st", st, po);
  //   if (st.num > 3) {
  //     return false;
  //   }
  //   return true;
  // }
  willMount() {
    console.log("初始化");
  }
  but() {
    this.setState( {num:this.state.num + 1})
  }
  render() {
    return (
      <h1>
        <button type="button" onclick={this.but.bind(this)}>+</button>
        {this.state.num}, {this.props.name}
      </h1>
    );
  }
}
function App() {
  return (
    <div>
        <Home key={1} name={`你好`} />
        <Home key={2} name={"你好"} />
    </div>
  );
}
ReactDOM.render(<App />, document.getElementById("root"));

到这我们实现了展示组件,组件的生命周期函数,setState功能。

实现diff算法

接下来就是实现react中的diff算法,当然这里的算法非常简单。复杂的算法 diff 算法原理概述
这里实现得比较简单,简单介绍下diff算法中的几种对比:

  • 纯文本或者数字的对比
  • 组件对比
  • 浏览器节点对比
  • 子节点的对比,等
/**
 * 节点对比
 * @param {*} dom 真实DOM
 * @param {*} vDom 虚拟DOM
 */
function diffNode(dom, vDom) {
  let newDom = dom;
  // 错误节点 修改为空
  if (vDom === null || vDom === undefined || typeof vDom === "boolean")vDom = "";
  // 文本对比 
  if (typeof vDom === "number" || typeof vDom === "string") {
    vDom = String(vDom);
    // 如果当前的DOM就是文本节点,则直接更新内容
    if (dom && dom.nodeType === 3) {
      if (dom.textContent !== vDom) {
        dom.textContent = vDom;
      }
    } else {
      // 创建节点
      newDom = document.createTextNode(vDom);
      // 判断当前 真实DOM 是否存在 如果是 就替换节点
      if (dom && dom.parentNode) {
        dom.parentNode.replaceChild(dom, newDom);
      }
    }
    return newDom;
  }

  // 组件对比
  if (typeof vDom === "object" && typeof vDom.tag === "function") {
    return diffComponent(newDom, vDom);
  }

  // 默认节点对比
  if (typeof vDom === "object" && typeof vDom.tag === "string") {
    // 判断 节点 类型 是否相同
    if (!dom || !isSameNodeType(dom, vDom)) {
      // 创建 新节点
      newDom = document.createElement(vDom.tag);
      if (dom) {
        if (vDom.children) {
          // 当 虚拟DOM有子节 时 将原来的子节点移到新节点下
          [...dom.childNodes].map(newDom.appendChild);
        }
        if (dom.parentNode) {
          // 移除掉原来的DOM对象
          dom.parentNode.replaceChild(newDom, dom);
        }
      }
      // 修改属性
      diffAttributes(newDom, vDom);
    }

    //
    if (
      (vDom.children && vDom.children.length > 0) ||
      (newDom.childNodes && newDom.childNodes.length > 0)
    ) {
      diffChildren(newDom, vDom.children);
    }

    return newDom;
  }
}

在节点对比过程中,会多次使用相同的功能和判断,抽取为公用函数。

/**
 * 删除组件 并调用离开生命周期
 * @param {*} component
 */
function unmountComponent(component) {
  if (component.willUnmount) component.willUnmount();
  removeNode(component.DOM);
}

/**
 *  删除 真实节点
 * @param {*} dom
 */
function removeNode(dom) {
  if (dom && dom.parentNode) {
    dom.parentNode.removeChild(dom);
  }
}

/**
 * 判断真实节点 和 虚拟DOM 类型是否相同
 * @param {*} dom
 * @param {*} VDOM
 */
function isSameNodeType(dom, VDOM) {
  if (typeof VDOM === "string" || typeof VDOM === "number") {
    return dom.nodeType === 3;
  }
  if (typeof VDOM.tag === "string") {
    return dom.nodeName.toLowerCase() === VDOM.tag.toLowerCase();
  }
  return dom && dom._component && dom._component.constructor === VDOM.tag;
}

组件对比,当组件类型相同修改属性,不同相同删除原来的组件,创建一个新的组件返回。

/**
 * 组件类型 处理
 * @param {*} dom 真实DOM
 * @param {*} vDom 虚拟DOM
 */
function diffComponent(dom, vDom) {
  // 之前的虚拟DOM
  let comp = dom && dom._component;

  if (comp && comp.constructor === vDom.tag) {
    // 之前的虚拟DOM 和 现在的是同一个
    // 修改 props 属性
    setComponentProps(comp, vDom.attrs);
    // 获取最新的 真实DOM
    dom = comp.DOM;
  } else {
    // 当两次虚拟DOM 不同 删除之前的组件
    if (comp) {
      unmountComponent(comp);
    }
    // 先创建新组件
    const component = createComponent(vDom.tag, vDom.attrs);
    // 设置属性 生成组件DOM
    setComponentProps(component, vDom.attrs);
    // 获取最新的 真实DOM
    dom = component.DOM;
  }
  return dom;
}

浏览器节点的属性修改。

/**
 * 修改节点 属性
 * @param {*} dom 真实DOM
 * @param {*} vDom 虚拟DOM
 */
function diffAttributes(dom, vDom) {
  const olds = {}; // 旧DOM的属性
  const attrs = vDom.attrs; // 虚拟DOM的属性
  for (let i = 0; i < dom.attributes.length; i++) {
    const attr = dom.attributes[i];
    olds[attr.name] = undefined;
  }
  // 如果原来的属性不在新的属性当中,则将其移除掉(属性值设为undefined)
  setAttr(dom, olds);
  // 更新新的属性值
  setAttr(dom, attrs);
}

子DOM节点的对比,这里写得很简单。

/**
 * 子组件对比
 * @param {*} dom
 * @param {*} vchildren
 */
function diffChildren(dom, vchildren) {
  // 获取原来的节点
  const domChildren = dom.childNodes;
  const keyed = {};
  // 将有key的节点获取
  if (domChildren.length > 0) {
    for (let i = 0; i < domChildren.length; i++) {
      const child = domChildren[i];
      const key = child._component?.props?.key;
      if (key) {
        keyed[key] = child;
      }
    }
  }

  // 子节点 对比
  if (vchildren && vchildren.length > 0) {

    for (let i = 0; i < vchildren.length; i++) {
      const vchild = vchildren[i];
      const key = vchild.attrs?.key;
      let child; // 旧的真实节点

      // 如果有key,找到对应key值的节点
      if (key) {
        if (keyed[key]) {
          child = keyed[key];
          keyed[key] = undefined;
        }
      }
      console.log(child,vchild,keyed)
      
      // 对比节点返回 新节点
      let newChild = diffNode(child, vchild);

      // 获取当前 虚拟DOM对应的真实节点
      const f = domChildren[i];

      if (newChild && newChild !== dom && newChild !== f) {
        if (!f) {
          // 如果更新前的对应位置为空,说明此节点是新增的
          dom.appendChild(newChild);
        } else if (newChild === f.nextSibling) {
          // 如果更新后的节点和更新前对应位置的下一个节点一样,说明当前位置的节点被移除了
          removeNode(f);
        } else {
          // 将更新后的节点移动到正确的位置
          // 在已有节点之前插入
          // 注意insertBefore的用法,第一个参数是要插入的节点,第二个参数是已存在的节点
          dom.insertBefore(newChild, f);
          if (!child) {
            removeNode(f);
          }
        }
      }
    }
  }
}

开始diff对比,要修改 ReactDOM.renderrenderComponent

/**
 *
 * @param {*} vDom 虚拟DOM
 * @param {*} container 容器
 */
ReactDOM.render = function (vDom, container) {
  container.innerHTML = ""; // 清空容器
  // 放入容器中
  // return container.appendChild(initComponent(vDom));
  return container.appendChild(diffNode(null, vDom));
};


/**
 * 生成对应真实DOM 并替换 旧DOM
 * @param {*} component 函数组件
 */
function renderComponent(component) {
  ...
  // 得到真实DOM
  // DOM = initComponent(vDom);
  DOM = diffNode(component.DOM, vDom);
  ...
}

现在我们实现了diff算法,这里主要是使用key来判断之前的组件是否存在,存在就使用之前的DOM只修改属性。

异步state

在进入下面之前先了解浏览器的事件循环机制。简单来说就是 js 在执行过程中有两个队列( 队列的特点是先进先出 ),一个宏任务队列,一个微任务队列。他们的执行顺序是,主任务执行过程中把对应的宏任务(setTimeout 等),微任务(Promise.then() 等)分别放入各自的队列中。主任务执行完后,在宏任务队列中执行一个宏任务,然后去执行所有的微任务。这里要注意的是,是所有的微任务都会执行。重复这个步骤直到没有任务。JavaScript 运行机制详解

之前我们实现的 setState 每次执行都会更新组件,如果一个操作中多次使用 setState 对性能影响较大。所以我们需要优化。先把一次操作中的所有 setState 都放入数组中,把要更新的组件也放入另个数组中(数组中,组件不添加重复的),然后把 setState 合并 和 更新组件的方法 flush() 放入微任务中,就能保证在主任务执行完后在执行这个微任务。

let setStateQueue = []; // state队列
let renderQueue = []; // 组件队列
/**
 * 合并本次的所有 state
 * @param {*} stateChange
 * @param {*} component
 */
function enqueueSetState(stateChange, component) {
  // 合并操作的微任务只需要执行一次
  if (setStateQueue.length === 0) {
    defer(flush);
  }

  // 合并 state 队列
  setStateQueue.push({ stateChange, component });
  // 只放入不存在的组件
  if (!renderQueue.some((item) => item === component)) {
    renderQueue.push(component);
  }
}

/**
 * 执行合并 state 和 更新组件
 * 并清空 state队列 和 组件队列
 */
function flush() {
  let item, component;

  while ((item = setStateQueue.shift())) {
    const { stateChange, component } = item;
    // 是否存在 上一个 state 不存在 添加 第一个
    if (!component.prevState) {
      component.prevState = Object.assign({}, component.state);
    }
    // 判断是否是方法
    if (typeof stateChange === "function") {
      // 合并 方法 返回的值 未最新 state
      Object.assign(
        component.state,
        stateChange(component.prevState, component.props)
      );
    } else {
      // 合并state
      Object.assign(component.state, stateChange);
    }

    // 更新 下一次 prevState 为最新
    component.prevState = component.state;
  }

  while ((component = renderQueue.shift())) {
    // 更新组件
    renderComponent(component);
  }
}

// 事件执行机制 主任务全部执行完后 执行 微任务
function defer(fn) {
  return Promise.resolve().then(fn);
}

然后修改基类 React.Component 加入异步state

...
  setState(stateChange) {
    // 保存上一次的状态
    this.oldState = JSON.parse(JSON.stringify(this.state));
    // 合并 state
    // Object.assign(this.state, stateChange);
    // 更新组件状态
    // renderComponent(this);
    // 异步state
    enqueueSetState(stateChange, this);
  }
...

现在 异步state 加入成功了。修改执行一次操作中加入多次setState就可以测试了,只更新一次表示成功。

class Home extends React.Component {
  ...
  but() {
    for (let i = 0; i < 10; i++) {
      this.setState((pre) => {
        return { num: 1 + pre.num };
      });
    }
  }
  ...
}

这篇文章的代码:源码地址

参考文章

simple-react
JavaScript 运行机制详解:再谈Event Loop