关于前端:从零实现极简版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
  • 残缺工程

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理