阅读 3866

[万字长文] 分享我的 ToyReact 学习与实现

前言

本篇文章适合使用React一年左右的小伙伴阅读。我希望它可以作为一把开启React源码大门的钥匙。我将会从搭建环境开始, 一步一步带着大家完成一个简易的React框架。代码我将会托管到我的github上, 供大家在阅读的过程中作为参考。

搭建环境

配置webpack.config.js

  1. 添加 babel-loader 将es高级语法转化为浏览器能够读懂的语法
  2. 使用 @babel/preset-env 作为预转译插件
  3. 使用 @babel/plugin-transform-react-jsx 解析jsx语法糖
module.exports = {
  entry: {
    main: './main.js'
  },
  module: {
    rules: [
      {
        test:  /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env'],
            plugins: [[
              '@babel/plugin-transform-react-jsx'
            ]]
          }
        }
      }
    ]
  },
  optimization: {
    minimize: false
  },
  mode: 'development'
}
复制代码

新建html文件引入main.js

为了更加直观的展示效果, 我们可以将打包后的script文件, 引入到html中。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
</body>
<script src="dist/main.js" ></script>
</html>
复制代码

编写jsx

我们在main.js中编写一段jsx, 然后在控制台查看main.js经过编译后的结果。

let a = <div id="hello">hello world!</div>
复制代码

经过webpack编译后, 我们发现jsx被编译成了:

  var a = /*#__PURE__*/React.createElement("div", {
   id: "hello"
 }, "hello world!");
复制代码

与此同时, 我们发现控制台报了一个错误❌.

Uncaught ReferenceError: React is not defined
  at eval (main.js:1)
  at Object../main.js (main.js:96)
  at __webpack_require__ (main.js:20)
  at main.js:84
  at main.js:87
复制代码

我们发现当@babel/plugin-transform-react-jsx 解析jsx的时候, 会自动地从React中调用createElement 方法。那么如果想要把React.createElement 修改成 ToyReact.createElement 该怎么办呢?

我们通过查看babel官网中的插件列表, 可以详细地看到babel-plugin-transform-react-jsx的用法。

  • pragma 接收一个string类型的字符串, 默认值是React.createElement. 当遇到jsx标签时, 将会用pragma的值去替换它。如果我们将pragma值设为 ToyReact.createElement, 那么main.js中的代码将会被解析成:
var a = ToyReact.createElement("div", {
  id: "hello"
}, "hello world!");
复制代码
  • pragmaFrag 接收一个 string类型的字符串, 默认值是 React.Fragment 当解析到空标签时, 会使用pragmaFrag的值替换它。比如我们将 main.js的内容改成 let a = <>hello world!</>。那么解析后的结果是:

    var a = ToyReact.createElement(React.Fragment, null, "hello world!");
    复制代码
  • useBuiltIns 接收一个 Boolean 类型的值, 默认值是 false 当传递prop的时候,直接用Object.assign()方法, 而不是其他的Babel插件。

  • useSpread

  • throwIfNamespace

我们更新一下webpack.config.js的配置,

 use: {
   loader: 'babel-loader',
   options: {
     presets: ['@babel/preset-env'],
     plugins: [[
       '@babel/plugin-transform-react-jsx',
       {
         pragma: 'ToyReact.createElement',
         pragmaFrag: 'ToyReact.Fragment'
       }
     ]]
   }
 }
复制代码

ok! 至此我们的环境搭建已经完成, 接下来可以愉快的开发啦!

编写ToyReact

创建ToyReact.js

由于我们并没有编写ToyReact的相关内容, 因此控制台会报错。接下来, 我们新建ToyReact.js, 并且新增一个createElement 方法来创建一个实体DOM。

那么createElement方法如何写呢?

 var a = ToyReact.createElement("div", {
  id: "hello"
	}, "hello world!");
复制代码

我们发现,createElement 传递三个参数

  • 标签的类型
  • 属性
  • 子节点
export const ToyReact = {
  createElement(type, attributes, ...children) {

    const element = document.createElement(type);
    
    for (let name in attributes) {
      element.setAttribute(name, attributes[name])
    }

    return element;
  }
}
复制代码

我们通过调用document.createElement 方法创建实体DOM, 并且遍历jsx的上的自定义属性,将各个属性挂载到实体DOM中。我们可以在控制台打印createElement 方法返回的值。

  <div id="hello"></div>
复制代码

这时候返回的是实体DOM, 貌似还缺点什么❓emm, 子节点好像没有考虑, 我们可以先简单处理一下children(后面优化)

```js
export const ToyReact = {
  createElement(type, attributes, ...children) {
    const element = document.createElement(type);
    for (let name in attributes) {
      element.setAttribute(name, attributes[name])
    }

   for (let child of children) {
      const node = document.createTextNode(child)
      element.appendChild(node)
    }
    return element;
  }
}
```
复制代码

接下来我们将实DOM插入到文档就可以看到 hello world!了。

import { ToyReact } from './ToyReact';
let a = <div id="hello">hello world!</div>
document.body.appendChild(a);
复制代码

重构ToyReact.js

为了保持与React API的风格一致, 我们需要改变main.js的代码

import { ToyReact } from './ToyReact';

class TestComponent {
  render() {
    return <div id="hello">hello world!</div>
  }
}

ToyReact.render(<TestComponent />, document.body)
复制代码

我们发现新的代码比之前的代码多了一个render的方法, 并且也使用类的写法通过render返回jsx. 那么应该从何处着手修改ToyReact里面的代码呢?

  • 实例化TestComponent, 获取它return的jsx
  • render还是负责将实DOM插入到document.body中

修改createElement

首先我们需要判断一下type的类型, 因为传进来的type不再是之前的元素标签(比如div)字符串了。而是变成了 function.其次呢, 我们最好将元素、文本节点剥离开来。元素节点是可以被添加属性以及子节点的, 而在文本节点上我们不能做任何事情。剥离开来的目的是为了让createElement方法变得不那么臃肿, 同时将元素和文本抽离出来, 各自的逻辑,维护起来也更加方便。

export const ToyReact = {
  createElement(type, attributes, ...children) {

    const element = document.createElement(type);

    for (let name in attributes) {
      element.setAttribute(name, attributes[name])
    }

    for (let child of children) {
      const node = document.createTextNode(child)
      element.appendChild(node)
    }

    return element;
  }
}
复制代码

定义ElementWrapper类

新建ElementWrapper类,并且新增 创建实体DOM方法 以及 setAttributeappendChild 方法。

class ElementWrapper {
    constructor(type) {
      this.root = document.createElement(type)
    }

    setAttribute(name, value) {
      this.root.setAttribute(name, value)
    }

    appendChild(component) {
      this.root.appendChild(component.root)
    }
}
复制代码

以上appendChild方法中component 都是经过 ElementWrapper 或者

TextNodeWrapper 实例化后的值。 component.root 指代的即是元素节点或者文本节点。

定义TextNodeWrapper类

新建TextNodeWrapper类, 并且只需要创建一个文本节点即可。

class TextNodeWrapper {
  constructor(content) {
    this.root = document.createTextNode(content);
  }
}
复制代码

修改createElement方法

createElement(type, attributes, ...children) {
  let element;
  // 判断element的类型, 如果是元素标签的字符串类型, 那么就通过ElementWrapper创建实DOM, 否则就直接实例化本身返回其render的jsx, 进行重新调用createElement构建元素。
  if(typeof type === 'string') {
     element = new ElementWrapper(type);
  } else {
    element = new type;
  }

  for (let name in attributes) {
    element.setAttribute(name, attributes[name])
  }

  for (let child of children) {
    // 如果child是字符串那么直接实例化TextNodeWrapper,得到文本节点。
    if(typeof child === 'string') {
      child = new TextNodeWrapper(child)
    }
    element.appendChild(child)
  }
  
  return element;
},

复制代码

新增Component类

这里有的同学可能会有疑问, 为什么需要新增一个额外的类。

当babel解析到TestComponent的时候, 我们直接实例化它, 并且给实例化后的值 setAttribute属性以及 appendChild 子节点。 我们当然不能在main.js中写这些方法的具体实现。因此我们让TestComponent去继承 Component, 让Component类去实现这两个方法。

export class Component {
  constructor(props) {
    this.props = Object.create(null);
    this._root = null;
    this.children = []
  }

  setAttribute(name, value) {
      this.props[name] = value; 
  }

  appendChild(component) {
    this.children.push(component);
  }

  get root() {
    if(!this._root) {
      this._root = this.render().root;
    }
    return this._root
  }
}
复制代码

新增render方法

render(component, parentElement) {
    parentElement.appendChild(component.root)
}
复制代码

修改main.js代码

相较于之前, 我们让TestComponent去继承了Component类, 从而使得TestComponent 拥有获取props和添加子节点的能力。

import { ToyReact, Component } from './ToyReact';

class TestComponent extends Component  {
  render() {
    return <div id="hello">hello world!</div>
  }
}

ToyReact.render(<TestComponent name="123" />, document.body)
复制代码

我们发现页面上已经能够正常显示我们编写的hello world!了。我们试着往TestComponent添加children。

import { ToyReact, Component } from './ToyReact';

class TestComponent extends Component  {
  render() {
    return <div id="hello">hello world!{this.children}</div>
  }
}

ToyReact.render(
  <TestComponent name="123">
    <div>i</div>
    <div>am</div>
  </TestComponent>, 
document.body)
复制代码

我们再次运行代码, 发现页面报错了。其实我们很容易能够猜到为什么?我们尝试打印this.children的值, this.children 是一个数组,里面包含了两个元素。然而我们之前的代码没有考虑过这种情况, 因此我们需要修改createElement方法, 让他能够把children里面的数组给解析出来。

createElement(type, attributes, ...children) {
  let element;
  if(typeof type === 'string') {
     element = new ElementWrapper(type);
  } else {
    element = new type;
  }

  
  for (let name in attributes) {
    element.setAttribute(name, attributes[name])
  }

  function insertChildren(children) {
    for (let child of children) {
      if(typeof child === 'string') {
        child = new TextNodeWrapper(child)
      }
      if(typeof child === 'object' && child instanceof Array) {
        insertChildren(child);
        return;
      }
      element.appendChild(child)
    }
  }

  insertChildren(children);
  
  return element;
},
复制代码

我们通过一个简单的递归函数去处理child是数组的情况。现在我们的页面可以正常的显示DOM结构, 并且拥有props的能力。 下一步我们需要让页面能够动起来, 也就是我们能够使用类似React中的this.setState()方法去改变页面的显示。

让ToyReact能够“动起来”

在让它动起来前, 我们先来了解一下一个不太常用的api range.

range的定义

MDN是这样定义它的: Range 接口表示一个包含节点与文本节点的一部分的文档片段.我认为在此基础上,稍稍修改一下会更好理解。 Range 接口能够表示文档中任意节点之间的一部分文档(HTML DOM)片段。

range API的简单使用

  <p id="p1"> hello<span> world !</span><span> world !</span></p>
复制代码
  • Range.setStart(startNode, startOffset) 设置Range的起点

    接收二个参数第一个参数是节点, 第二个参数是节点的偏移量。比如拿上面的例子来说:

    const p1 = document.getElementById('p1');
    range.setStart(p1, 1)
    复制代码

    range的起始位置应该是

                      range起始位置
                        |
                        |
                        |  
        <p id="p1">hello <span> world !</span><span> world !</span></p>
    复制代码

    那么如果setStart的第二个参数是0,那么range的起始位置则是:

              range起始位置
                |
                |
                |  
     <p id="p1">hello <span> world !</span><span> world !</span></p>
    复制代码

    其实很容易理解,p1元素节点下面有三个子节点。一个是文本节点hello, 另外两个则是元素节点 <span> world !</span>

  • Range.setEnd(startNode, startOffset) 设置Range的结束位置。

    接收二个参数第一个参数是节点, 第二个参数是节点的偏移量。我们还是拿上面的例子来说:

    const p1 = document.getElementById('p1');
    range.setEnd(p1, p1.childNodes.length)
    复制代码
                                                            range结束位置
                                                              |
                                                              |
                                                              |  
     <p id="p1">hello <span> world !</span><span> world !</span> </p>
    复制代码
  • Range.insertNode(Node) 在Range的起始位置插入节点。

     <p id="p1"> hello<span> world !</span><span> world !</span><span> world!</span></p>
    复制代码
```js
const range = document.createRange();
const p1 = document.getElementById('p1');
const element = document.createElement('p');
element.appendChild(document.createTextNode('123'));
range.setStart(p1, 0);
range.setEnd(p1, p1.childNodes.length);
range.insertNode(element)
```
复制代码

当执行完insertNode 方法后,会在文本节点hello前面添加一个p元素节点。

  • Range.deleteContents() 移除来自 Document的Range 内容。

调用此方法会删除range内的所有节点。

 <p id="p1"> hello<span> world !</span><span> world !</span><span> world!</span></p>
复制代码
 const range = document.createRange();
 const p1 = document.getElementById('p1');
 range.setStart(p1, 0)
 range.setEnd(p1, p1.childNodes.length)
 range.deleteContents()
复制代码

以上代码执行后p1节点下面的所有节点都将被删除。

range的其他api本篇文章中不会涉及,因此就不一一介绍了。

使用range重构ToyReact

我们为什么要用range去重构之前的代码呢?我认为主要是出于以下的考虑:

  • 1.使用range我们可以在任意节点处插入DOM

  • 2.为接下来的重新渲染与虚拟DOM的比对做铺垫

我们修改的基本思路是:

  • 从渲染DOM的地方开始着手, 使用range去完成DOM的实际操作
  • 仔细阅读之前的代码, 你会发现它无法进行重新渲染。因此我们需要定义一个私有的方法能够让DOM树重新render。

为了让渲染DOM树的方法, 变得不那么容易让外部调用, 我们使用Symbol 返回的唯一标识符作为函数名。

  const RENDER_TO_DOM = Symbol('render to dom')
复制代码

修改Component类

我们需要在Component类中添加一个私有的方法, 因为this.render()返回的值有可能是一个Component, ElementWrapper, TextNodeWrapper。因此在其余二个类中, 我们 也需要去添加RENDER_TO_DOM 方法。

  [RENDER_TO_DOM](range) {
    this.render()[RENDER_TO_DOM](range);
  }
复制代码

修改ElementWrapper和TextNodeWrapper类

在这二个类中, 我们将实DOM渲染到页面。因此在RENDER_TO_DOM 中我们需要往range中插入节点。

  [RENDER_TO_DOM](range) {
    range.deleteContents();
    range.insertNode(this.root);
  }
复制代码

修改render函数

由于我们不再使用get root() 方法来获取实DOM, 因此我们通过调用 RENDER_TO_DOM 来插入节点。

  render(component, parentElement) {
   const range = document.createRange();
   range.setStart(parentElement, 0);
   range.setEnd(parentElement, parentElement.childNodes.length);
   range.deleteContents();
   component[RENDER_TO_DOM](range)
 }
复制代码

我们已经初步完成了重构,距离页面动起来还有点距离, 但是此时页面正常显示没问题。如果代码跑不起来的可以查看 feautre/range分支的代码. review代码看看哪儿出错了。

修改main.js

我们需要有一个主动的行为去更新页面。 我们在页面添加一个计数器, 每点击一次按钮, 页面上的数字加一.

import { ToyReact, Component } from './ToyReact';

class TestComponent extends Component {
  constructor() {
    super();
    this.state = {
      count: 1
    }
  }
  render() {
    return <div id="hello">
      hello world!
      <span>{
          this.state.count.toString()
        }
        <button onClick={() => this.count ++ }>点击</button>
        </span>
        {
          this.children
        }
      </div>
  }
}

ToyReact.render(<TestComponent></TestComponent>, document.body)
复制代码

支持事件的绑定以及新增重新渲染函数

我们点击onClick页面似乎没有反应。其实这边有二个很重要的点没有处理:

  • 我们需要处理类似onClick 等事件
  • 我们需要将改变后count的值重新渲染到页面上

首先只有元素节点上才能绑定事件, 因此我们肯定是在ElementWrapper类中进行修改。我们写一个简单的正则来匹配所有on开头的事件, 比如onClick, onHover, onMouseUp.....。

setAttribute(name, value) {
  if(name.match(/^on([\s\S]+)/)) {
    this.root.addEventListener(RegExp.$1.replace(/^[\s\S]/, s => s.toLowerCase()), value)
  }
  this.root.setAttribute(name, value)
}
复制代码

接下来就是思考如何编写一个重新渲染的方法了, 当我们点击按钮的时候, count的值其实已经改变了。只是内容没有改变, 那么如果要做到count实时更新,我们就需要每次都去更新range的内容。 我们在Component类中新增一个rerender方法进行更新操作。

constructor(props) {
  ...
  this._range = null;
}

[RENDER_TO_DOM](range) {
    this._range = range;
    this.render()[RENDER_TO_DOM](range);
}

rerender() {
  this._range.deleteContents();
  this[RENDER_TO_DOM](this._range) 
}
复制代码

实现的过程很简单, 其实就是将range的内容全部删除(不删除的话之前的内容将会保留), 然后重新执行添加Node的方法。

我们在main.js中的按钮点击事件修改为 <button onClick={() => { this.state.count ++; this.rerender()} }>点击</button>。至此, 页面已经能够动起来了。但是为了 与React的API保持一致, 我们需要将 this.state.count ++; this.rerender() 合并为 this.setState({ count: count++ })

新增setState方法

setState方法主要是将新的state与老的state比较, 然后进行一个深拷贝的操作。如果this.state不存在或者类型不是对象的时候, 我们直接使用新的state去替换它。 然后通过递归将新的state中的值直接赋值到旧的对应的state值。

 setState(newState) {
   if(this.state === null && typeof this.state !== 'object') {
     this.state = newState;
     this.rerender();
     return;
   }

   let merge = (oldState, newState) => {
       for (const key in newState) {
         if(oldState[key] === null || typeof oldState[key] !== 'object') {
           oldState[key] = newState[key]
         } else {
           merge(oldState[key], newState[key]);
         }
       }
   }
   merge(this.state, newState);
   this.rerender();
 }
复制代码

集成React官网示例Tic Tac Toe

为了让ToyReact更加健壮, 我们将React官网的例子作为ToyReact的Demo, 顺便对ToyReact进行一些小修小补。

  • 修改main.js

我们需要将官网中函数式写法修改为类写法, 因为ToyReact暂时不能处理传入函数式的组件。

import { ToyReact, Component } from './ToyReact';

class Square extends Component {
  render() {
    return (
      <button className="square" onClick={this.props.onClick}>
        {this.props.value}
      </button>
    );
  }
}

class Board extends Component {
  renderSquare(i) {
    return (
      <Square
        value={this.props.squares[i]}
        onClick={() => this.props.onClick(i)}
      />
    );
  }

  render() {
    return (
      <div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }
}

class Game extends Component {
  constructor(props) {
    super(props);
    this.state = {
      history: [
        {
          squares: Array(9).fill(null)
        }
      ],
      stepNumber: 0,
      xIsNext: true
    };
  }

  handleClick(i) {
    const history = this.state.history.slice(0, this.state.stepNumber + 1);
    const current = history[history.length - 1];
    const squares = current.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? "X" : "O";
    this.setState({
      history: history.concat([
        {
          squares: squares
        }
      ]),
      stepNumber: history.length,
      xIsNext: !this.state.xIsNext
    });
  }

  jumpTo(step) {
    this.setState({
      stepNumber: step,
      xIsNext: (step % 2) === 0
    });
  }

  render() {
    const history = this.state.history;
    const current = history[this.state.stepNumber];
    const winner = calculateWinner(current.squares);

    const moves = history.map((step, move) => {
      const desc = move ?
        'Go to move #' + move :
        'Go to game start';
      return (
        <li key={move}>
          <button onClick={() => this.jumpTo(move)}>{desc}</button>
        </li>
      );
    });

    let status;
    if (winner) {
      status = "Winner: " + winner;
    } else {
      status = "Next player: " + (this.state.xIsNext ? "X" : "O");
    }

    return (
      <div className="game">
        <div className="game-board">
          <Board
            squares={current.squares}
            onClick={i => this.handleClick(i)}
          />
        </div>
        <div className="game-info">
          <div>{status}</div>
          <ol>{moves}</ol>
        </div>
      </div>
    );
  }
}

ToyReact.render(<Game />, document.getElementById("root"));

function calculateWinner(squares) {
  const lines = [
    [0, 1, 2],
    [3, 4, 5],
    [6, 7, 8],
    [0, 3, 6],
    [1, 4, 7],
    [2, 5, 8],
    [0, 4, 8],
    [2, 4, 6]
  ];
  for (let i = 0; i < lines.length; i++) {
    const [a, b, c] = lines[i];
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return squares[a];
    }
  }
  return null;
}

复制代码
  • 修改main.html

我们将官网的样式与根节点root引入。

  <!DOCTYPE html>
  <html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
  </head>
  <style>

  body {
    font: 14px "Century Gothic", Futura, sans-serif;
    margin: 20px;
  }

  ol, ul {
    padding-left: 30px;
  }

  .board-row:after {
    clear: both;
    content: "";
    display: table;
  }

  .status {
    margin-bottom: 10px;
  }

  .square {
    background: #fff;
    border: 1px solid #999;
    float: left;
    font-size: 24px;
    font-weight: bold;
    line-height: 34px;
    height: 34px;
    margin-right: -1px;
    margin-top: -1px;
    padding: 0;
    text-align: center;
    width: 34px;
  }

  .square:focus {
    outline: none;
  }

  .kbd-navigation .square:focus {
    background: #ddd;
  }

  .game {
    display: flex;
    flex-direction: row;
  }

  .game-info {
    margin-left: 20px;
  }

  </style>
  <body>
    <div id="root"></div>
  </body>
  <script src="dist/main.js" ></script>
  <script >
  </script>
  </html>
复制代码
  • 修改ElementWrapper类支持className

    我需要单独处理className这个属性, 因为元素节点的类名是通过赋值到class上才能生效.

    setAttribute(name, value) {
      // ...
      if(name === 'className') {
        name = 'class'
      }
      // ...
    }
    复制代码

    我们尝试打包, 查看页面渲染情况, 官网的例子在我们这儿也能够正常运行。

让ToyReact拥有虚拟DOM与Diff算法

什么是虚拟DOM

虚拟DOM本质上其实是对真实DOM的一种映射关系。它是一种以对象的形态来表示真实存在的DOM。举个例子:

<ul id="ul1">
 <li name="1">world !</li>
 <li name="2">world !</li>
 <li name="3">world !</li>
</ul>
复制代码

上面的html代码如果以虚拟DOM的形态来表示的话, 那么就是:

 {
     type: 'ul',
     props: {      
       id: 'ul1'
     },
     children: [
       { type: 'li', props: {name: '1'}, children: ['world !']},
       { type: 'li', props: {name: '2'}, children: ['world !']},
       { type: 'li', props: {name: '3'}, children: ['world !']},
     ]
 }
复制代码

什么是Diff算法

Diff算法其实是通过比对新旧虚拟DOM树,然后将不同的部分渲染到页面中,从而达到最小化更新DOM的目的。

以下图DOM为例:

Diff算法采用按照深度遍历规则遍历的, 因此遍历的过程如下:

    1. 对比节点1(没有变化)
    1. 对比节点2(没有变化)
    1. 对比节点4(没有变化)
    1. 对比节点5(节点5被移除, 记录一个删除的操作)
    1. 对比节点3(没有变化)
    1. 对比节点3的children(新增节点5, 记录一个新增操作)

因此在实际渲染的过程中, 会执行节点5的删除和新增操作, 其余节点不会发生任何变化。

ToyReact融入虚拟dom功能

在对虚拟DOM和Diff算法有所了解后, 我们又得重构ToyReact.js。这一路, 我们一直在重构ToyReact.js的路上。

重构之前我们对比一下官方的例子和我们的例子区别:

官网的例子:

我们每次点击按钮, 它都只更新自个儿的节点。其他节点都不会重新渲染。我们来继续看一下ToyReact的渲染的过程。

我们每次点击按钮, 整颗DOM树都重新渲染了, 这对于复杂页面的性能消耗将会非常巨大。因此我们迫切需要引入虚拟DOM+Diff算法来解决这个问题。

定义虚拟DOM

对于元素节点来说, 虚拟DOM应该包含三样东西:

  • 节点的类型(比如div, span, p)
  • 节点上的props
  • 节点的children

然而对于文本节点来说, 它的类型是固定的, 唯一不同的就是他的内容了, 因此它的虚拟DOM就比较简单了

  • 节点的类型(text)
  • 节点的内容(content)

那么对应到ToyReact中的代码片段应该是ElementWrapperTextNodeWrapper 这两个类。


class ElementWrapper {
  // ...

  get vdom() {
    return {
      type: this.type,
      props: ???,
      children: ???
    }
  }

  // ...
}

复制代码

除了type属性我们可以通过构造函数中的参数获得, 其余的props和children我们都无法获得。但是呢这二个属性在Component类中都有, 因此我们可以让ElementWrapper类去继承Component类。、

  class ElementWrapper {
  // ...

  get vdom() {
    return {
      type: this.type,
      props: this.props,
      children: this.children.map(item => item.vdom)
    }
  }

  // ...
}
复制代码

同时我们还需要先注释掉ElementWrapper类中的 setAttributeappendChild 方法。不然的话我们子节点的虚拟dom就塞不进去了, 因为ElementWrapper类中方法与Component类中方法重名了。

由于它是虚拟DOM, 因此它的children也应该是虚拟的children.因此在TextNodeWrapper 类中, 也需要定义一个获取虚拟DOM的方法。

class TextNodeWrapper {
  constructor(content) {
    this.root = document.createTextNode(content);
    this.content = content;
  }

// ...
  get vdom() {
    return {
      type: '#text',
      content: this.content
    }
  }
  // ...
}
复制代码

ok, 我最后需要在Component类中也定义个获取虚拟dom的方法, 通过递归的方法获取虚拟dom树。

export class Component {
  // ...
  get vdom() {
    return this.render().vdom;
  }
  // ..
}
复制代码

查看虚拟DOM

我们可以修改main.js中的代码来输出虚拟dom.

let game = <Game />
console.log(game.vdom);
复制代码

ok! nice, 这就是我们想要的虚拟dom的结构。我们接下来先将注释掉的二个方法重新补上, 因为setAttributeappendChild方法都是对实DOM的操作, 所以我打算把这两个 函数的实现全部放到 RENDER_TO_DOM函数中。

[RENDER_TO_DOM](range) {
  range.deleteContents();
  let root = document.createElement(this.type);

  for (const name in this.props) {
    let value = this.props[name];
    if (name.match(/^on([\s\S]+)/)) {
      root.addEventListener(RegExp.$1.replace(/^[\s\S]/, s => s.toLowerCase()), value)
    }
    if (name === 'className') {
      root.setAttribute('class', value)
    } else {
      root.setAttribute(name, value)
    }
  }

  for (const child of this.children) {
      const childRange = document.createRange();
      childRange.setStart(root, root.childNodes.length);
      childRange.setEnd(root, root.childNodes.length);
      child[RENDER_TO_DOM](childRange);
  }

  range.insertNode(root);
}

复制代码

第一部分的for循环其实做的就是 setAttribute 的事情, 将属性赋值到元素上, 第二部分的for循环做的事情则是通过递归的方式插入child.

那么如何将虚拟DOM与实DOM结合一起呢。其实很简单, 我们通过遍历虚拟的children, 来构造一颗虚拟的树。最终将这棵虚拟的树转化为实际的树。 那么对应到代码上应该如何修改呢? 我们还是从 Component中的 RENDER_TO_DOM主体函数上着手, 我们仔细研读以下代码, 我们发现这边的children 还是实际的children, 并不是虚拟的children, 因此我们需要在Component类中定义一个vchildren。

  get vchildren() {
    return this.children.map(child => child.vdom)
  }
复制代码

同理我们遍历的时候就用vchildren进行遍历, 这样我们的这颗树就是虚拟的树。

  for (const child of this.vchildren) {
      const childRange = document.createRange();
      childRange.setStart(root, root.childNodes.length);
      childRange.setEnd(root, root.childNodes.length);
      child[RENDER_TO_DOM](childRange);
  }
复制代码

虚拟dom这块实现的代码可以查看github.com/Summer-andy…。这块确实挺绕的, 我当时也思考了蛮久的, 有兴趣的同学可以查看feature/vdom 分支的源码进行阅读。

ToyReact融入Diff算法

Diff算法在前面我们已经稍微介绍过了, 我们不会跟React一样去实现他的diff算法, 因为这不是本文的重点。本文的重点旨在让大家理解Diff算法是如何贯穿于虚拟DOM的。但是我们会尽可能的多考虑Diff重绘的情况。那么哪几种情况会导致我们的DOM树重绘制呢?

  • 节点的类型不同

  • 新老节点的props值不同

  • 新节点的props少于老节点的props

  • 文本节点的内容不同

我们定义一个isSameNode 方法来做虚拟DOM的diff

 let isSameNode = (oldNode, newNode) => {
   if(oldNode.type !== newNode.type) {
     return false
   }

   for (const key in newNode.props) {
     if (oldNode.props[key] !== newNode.props[key]) {
         return false
     }
   }

   if(Object.keys(oldNode.props).length >  Object.keys(newNode.props).length)
   return false

   if(newNode.type === "#text") {
     if(newNode.content !== oldNode.content) {
       return false;
     }
   }

   return true;
 }
复制代码

好, 那么接下来, 我们需要做的就是遍历我们的虚拟DOM树.首先判断新的虚拟DOM节点与老的虚拟DOM节点 是否一样, 如果一样那么就直接替换range, 并且通过递归的方式去比对子节点。 如果节点不一样, 那么就更新当前节点下的range, 从而达到部分更新的效果。


 let update = (oldNode, newNode) => {

   if(!isSameNode(oldNode, newNode)) {
       newNode[RENDER_TO_DOM](oldNode._range)
       return;
   }

   newNode._range = oldNode._range;

   let newChildren = newNode.vchildren;
   let oldChildren = oldNode.vchildren;

   for (let index = 0; index < newChildren.length; index++) {
     const newChild = newChildren[index];
     const oldChild = oldChildren[index];
     if(index < oldChildren.length) {
       update(oldChild, newChild);
     } else {
         // ...
     }
   }
 }

复制代码

还差最后一步, 每次更新后, 还需要将虚拟DOM树也更新, 为下次Diff做准备。我们定义一个vdom 来接收最新的虚拟DOM树, 执行完更新虚拟的函数后, 我们需要将老的虚拟dom树(this._vdom)换成更新完后 的虚拟dom树。至此整个更新流程就完成了, 我们来查看一下最后的效果。

let vdom = this.vdom;
update(this._vdom, vdom);
this._vdom = vdom;
复制代码

我们发现DOM树已经不再是重头绘制了。它的更新范围已经缩小在了 Board 类对应的DOM树范围内了。 本篇文章的完整代码可以查看github.com/Summer-andy…。 但是这边有一个小问题, 为什么我们不能单个button格子更新呢? 我打算用debug的方式来说明这个问题。 首先, 在 isSameNode 函数上打一个断点, 方便我们调试.

 let isSameNode = (oldNode, newNode) => {
     debugger;

     if(oldNode.type !== newNode.type) {
       return false
     }

     for (const key in newNode.props) {
       if (oldNode.props[key] !== newNode.props[key]) {
           return false
       }
     }

     if(Object.keys(oldNode.props).length >  Object.keys(newNode.props).length)
     return false

     if(newNode.type === "#text") {
       if(newNode.content !== oldNode.content) {
         return false;
       }
     }

     return true;
   }
复制代码

点击左上方的格子, 打开浏览器调试模式, 程序即可进入debugger模式。我们直接进入到最后一次比较格子的地方。

老的虚拟DOM的content 是空值, 按照正常逻辑此时的对应的新的虚拟DOM中的content值应该是一个x.我们来校验一下结果。

ok, 那么我们接下去需要知道的是, 它是在比较什么地方的时候, 出现了不同, 导致重绘的。我们发现在比对新老节点的props的时候, 发生了值不一样的情况。而不一样的key, 是 onClick .

调试到这里我们应该明白了为什么它重绘的区域与我们想象的不一样了。在比对事件的时候, 我们每次都会实例化一个新的事件函数。这就导致了我们的ToyReact处理不了关于事件调度方面的diff了。那么如果想要达到React那样, 只更新某个节点 这样的效果的话, 我们可以采取最残暴的手法, 直接忽略所有的事件。

    for (const key in newNode.props) {
      if (oldNode.props[key] !== newNode.props[key]) {
        if(typeof newNode.props[key] !== 'function') {
          return false
        }
      }
    }
复制代码

我们继续看看, 最后的效果如何?

It is Cool! 我们也做到了只更新某个节点的效果了。 不过大家乐呵乐呵就行, 学技术还是得看React官方源码, 哈哈。

结语

如果认真阅读完本文, 并且动手一行一行敲了代码, 我相信大家会有很多的收获。不知道大家在阅读过程中, 是不是经常会有这样的一个想法: 咦, React的更新流程是咋样的呀? React的diff算法是怎么样的呀? 等等。 我觉得正是在 这种好奇心的驱使下, 才能够让我们在学习的路上越走越远, 希望大家阅读完本文, 能够勾起大家对React的好奇心, 帮助大家在阅读React源码的道路上越走越远。