模拟 React 01 初始化项目、Virtual DOM介绍和创建

初始化项目

通过模拟一个精简版的 React 学习 Virtual DOM、Diff 算法、生命周期等知识。

首先初始化项目,使用 webpack 打包工具。

目录结构

├─ src
│   ├─ TinyReact		# 模拟 React 的文件
│   │   └─ index.js	# 空的 JavaScript 文件
│   ├─ index.html
│   └─ index.js			# 空的入口文件
├─ package.json
└─ webpack.config.js

文件内容

package.json

{
  "name": "tiny-react",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "webpack-dev-server"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@babel/core": "^7.11.4",
    "@babel/preset-env": "^7.11.0",
    "@babel/preset-react": "^7.10.4",
    "babel-loader": "^8.1.0",
    "clean-webpack-plugin": "^3.0.0",
    "html-webpack-plugin": "^4.3.0",
    "webpack": "^4.44.1",
    "webpack-cli": "^3.3.12",
    "webpack-dev-server": "^3.11.0"
  },
  "dependencies": {}
}

webpack.config.js

const path = require("path")
const HtmlWebpackPlugin = require("html-webpack-plugin")
const { CleanWebpackPlugin } = require("clean-webpack-plugin")

module.exports = {
  entry: "./src/index.js",
  output: {
    path: path.resolve("dist"),
    filename: "bundle.js"
  },
  devtool: "inline-source-map",
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: "babel-loader"
      }
    ]
  },
  plugins: [
    // 在构建之前将dist文件夹清理掉
    new CleanWebpackPlugin({
      cleanOnceBeforeBuildPatterns: ["./dist"]
    }),
    // 指定HTML模板, 插件会将构建好的js文件自动插入到HTML文件中
    new HtmlWebpackPlugin({
      template: "./src/index.html"
    })
  ],
  devServer: {
    // 指定开发环境应用运行的根目录
    contentBase: "./dist",
    // 指定控制台输出的信息
    stats: "errors-only",
    // 不启动压缩
    compress: false,
    host: "localhost",
    port: 5000
  }
}

src/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>TinyReact</title>
    <link
      href="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap.min.css"
      rel="stylesheet"
    />
  </head>
  <body></body>
</html>

安装&启动

npm i
npm start
# 访问 http://localhost:5000

JSX 到底是什么

使用 React 就一定会写 JSX,JSX 到底是什么?

它是一种 JavaScript 语法的扩展,React 使用它来描述用户界面的样子。

它看起来非常像 HTML,在 React 代码执行之前,Babel 会将 JSX 编译为 React API,使得浏览器支持。

<div className="container">
	<h3>Hello React</h3>
  <p>React is great</p>
</div>
React.createElement(
	'div',
  {
    className: 'container'
  },
  React.createElement('h3', null, 'Hello React'),
  React.createElement('p', null, 'React is great')
)

从两种语法对比来看,JSX 语法的出现是为了让 React 开发人员编写用户界面代码更加轻松。

Babel/repl

<div className="container">
  <h2>Hello World</h2>
</div>

function App () {
	return <div>Hello React</div>
}

在这里插入图片描述

JSX 代码被转换的过程:

  1. JSX 会先被 Babel 转换为 React.createElement() 的调用
  2. React.createElement() 方法会返回一个 Virtual DOM 对象
  3. React 会将 Virtual DOM 转换为真实的 DOM 对象,并显示到页面中

DOM 操作问题

在现代 web 应用程序中使用 JavaScript 操作 DOM 是必不可少且频繁的。

遗憾的是这个操作是非常消耗性能的, JavaScript 操作 DOM 对象远比操作其他对象要慢的多。

大多数 JavaScript 框架对于 DOM 的更新远远超过其必须进行的更新,从而使得这种缓慢操作变得更糟。

举个例子,你有一个数组,数组中存储了10项内容,通过这10项内容生成了10个 <li>,当数组中某一项内容发生变化时,大多数 JavaScript 框架会根据这个数组重新构建整个列表,这比必要的工作(只更新一项)多出了十倍。

更新效率低下已经成为严重问题,为了解决这个问题,React 普及了一种叫做 Virtual DOM 的东西。

Virtual DOM 就是为了提高 JavaScript 操作 DOM 对象的效率。

什么是 Virtual DOM

Virtual DOM 对象(虚拟 DOM)是 DOM 对象的 JavaScript 对象表现形式。

其实就是使用 JavaScript 对象来描述 DOM 对象信息,比如 DOM 对象的类型、属性、子元素等。

在 React 中,每个 DOM 对象都有一个对应的 Virtual DOM 对象。

可以把 Virtual DOM 对象理解为 DOM 对象的副本,但是它不能直接显示在屏幕上。

<div className="container">
	<h3>Hello React</h3>
  <p>React is great</p>
</div>
{
  // type: 节点的类型
  type: 'div',
  // props: 节点的属性
  props: { className: 'container' },
  // children: 节点的子节点
  children: [
    {
      type: 'h3',
      props: null,
      children: [
        {
          type: 'text',
          props: {
            textContent: 'Hello React'
          }
        }
      ]
    },
    {
      type: 'p',
      props: null,
      children: [
        {
          type: 'text',
          props: {
            textContent: 'React is great'
          }
        }
      ]
    }
  ]
}

Virtual DOM 如何提升效率

Virtual DOM 最核心的原则就是最小化 DOM 操作,精准找出发生变化的 DOM 对象,只更新发生变化的部分

在 React 第一个创建 DOM 对象后,会为每个 DOM 对象创建其对应的 Virtual DOM 对象,在 DOM 对象发生更新之前,React 会先更新所有的 Virtual DOM 对象,然后 React 会将更新后的 Virtual DOM 和 更新前的 Virtual DOM 进行比较,从而找出发生变化的部分,React 会将发生变化的部分更新到真实的 DOM 对象中,React 仅更新必要更新的部分。

Virtual DOM 对象的更新和比较仅发生在内存中,不会在视图中渲染任何内容,所有这一部分的性能损耗成本是微不足道的。

并且 JavaScript 操作 JavaScript 对象相比操作 DOM 对象是非常快的,所以这提高了操作 DOM 的性能。

更新前的 JSX:

<div id="container">Hello World</div>

更新后的 JSX:

<div id="container">Hello React</div>

更新前的 Virtual DOM:

const before = {
  type: 'div',
  props: null,
  children: [
    {
      type: 'text',
      props: {
        textContent: 'Hello World'
      }
    }
  ]
}

更新后的 Virtual DOM:

const before = {
  type: 'div',
  props: null,
  children: [
    {
      type: 'text',
      props: {
        textContent: 'Hello React'
      }
    }
  ]
}

两个 Virtual DOM 对比后仅会更新 DOM 的文本内容,而不会更新整个 DOM 树(整个<div>)。

创建 Virtual DOM

Virtual DOM 对象是由 JSX 转换来的。

替换编译 JSX 时使用的函数

在 React 代码执行前,JSX 会先被 Babel 转换为 React.createElement() 方法的调用,Babel 会向这个方法传入元素的类型、属性、子元素作为参数。

而当前我们要模拟一个精简版的 createElement 方法:TinyReact.createElement,需要配置 Babel(.babellc),替换编译 JSX 表达式时使用的函数(progma:默认 React.createElement):

{
  "presets": [
    "@babel/preset-env",
    [
      "@babel/preset-react",
      {
        "pragma": "TinyReact.createElement"
      }
    ]
  ]
}

也可以使用行注释,Babel/repl

在这里插入图片描述

createElement()

createElement 方法根据传递的参数(元素的类型、属性、子节点)返回一个 Virtual DOM 对象,对象也要包含:

  • type:表示节点的类型
  • props:表示节点的属性
  • children:表示子节点

新建 src/TinyReact/createElement.js文件定义 createElement() 方法:

/**
 * 创建 Virtual DOM
 * @param {string} type 类型
 * @param {object | null} props 属性
 * @param  {createElement[]} children 子元素
 * @return {object} Virtual DOM
 */
export default function createElement (type, props, ...children) {
  return {
    type,
    props,
    children
  }
}

src/TinyReact/index.js中导入这个方法:

import createElement from './createElement'

export default {
  createElement
}

src/index.js 编写 demo 测试结果:

import TinyReact from './TinyReact'

const virtualDOM = (
  <div className="container">
    <h1>你好 Tiny React</h1>
    <h2 data-test="test">(编码必杀技)</h2>
    <div>
      嵌套1 <div>嵌套 1.1</div>
    </div>
    <h3>(观察:这个将会被改变)</h3>
    {2 == 1 && <div>如果2和1相等渲染当前内容</div>}
    {2 == 2 && <div>2</div>}
    <span>这是一段内容</span>
    <button onClick={() => alert('你好')}>点击我</button>
    <h3>这个将会被删除</h3>
    2, 3
    <input type="text" value="13" />
  </div>
)

console.log(virtualDOM)

在这里插入图片描述

现在,我们创建了一个 Virtual DOM 对象,但是里面还有一些问题:

  1. 文本节点是以字符串形式存在的,例如 "你好 Tiny React""2, 3"
  2. 过滤JS 表达式结果为 false 的节点。
  3. 无法通过 props.children 获取子节点。

文本节点处理

现在 Virtual DOM 对象中的文本节点是以字符串形式存在的。

这不符合我们的要求:文本节点也要以一个对象的形式表现,例如 {type: "text", textContent: "2, 3"}

扩展 createElement 方法:

/**
 * 创建 Virtual DOM
 * @param {string} type 类型
 * @param {object | null} props 属性
 * @param  {createElement[]} children 子元素
 * @return {object} Virtual DOM
 */
export default function createElement (type, props, ...children) {
  const childElements = [].concat(...children).map(child => {
    if (child instanceof Object) {
      return child
    } else {
      // 文本节点
      return createElement("text", { textContent: child })
    }
  })
  return {
    type,
    props,
    children: childElements
  }
}

在这里插入图片描述

过滤 false 节点

在 JSX 中,结果为 Booleannull 的 JS 表达式不会被渲染出来。

为了精简操作,我们直接对生成的 Virtual DOM 对象进行过滤,以实现不执行渲染的结果(React 不是这么处理的)。

/**
 * 创建 Virtual DOM
 * @param {string} type 类型
 * @param {object | null} props 属性
 * @param  {createElement[]} children 子元素
 * @return {object} Virtual DOM
 */
export default function createElement (type, props, ...children) {
  const childElements = [].concat(...children).reduce((result, child) => {
    if (child !== false && child !== true && child !== null) {
      if (child instanceof Object) {
        result.push(child)
      } else {
        // 文本节点
        result.push(createElement("text", { textContent: child }))
      }
    }
    return result
  }, [])
  return {
    type,
    props,
    children: childElements
  }
}

通过 props 获取子节点

在 React 组件中,可以通过 props.children 获取组件的子节点。

/**
 * 创建 Virtual DOM
 * @param {string} type 类型
 * @param {object | null} props 属性
 * @param  {createElement[]} children 子元素
 * @return {object} Virtual DOM
 */
export default function createElement (type, props, ...children) {
  const childElements = [].concat(...children).reduce((result, child) => {
    if (child !== false && child !== true && child !== null) {
      if (child instanceof Object) {
        result.push(child)
      } else {
        // 文本节点
        result.push(createElement("text", { textContent: child }))
      }
    }
    return result
  }, [])
  return {
    type,
    props: Object.assign({children: childElements}, props),
    children: childElements
  }
}

在这里插入图片描述

总结

  • createElement 方法用于创建一个 Virtual DOM 对象。
  • 在创建 Virtual DOM 对象的时候,要将文本节点也转换成一个 JS 对象。
  • 返回值为 Booleannull 的节点不会渲染到视图中,所以要过滤掉。
  • 在组件中要通过 props.children属性获取它的子节点,所以要给 props 添加 children 属性。

普通 Virtual DOM 对象转换为真实 DOM 对象

现在要实现将普通 Virtual DOM 对象转换为真实 DOM 对象,并且将转换后的 DOM 展示到页面当中。

这里的 Virtual DOM 对象指的是原生 DOM 转化的对象(不是组件转化的)。

要实现这个需求就要用到 render 方法。

创建、导入、调用这个方法

// src/TinyReact/render.js
export default function render(virtualDOM, container, oldDOM) {}
// src/TinyReact/index.js
import createElement from './createElement'
import render from './render'

export default {
  createElement,
  render
}
// src/index.js
import TinyReact from './TinyReact'

// 容器
const root = document.querySelector('#root')

const virtualDOM = (
  ...
)

TinyReact.render(virtualDOM, root)

console.log(virtualDOM)

<!-- src/index.html -->
<body>
  <div id="root"></div>
</body>

补充方法调用链

Virtual DOM 转化为真实 DOM 并渲染到页面之前需要与旧的 DOM 进行对比(Diff)。

// src/TinyReact/render.js
import diff from './diff'
export default function render(virtualDOM, container, oldDOM) {
  diff(virtualDOM, container, oldDOM)
}

首先判断是否不存在旧的 DOM,即是否首次渲染,如果是则执行挂载:

// src/TinyReact/diff.js
import mountElement from './mountElement'
export default function diff(virtualDOM, container, oldDOM) {
  // 判断 oldDOM 是否存在
  if (!oldDOM) {
    mountElement(virtualDOM, container)
  }
}

加载元素的时候还要判断是组件还是原生 DOM(普通的 Virtual DOM),当前仅处理原生的 DOM:

// src/TinyReact/mountElement.js
import mountNativeElement from './mountNativeElement'
export default function mountElement(virtualDOM, container) {
  // Component VS NativeElement
  mountNativeElement(virtualDOM, container)
}

// src/TinyReact/mountElement.js
export default function mountNativeElement(virtualDOM, container) {}

mountNativeElement

  1. 判断节点类型
    1. 元素:创建元素节点
    2. 文本:创建文本节点
  2. 递归创建子节点
  3. 将转换之后的 DOM 对象放置到页面中
// src/TinyReact/mountElement.js
import mountElement from './mountElement'
export default function mountNativeElement(virtualDOM, container) {
  let newElement = null
  if (virtualDOM.type === 'text') {
    // 文本节点
    newElement = document.createTextNode(virtualDOM.props.textContent)
  } else {
    // 元素节点
    newElement = document.createElement(virtualDOM.type)
  }

  // 递归创建子节点
  virtualDOM.children.forEach(child => {
    mountElement(child, newElement)
  })

  // 将转换之后的 DOM 对象放置到页面中
  container.appendChild(newElement)
}

现在打开页面可以看到 Virtual DOM 被正常渲染到页面中了。

创建节点的方法在其他地方也会用到,所以这里将它单独作为一个方法 createDOMElement() 提取出来:

// src/TinyReact/mountElement.js
import createDOMElement from './createDOMElement'
export default function mountNativeElement(virtualDOM, container) {
  const newElement = createDOMElement(virtualDOM)

  // 将转换之后的 DOM 对象放置到页面中
  container.appendChild(newElement)
}

// src/TinyReact/createDOMElement.js
import mountElement from './mountElement'
export default function createDOMElement(virtualDOM) {
  let newElement = null

  if (virtualDOM.type === 'text') {
    // 文本节点
    newElement = document.createTextNode(virtualDOM.props.textContent)
  } else {
    // 元素节点
    newElement = document.createElement(virtualDOM.type)
  }

  // 递归创建子节点
  virtualDOM.children.forEach(child => {
    mountElement(child, newElement)
  })

  return newElement
}

总结

  1. 在 HTML 文件中添加了一个root容器,用于放置 Virtual DOM 转换的真实DOM。
  2. render()方法用于将 Virtual DOM 转换的真实 DOM,并放置到容器中。
  3. render()方法是框架向外部提供开发者使用的方法,其中使用了一些内部方法,例如 diff()
  4. diff()方法接受3个参数:
    1. 要转换的 Virtual DOM
    2. 转换后要放置的位置
    3. 页面中已经存在的旧的 DOM 节点
  5. diff() 中要进行判断,如果存在旧的 DOM 节点则进行比对,如果不存在则直接挂载mountElement()
  6. mountElement()挂载DOM要判断当前是组件 Virtual DOM 还是普通的 Virtual DOM,执行相应的处理(当前只处理了普通的 Virtual DOM)。
  7. 如果是普通的 Virtual DOM 则调用 mountNativeElement() 转换为真实 DOM 并展示到页面中。
  8. mountNativeElement()
    1. 先创建一个 newElement 变量用于存储创建的节点
    2. 然后判断节点类型 type,创建相应的节点node
    3. 然后还要递归转换当前节点的子节点,继续调用 mountElement() 方法
    4. 最后将转换后的 DOM 对象(newElement)放置到页面中

为 DOM 对象添加属性

上面转换的真实 DOM 对象上是没有属性的,如: classdata-testonclicktypevalue

属性被存储在 Virtual DOM 对象的 props 属性上。

当节点被创建后,我们要为其添加属性。

在添加属性的时候还要进行一些判断:

  • 是否是事件属性
    • 根据属性名是否以on开头判断
    • 然后使用 addEventListener() 添加事件处理函数
  • 是否是 checkedvalue 属性,无法使用 setAttribute() 设置
  • 是否是 children 属性,它根本不是属性,而是提供给 React 元素,用于获取子元素的。
  • 是否是 className,添加 class 属性。
  • 普通属性用 setAttribute() 方法设置即可
// src/TinyReact/createDOMElement.js
import mountElement from './mountElement'
import updateNodeElement from './updateNodeElement'
export default function createDOMElement(virtualDOM) {
  let newElement = null

  if (virtualDOM.type === 'text') {
    // 文本节点
    newElement = document.createTextNode(virtualDOM.props.textContent)
  } else {
    // 元素节点
    newElement = document.createElement(virtualDOM.type)

    updateNodeElement(newElement, virtualDOM)
  }

  // 递归创建子节点
  virtualDOM.children.forEach(child => {
    mountElement(child, newElement)
  })

  return newElement
}

// src/TinyReact/updateNodeElement.js
export default function updateNodeElement(newElement, virtualDOM) {
  // 获取节点对应的属性对象
  const newProps = virtualDOM.props

  Object.keys(newProps).forEach(propName => {
    // 获取属性值
    const newPropsValue = newProps[propName]

    if (propName.startsWith('on')) {
      // 判断属性是否是事件属性
      // 事件名称 onClick -> click
      const eventName = propName.toLowerCase().slice(2)
      // 为元素添加事件
      newElement.addEventListener(eventName, newPropsValue)
    } else if (propName === 'value' || propName === 'checked') {
      // 判断是否是不能用 setAttribute() 设置的属性
      newElement[propName] = newPropsValue
    } else if (propName !== 'children') {
      // 过滤 children 属性
      if (propName === 'className') {
        newElement.setAttribute('class', newPropsValue)
      } else {
        newElement.setAttribute(propName, newPropsValue)
      }
    }
  })
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值