美文网首页前端译趣
160行实现山寨版React

160行实现山寨版React

作者: linc2046 | 来源:发表于2018-05-24 10:05 被阅读0次
    160行实现山寨版React

    引言

    React是一个伟大的库,由于其简单、性能、声明式编程方式,深受许多开发者的喜爱。

    但我个人对react有个特殊情节,那就是它的工作原理。

    我发现react背后的理念十分简单却让我着迷。

    我相信理解它的核心原理将会帮助你编写更高效和安全的代码。

    本教程中,我会教你写一个完整功能的react版本,包括组件API,自定义虚拟DOM实现。我将会分四个部分,分主题介绍:

    • 元素: 我们会学到如何处理JSX代码块为轻量级的虚拟DOM

    • 渲染: 将虚拟DOM转换成真实DOM

    • 修补: 将会演示为什么key属性如此重要,如何保证虚拟DOM高效更新真实DOM

    • 组件: 最后一节介绍react组件系统和创建、生命周期、渲染步骤。

    每个部分都会以在线codepen示例结束,这样你可以很快知道写到哪儿了。

    我们现在开始。

    元素

    元素是真实DOM的轻量级对象表示。

    元素维持像节点类型、属性、子代等重要信息,以便后续渲染。

    树形组合元素结构称作虚拟DOM,下面示例:

    {
        "type": "ul",
        "props": {
            "className": "some-list"
        },
        "children": [
            {
                "type": "li",
                "props": {
                    "className": "some-list__item"
                },
                "children": [
                    "One"
                ]
            },
            {
                "type": "li",
                "props": {
                    "className": "some-list__item"
                },
                "children": [
                    "Two"
                ]
            }
        ]
    }
    

    为了避免每次写臃肿的js对象, 大部分react开发者使用JSX语法, JSX看起来像js和HTML标签的混合

    /** @jsx createElement */
    const list = <ul className="some-list">
        <li className="some-list__item">One</li>
        <li className="some-list__item">Two</li>
    </ul>;
    

    为了保证执行,JSX需要编译成正常的函数调用,注意编译指示注释中定义必须使用的函数:

    const list = createElement('ul', {className: 'some-list'},
        createElement('li', {className: 'some-list__item'}, 'One'),
        createElement('li', {className: 'some-list__item'}, 'Two'),
    );
    

    最后,编译函数调用应该会生成虚拟DOM结构。

    我们的实现很简短,但看起来很原始,但完美达到目的。

    const createElement = (type, props, ...children) => {
        props = props != null ? props : {};
        return {type, props, children};
    };
    

    第一部分的CodePen可运行代码示例,包含上面说的方法,实现了生成虚拟DOM树。

    渲染

    渲染是把虚拟DOM转换成真实DOM。

    一般来说,最直接的逻辑就是遍历虚拟DOM树,然后创建每个节点对应的真实DOM元素:

    const render = (vdom, parent=null) => {
        if (parent) parent.textContent = '';
        const mount = parent ? (el => parent.appendChild(el)) : (el => el);
        if (typeof vdom == 'string' || typeof vdom == 'number') {
            return mount(document.createTextNode(vdom));
        } else if (typeof vdom == 'boolean' || vdom === null) {
            return mount(document.createTextNode(''));
        } else if (typeof vdom == 'object' && typeof vdom.type == 'function') {
            return mount(Component.render(vdom));
        } else if (typeof vdom == 'object' && typeof vdom.type == 'string') {
            const dom = document.createElement(vdom.type);
            for (const child of [].concat(...vdom.children)) // flatten
                dom.appendChild(render(child));
            for (const prop in vdom.props)
                setAttribute(dom, prop, vdom.props[prop]);
            return mount(dom);
        } else {
            throw new Error(`Invalid VDOM: ${vdom}.`);
        }
    };
    
    const setAttribute = (dom, key, value) => {
        if (typeof value == 'function' && key.startsWith('on')) {
            const eventType = key.slice(2).toLowerCase();
            dom.__gooactHandlers = dom.__gooactHandlers || {};
            dom.removeEventListener(eventType, dom.__gooactHandlers[eventType]);
            dom.__gooactHandlers[eventType] = value;
            dom.addEventListener(eventType, dom.__gooactHandlers[eventType]);
        } else if (key == 'checked' || key == 'value' || key == 'id') {
            dom[key] = value;
        } else if (key == 'key') {
            dom.__gooactKey = value;
        } else if (typeof value != 'object' && typeof value != 'function') {
            dom.setAttribute(key, value);
        }
    };
    

    上面的代码看起来有点丑,这里我们分解成不那么复杂的小部分代码:

    • 自定义属性设置器: 虚拟DOM接受的属性一般不是有效DOM属性。像事件处理器,键标识和值都需要单独处理。

    • 原始虚拟DOM渲染:原始类型,像字符串、数字、布尔型和空值,会被转换成空文本节点。

    • 复杂虚拟DOM渲染: 带字符串标签的节点会递归渲染子代,最后转成DOM元素。

    • 组件虚拟DOM渲染: 带函数标签的节点会分开处理,暂时不关注,我们后续会处理这部分。

    更新

    更新是调配当前DOM成最新构建的虚拟DOM树。

    想象你有些深嵌套和频繁更新的虚拟DOM,当有变化时,即使是最小的部分,也需要显示。单纯实现需要每次全量渲染:

    • 删除现有的DOM节点

    • 重新渲染一切

    从效率角度考虑,这很烂,创建并正确渲染DOM是很低效操作。

    我们可以写个更新逻辑,保证更少的DOM修改进行优化。

    • 创建全新虚拟DOM

    • 同当前虚拟DOM进行递归比较

    • 定位任何添加、删除或变化的节点

    • 更新变化

    计算复杂度的问题也出现了。

    比较两棵树有 O(n3)复杂度, 例如,你要更新上千个元素,需要十亿次比较,数量相当庞大。

    相反,我们将会采用探索式的 O(n)逻辑,先假设两点:

    • 两个不同类型的元素将会产生不同的树

    • 通过渲染时指定key属性,开发者可以找子代元素的位置

    实际上,这些假设适用于大部分的真实用例。

    现在我们看看这一部分的代码:

    const render = (vdom, parent=null) => {
        if (parent) parent.textContent = '';
        const mount = parent ? (el => parent.appendChild(el)) : (el => el);
        if (typeof vdom == 'string' || typeof vdom == 'number') {
            return mount(document.createTextNode(vdom));
        } else if (typeof vdom == 'boolean' || vdom === null) {
            return mount(document.createTextNode(''));
        } else if (typeof vdom == 'object' && typeof vdom.type == 'function') {
            return mount(Component.render(vdom));
        } else if (typeof vdom == 'object' && typeof vdom.type == 'string') {
            const dom = document.createElement(vdom.type);
            for (const child of [].concat(...vdom.children)) // flatten
                dom.appendChild(render(child));
            for (const prop in vdom.props)
                setAttribute(dom, prop, vdom.props[prop]);
            return mount(dom);
        } else {
            throw new Error(`Invalid VDOM: ${vdom}.`);
        }
    };
    
    const setAttribute = (dom, key, value) => {
        if (typeof value == 'function' && key.startsWith('on')) {
            const eventType = key.slice(2).toLowerCase();
            dom.__gooactHandlers = dom.__gooactHandlers || {};
            dom.removeEventListener(eventType, dom.__gooactHandlers[eventType]);
            dom.__gooactHandlers[eventType] = value;
            dom.addEventListener(eventType, dom.__gooactHandlers[eventType]);
        } else if (key == 'checked' || key == 'value' || key == 'id') {
            dom[key] = value;
        } else if (key == 'key') {
            dom.__gooactKey = value;
        } else if (typeof value != 'object' && typeof value != 'function') {
            dom.setAttribute(key, value);
        }
    };
    

    我们看看看可能的组合:

    • 原始虚拟DOM + 文本节点情况下,比较DOM文本内容,根据不同,执行全量更新

    • 原始虚拟DOM + 元素节点: 全量渲染

    • 复杂虚拟DOM + 文本节点: 全量渲染

    • 复杂虚拟DOM + 不同类型的元素DOM: 全量渲染

    • 复杂虚拟DOM + 同类型的元素DOM: 这是最有趣的组合,子代调度逻辑会在这里执行。

    • 组件虚拟DOM + 任意类型DOM: 就像上面部分,需要单独处理。

    文本和复杂节点一般无法兼容,需要全量渲染,幸运的是它们一般也不会变化。

    子代递归调度会这样实现:

    • 当前活动元素被暂存,调度过程会有时中断焦点

    • DOM子代根据相应的键被迁移至暂存池中,索引会当做默认键

    • 虚拟DOM子代和真实DOM节点通过键对应,然后递归更新,如果没有对应,会重新渲染

    • 没有虚拟DOM对应的DOM节点会被移除

    • 新属性会应用到最终父元素

    • 焦点会返回到上一个活动元素中

    组件

    组件在概念上和js函数很类似,接收任意props输入,返回描述界面的元素集合。

    组件可以是无状态函数或有自身状态和方法、生命周期钩子的派生类。

    class Component {
        constructor(props) {
            this.props = props || {};
            this.state = null;
        }
    
        static render(vdom, parent=null) {
            const props = Object.assign({}, vdom.props, {children: vdom.children});
            if (Component.isPrototypeOf(vdom.type)) {
                const instance = new (vdom.type)(props);
                instance.componentWillMount();
                instance.base = render(instance.render(), parent);
                instance.base.__gooactInstance = instance;
                instance.base.__gooactKey = vdom.props.key;
                instance.componentDidMount();
                return instance.base;
            } else {
                return render(vdom.type(props), parent);
            }
        }
    
        static patch(dom, vdom, parent=dom.parentNode) {
            const props = Object.assign({}, vdom.props, {children: vdom.children});
            if (dom.__gooactInstance && dom.__gooactInstance.constructor == vdom.type) {
                dom.__gooactInstance.componentWillReceiveProps(props);
                dom.__gooactInstance.props = props;
                return patch(dom, dom.__gooactInstance.render());
            } else if (Component.isPrototypeOf(vdom.type)) {
                const ndom = Component.render(vdom);
                return parent ? (parent.replaceChild(ndom, dom) && ndom) : (ndom);
            } else if (!Component.isPrototypeOf(vdom.type)) {
                return patch(dom, vdom.type(props));
            }
        }
    
        setState(nextState) {
            if (this.base && this.shouldComponentUpdate(this.props, nextState)) {
                const prevState = this.state;
                this.componentWillUpdate(this.props, nextState);
                this.state = nextState;
                patch(this.base, this.render());
                this.componentDidUpdate(this.props, prevState);
            } else {
                this.state = nextState;
            }
        }
    
        shouldComponentUpdate(nextProps, nextState) {
            return nextProps != this.props || nextState != this.state;
        }
    
        componentWillReceiveProps(nextProps) {
            return undefined;
        }
    
        componentWillUpdate(nextProps, nextState) {
            return undefined;
        }
    
        componentDidUpdate(prevProps, prevState) {
            return undefined;
        }
    
        componentWillMount() {
            return undefined;
        }
    
        componentDidMount() {
            return undefined;
        }
    
        componentWillUnmount() {
            return undefined;
        }
    }
    

    静态方法会在内部被调用。

    • 渲染: 执行初始化渲染,无状态组件会当成一般函数调用,结果会立刻显示。类组件实例化后会添加到DOM才会渲染。

    • 更新: 执行后面更新。有时DOM节点已经有对应的组件实例绑定,向组件实例传递新属性,然后更新差异,或者执行全量渲染。

    实例方法意味着用户会在派生类内部重写或调用。

    • 构造函数: 处理属性和定义初始状态

    • 状态调整: 处理新状态,触发对应的生命周期钩子方法,初始化更新循环

    • 生命周期钩子: 在组件周期内,挂载、更新和删除之前,会被触发的一系列方法

    注意没有渲染方法,意味着会在子类中定义。

    以下是所有代码和todo应用示例代码:

    /* Scroll down to reach playground: */
    /** @jsx createElement */
    const createElement = (type, props, ...children) => {
      props = props != null ? props : {};
      return {type, props, children};
    };
    
    const setAttribute = (dom, key, value) => {
      if (typeof value == 'function' && key.startsWith('on')) {
        const eventType = key.slice(2).toLowerCase();
        dom.__gooactHandlers = dom.__gooactHandlers || {};
        dom.removeEventListener(eventType, dom.__gooactHandlers[eventType]);
        dom.__gooactHandlers[eventType] = value;
        dom.addEventListener(eventType, dom.__gooactHandlers[eventType]);
      } else if (key == 'checked' || key == 'value' || key == 'id') {
        dom[key] = value;
      } else if (key == 'key') {
        dom.__gooactKey = value;
      } else if (typeof value != 'object' && typeof value != 'function') {
        dom.setAttribute(key, value);
      }
    };
    
    const render = (vdom, parent=null) => {
      if (parent) parent.textContent = '';
      const mount = parent ? (el => parent.appendChild(el)) : (el => el);
      if (typeof vdom == 'string' || typeof vdom == 'number') {
        return mount(document.createTextNode(vdom));
      } else if (typeof vdom == 'boolean' || vdom === null) {
        return mount(document.createTextNode(''));
      } else if (typeof vdom == 'object' && typeof vdom.type == 'function') {
        return mount(Component.render(vdom));
      } else if (typeof vdom == 'object' && typeof vdom.type == 'string') {
        const dom = document.createElement(vdom.type);
        for (const child of [/* flatten */].concat(...vdom.children))
          dom.appendChild(render(child));
        for (const attr of dom.attributes) console.log(attr);
        for (const prop in vdom.props) setAttribute(dom, prop, vdom.props[prop]);
        return mount(dom);
      } else {
        throw new Error(`Invalid VDOM: ${vdom}.`);
      }
    };
    
    
    const patch = (dom, vdom, parent=dom.parentNode) => {
      const replace = parent ? el => (parent.replaceChild(el, dom) && el) : (el => el);
      if (typeof vdom == 'object' && typeof vdom.type == 'function') {
        return Component.patch(dom, vdom, parent);
      } else if (typeof vdom != 'object' && dom instanceof Text) {
        return dom.textContent != vdom ? replace(render(vdom)) : dom;
      } else if (typeof vdom == 'object' && dom instanceof Text) {
        return replace(render(vdom));
      } else if (typeof vdom == 'object' && dom.nodeName != vdom.type.toUpperCase()) {
        return replace(render(vdom));
      } else if (typeof vdom == 'object' && dom.nodeName == vdom.type.toUpperCase()) {
        const pool = {};
        const active = document.activeElement;
        for (const index in Array.from(dom.childNodes)) {
          const child = dom.childNodes[index];
          const key = child.__gooactKey || index;
          pool[key] = child;
        }
        const vchildren = [/* flatten */].concat(...vdom.children);
        for (const index in vchildren) {
          const child = vchildren[index];
          const key = child.props && child.props.key || index;
          dom.appendChild(pool[key] ? patch(pool[key], child) : render(child));
          delete pool[key];
        }
        for (const key in pool) {
          if (pool[key].__gooactInstance)
            pool[key].__gooactInstance.componentWillUnmount();
          pool[key].remove();
        }
        for (const attr of dom.attributes) dom.removeAttribute(attr.name);
        for (const prop in vdom.props) setAttribute(dom, prop, vdom.props[prop]);
        active.focus();
        return dom;
      }
    };
    
    class Component {
      constructor(props) {
        this.props = props || {};
        this.state = null;
      }
    
      static render(vdom, parent=null) {
        const props = Object.assign({}, vdom.props, {children: vdom.children});
        if (Component.isPrototypeOf(vdom.type)) {
          const instance = new (vdom.type)(props);
          instance.componentWillMount();
          instance.base = render(instance.render(), parent);
          instance.base.__gooactInstance = instance;
          instance.base.__gooactKey = vdom.props.key;
          instance.componentDidMount();
          return instance.base;
        } else {
          return render(vdom.type(props), parent);
        }
      }
    
      static patch(dom, vdom, parent=dom.parentNode) {
        const props = Object.assign({}, vdom.props, {children: vdom.children});
        if (dom.__gooactInstance.constructor == vdom.type) {
          dom.__gooactInstance.componentWillReceiveProps(props);
          dom.__gooactInstance.props = props;
          return patch(dom, dom.__gooactInstance.render());
        } else if (Component.isPrototypeOf(vdom.type)) {
          const ndom = Component.render(vdom);
          return parent ? (parent.replaceChild(ndom, dom) && ndom) : (ndom);
        } else if (!Component.isPrototypeOf(vdom.type)) {
          return patch(dom, vdom.type(props));
        }
      }
    
      setState(nextState) {
        if (this.base && this.shouldComponentUpdate(this.props, nextState)) {
          const prevState = this.state;
          this.componentWillUpdate(this.props, nextState);
          this.state = nextState;
          patch(this.base, this.render());
          this.componentDidUpdate(this.props, prevState);
        } else {
          this.state = nextState;
        }
      }
    
      shouldComponentUpdate(nextProps, nextState) {
        return nextProps != this.props || nextState != this.state;
      }
    
      componentWillReceiveProps(nextProps) {
        return undefined;
      }
    
      componentWillUpdate(nextProps, nextState) {
        return undefined;
      }
    
      componentDidUpdate(prevProps, prevState) {
        return undefined;
      }
    
      componentWillMount() {
        return undefined;
      }
    
      componentDidMount() {
        return undefined;
      }
    
      componentWillUnmount() {
        return undefined;
      }
    }
    
    /* Playground: */
    class TodoItem extends Component {
      render() {
        return <li className="todo__item">
          <span>{this.props.text} - </span>
          <a href="#" onClick={this.props.onClick}>X</a>
        </li>;
      }
    }
    
    class Todo extends Component {
      constructor(props) {
        super(props);
        this.state = {
          input: '',
          items: [],
        };
        this.handleAdd('Goal #1');
        this.handleAdd('Goal #2');
        this.handleAdd('Goal #3');
      }
      
      handleInput(e) {
        this.setState({
           input: e.target.value,
           items: this.state.items,
        });
      }
      
      handleAdd(text) {
        const newItems = [].concat(this.state.items);
        newItems.push({
          id: Math.random(),
          text,
        });
        this.setState({
          input: '',
          items: newItems,
        });
      }
      
      handleRemove(index) {
        const newItems = [].concat(this.state.items);
        newItems.splice(index, 1);
        this.setState({
          input: this.state.input,
          items: newItems,
        });
      }
      
      render() {
        return <div className="todo">
          <ul className="todo__items">
            {this.state.items.map((item, index) => <TodoItem
              key={item.id}
              text={item.text}
              onClick={e => this.handleRemove(index)}
            />)}
          </ul>
          <input type="text" onInput={e => this.handleInput(e)} value={this.state.input}/>
          <button onClick={e => this.handleAdd(this.state.input)}>Add</button>
        </div>;
      }
    }
    
    render(<Todo/>, document.getElementById('root'));
    

    总结

    至此,我们实现了完整功能的react山寨版,Gooact用来致敬我的好朋友,我们看看最终的结果:

    • Gooact能够根据虚拟DOM引用创建和高效更新复杂DOM树

    • Gooact支持函数和类组件,实现内部状态管理和复杂生命周期钩子

    • Gooact代码由Babel转译生成

    • Gooact没压缩前只有160行代码

    本文的主要目的是展示React的核心原理,无需深入辅助API来了解内部结构,所以Gooact不包括这些:

    • Gooact不支持Fragment、Portals、contenxt和引用,还有最新版引入的特性也没有

    • Gooact没有采用复杂的React Fiber,我会在后面的文章中介绍

    • Gooact不会跟踪重复键,以防有时产生bug

    • Gooact对于有些方法缺乏额外回调支持

    译者注

    • 原文链接

    • 译文有删减,因译者水平有限,如有错误,欢迎指正交流

    相关文章

      网友评论

        本文标题:160行实现山寨版React

        本文链接:https://www.haomeiwen.com/subject/gwcdjftx.html