前端译趣

160行实现山寨版React

2018-05-24  本文已影响0人  linc2046
160行实现山寨版React

引言

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

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

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

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

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

每个部分都会以在线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修改进行优化。

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

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

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

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

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

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);
    }
};

我们看看看可能的组合:

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

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

组件

组件在概念上和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;
    }
}

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

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

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

以下是所有代码和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用来致敬我的好朋友,我们看看最终的结果:

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

译者注

上一篇 下一篇

猜你喜欢

热点阅读