Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

从零实现简单的 React #73

Open
yinguangyao opened this issue Nov 9, 2021 · 0 comments
Open

从零实现简单的 React #73

yinguangyao opened this issue Nov 9, 2021 · 0 comments

Comments

@yinguangyao
Copy link
Owner

1. 前言

这是 React 系列的最后一篇,在前面我们讲了很多 React 相关的技术栈,也带大家理解了 React Hooks、Redux、Mobx 等原理,今天就带领大家来攻克 React 的原理。
由于篇幅有限,本文只是简单地实现了一个“乞丐版”的 React,方便引导大家对 React 后续的深入学习。
我将这个这个简单的 React 放到了我的 GitHub 上面:simple-react

2. 编译 JSX

在开始写之前,我们再来回顾一下 JSX 的语法:

import React from 'react'
import ReactDOM from 'react-dom'

const App = () => <h1>hello, world</h1>
ReactDOM.render(
    <App />,
    document.querySelector('root')
)

这里声明了一个 App 组件,很多初学者都会好奇,为什么没有用到 React 变量,我们还要引入它呢?

2.1 transform-react-jsx

这是因为,浏览器是无法识别 JSX 语法的,需要借助 Babel 来编译为浏览器可以识别的语法。
在这里我们用到了一个 Babel 插件,那就是 @babel/plugin-transform-react-jsx,它会把 JSX 语法编译为 React.createElement 的形式,这就是为什么我们没有用到 React 变量,还要手动引入。
被编译之后的代码结构如下:

function App() {
  return react.createElement("h1", null, "hello, world");
};
ReactDOM.render(
    react.createElement(App, null),
    document.querySelector('root')
)

第一个参数是组件的类型,第二个是组件接收的 props,后面的都是组件的 children

2.2 Virtual DOM

如果我们使用类似 JSON 的格式来描述上述代码,应该是这样的:

// ReactDOM.render
{
    type: App,
    props: null,
}
// App
{
    type: App,
    props: null,
    children: ["hello, world"]
}

如果 children 也是个 JSX 元素,那么这就形成了一个树形结构,也就是传说中的“虚拟 DOM”(Virtual DOM)。
虚拟 DOM 并不是真正的 DOM,它只是在浏览器渲染真实 DOM 之前存在的描述页面结构的数据结构。而虚拟 DOM 也不能使页面速度变快,真正能提高性能的是虚拟 DOM 结合 diff 算法之后带来的高效更新。

3. Virtual DOM、Element、Component

很多初学者往往分不清这三者的区别,这里来帮大家辨别一下这三者的区别。

image_1e7v974n0a9d10nsessq8g1mtj9.png-14.8kB

前面我们讲过了 Virtual DOM,它就是 Babel 编译 JSX 之后运行 React.createElement 得到的结果,它是一种数据结构。
Element 则是指 JSX 元素,也就是我们常说的 JSX 语法,下面的 header 变量就是一个 JSX Element。

const header = <h1>hello, world</h1>

Component 则是指 React 组件,也就是我们继承 React.Component 之后的类组件或者函数组件。

const Header = () => <h1>hello, world</h1>
<Header />

那么 Element 和 Component 有什么区别呢?从上面代码中看到两者区别只是一个箭头函数。
实际上,Header 组件返回的就是 JSX Element,而 Element 经过 Babel 编译执行 createElement 之后就是虚拟 DOM,而虚拟 DOM 的 type 属性就指向了组件。

4. 实现 React

弄清楚上面关于三者的区别之后,我们会发现流程清晰了很多。
一个 React 应用入口都是从 ReactDOM.render 开始,这里初次渲染将 DOM 插入到给定的节点之中。
主要做了下面几步:

  1. 解析 ReactDOM.render 中的 JSX 语法,编译为虚拟 DOM。
  2. 根据虚拟 DOM 的 type,来将传入的 props 绑定到组件中,并拿到组件返回的虚拟 DOM。
  3. 递归重复步骤2
  4. 如果已经没有子组件了,那么就将当前的虚拟 DOM 渲染到真实 DOM 当中。

那么我们就开始按照上面这几步来一步步实现我们的简易版 React。

4.1 createElement

编写 React 当然应该从 createElement 开始做起,这个函数可以写的非常简单,只将接收到的参数原封不动返回就行了。

export function createElement(type, props, ...children) {
    return {
        children,
        props,
        type
    }
}

4.2 ReactDOM.render

这是整个 React 应用的入口,主要是将我们渲染创建好的 DOM 插入到给定的真实 DOM 节点之中。

export const render = (vnode, root) => {
    root.appendChild(_render(vnode))
}

我们主要来实现这个 _render 方法。它做了哪些事情呢?

  1. 遇到文本,直接创建并返回文本类型节点。
  2. 遇到原生标签,那么就创建返回原生 DOM 元素,并将 props 当做 attribute 挂载到原生元素上面
  3. 遇到 React 组件,则去实例化这个组件类,执行生命周期,返回最终的 DOM 元素。

那么我们先来实现前面两步:

const textNodeTypes = ['string', 'number']
export const _render = (vnode) => {
    const { type, children } = vnode
    // 如果虚拟 DOM 是文本或者数字
    if (textNodeTypes.indexOf(typeof vnode) >= 0) {
        return document.createTextNode(`${vnode}`)
    }
    // 如果 type 是个函数(React 类)
    if (typeof type === 'function') {
        return renderComponent(vnode)
    }
    // 如果 type 是其他的(string),代表是原生元素
    const dom = document.createElement(vnode.type);
    if (vnode.props) {
        Object.keys(vnode.props).forEach(key => {
            const value = vnode.props[key];
            setAttribute(dom, key, value); // 挂载 attribute
        });
    }
    // 遍历子节点,继续渲染
    children.forEach(child => render(child, dom));
    return dom;
}

这里有个需要注意的点,那就是 setAttribute,我们来思考一下,JSX Element 可以传入的 props 有哪些?

  1. 普通的 style 元素
  2. className(对应原生 DOM 中的 class)
  3. onXXX 事件,主要以 on+大写 这种形式存在
  4. 普通的组件 props

那么这个实现也比较简单了,如果是 className,那么需要转换为 class
对于合成事件来说,我们可以简单地用正则去匹配传入的是否为事件。
如果是 style 属性,React 中的 style 是一个对象,所以我们需要遍历其属性来解析。

const setAttribute = (dom, name, value) => {
    if (name === 'className') {
        name = 'class'
    }
    // 正则匹配到事件函数
    if (/on\w+/.test(name)) {
        name = name.toLowerCase();
        dom[name] = value || '';
        // 如果属性名是style,则更新style对象
    } else if (name === 'style') {
        for (let name in value) {
            dom.style[name] = typeof value[name] === 'number' ? value[name] + 'px' : value[name];
        }
        // 普通属性
    } else {
        if (name in dom) {
            dom[name] = value || '';
        }
        if (value) {
            dom.setAttribute(name, value);
        } else {
            dom.removeAttribute(name);
        }
    }
}

到了这里,我们可以先用原生的元素来做一个测试。

render(
    <h1>hello, world</h1>,
    document.getElementById('root')
);

展示效果如下,非常完美!

image_1e7vaenjrep61nkn1vp612fu1amm.png-38.2kB

4.3 实例化组件

针对原生的 JSX Element,我们已经可以很好的运行了。接着我们就要来实现怎么渲染 React 组件了。
首先,我们需要来将组件类进行实例化。

export const createComponent = (component, props) => {
    return new component(props)
}

这样就够了吗?当然不行,因为针对类组件和函数组件,最后得到的实例是不一样的。
类组件是从 render 方法中获取 Element 的,而函数组件直接返回了 Element,所以这里要抹平差异。

export const createComponent = (component, props) => {
    // 类组件
    if (component.prototype && component.prototype.render) {
        return new component(props);
    // 函数组件
    } else {
        const instance = new Component(props);
        instance.constructor = component;
        // 对函数组件实例增加 `render` 方法
        instance.render = function () {
            return this.constructor(props);
        }
        return instance
    }
}

接下来,我们需要开始执行生命周期,例如 componentWillMount 或者 componentWillReceiveProps

if (!component.dom) {
        if (component.componentWillMount) component.componentWillMount();
    } else if (component.componentWillReceiveProps) {
        component.componentWillReceiveProps(props);
    }

我们这里使用了一个 component.dom 来区分是初始化阶段还是更新阶段,这个 component.dom 就是我们初始化之后设置的一个属性,后面会说到。
接着,我们需要将 props 设置到组件上面,我们可以这样:

component.props = props;

通过这行代码就可以知道,为什么我们明明没有在组件里面执行 super(props),却还能在后面拿到正确的 props?那是因为 React 在实例化之后会再帮你手动设置一次。

4.4 渲染组件

接着,我们可以开始渲染组件了。这一阶段也非常简单,我们就对组件执行 render 返回的虚拟 DOM 进行类似上面 ReactDOM.render 的解析,可以重复利用上面的 _render 方法。
然后我们要执行 componentWillUpdatecomponentDidUpdatecomponentDidMount 这几个生命周期钩子函数。

export const _renderComponent = (component) => {

    let dom;

    const renderer = component.render();

    if (component.dom && component.componentWillUpdate) {
        component.componentWillUpdate();
    }

    dom = _render(renderer);

    if (component.dom) {
        if (component.componentDidUpdate) component.componentDidUpdate();
    } else if (component.componentDidMount) {
        component.componentDidMount();
    }

}

你以为到这里就已经完了吗?no,我们这里还没有做更新呢。
我们需要将最后得到的 DOM 替换掉上一次的 DOM,以此来实现页面刷新。

export const _renderComponent = (component) => {

    let dom;

    const renderer = component.render();

    if (component.dom && component.componentWillUpdate) {
        component.componentWillUpdate();
    }

    dom = _render(renderer);

    if (component.dom) {
        if (component.componentDidUpdate) component.componentDidUpdate();
    } else if (component.componentDidMount) {
        component.componentDidMount();
    }

    if (component.dom && component.dom.parentNode) {
        component.dom.parentNode.replaceChild(dom, component.dom);
    }
    // 和真实 dom 互相引用
    component.dom = dom;
    dom._component = component;

}

最后,我们来写个复杂点儿的例子验证一下能不能正常运行:

class Counter extends Component {
    constructor(props) {
        super(props);
        this.state = {
            count: 0
        }
    }
    increment = () => {
        this.setState({
            count: this.state.count + 1
        })
    }
    render() {
        return <h1 onClick={this.increment}>{this.state.count}</h1>
    }
}
render(
    <Counter />,
    document.getElementById('root')
);

可以看到,已经可以很完美的运行了。

react.gif-1960.8kB

4.5 总结

最后,我们实现了这个简单版的 React,但是基于篇幅问题,还有很多特性没有加上,比如 diff、合并 state、fiber 等等。之后有时间我会在自己 GitHub 的博客上进行更新。

关于 diff 的实现可以参考我这篇文章:浅谈 React diff 实现

5. 一些 React 冷知识

实现了这个简单的 React 之后,我们再来补充一些 React 相关的小知识。

5.1 key

React中的 diff 会根据子组件的 key 来对比前后两次 Virtual DOM(即使前后两次子组件顺序打乱),所以这里的 key 最好使用不会变化的值,比如 id 之类的,最好别用 index,如果有两个子组件互换了位置,那么 index 改变就会导致 diff 失效。
如果你看过我上面那篇 浅谈 React diff 实现 就会理解为什么有这种现象出现了。

5.2 短路操作符判断

为什么布尔类型和 null 类型的值可以这么写,而数字类型却不行?

showLoading && <Loading />

如果 showLoading 是个数字0,那么最后渲染出来的居然是个0,但是 showLoading 是个 false 或者 null`,最后就什么都不渲染,这个是为什么?
首先上述代码会被 Babel 编译为如下格式:

showLoading && React.createElement(Loading, null)

而如果 showLoadingfalse 或者0 的时候,就会短路掉后面的组件,最后渲染出来的应该是个 showLoading
但是 React 在渲染子组件的时候,会判断当前是否为布尔类型和 null,如果是布尔类型或者 null,则会被直接过滤掉。

function collectChild(child, children) {
    if (child != null && typeof child !== 'boolean') {
        if (!child.vtype) {
            // convert immutablejs data
            if (child.toJS) {
                child = child.toJS()
                if (_.isArr(child)) {
                    _.flatEach(child, collectChild, children)
                } else {
                    collectChild(child, children)
                }
                return
            }
            child = '' + child
        }
        children[children.length] = child
    }
}

5.3 shouldComponentUpdate

shouldComponentUpdate 返回 false 的时候,组件没有重新渲染,但是更新后的 stateprops 已经挂载到了组件上面,这个时候如果打印 state props,会发现拿到的已经是更新后的了。

5.4 setState

React 里面 setState 后不会立即更新,但在某些场景下也会立即更新,下面这几种情况打印的值你都能回答的上来吗?

class App extends React.Component {
    state = {
        count: 0;
    }
    test() {
        this.setState({
            count: this.state.count + 1
        }); 
        console.log(this.state.count); // 此时为0
        this.setState({
            count: this.state.count + 1
        });
        console.log(this.state.count); // 此时为0
    }
    test2() {
        setTimeout(() => {
            this.setState({
                count: this.state.count + 1
            });
            console.log(this.state.count); // 此时为1
            this.setState({
                count: this.state.count + 1
            });
            console.log(this.state.count); // 此时为2
        })
    }
    test3() {
        Promise.resolve().then(() => {
            this.setState({
                count: this.state.count + 1
            });
            console.log(this.state.count); // 此时为1
            this.setState({
                count: this.state.count + 1
            });
            console.log(this.state.count); // 此时为2
        })
    }
    test4() {
        this.setState(prevState => {
            console.log(prevState.count); // 0
        return {
            count: prevState.count + 1
        };
        });
        this.setState(prevState => {
            console.log(prevState.count); // 1
            return {
                count: prevState.count + 1
            };
        });
    }
    async test4() {
        await 0;
        this.setState({
            count: this.state.count + 1
        });
        console.log(this.state.count); // 此时为1
        this.setState({
            count: this.state.count + 1
        });
        console.log(this.state.count); // 此时为2
    }
}

在 React 中为了防止多次 setState 导致多次渲染带来不必要的性能开销,会将待更新的 state 放到队列中,等到合适的时机(生命周期钩子和事件)后进行batchUpdate,所以在 setState 后无法立即拿到更新后的 state

所以很多人说 setState 是异步的,setState 表现确实是异步,但是里面没有用异步代码实现。而且不是等主线程代码执行结束后才执行的,而是需要手动触发。

如果是给 setState 传入一个函数,这个函数是执行前一个 setState 后才被调用的,所以函数返回的参数可以拿到更新后的 state
但是如果将 setState 在异步方法中(setTimeoutPromise等等)调用,由于这些方法是异步的,会导致生命周期钩子或者事件方法先执行,执行完这些后会将更新队列的 pending 状态置为 false,这个时候在执行 setState 后会导致组件立即更新。从这里也能说明 setState 本质并不是异步的,只是模拟了异步的表现。

5.5 合成事件

React 里面将可以冒泡的事件委托到了 document 上,通过向上遍历父节点模拟了冒泡的机制。
比如当触发 onClick 事件时,会先执行 target 元素的 onClick 事件回调函数,如果回调函数里面阻止了冒泡,就不会继续向上查找父元素。否则,就会继续向上查找父元素,并执行其 onClick 的回调函数。
当跳出循环的时候,就会开始进行组件的批量更新(如果没有收到新的 props 或者 state 队列为空就不会进行更新)。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant