阅读 114

用TypeScript手摸手造一个React轮子(DOM元素渲染篇)

用TypeScript手摸手造一个React轮子(DOM元素渲染篇)

本篇文章是在阅读小村儿大佬的react学习系列之后自己的实践和补充, 正好最近也想通过用Typescript造轮子的过程加深对TS和类型思想的理解, 毕竟React对TS的支持度还是很高的(点名批评Vue). 理解源码最好的方式可能就是自己造一个. 这里大部分是我对代码思路的一些整理和提炼, 希望也能对你有所帮助. 如果有哪里不对或者不准确的地方, 也希望你能够毫不吝啬地指出来????

目录

  • 项目准备

  • Why VirtualDOM

  • VitualDOM in a Nutshell

  • 1. createElement

  • 2. 渲染DOM元素

项目准备

  • tsconfig.json: 基本就是tsc --init 生成的, 目前只需要确保jsx选项用的是“preserve”即可.

{     "compilerOptions": {         "target": "es2016",          "jsx": "preserve",          "module": "commonjs",         "esModuleInterop": true,          "strict": true,         "forceConsistentCasingInFileNames": true,         "skipLibCheck": true     } } 复制代码

  • 文件结构

├─demo └─src |  ├─MyReact // 具体实现的代码放这里 |  └─shared // 一些辅助函数和TS类型 复制代码

  • 安装所需依赖:

    • React和TS: yarn add react typescript

    • Webpack相关: yarn add -D webpack webpack-cli webpack-dev-server style-loader sass-loader node-sass css-loader clean-webpack-plugin html-webpack-plugin babel-plugin-react-transform babel-loader @babel/core @babel/preset-env @babel/preset-react

    • TS代码提示: yarn add -D @types/react @types/dom

  • webpack.config.js

    const path = require("path") const HtmlWebpackPlugin = require("html-webpack-plugin") const { CleanWebpackPlugin } = require("clean-webpack-plugin") module.exports = {   mode: 'development',   entry: "./demo/index.tsx",   output: {     path: path.resolve("dist"),     filename: "bundle.js",     // devtoolModuleFilenameTemplate: '../[resource-path]'   },   // 需要解析的文件类型   resolve: {     extensions: ['.ts', '.tsx', '.json', '.js'],   },   devtool: "inline-source-map",   module: {     rules: [       {         test: /\.tsx?$/,         use: ['babel-loader', 'ts-loader'],       },       {         test: /\.scss?$/,         use: ['style-loader', 'css-loader', 'sass-loader']       }     ]   },   plugins: [     // 在构建之前将dist文件夹清理掉     new CleanWebpackPlugin({       cleanOnceBeforeBuildPatterns: ["./dist"]     }),     // 指定HTML模板, 插件会将构建好的js文件自动插入到HTML文件中     new HtmlWebpackPlugin({       template: "./demo/index.html"     })   ],   devServer: {     // 指定开发环境应用运行的根据目录     // contentBase: "./dist",     // 指定控制台输出的信息     // stats: "errors-only",     // 不启动压缩     compress: false,     host: "localhost",     port: 5000,     hot: true,   } } 复制代码

Why VirtualDOM

用脚本进行DOM操作的代价很昂贵.有个贴切的比喻,把DOM和JavaScript各自想象为一个岛屿,它们之间用收费桥梁连接,js每次访问DOM,都要途径这座桥,并交纳“过桥费”,访问DOM的次数越多,费用也就越高. 因此,推荐的做法是尽量减少过桥的次数,努力待在ECMAScript岛上. 现代浏览器使用JavaScript操作DOM是必不可少的,但是这个动作是非常消耗性能的,因为使用JavaScript操作DOM对象要比JavaScript操作普通对象要慢很多,页面如果频繁的DOM操作会造成页面卡顿,应用流畅度降低,造成非常不好的体验.

Virtual DOM其实本质上就是React用来描述DOM对象的JavaScript对象,使用Virtual DOM的最主要原因便是提升效率——通过精确的找出发生变化的DOM对象,从而在在最少程度上减少直接操作DOM的次数.

VitualDOM in a Nutshell

用三句话总结虚拟DOM的本质便是:

  1. 虚拟DOM是Object类型的对象

  2. 虚拟DOM无需真实DOM的诸多属性

  3. 虚拟DOM最终会被React转化为真实DOM

借助babel,我们可以很清楚的看到jsx是怎样被编译的

Babel编译虚拟DOM

// jsx代码 <div className="container">   <h3>Hello React</h3>   <p>React is great</p> </div> // babel 编译过后 React.createElement (   "div",   {     className: "container"   },   React.createElement("h3", null, "Hello React"),   React.createElement("p", null, "React is great") ) 复制代码

虚拟DOM的基本结构

而此时,如果我们在console.log中打印出上面这段jsx代码, 可以看到对应虚拟DOM的基本结构

{   type: "div",   props: { className: "container" },   children: [     {       type: "h3",       props: null,       children: [         {           type: "text",           props: {             textContent: "Hello React"           }         }       ]     },     {       type: "p",       props: null,       children: [         {           type: "text",           props: {             textContent: "React is great"           }         }       ]     }   ] } 复制代码

1. createElement

为了了解createElement实现的原理,我们需要自己写一个简单的createElement方法,首先在react项目中的.babelrc中指明自定义的方法

// .babelrc {   "presets": [     "@babel/preset-env",     [       "babel/preset-react",       {         "pragma": "MyReact.createElement"       }     ]   ] } 复制代码

这样一来虚拟DOM都会通过MyReact.createElement这个方法被构造.

为了让createElement返回的对象符合React虚拟DOM的数据结构,createElement需要参照上一节中虚拟DOM的基本结构来构造这个函数的返回.

/* MyReact/MyReactCreateElement.ts */ /**  *   * @param type 元素类型  * @param props 属性  * @param children 子元素   * @returns   */ export const createElement = (type: any, props: any, ...children: any): MyReactElement => {   // 对子元素进行处理   const childElements = children.map((child: any) => {     // 如果子元素为虚拟DOM对象,直接返回     if (child instanceof Object) {       return child     }     // 如果子元素为纯文本,将文本储存在props.textContent中返回     else {       return createElement('text', { textContent: child })     }   })   // props 中必须保存children信息    props = Object.assign({}, props, { children: childElements })   // 这两个属性后期会用到   const key = props.key || null   const ref = props.ref || null   return {     type,     props,     key,     ref,   } } 复制代码

这里还有几个以后会用到的类型 :

/* shared/MyReactTypes.ts */ import { createElement, createRef } from "react" export interface MyReactElement {   type: any,   props: { [key: string]: any },   key: any | null,   ref?: MyRef<any>;   component?: MyReactComponent; } export interface MyReactComponent {   [key: string]: any; } export type MyHTMLElement = HTMLElement & { __virtualDOM: MyReactElement } | HTMLInputElement & { __virtualDOM: MyReactElement } // createRef构造的对象 export interface MyRefObject<T> {   readonly current: T | null; } // 函数式的ref export type MyRefCallback<T> = (instace: T) => {} // 现在可使用ref对象,ref回调和ref字符串的形式定义ref export type MyRef<T> = MyRefObject<T> | MyRefCallback<T> | String | null   复制代码

2. 渲染DOM元素

我们先用createElement来渲染几个DOM元素看看, 这里首先需要对DOM元素的类型进行判断——如果为文本类型,把文本放到props.textContent里面;如果是DOM元素,先用document.createElement创造元素,然后根据传进来的props键值对的key来分类型地添加DOM属性;

在创建DOM元素的同时我们还需要保存下渲染出这个DOM元素的虚拟DOM,这是之后Diff算法实现重要的一步.

2.1 添加DOM元素

/**  * 渲染原生DOM元素  * @param virtualDOM 虚拟DOM  * @param container 父容器   */ export const mountDOMElement = (virtualDOM: MyReactElement, container: HTMLElement | null) => {   let newElement: any   const { type, props } = virtualDOM   // 为纯文本   if (type === 'text') {     newElement = document.createTextNode(props?.textContent)   }   // 为DOM元素   else {     // 创建元素     newElement = document.createElement(type)     // 更新属性     attachProps(virtualDOM, newElement)     // 递归渲染子元素     props?.children.forEach((child: MyReactElement) => {       mountDOMElement(child, newElement)     })   }   //* 创建DOM元素的时候记录下当前的虚拟DOM, 这个以后会用到   newElement.__virtualDOM = virtualDOM   // 创建完之后添加到父容器中   container?.appendChild(newElement) } 复制代码

2.2 给DOM元素添加props属性

在添加props属性的时候,需要判断下面几个特殊情况

  • 如果有事件属性,需要添加事件

  • 如果有有value或者checked属性直接赋值(无法直接使用setAttribute生成)

  • 如果有className属性,添加class样式

  • 如果有ref属性,这个以后处理

除此之外的属性其他一律使用Element.setAttribute()方法添加

 /**  * 更新props属性  * @param virtualDOM   * @param element   */ export const attachProps = (virtualDOM: MyReactElement, element: MyHTMLElement) => {   // 获取props键值对   const props: { [key: string]: any } = virtualDOM.props   const keys = Object.keys(props)   // 遍历属性   keys && keys.forEach((propName: string) => {     updateProp(propName, props[propName], element)   }) } /**  * 更新单个属性  * @param propName   * @param propValue   * @param element   * @returns   */ export const updateProp = (propName: string, propValue: any, element: MyHTMLElement) => {   // 如果是children 跳过   if (propName === 'children') return   // 事件以‘on’开头   if (propName.slice(0, 2) === 'on') {     const eventName = propName.toLocaleLowerCase().slice(2)     element.addEventListener(eventName, propValue)   }   // className 附加属性   else if (propName === 'className') {     element.setAttribute('class', propValue)   }   // ref 接受string或者回调函数   else if (propName === 'ref') {     //     }   // value或者checked属性   else if (propName === 'value') {     // element.value     (element as HTMLInputElement).value = propValue   }   else if (propName === 'checked') {     (element as HTMLInputElement).checked = propValue   }   // 其他   else {     element.setAttribute(propName, propValue)   } } 复制代码

2.3 实现渲染: MyReact.render()

我们知道在React中render函数都是以ReactDOM.render(<App/>, root)这种形式出现的,第一个参数<App/>首先会被我们自定义的createElement经由Babel编译成虚拟DOM,第二个参数是父容器.那么仿造此种写法我们就可以实现一个简单的render:

export const render = (virtualDOM: MyReactElement, container: HTMLElement) => {   // 渲染原生DOM元素   mountDOMElement(virtualDOM, container) } 复制代码

现在我们就来实际测试一下结果:

/* demo/index.tsx */ import React from "react"; import * as MyReact from "../src/MyReact"; import { MyHTMLElement } from "../src/shared/MyReactTypes"; import './styles.scss' const vDOM = (   <div className="container">     <ul className="todos" ref="todos">       <li className="completed" onClick={() => alert('completed')}>createElement</li>       <li className="completed" onClick={() => alert('completed')} >rendering DOM</li>       <li className="ongoing" onClick={() => alert('ongoing')} >rendering Component</li>       <li className="todo" onClick={() => alert('todo')} >diff</li>       <li className="todo" onClick={() => alert('todo')} >state</li>     </ul>   </div> ) const root = document.getElementById('app') as MyHTMLElement MyReact.render(vDOM, root) 复制代码

为了测试props属性是否生效, 这段tsx中还需要加入了一些简单的样式和点击alert事件

所以在同目录的styles.scss

.todos {   .completed {     color: CornflowerBlue;   }   .ongoing {     color: DarkSalmon;   } } 复制代码

如此一来, 打印在页面上的效果就会是下面这样的, 点击每一个节点, 发现下点击事件也是可以用的.

Screen Shot 2022-01-18 at 3.33.36 PM.png

以上, DOM渲染篇结束. 感兴趣的也可以去我的github查看源码(更新到Diff算法, 文章整理中Q_Q)


作者:winoooops
链接:https://juejin.cn/post/7054448229538070564


文章分类
代码人生
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐