乐趣区

关于前端:从零实现极简版react

从学习虚构 dom 工作原理登程,实现一个极简版的 toy-react,其中蕴含了 react 的某几个要害个性,并非真正的 react

起步

说到 react,第一个想到的就是 JSX,所以为了便于开发和调试,咱们的工程要具备以下几点:

  • 反对 ES6
  • 反对 JSX 语法解析
  • 主动打包输入
{
  "@babel/core": "^7.2.2",
  "@babel/plugin-transform-react-jsx": "^7.10.4", 
  "@babel/preset-env": "^7.3.1",
  "babel-loader": "^8.0.5",
  "css-loader": "^4.2.2",
  "html-loader": "^1.2.1",
  "html-webpack-plugin": "^4.3.0",
  "style-loader": "^1.2.1",
  "webpack": "^4.29.0",
  "webpack-cli": "^3.2.1",
  "webpack-dev-server": "^3.1.14"
}

工程搭建

装置完了以上依赖,下一步是 webpack 的配置

  • babel-loader 相干配置负责兼容 ES6 及 JSX 解析

    • @babel/plugin-transform-react-jsx 插件中指定 pragma 配置为 createElement,前面会本人定制 createElement 办法,不改的话默认输入 React.createElement
  • css-loader 相干配置解决 css 文件资源
  • HtmlWebpackPlugin 负责输入 html 文件
  • 开启 watch 模式防止每次更改代码后手动编译
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  mode: 'development',
  entry: './index.js',
  watch: true,
  optimization: {minimize: false,},
  output: {path: path.join(__dirname, 'dist'),
    publicPath: './',
    filename: 'bundle.js',
    chunkFilename: '[name].js',
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [{ loader: 'style-loader'},
          {loader: 'css-loader',},
        ],
      },
      {
        test: /\.js$/,
        use: {
          loader: 'babel-loader',
          options: {presets: ['@babel/preset-env'],
            plugins: [
              [
                '@babel/plugin-transform-react-jsx',
                {pragma: 'createElement'},
              ],
            ],
          },
        },
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: 'toy-react',
      template: 'index.html',
    }),
  ],
  devServer: {contentBase: path.join(__dirname, '/dist/'),
    inline: true,
    host: 'localhost',
    port: 8080,
  },
}

目录构造

依据以下目录构造创立对应文件

toy-react
├── README.md
├── index.html
├── index.js         
├── package.json
├── style.css
├── toy-react-util.js
├── toy-react.js
└── webpack.config.js

执行 webpack 命令,试试成果

npx webpack 

打包输入后果如下,bundle.js 为最终打包文件,其中蕴含了 js 和 css
执行 index.html 能够间接拜访打包后的网页成果

dist
├── bundle.js
└── index.html

toy-react

实现的性能和 diff 比照逻辑比较简单,上面会依据代码片段列出对应的性能实现
以 react 官网提供的《Tic Tac Toe》小游戏为演示 demo,应用 toy-react 运行,成果如下:

createElement

通过 webpack 打包输入的 JSX 内容,能够看到 createElement 办法,一共蕴含了 3 个参数:

  • type

    • 依据 type 类型解决并返回不同类型的 element,目前只波及一般标签及文字内容,后续会对 ElementWrapper 和 TextWrapper 进行封装
  • attributes

    • 包含事件、class、自定义属性等
  • children

    • 子元素
/**
 * 创立 Element
 * @param {*} type element 类型: ElementWrapper or Component
 * @param {*} attributes element 属性: attributes or props
 * @param  {...any} children 子元素
 */
export const createElement = (type, attributes, ...children) => {
  let element
  typeof type === 'string'
    ? (element = new ElementWrapper(type))
    : (element = new type())

  // setProps
  for (let p in attributes) {element.setProps(p, attributes[p])
  }

  const insertChildren = children => {for (let child of children) {
      // text content
      if (typeof child === 'string') {child = new TextWrapper(child)
      }

      // element
      if (typeof child === 'object' && child instanceof Array) {insertChildren(child)
      } else {element.appendChild(child)
      }
    }
  }
  insertChildren(children)

  return element
}

Component Class

次要实现了以下几点:

  • get vdom

    • 初始化 vdom
  • props

    • 设置 props
  • appendChild

    • 插入子组件
  • RENDER_TO_DOM

    • 保留 range 和 vdom 进行 RENDER_TO_DOM
  • update

    • 比照更新 vdom 后 RENDER_TO_DOM
  • setState

    • 初始化和更新 state
/**
 * Component 类
 */
export class Component {constructor() {this.props = Object.create(null)
    this.children = []
    this._root = null
    this._range = null
  }

  get vdom() {return this.render().vdom
  }

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

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

  [RENDER_TO_DOM](range) {
    this._range = range
    this._vdom = this.vdom
    this._vdom[RENDER_TO_DOM](range)
  }

  update() {
    const vdom = this.vdom
    diff(this._vdom, vdom)
    this._vdom = vdom
  }

  setState(newState) {if (this.state === null || typeof this.state !== 'object')
      return (this.state = newState)

    merge(this.state, newState)
    this.update()}
}

update、diff 简易版实现

依据实在 dom 树结构,创立虚构 vdom,数据变更时会进行新旧 vdom 比照,并且将变动过的节点范畴 range 记录,递归比照实现后,依据变更的节点内容及 range 替换该范畴的 dom 内容,也就是RENDER_TO_DOM

  • 首先要比照新旧 vdom 是否雷同,这里采纳比较简单粗犷的形式,间接比照节点类型、节点属性,文字类型的节点判断 content,如果这几点产生扭转,间接 RENDER_TO_DOM
/**
 * 判断 node 是否雷同
 * @param {*} oldNode
 * @param {*} newNode
 */
export const isSameNode = (oldNode, newNode) => {
  // 判断 dom 类型
  if (oldNode.type !== newNode.type) return false

  // 判断 dom 属性
  for (let name in newNode.props) {if (newNode.props[name] !== oldNode.props[name]) 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
}
  • 如果上述几点没产生扭转,递归比照新旧 vdom 的 children,直到比照实现后 RENDER_TO_DOM,保留以后 vdom,实现 Component 的 update 操作;这里须要留神的是追加节点的边缘地位须要记录
/**
 * _vdom 比照
 * @param {*} oldNode 旧的 vdom
 * @param {*} newNode 新的 vdom
 */
export const diff = (oldNode, newNode) => {if (!isSameNode(oldNode, newNode))
    return newNode[RENDER_TO_DOM](oldNode._range)

  newNode._range = oldNode._range
  const newChildren = newNode.vchildren
  const oldChildren = oldNode.vchildren

  if (!newChildren || !newChildren.length) return

  // 结点边缘地位
  let tailRange = oldChildren[oldChildren.length - 1]._range

  for (let i = 0; i < newChildren.length; i++) {const newChild = newChildren[i]
    const oldChild = oldChildren[i]

    if (i < oldChildren.length) {diff(oldChild, newChild)
    } else {
      const range = getRange(
        tailRange.endContainer,
        tailRange.endOffset,
        tailRange.endContainer,
        tailRange.endOffset
      )
      // 追加 newChild 后批改 tailRange
      newChild[RENDER_TO_DOM](range)
      tailRange = range
    }
  }
}

/**
 * 替换 node
 * @param {*} range
 * @param {*} node
 */
export const replaceContent = (range, node) => {range.insertNode(node)
  range.setStartAfter(node)
  range.deleteContents()

  range.setStartBefore(node)
  range.setEndAfter(node)
}

setState

设置 state 实现形式是比照新旧状态,如果为初始化就间接用传入的状态,如果存在新旧状态就进行比照 merge,状态 merge 后调用 update 来对 dom 构造进行更新渲染

/**
 * 新旧状态比照替换
 * @param {*} oldState
 * @param {*} newState
 */
export const merge = (oldState, newState) => {for (let p in newState) {if (oldState[p] === null || typeof oldState[p] !== 'object') {oldState[p] = newState[p]
    } else {merge(oldState[p], newState[p])
    }
  }
}

ElementWraper Class

继承自 Component,实现以下几点:

  • get vdom

    • 设置 vchildren,确保不为 null,用于 RENDER_TO_DOM,
  • setAttribute

    • 处理事件、class、自定义属性等
  • RENDER_TO_DOM

    • 递归解决 vchildren 中的 vdom,依据取到的 range 进行 RENDER_TO_DOM
/**
 * Element 类
 */
class ElementWrapper extends Component {constructor(type) {super(type)
    this.type = type
  }

  get vdom() {
    this.vchildren = this.children.map(child => {if (child) return child.vdom
    })
    return this
  }

  setAttribute(root) {for (let name in this.props) {const value = this.props[name]

      // 过滤 on 事件命名所有字符
      if (name.match(/^on([\s\S]+)$/)) {
        // 按小写字母解决
        root.addEventListener(RegExp.$1.replace(/^[\s\S]/, c => c.toLowerCase()),
          value
        )
      } else {if (name === 'className') {root.setAttribute('class', value)
        } else {root.setAttribute(name, value)
        }
      }
    }
  }

  [RENDER_TO_DOM](range) {
    this._range = range
    const root = document.createElement(this.type)
    this.setAttribute(root)

    if (!this.vchildren) this.vchildren = this.children.map(child => child.vdom)
    for (let child of this.vchildren) {if (child) {child[RENDER_TO_DOM](getRange(root, root.childNodes.length, root, root.childNodes.length)
        )
      }
    }
    replaceContent(range, root)
  }
}

TextWrapper Class

继承自 Component,实现以下几点:

  • vdom

    • 获取 vdom
  • RENDER_TO_DOM

    • 依据 range 从新替换 dom
/**
 * Text 类
 */
class TextWrapper extends Component {constructor(content) {super(content)
    this.type = '#text'
    this.content = content
  }

  get vdom() {return this}

  [RENDER_TO_DOM](range) {
    this._range = range
    const root = document.createTextNode(this.content)
    replaceContent(range, root)
  }
}

render

Component 通过 RENDER_TO_DOM 渲染到 parentElement 中,range 间接应用 parentElement 的即可

/**
 * 渲染函数
 * @param {*} component
 * @param {*} parentElement
 */
export const render = (component, parentElement) => {component[RENDER_TO_DOM](
    getRange(
      parentElement,
      0,
      parentElement,
      parentElement.childNodes.length,
      true
    )
  )
}

// 将定义好的 Game 组件传入 render, 渲染到 id 为 root 的父元素中
render(<Game />, document.getElementById('root'))

总结

实现以上流程,曾经初步理解了 vdom 的工作原理,能够作为学习 react、vue 等框架源码的参考,后续很多细节须要去学习、补充,例如:

  • vdom 的比照精度不够细
  • 渲染形式毛糙
  • 生命周期不够欠缺
  • hooks

????????????

参考资料

  • Web API:Range
  • 残缺工程
退出移动版