用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的本质便是:
虚拟DOM是Object类型的对象
虚拟DOM无需真实DOM的诸多属性
虚拟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; } } 复制代码
如此一来, 打印在页面上的效果就会是下面这样的, 点击每一个节点, 发现下点击事件也是可以用的.
以上, DOM渲染篇结束. 感兴趣的也可以去我的github查看源码(更新到Diff算法, 文章整理中Q_Q)
作者:winoooops
链接:https://juejin.cn/post/7054448229538070564