Giter Club home page Giter Club logo

blog's People

Contributors

hujiulong avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

blog's Issues

在canvas中模拟光照效果——光照下颜色的计算

光照

我们能看到物体,是因为光照射在物体上然后反射到我们的眼睛当中。其中的影响因素非常多:观察者的位置、光源的位置、光的颜色、物体表面的颜色、材质和粗糙程度等等。以后我们将会详细探究如何模拟物体的材质,在这篇文章中我们只讨论光源。

平行光源

太阳的尺度相对地球来说非常大,所以可以认为从太阳照射来的光线都是平行的,即太阳是一个平行光源。

模拟平行光源的光照非常简单,当光垂直照射到平面上,即光线方向和平面呈90度角时,这时光照是最强的。如果照射的角度不断变大(或者说光线和平面的夹角不断变小),光照也会随之变弱,当光线方向完全和平面平行时,这时没有光能照射到平面上,光强变成了0。

可以总结出,平行光的光照情况和两个方向有关:光线的方向和受光照平面的朝向。

我们用一个垂直于平面的向量去描述平面的朝向,在图形学中,一般把这个向量称为“法向量”。

我们可以用向量的“点乘”运算来计算光强变化。

点乘也叫数量积,是接受在实数R上的两个向量并返回一个实数值标量的二元运算。点乘运算规则非常简单,将两个向量对应坐标的乘积求和就行了。

这里我们计算的是三维向量,我们用数组来表示向量,写一个简单的方法来计算点乘:

/**
 * 点乘运算
 * @param {Array<number>} v1 向量v1
 * @param {Array<number>} v2 向量v2
 * @return {number} 点乘结果
 */
function dot( v1, v2 ) {
    return v1[ 0 ] * v2[ 0 ] + v1[ 1 ] * v2[ 1 ] + v1[ 2 ] * v2[ 2 ];
}

还有几个重要的向量运算我们也会用到,在这里我们提前定义好,为减小篇幅,这里省略掉具体实现,代码可以看最后的实例源码。

/**
 * 将向量转为单位向量
 * @param {Array<number>} v
 * @return {Array<number>} 单位向量
 */
function normalize( v ) { /* ... */ }


/**
 * 两向量相减
 * @param {Array<number>} v1
 * @param {Array<number>} v2
 * @return {Array<number>}
 */
function sub( v1, v2 ) { /* ... */ }


/**
 * 计算一个向量的反方向向量
 * @param {Array<number>} v
 * @return {Array<number>}
 */
function negate( v ) { /* ... */ }

我们假设页面的左上角为原点O,右方向为x轴正方向,下方向为y轴正方向,垂直屏幕向外的方向为z轴正方向。我们可以这样定义一个宽高都为500的平面:

var plane = {
    center: [ 250, 250, 0 ],    // 平面中心点坐标
    width: 500,                 // 宽
    height: 500,                // 高
    normal: [ 0, 0, 1 ],        // 朝向,即法向量     
    color: { r: 255, g: 0, b: 0 }   // 颜色为红色
}

对于平行光,只需要关心它的方向和颜色,我们可以这样来定义一个平行光源:

var directionalLight = {
    direction: [ 0, 0, -1 ],        // 从屏幕外垂直照向屏幕
    color: { r: 255, g: 255, b: 255 }   // 颜色为纯白色
}

平行光的光线都是平行的,所以它照射到平面上各个位置的效果都是一样的,换言之,整个平面都应该是同一个颜色。
根据上面的规则(光强等于光线反方向向量点乘平面法向量),我们可以计算出这个颜色:

// ...
var reverseLightDirection = negate( directionalLight.direction );   // 计算平行光的反方向向量
var intensity = dot( reverseLightDirection, plane.normal );         // 计算两向量点乘

// 计算有光照时的颜色
var color = {
    r: intensity * plane.color.r + intensity * directionalLight.r,
    g: intensity * plane.color.g + intensity * directionalLight.g,
    b: intensity * plane.color.b + intensity * directionalLight.g,
}

var canvas = document.getElementById( 'canvas' );
var ctx = canvas.getElementById( '2d' );
ctx.rect( plane.center[ 0 ], plane.center[ 1 ], plane.width, plane.height );
ctx.fillStyle = 'rgb(' + color.r + ',' + color.g + ',' + color.b ')';
ctx.fill();

我写了一个示例,可以调整光线方向来观察不同方向下的光照效果。
在线运行示例
4

点光源

在日常生活中,点光源更加常见,白炽灯、台灯等都可以认为是点光源。

首先,我们先定义一个点光源,对于一个点光源来说,我们只需要关心它的位置和颜色:

var pointLight = {
    position: [ 250, 250, 100 ],    // 光源位于平面中心上方100处
    color: { r: 255, g: 255, b: 255 }   // 颜色为纯白色
}

光强的计算规则仍然不变:光强等于光线反方向向量点乘平面法向量。但是点光源的光是从一个点发射出来,它们照射到平面上时,所有光线的方向都不一样。所以,我们必须挨个计算平面上所有像素的光强。

这里需要用到canvas提供的putImageData,这个方法可以直接填入一个区域的像素颜色值来绘图。代码如下:

// ...
var imageData = ctx.createImageData( 500, 500 );    // 创建一个ImageData,用来保存像素数据

for ( var x = 0; x < imageData.width; x++ ) {
    for ( var y = 0; y < imageData.height; y++ ) {
        var index = y * imageData.width + x;        // 当前计算的像素点的索引

        var point = [ x, y, 0 ];
        var normal = [ 0, 0, 1 ];

        var reverseLightDirection = normalize( sub( pointLight.position, point ) );  // 光线方向的反方向向量

        var light = dot( reverseLightDirection, normal );

        imageData.data[ index * 4 ] = pointLight.color.r * intensity + plane.color.r * intensity;
        imageData.data[ index * 4 + 1 ] = pointLight.color.g * intensity + plane.color.g * intensity;
        imageData.data[ index * 4 + 2 ] = pointLight.color.b * intensity + plane.color.b * intensity;
        imageData.data[ index * 4 + 3 ] = 255;
    }
}

ctx.putImageData( imageData, 100, 100 );

这样就可以看到结果了:

tim 20180107034502

我写了一个更复杂一点的例子,可以通过鼠标去移动光源,滑动滚轮来改变光源高度:
在线运行示例

5
动态图看起来有很多圈圈,实际上并没有,可以自己玩一下

WebGL的优势

对于一个500*500的平面,我们去计算它在点光源光照下的颜色,需要挨个计算平面上所有点,需要循环500*500=250000次,这其实是非常低效的。并且在做复杂场景的渲染时,不会只有一个光源,而且还会有投影等计算,计算量将会非常大。

从更底层的角度来说,这是因为每次计算都是由CPU完成的,而CPU只能串行计算,它只能完成一个计算以后才能开始下一次计算,所以非常缓慢。

这种复杂的渲染其实更适合用WebGL来做,因为每一次计算其实前后无关,WebGL可以利用GPU的并行计算能力,同时去计算所有点的光照强度。一个500*500的平面,理论上只需要花一次计算的时间,这个提升是非常大的。

这篇文章也是想通过这个简单的光照计算来引出WebGL,后面的文章我会用WebGL来重新实现这个效果。

tim 20180107040503
WebGL渲染的光照效果

关于我的博客

这篇文章到这里就结束了。

我计划写一系列关于前端图形渲染的文章,将会涵盖常用的前端图形绘制技术:canvas、svg和WebGL。希望通过这一系列文章能让读者对前端的各种图形绘制接口以及图像处理、图形学的基础知识有所了解。希望在分享的同时,也能巩固和复习自己所学知识,和大家共同进步。

系列博客地址:https://github.com/hujiulong/blog

如果能帮助到你,欢迎star,这样也能及时追踪博客的更新。

从零开始实现一个React(二):组件和生命周期

前言

在上一篇文章JSX和虚拟DOM中,我们实现了基础的JSX渲染功能,但是React的意义在于组件化。在这篇文章中,我们就要实现React的组件功能。

组件

React定义组件的方式可以分为两种:函数和类,函数定义可以看做是类定义的一种简单形式。

createElement的变化

回顾一下上一篇文章中我们对React.createElement的实现:

function createElement( tag, attrs, ...children ) {
    return {
        tag,
        attrs,
        children
    }
}

这种实现我们前面暂时只用来渲染原生DOM元素,而对于组件,createElement得到的参数略有不同:
如果JSX片段中的某个元素是组件,那么createElement的第一个参数tag将会是一个方法,而不是字符串。

区分组件和原生DOM的工作,是babel-plugin-transform-react-jsx帮我们做的

例如在处理<Welcome name="Sara" />时,createElement方法的第一个参数tag,实际上就是我们定义Welcome的方法:

function Welcome( props ) {
    return <h1>Hello, {props.name}</h1>;
}

我们不需要对createElement做修改,只需要知道如果渲染的是组件,tag的值将是一个函数

组件基类React.Component

通过类的方式定义组件,我们需要继承React.Component

class Welcome extends React.Component {
    render() {
        return <h1>Hello, {this.props.name}</h1>;
    }
}

所以我们就需要先来实现React.Component这个类:

Component

React.Component包含了一些预先定义好的变量和方法,我们来一步一步地实现它:
先定义一个Component类:

class Component {}

state & props

通过继承React.Component定义的组件有自己的私有状态state,可以通过this.state获取到。同时也能通过this.props来获取传入的数据。
所以在构造函数中,我们需要初始化stateprops

// React.Component
class Component {
    constructor( props = {} ) {
        this.state = {};
        this.props = props;
    }
}

setState

组件内部的state和渲染结果相关,当state改变时通常会触发渲染,为了让React知道我们改变了state,我们只能通过setState方法去修改数据。我们可以通过Object.assign来做一个简单的实现。
在每次更新state后,我们需要调用renderComponent方法来重新渲染组件,renderComponent方法的实现后文会讲到。

import { renderComponent } from '../react-dom/render'
class Component {
    constructor( props = {} ) {
        // ...
    }

    setState( stateChange ) {
        // 将修改合并到state
        Object.assign( this.state, stateChange );
        renderComponent( this );
    }
}

你可能听说过React的setState是异步的,同时它有很多优化手段,这里我们暂时不去管它,在以后会有一篇文章专门来讲setState方法。

render

上一篇文章中实现的render方法只支持渲染原生DOM元素,我们需要修改ReactDOM.render方法,让其支持渲染组件。
修改之前我们先来回顾一下上一篇文章中我们对ReactDOM.render的实现:

function render( vnode, container ) {
    return container.appendChild( _render( vnode ) );
}

function _render( vnode ) {

    if ( vnode === undefined || vnode === null || typeof vnode === 'boolean' ) vnode = '';

    if ( typeof vnode === 'number' ) vnode = String( vnode );

    if ( typeof vnode === 'string' ) {
        let textNode = document.createTextNode( vnode );
        return textNode;
    }

    const dom = document.createElement( vnode.tag );

    if ( vnode.attrs ) {
        Object.keys( vnode.attrs ).forEach( key => {
            const value = vnode.attrs[ key ];
            setAttribute( dom, key, value );
        } );
    }

    vnode.children.forEach( child => render( child, dom ) );    // 递归渲染子节点

    return dom; 
}

我们需要在其中加一段用来渲染组件的代码:

function _render( vnode ) {

    // ...

    if ( typeof vnode.tag === 'function' ) {

        const component = createComponent( vnode.tag, vnode.attrs );

        setComponentProps( component, vnode.attrs );

        return component.base;
    }
    
    // ...
}

组件渲染和生命周期

在上面的方法中用到了createComponentsetComponentProps两个方法,组件的生命周期方法也会在这里面实现。

生命周期方法是一些在特殊时机执行的函数,例如componentDidMount方法会在组件挂载后执行

createComponent方法用来创建组件实例,并且将函数定义组件扩展为类定义组件进行处理,以免其他地方需要区分不同定义方式。

// 创建组件
function createComponent( component, props ) {

    let inst;
    // 如果是类定义组件,则直接返回实例
    if ( component.prototype && component.prototype.render ) {
        inst = new component( props );
    // 如果是函数定义组件,则将其扩展为类定义组件
    } else {
        inst = new Component( props );
        inst.constructor = component;
        inst.render = function() {
            return this.constructor( props );
        }
    }

    return inst;
}

setComponentProps方法用来更新props,在其中可以实现componentWillMountcomponentWillReceiveProps两个生命周期方法

// set props
function setComponentProps( component, props ) {

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

    component.props = props;

    renderComponent( component );

}

renderComponent方法用来渲染组件,setState方法中会直接调用这个方法进行重新渲染,在这个方法里可以实现componentWillUpdatecomponentDidUpdatecomponentDidMount几个生命周期方法。

export function renderComponent( component ) {

    let base;

    const renderer = component.render();

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

    base = _render( renderer );

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

    if ( component.base && component.base.parentNode ) {
        component.base.parentNode.replaceChild( base, component.base );
    }

    component.base = base;
    base._component = component;

}

渲染组件

现在大部分工作已经完成,我们可以用它来渲染组件了。

渲染函数定义组件

渲染前文提到的Welcome组件:

const element = <Welcome name="Sara" />;
ReactDOM.render(
    element,
    document.getElementById( 'root' )
);

在浏览器中可以看到结果:

1

试试更复杂的例子,将多个组件组合起来:

function App() {
    return (
        <div>
            <Welcome name="Sara" />
            <Welcome name="Cahal" />
            <Welcome name="Edite" />
        </div>
    );
}
ReactDOM.render(
    <App />,
    document.getElementById( 'root' )
);

在浏览器中可以看到结果:
2

渲染类定义组件

我们来试一试将刚才函数定义组件改成类定义:

class Welcome extends React.Component {
    render() {
        return <h1>Hello, {this.props.name}</h1>;
    }
}

class App extends React.Component {
    render() {
        return (
            <div>
                <Welcome name="Sara" />
                <Welcome name="Cahal" />
                <Welcome name="Edite" />
            </div>
        );
    }
}
ReactDOM.render(
    <App />,
    document.getElementById( 'root' )
);

运行起来结果和函数定义组件完全一致:
1

再来尝试一个能体现出类定义组件区别的例子,实现一个计数器Counter,每点击一次就会加1。
并且组件中还增加了两个生命周期函数:

class Counter extends React.Component {
    constructor( props ) {
        super( props );
        this.state = {
            num: 0
        }
    }

    componentWillUpdate() {
        console.log( 'update' );
    }

    componentWillMount() {
        console.log( 'mount' );
    }

    onClick() {
        this.setState( { num: this.state.num + 1 } );
    }

    render() {
        return (
            <div onClick={ () => this.onClick() }>
                <h1>number: {this.state.num}</h1>
                <button>add</button>
            </div>
        );
    }
}

ReactDOM.render(
    <Counter />,
    document.getElementById( 'root' )
);

可以看到结果:
2

mount只在挂载时输出了一次,后面每次更新时会输出update

后话

至此我们已经从API层面实现了React的核心功能。但是我们目前的做法是每次更新都重新渲染整个组件甚至是整个应用,这样的做法在页面复杂时将会暴露出性能上的问题,DOM操作非常昂贵,而为了减少DOM操作,React又做了哪些事?这就是我们下一篇文章的内容了。

这篇文章的代码:https://github.com/hujiulong/simple-react/tree/chapter-2

从零开始实现React系列

React是前端最受欢迎的框架之一,解读其源码的文章非常多,但是我想从另一个角度去解读React:从零开始实现一个React,从API层面实现React的大部分功能,在这个过程中去探索为什么有虚拟DOM、diff、为什么setState这样设计等问题。

整个系列大概会有四篇左右,我每周会更新一到两篇,我会第一时间在github上更新,有问题需要探讨也请在github上回复我~

博客地址: https://github.com/hujiulong/blog
关注点star,订阅点watch

上一篇文章

从零开始实现一个React(一):JSX和虚拟DOM

下一篇文章

从零开始实现一个React(三):diff算法

怎样给文件命名才显得比较专业?

前言

写这篇博客的动机很简单,就是我周末闲的无聊,爬了github上star数前1000的js项目的所有文件的名称,看看大佬们都喜欢给文件和目录取什么名字,我跟着学两招也好显得我比较专业。

注意是js项目啊,你要是搞java的搞PHP的,也跟着学搞不好会被拿去祭天。

统计结果

排除掉了.babelrcpackage.json.github这类文件,得到了93117个文件名。

然后我对文件名计数,同一个项目中多次出现的名称只计算一次。也就是1000个项目中,出现频次最高的名称也不会超过1000。

结果这里写不下,可以点击这里看所有结果,我画了个图大家感受一下就行了
1 1

有点意外,出现次数最多的是test,565次,其次才是是src,556次

顾名思义,src是源代码,test就是测试,同样jscssimg这些目录名都很直观。

但是也有一些不太直观的名字在这些项目中也大量出现,比如legacybenchmarksfixtures等等。

常用名称

下面列出了一些常用的名称,并不是按出现频次排序,但是列出来的都是出现频次非常高的
单复数形式都出现了的只列出次数最多的
注意,都是目录名称,不是文件名

还有一大堆,总共500多个,我不一一解释了,有兴趣可以点击看统计结果

后话

其实大部分文件名看名字就能知道意思,也有一些是约定俗成的缩写。
相比之下更有意义的是对变量和函数名称的分析,我下次把这些项目的所有js文件内容爬下来然后再做一个分析。

从零开始实现一个React(三):diff算法

前言

上一篇文章,我们已经实现了React的组件功能,从功能的角度来说已经实现了React的核心功能了。

但是我们的实现方式有很大的问题:每次更新都重新渲染整个应用或者整个组件,DOM操作十分昂贵,这样性能损耗非常大。

为了减少DOM更新,我们需要找渲染前后真正变化的部分,只更新这一部分DOM。而对比变化,找出需要更新部分的算法我们称之为diff算法

对比策略

在前面两篇文章后,我们实现了一个render方法,它能将虚拟DOM渲染成真正的DOM,我们现在就需要改进它,让它不要再傻乎乎地重新渲染整个DOM树,而是找出真正变化的部分。

这部分很多类React框架实现方式都不太一样,有的框架会选择保存上次渲染的虚拟DOM,然后对比虚拟DOM前后的变化,得到一系列更新的数据,然后再将这些更新应用到真正的DOM上。

但也有一些框架会选择直接对比虚拟DOM和真实DOM,这样就不需要额外保存上一次渲染的虚拟DOM,并且能够一边对比一边更新,这也是我们选择的方式。

不管是DOM还是虚拟DOM,它们的结构都是一棵树,完全对比两棵树变化的算法时间复杂度是O(n^3),但是考虑到我们很少会跨层级移动DOM,所以我们只需要对比同一层级的变化。

image
只需要对比同一颜色框内的节点

总而言之,我们的diff算法有两个原则:

  • 对比当前真实的DOM和虚拟DOM,在对比过程中直接更新真实DOM
  • 只对比同一层级的变化

实现

我们需要实现一个diff方法,它的作用是对比真实DOM和虚拟DOM,最后返回更新后的DOM

/**
 * @param {HTMLElement} dom 真实DOM
 * @param {vnode} vnode 虚拟DOM
 * @returns {HTMLElement} 更新后的DOM
 */
function diff( dom, vnode ) {
    // ...
}

接下来就要实现这个方法。
在这之前先来回忆一下我们虚拟DOM的结构:
虚拟DOM的结构可以分为三种,分别表示文本、原生DOM节点以及组件。

// 原生DOM节点的vnode
{
    tag: 'div',
    attrs: {
        className: 'container'
    },
    children: []
}

// 文本节点的vnode
"hello,world"

// 组件的vnode
{
    tag: ComponentConstrucotr,
    attrs: {
        className: 'container'
    },
    children: []
}

对比文本节点

首先考虑最简单的文本节点,如果当前的DOM就是文本节点,则直接更新内容,否则就新建一个文本节点,并移除掉原来的DOM。

// diff text node
if ( typeof vnode === 'string' ) {

    // 如果当前的DOM就是文本节点,则直接更新内容
    if ( dom && dom.nodeType === 3 ) {    // nodeType: https://developer.mozilla.org/zh-CN/docs/Web/API/Node/nodeType
        if ( dom.textContent !== vnode ) {
            dom.textContent = vnode;
        }
    // 如果DOM不是文本节点,则新建一个文本节点DOM,并移除掉原来的
    } else {
        out = document.createTextNode( vnode );
        if ( dom && dom.parentNode ) {
            dom.parentNode.replaceChild( out, dom );
        }
    }

    return out;
}

文本节点十分简单,它没有属性,也没有子元素,所以这一步结束后就可以直接返回结果了。

对比非文本DOM节点

如果vnode表示的是一个非文本的DOM节点,那就要分两种情况了:
情况一:如果真实DOM不存在,表示此节点是新增的,或者新旧两个节点的类型不一样,那么就新建一个DOM元素,并将原来的子节点(如果有的话)移动到新建的DOM节点下。

if ( !dom || dom.nodeName.toLowerCase() !== vnode.tag.toLowerCase() ) {
    out = document.createElement( vnode.tag );

    if ( dom ) {
        [ ...dom.childNodes ].map( out.appendChild );    // 将原来的子节点移到新节点下

        if ( dom.parentNode ) {
            dom.parentNode.replaceChild( out, dom );    // 移除掉原来的DOM对象
        }
    }
}

情况二:如果真实DOM存在,并且和虚拟DOM是同一类型的,那我们暂时不需要做别的,只需要等待后面对比属性和对比子节点。

对比属性

实际上diff算法不仅仅是找出节点类型的变化,它还要找出来节点的属性以及事件监听的变化。我们将对比属性单独拿出来作为一个方法:

function diffAttributes( dom, vnode ) {

    const old = {};    // 当前DOM的属性
    const attrs = vnode.attrs;     // 虚拟DOM的属性

    for ( let i = 0 ; i < dom.attributes.length; i++ ) {
        const attr = dom.attributes[ i ];
        old[ attr.name ] = attr.value;
    }

    // 如果原来的属性不在新的属性当中,则将其移除掉(属性值设为undefined)
    for ( let name in old ) {

        if ( !( name in attrs ) ) {
            setAttribute( dom, name, undefined );
        }

    }

    // 更新新的属性值
    for ( let name in attrs ) {

        if ( old[ name ] !== attrs[ name ] ) {
            setAttribute( dom, name, attrs[ name ] );
        }

    }

}

setAttribute方法的实现参见第一篇文章

对比子节点

节点本身对比完成了,接下来就是对比它的子节点。
这里会面临一个问题,前面我们实现的不同diff方法,都是明确知道哪一个真实DOM和虚拟DOM对比,但是子节点是一个数组,它们可能改变了顺序,或者数量有所变化,我们很难确定要和虚拟DOM对比的是哪一个。
为了简化逻辑,我们可以让用户提供一些线索:给节点设一个key值,重新渲染时对比key值相同的节点。

// diff方法
if ( vnode.children && vnode.children.length > 0 || ( out.childNodes && out.childNodes.length > 0 ) ) {
    diffChildren( out, vnode.children );
}
function diffChildren( dom, vchildren ) {

    const domChildren = dom.childNodes;
    const children = [];

    const keyed = {};

    // 将有key的节点和没有key的节点分开
    if ( domChildren.length > 0 ) {
        for ( let i = 0; i < domChildren.length; i++ ) {
            const child = domChildren[ i ];
            const key = child.key;
            if ( key ) {
                keyed[ key ] = child;
            } else {
                children.push( child );
            }
        }
    }

    if ( vchildren && vchildren.length > 0 ) {

        let min = 0;
        let childrenLen = children.length;

        for ( let i = 0; i < vchildren.length; i++ ) {

            const vchild = vchildren[ i ];
            const key = vchild.key;
            let child;

            // 如果有key,找到对应key值的节点
            if ( key ) {

                if ( keyed[ key ] ) {
                    child = keyed[ key ];
                    keyed[ key ] = undefined;
                }

            // 如果没有key,则优先找类型相同的节点
            } else if ( min < childrenLen ) {

                for ( let j = min; j < childrenLen; j++ ) {

                    let c = children[ j ];

                    if ( c && isSameNodeType( c, vchild ) ) {

                        child = c;
                        children[ j ] = undefined;

                        if ( j === childrenLen - 1 ) childrenLen--;
                        if ( j === min ) min++;
                        break;

                    }

                }

            }

            // 对比
            child = diff( child, vchild );

            // 更新DOM
            const f = domChildren[ i ];
            if ( child && child !== dom && child !== f ) {
                // 如果更新前的对应位置为空,说明此节点是新增的
                if ( !f ) {
                    dom.appendChild(child);
                // 如果更新后的节点和更新前对应位置的下一个节点一样,说明当前位置的节点被移除了
                } else if ( child === f.nextSibling ) {
                    removeNode( f );
               // 将更新后的节点移动到正确的位置
                } else {
                    // 注意insertBefore的用法,第一个参数是要插入的节点,第二个参数是已存在的节点
                    dom.insertBefore( child, f );
                }
            }

        }
    }

}

对比组件

如果vnode是一个组件,我们也单独拿出来作为一个方法:

function diffComponent( dom, vnode ) {

    let c = dom && dom._component;
    let oldDom = dom;

    // 如果组件类型没有变化,则重新set props
    if ( c && c.constructor === vnode.tag ) {
        setComponentProps( c, vnode.attrs );
        dom = c.base;
    // 如果组件类型变化,则移除掉原来组件,并渲染新的组件
    } else {

        if ( c ) {
            unmountComponent( c );
            oldDom = null;
        }

        c = createComponent( vnode.tag, vnode.attrs );

        setComponentProps( c, vnode.attrs );
        dom = c.base;

        if ( oldDom && dom !== oldDom ) {
            oldDom._component = null;
            removeNode( oldDom );
        }

    }

    return dom;

}

下面是相关的工具方法的实现,和上一篇文章的实现相比,只需要修改renderComponent方法的两个地方。

function renderComponent( component ) {
    
    // ...

    // base = base = _render( renderer );          // 将_render改成diff
    base = diff( component.base, renderer );

    // ...
   
   // 去掉这部分
   // if ( component.base && component.base.parentNode ) {
   //     component.base.parentNode.replaceChild( base, component.base );
   // }

    // ...
}

完整diff实现看这个文件

渲染

现在我们实现了diff方法,我们尝试渲染上一篇文章中定义的Counter组件,来感受一下有无diff方法的不同。

class Counter extends React.Component {
    constructor( props ) {
        super( props );
        this.state = {
            num: 1
        }
    }

    onClick() {
        this.setState( { num: this.state.num + 1 } );
    }

    render() {
        return (
            <div>
                <h1>count: { this.state.num }</h1>
                <button onClick={ () => this.onClick()}>add</button>
            </div>
        );
    }
}

不使用diff

使用上一篇文章的实现,从chrome的调试工具中可以看到,闪烁的部分是每次更新的部分,每次点击按钮,都会重新渲染整个组件。
2

使用diff

而实现了diff方法后,每次点击按钮,都只会重新渲染变化的部分。
2

后话

在这篇文章中我们实现了diff算法,通过它做到了每次只更新需要更新的部分,极大地减少了DOM操作。React实现远比这个要复杂,特别是在React 16之后还引入了Fiber架构,但是主要的**是一致的。

实现diff算法可以说性能有了很大的提升,但是在别的地方仍然后很多改进的空间:每次调用setState后会立即调用renderComponent重新渲染组件,但现实情况是,我们可能会在极短的时间内多次调用setState。
假设我们在上文的Counter组件中写出了这种代码

onClick() {
    for ( let i = 0; i < 100; i++ ) {
        this.setState( { num: this.state.num + 1 } );
    }
}

那以目前的实现,每次点击都会渲染100次组件,对性能肯定有很大的影响。
下一篇文章我们就要来改进setState方法

这篇文章的代码:https://github.com/hujiulong/simple-react/tree/chapter-3

从零开始实现React系列

React是前端最受欢迎的框架之一,解读其源码的文章非常多,但是我想从另一个角度去解读React:从零开始实现一个React,从API层面实现React的大部分功能,在这个过程中去探索为什么有虚拟DOM、diff、为什么setState这样设计等问题。

整个系列大概会有四篇,我每周会更新一到两篇,我会第一时间在github上更新,有问题需要探讨也请在github上回复我~

博客地址: https://github.com/hujiulong/blog
关注点star,订阅点watch

上一篇文章

从零开始实现一个React(二):组件和生命周期

下一篇文章

从零开始实现一个React(四):异步的setState

从零开始实现一个React(四):异步的setState

前言

上一篇文章中,我们实现了diff算法,性能有非常大的改进。但是文章末尾也指出了一个问题:按照目前的实现,每次调用setState都会触发更新,如果组件内执行这样一段代码:

for ( let i = 0; i < 100; i++ ) {
    this.setState( { num: this.state.num + 1 } );
}

那么执行这段代码会导致这个组件被重新渲染100次,这对性能是一个非常大的负担。

真正的React是怎么做的

React显然也遇到了这样的问题,所以针对setState做了一些特别的优化:React会将多个setState的调用合并成一个来执行,这意味着当调用setState时,state并不会立即更新,举个栗子:

class App extends Component {
    constructor() {
        super();
        this.state = {
            num: 0
        }
    }
    componentDidMount() {
        for ( let i = 0; i < 100; i++ ) {
            this.setState( { num: this.state.num + 1 } );
            console.log( this.state.num );    // 会输出什么?
        }
    }
    render() {
        return (
            <div className="App">
                <h1>{ this.state.num }</h1>
            </div>
        );
    }
}

我们定义了一个App组件,在组件挂载后,会循环100次,每次让this.state.num增加1,我们用真正的React来渲染这个组件,看看结果:

1

组件渲染的结果是1,并且在控制台中输出了100次0,说明每个循环中,拿到的state仍然是更新之前的。

这是React的优化手段,但是显然它也会在导致一些不符合直觉的问题(就如上面这个例子),所以针对这种情况,React给出了一种解决方案:setState接收的参数还可以是一个函数,在这个函数中可以拿先前的状态,并通过这个函数的返回值得到下一个状态。

我们可以通过这种方式来修正App组件:

componentDidMount() {
    for ( let i = 0; i < 100; i++ ) {
        this.setState( prevState => {
            console.log( prevState.num );
            return {
                num: prevState.num + 1
            }
        } );
    }
}

这种用法是不是很像数组的reduce方法?

现在来看看App组件的渲染结果:
1
现在终于能得到我们想要的结果了。

所以,这篇文章的目标也明确了,我们要实现以下两个功能

  1. 异步更新state,将短时间内的多个setState合并成一个
  2. 为了解决异步更新导致的问题,增加另一种形式的setState:接受一个函数作为参数,在函数中可以得到前一个状态并返回下一个状态

合并setState

回顾一下第二篇文章中对setState的实现:

setState( stateChange ) {
    Object.assign( this.state, stateChange );
    renderComponent( this );
}

这种实现,每次调用setState都会更新state并马上渲染一次。

setState队列

为了合并setState,我们需要一个队列来保存每次setState的数据,然后在一段时间后,清空这个队列并渲染组件。

队列是一种数据结构,它的特点是“先进先出”,可以通过js数组的push和shift方法模拟
然后需要定义一个”入队“的方法,用来将更新添加进队列。

const queue = [];
function enqueueSetState( stateChange, component ) {
    queue.push( {
        stateChange,
        component
    } );
}

然后修改组件的setState方法,不再直接更新state和渲染组件,而是添加进更新队列。

setState( stateChange ) {
    enqueueSetState( stateChange, this );
}

现在队列是有了,怎么清空队列并渲染组件呢?

清空队列

我们定义一个flush方法,它的作用就是清空队列

function flush() {
    let item;
    // 遍历
    while( item = setStateQueue.shift() ) {

        const { stateChange, component } = item;

        // 如果没有prevState,则将当前的state作为初始的prevState
        if ( !component.prevState ) {
            component.prevState = Object.assign( {}, component.state );
        }

        // 如果stateChange是一个方法,也就是setState的第二种形式
        if ( typeof stateChange === 'function' ) {
            Object.assign( component.state, stateChange( component.prevState, component.props ) );
        } else {
            // 如果stateChange是一个对象,则直接合并到setState中
            Object.assign( component.state, stateChange );
        }

        component.prevState = component.state;

    }
}

这只是实现了state的更新,我们还没有渲染组件。渲染组件不能在遍历队列时进行,因为同一个组件可能会多次添加到队列中,我们需要另一个队列保存所有组件,不同之处是,这个队列内不会有重复的组件。

我们在enqueueSetState时,就可以做这件事

const queue = [];
const renderQueue = [];
function enqueueSetState( stateChange, component ) {
    queue.push( {
        stateChange,
        component
    } );
    // 如果renderQueue里没有当前组件,则添加到队列中
    if ( !renderQueue.some( item => item === component ) ) {
        renderQueue.push( component );
    }
}

在flush方法中,我们还需要遍历renderQueue,来渲染每一个组件

function flush() {
    let item, component;
    while( item = queue.shift() ) {
        // ...
    }
    // 渲染每一个组件
    while( component = renderQueue.shift() ) {
        renderComponent( component );
    }

}

延迟执行

现在还有一件最重要的事情:什么时候执行flush方法。
我们需要合并一段时间内所有的setState,也就是在一段时间后才执行flush方法来清空队列,关键是这个“一段时间“怎么决定。

一个比较好的做法是利用js的事件队列机制。

先来看这样一段代码:

setTimeout( () => {
    console.log( 2 );
}, 0 );
Promise.resolve().then( () => console.log( 1 ) );
console.log( 3 );

你可以打开浏览器的调试工具运行一下,它们打印的结果是:

3
1
2

具体的原理可以看阮一峰的这篇文章,这里就不再赘述了。

我们可以利用事件队列,让flush在所有同步任务后执行

function enqueueSetState( stateChange, component ) {
    // 如果queue的长度是0,也就是在上次flush执行之后第一次往队列里添加
    if ( queue.length === 0 ) {
        defer( flush );
    }
    queue.push( {
        stateChange,
        component
    } );
    if ( !renderQueue.some( item => item === component ) ) {
        renderQueue.push( component );
    }
}

定义defer方法,利用刚才题目中出现的Promise.resolve

function defer( fn ) {
    return Promise.resolve().then( fn );
}

这样在一次“事件循环“中,最多只会执行一次flush了,在这个“事件循环”中,所有的setState都会被合并,并只渲染一次组件。

别的延迟执行方法

除了用Promise.resolve().then( fn ),我们也可以用上文中提到的setTimeout( fn, 0 ),setTimeout的时间也可以是别的值,例如16毫秒。

16毫秒的间隔在一秒内大概可以执行60次,也就是60帧,人眼每秒只能捕获60幅画面

另外也可以用requestAnimationFrame或者requestIdleCallback

function defer( fn ) {
    return requestAnimationFrame( fn );
}

试试效果

就试试渲染上文中用React渲染的那两个例子:

class App extends Component {
    constructor() {
        super();
        this.state = {
            num: 0
        }
    }
    componentDidMount() {
        for ( let i = 0; i < 100; i++ ) {
            this.setState( { num: this.state.num + 1 } );
            console.log( this.state.num ); 
        }
    }
    render() {
        return (
            <div className="App">
                <h1>{ this.state.num }</h1>
            </div>
        );
    }
}

效果和React完全一样
1
同样,用第二种方式调用setState:

componentDidMount() {
    for ( let i = 0; i < 100; i++ ) {
        this.setState( prevState => {
            console.log( prevState.num );
            return {
                num: prevState.num + 1
            }
        } );
    }
}

结果也完全一样:
1

后话

在这篇文章中,我们又实现了一个很重要的优化:合并短时间内的多次setState,异步更新state。
到这里我们已经实现了React的大部分核心功能和优化手段了,所以这篇文章也是这个系列的最后一篇了。

这篇文章的所有代码都在这里:https://github.com/hujiulong/simple-react/tree/chapter-4

从零开始实现React系列

React是前端最受欢迎的框架之一,解读其源码的文章非常多,但是我想从另一个角度去解读React:从零开始实现一个React,从API层面实现React的大部分功能,在这个过程中去探索为什么有虚拟DOM、diff、为什么setState这样设计等问题。

整个系列大概会有四篇左右,我每周会更新一到两篇,我会第一时间在github上更新,有问题需要探讨也请在github上回复我~

博客地址: https://github.com/hujiulong/blog
关注点star,订阅点watch

上一篇文章

从零开始实现一个React(三):diff算法

用canvas绘制一个曲线动画——深入理解贝塞尔曲线

前言

在前端开发中,贝赛尔曲线无处不在:

  • 它可以用来绘制曲线,在svg和canvas中,原生提供的曲线绘制都是使用贝赛尔曲线
  • 它也可以用来描述一个缓动算法,设置css的transition-timing-function属性,可以使用贝塞尔曲线来描述过渡的缓动计算
  • 几乎所有前端2D或3D图形图表库(echarts,d3,three.js)都会使用到贝塞尔曲线

这篇文章我准备从实现一个非常简单的曲线动画效果入手,帮助大家彻底地弄懂什么是贝塞尔曲线,以及它有哪些特性,文章中有一点点数学公式,但是都非常简单:)。

160935917f7f0d3f
实现这样一个曲线动画

可以点击这里查看在线演示

在写代码之前,先了解一下什么是贝塞尔曲线吧。

贝塞尔曲线

贝塞尔曲线(Bezier curve)是计算机图形学中相当重要的参数曲线,它通过一个方程来描述一条曲线,根据方程的最高阶数,又分为线性贝赛尔曲线,二次贝塞尔曲线、三次贝塞尔曲线和更高阶的贝塞尔曲线。

下面详细介绍一下用得比较多的二次贝塞尔曲线和三次贝塞尔曲线

二次贝塞尔曲线

二次贝塞尔曲线由三个点P0,P1,P2来确定,这些点也被称作控制点。曲线的方程为:

这个方程其实有它的几何意义,它表示可以通过这样的步骤来绘制一条曲线:

  • 选定一个0-1t
  • 通过P0P1计算出点Q0Q0P0 P1连成的直线上,并且length( P0, Q0 ) = length( P0, P1 ) * t
  • 同样,通过P1P2计算出Q1,使得length( P1, Q1 ) = length( P1, P2 ) * t
  • 再重复一次这个步骤,通过Q1Q2计算出B,使得length( Q0, Q1 ) = length( Q0, B ) * tB就为当前曲线上的点

注:上面的length表示两点之间的长度


图:二次贝塞尔曲线结构

有了曲线方程,我们直接代入具体的t值就能算出点B了。

如果将t的值从0过渡到1,不断计算点B,就可以得到一条二次贝塞尔曲线:

s
图:二次贝塞尔线绘制过程

在canvas中,绘制二次贝塞尔曲线的方法为

ctx.quadraticCurveTo( p1x, p1y, p2x, p2y )

其中p1x, p1y, p2x, p2y为后两个控制点(P1P2)的横纵坐标,它默认将当前路径的起点作为一个控制点(P0)。

三次贝塞尔曲线

三次贝塞尔曲线需要四个点P0,P1,P2,P3来确定,曲线方程为

它的计算过程和二次贝塞尔曲线类似,这里不再赘述,可以看下图:


图:三次贝塞尔曲线结构

同样,将t的值从0过渡到1,就可以绘制出一条三次贝塞尔曲线:


图:三次贝塞尔曲线绘制过程

在canvas中,绘制三次贝塞尔曲线的方法为

ctx.bezierCurveTo( p1x, p1y, p2x, p2y, p3x, p3y )

其中p1x, p1y, p2x, p2y, p3x, p3y为后三个控制点(P1,P2P3)的横纵坐标,它默认将当前路径的起点作为一个控制点(P0)。

贝塞尔曲线的特征

在三次贝塞尔曲线后面,还有更高阶的贝塞尔曲线,同样它们绘制的过程也更加复杂

四次贝塞尔曲线


图:四次贝塞尔曲线

五次贝塞尔曲线

1608e389f3e76e8d

图:五次贝塞尔曲线

我们可以归纳出贝塞尔曲线有几个重要的特征:

  1. n阶贝塞尔曲线需要n+1个点来确定
  2. 贝塞尔曲线是平滑的
  3. 贝塞尔曲线的起点和终点与对应控制点的连线相切

绘制贝塞尔曲线

复习完基础概念,接下来就要讲怎样绘制贝塞尔曲线啦

为简单起见,我们选择使用二次贝塞尔曲线

我们先不考虑动画的事,我们先将问题简化成:给定一个起点和一个终点,需要实现一个函数,它能够绘制出一条曲线。

也就是说我们需要实现一个函数drawCurvePath,除渲染上下文ctx外(不清楚ctx是什么的同学可以先熟悉下canvas的基本概念),它接受三个参数,分别为二次贝塞尔曲线的三个控制点。我们将样式控制移到函数外,drawCurvePath只用来绘制路径。

/**
 * 绘制二次贝赛尔曲线路径
 * @param  {Object} ctx
 * @param  {Array<number>} p0
 * @param  {Array<number>} p1
 * @param  {Array<number>} p2
 */
function drawCurvePath( ctx, p0, p1, p2 ) {
    // ...
}

前文提到过,在canvas中,绘制二次贝赛尔曲线的方法是quadraticCurveTo,所以只要短短两行就能完成这个方法。

/**
 * 绘制二次贝赛尔曲线路径
 * @param  {CanvasRenderingContext2D} ctx
 * @param  {Array<number>} p0
 * @param  {Array<number>} p1
 * @param  {Array<number>} p2
 */
function drawCurvePath( ctx, p0, p1, p2 ) {
    ctx.moveTo( p0[ 0 ], p0[ 1 ] );
    ctx.quadraticCurveTo( 
        p1[ 0 ], p1[ 1 ],
        p2[ 0 ], p2[ 1 ]
    );
}

这样就完成了基本的绘制二次贝塞尔曲线的方法了。

但是函数这样设计有点小问题

如果我们是在做一个图形库,我们想给使用者提供一个绘制曲线的方法。

对于使用者来说,他只想在给定的起点和终点间间绘制一条曲线,他想要得到的曲线尽量美观,但是又不想关心具体的实现细节,如果还需要给第三个点,使用者会有一定的学习成本(至少需要弄明白什么是贝塞尔曲线)。

看到这里你可能会比较疑惑,即使是二次贝塞尔曲线也需要三个控制点,只有起点和终点怎么绘制曲线呢。

我们可以在起点和终点的垂直平分线上选一点作为第三个控制点,可以提供给使用者一个参数来控制曲线的弯曲程度,现在函数就变成了这样

/**
 * 绘制一条曲线路径
 * @param  {CanvasRenderingContext2D} ctx
 * @param  {Array<number>} start 起点
 * @param  {Array<number>} end 终点
 * @param  {number} curveness 曲度(0-1)
 */
function drawCurvePath( ctx, start, end, curveness ) {
    // ...
}

我们用curveness来表示曲线的弯曲程度,也就是第三个控制点的偏离程度。这样很容易就能计算出中间点。
现在完整的函数变成了这样:

/**
 * 绘制一条曲线路径
 * @param  {Object} ctx canvas渲染上下文
 * @param  {Array<number>} start 起点
 * @param  {Array<number>} end 终点
 * @param  {number} curveness 曲度(0-1)
 */
function drawCurvePath( ctx, start, end, curveness ) {
    // 计算中间控制点
    var cp = [
         ( start[ 0 ] + end[ 0 ] ) / 2 - ( start[ 1 ] - end[ 1 ] ) * curveness,
         ( start[ 1 ] + end[ 1 ] ) / 2 - ( end[ 0 ] - start[ 0 ] ) * curveness
    ];
    ctx.moveTo( start[ 0 ], start[ 1 ] );
    ctx.quadraticCurveTo( 
        cp[ 0 ], cp[ 1 ],
        end[ 0 ], end[ 1 ]
    );
}

对,就这么短短几行,接下来我们就可以通过它来绘制一条曲线了,代码如下

<!DOCTYPE html>
<html lang="en">
    <head>
        <title>draw curve</title>
    </head>
    <body>
        <canvas id="canvas" width="800" height="800"></canvas>
        <script>
            var canvas = document.getElementById( 'canvas' );
            var ctx = canvas.getContext( '2d' );
            
            ctx.lineWidth = 2;
            ctx.strokeStyle = '#000';
            ctx.beginPath();
    
            drawCurvePath( 
                ctx,
                [ 100, 100 ],
                [ 200, 300 ],
                0.4
            );
            
            ctx.stroke();
            
            function drawCurvePath( ctx, start, end, curveness ) {
                // ...
            }
        </script>
    </body>
</html>

绘制结果:

qq 20171226233508
绘制一条曲线

绘制贝塞尔曲线动画

终于来到文章的本体啦,我们的目的不是绘制一条静态的曲线,我们想绘制一条有过渡效果的曲线。

简化一下问题,那就是我们希望绘制曲线的函数还接受另一个参数,表示绘制曲线的百分比。我们定时去调用这个函数,递增百分比这个参数,就能画出动画了。

我们新增一个参数percent来表示百分比,现在函数变成了这样:

/**
 * 绘制一条曲线路径
 * @param  {Object} ctx canvas渲染上下文
 * @param  {Array<number>} start 起点
 * @param  {Array<number>} end 终点
 * @param  {number} curveness 曲度(0-1)
 * @param  {number} percent 绘制百分比(0-100)
 */
function drawCurvePath( ctx, start, end, curveness, percent ) {
    // ...
}

但是canvas提供的quadraticCurveTo方法只能绘制一条完整的二次贝赛尔曲线,没有办法去控制它只画一部分。

画完后用clearRect擦除掉一部分?这不太可行,因为很难确定要擦除的范围。如果曲线的线宽比较宽,就还需要保证擦除的边界和曲线末端垂直,问题就变得很复杂了。

现在再重新看看这张图

s

我们是不是可以将percent这个参数理解成t值,然后通过贝赛尔曲线方程去计算出中间所有的点,用直线连接起来,以此模拟绘制贝赛尔曲线的一部分呢?

方法一

我们不再用canvas提供的quadraticCurveTo来绘制曲线,而是通过贝赛尔曲线的方程计算出一系列点,用多端直线来模拟曲线。

这样做的好处时,我们可以很容易的控制绘制的范围。

那么函数实现就变成了这样:

/**
 * 绘制一条曲线路径
 * @param  {Object} ctx canvas渲染上下文
 * @param  {Array<number>} start 起点
 * @param  {Array<number>} end 终点
 * @param  {number} curveness 曲度(0-1)
 * @param  {number} percent 绘制百分比(0-100)
 */
function drawCurvePath( ctx, start, end, curveness, percent ) {

    var cp = [
         ( start[ 0 ] + end[ 0 ] ) / 2 - ( start[ 1 ] - end[ 1 ] ) * curveness,
         ( start[ 1 ] + end[ 1 ] ) / 2 - ( end[ 0 ] - start[ 0 ] ) * curveness
    ];
    
    ctx.moveTo( start[ 0 ], start[ 1 ] );
    
    for ( var t = 0; t <= percent / 100; t += 0.01 ) {

        var x = quadraticBezier( start[ 0 ], cp[ 0 ], end[ 0 ], t );
        var y = quadraticBezier( start[ 1 ], cp[ 1 ], end[ 1 ], t );
        
        ctx.lineTo( x, y );
    }
    
}

function quadraticBezier( p0, p1, p2, t ) {
    var k = 1 - t;
    return k * k * p0 + 2 * ( 1 - t ) * t * p1 + t * t * p2;    // 这个方程就是二次贝赛尔曲线方程
}

接下来就可以通过设置定时器,每隔一段时间调用一次这个方法,并且递增percent

为了动画更加平滑,我们使用requestAnimationFrame来代替定时器

<!DOCTYPE html>
<html lang="en">
    <head>
        <title>draw curve</title>
    </head>
    <body>
        <canvas id="canvas" width="800" height="800"></canvas>
        <script>
            var canvas = document.getElementById( 'canvas' );
            var ctx = canvas.getContext( '2d' );
            
            ctx.lineWidth = 2;
            ctx.strokeStyle = '#000';
            
            var percent = 0;
            
            function animate() {
                
                ctx.clearRect( 0, 0, 800, 800 );
                ctx.beginPath();

                drawCurvePath( 
                    ctx,
                    [ 100, 100 ],
                    [ 200, 300 ],
                    0.2,
                    percent
                );
    
                ctx.stroke();
    
                percent = ( percent + 1 ) % 100;
                
                requestAnimationFrame( animate );
                
            }
            
            animate();
            
            function drawCurvePath( ctx, start, end, curveness, percent ) {
                // ...
            }
        </script>
    </body>
</html>

得到的结果:

这样基本实现了我们的需求,但它有一个问题:

测试发现,进行一次lineTo的时间和一次quadraticCurveTo的时间差不多,但是quadraticCurveTo只需要一次就能画出曲线,而使用lineTo则需要数十次。

换言之,用这样的方式绘制曲线,和我们前面的实现方式相比性能下降了数十倍之多。在绘制一条曲线时可能感觉不到区别,但是如果需要同时绘制上千条曲线,性能就会受到很大的影响。

方法二

那有没有什么方法可以做到用quadraticCurveTo来实现绘制完整曲线的一部分呢?

我们再次回到这张图

s

在中间的某一时刻,例如t=0.25时,它是这样的:

我们注意到,曲线P0-B这一段似乎也是贝赛尔曲线,它的控制点变成了P0,Q0,B

现在问题就迎刃而解了,我们只需要每次计算出Q0,B,就能得到其中一小段贝赛尔曲线的控制点,然后就可以通过quadraticCurveTo来绘制它了。

代码如下:

/**
 * 绘制一条曲线路径
 * @param  {Object} ctx canvas渲染上下文
 * @param  {Array<number>} start 起点
 * @param  {Array<number>} end 终点
 * @param  {number} curveness 曲度(0-1)
 * @param  {number} percent 绘制百分比(0-100)
 */
function drawCurvePath( ctx, start, end, curveness, percent ) {

    var cp = [
         ( start[ 0 ] + end[ 0 ] ) / 2 - ( start[ 1 ] - end[ 1 ] ) * curveness,
         ( start[ 1 ] + end[ 1 ] ) / 2 - ( end[ 0 ] - start[ 0 ] ) * curveness
    ];
    
    var t = percent / 100;
    
    var p0 = start;
    var p1 = cp;
    var p2 = end;
    
    var v01 = [ p1[ 0 ] - p0[ 0 ], p1[ 1 ] - p0[ 1 ] ];     // 向量<p0, p1>
    var v12 = [ p2[ 0 ] - p1[ 0 ], p2[ 1 ] - p1[ 1 ] ];     // 向量<p1, p2>

    var q0 = [ p0[ 0 ] + v01[ 0 ] * t, p0[ 1 ] + v01[ 1 ] * t ];
    var q1 = [ p1[ 0 ] + v12[ 0 ] * t, p1[ 1 ] + v12[ 1 ] * t ];
    
    var v = [ q1[ 0 ] - q0[ 0 ], q1[ 1 ] - q0[ 1 ] ];       // 向量<q0, q1>

    var b = [ q0[ 0 ] + v[ 0 ] * t, q0[ 1 ] + v[ 1 ] * t ];
    
    ctx.moveTo( p0[ 0 ], p0[ 1 ] );

    ctx.quadraticCurveTo( 
        q0[ 0 ], q0[ 1 ],
        b[ 0 ], b[ 1 ]
    );

}

将前面写的页面替换成上面的代码,可以看到得到的结果是一样的:

绘制动画

现在已经解决了最关键的问题,我们可以绘制动画啦。
不过这一部分并不重要,我就不贴代码了。

完整代码可以看这里

160935917f7f0d3f

关于我的博客

这篇文章到这里就结束了。

我计划写一系列关于前端图形渲染的文章,将会涵盖常用的前端图形绘制技术:canvas、svg和WebGL。希望通过这一系列文章能让读者对前端的各种图形绘制接口以及图像处理、图形学的基础知识有所了解。希望在分享的同时,也能巩固和复习自己所学知识,和大家共同进步。

系列博客地址:https://github.com/hujiulong/blog

如果能帮助到你,欢迎star,这样也能及时追踪博客的更新。

通过分析AST自动重构three.js的老旧代码

前言

先简单介绍一些背景:
three.js是一个非常流行的JS三维渲染库,通常是做web端三维效果的第一选择。但是同时three.js已经有了将近9年的历史,所有它很多代码仍然是使用非常老旧的模式。

three.js曾经所有的文件都是使用全局变量THREE的方式来组织,比如欧拉角Euler.js

// three.js/src/math/Euler.js
THREE.Euler = function ( x, y, z, order ) {
  this._x = x || 0;
  this._y = y || 0;
  this._z = z || 0;
  this._order = order || THREE.Euler.DefaultOrder;
};

在经历几次重构以后,three.js的核心代码已经完全迁移成用ES6 Module来组织了,直接通过export { Euler }来输出变量。

但是在核心代码以外,仍然有大量非常常用的代码使用这种老旧方式来组织,比如所有的模型加载器loaders,以及控制器controls。如果想直接import它们,需要自己手动去改成ES6 Module的形式,在我以前的一个项目vue-3d-model中,所有的loaders就是我手动修改的。

为什么要用AST来做

粗略看来这些老旧代码大多遵循一些特定的模式,例如很多都是以THREE.XX = xx的形式来输出变量,很容易想到用正则去处理它。
但是用正则匹配会遇到非常多的问题:

1.正则要求很严格,每一个字符都要写规则来匹配它
如果代码风格不统一,例如想匹配THREE.XX = xx这种代码,你写的正则必须要同时兼容THREE.XX=xx这种等号两边没有空格的情况。实践中还要处理各种特殊情况,非常麻烦。

2.很难避开注释中的代码
注释中也可能会出现你要匹配的字符串,会导致很多错误。

但是绕过代码本身,直接分析代码的抽象语法树(AST),这些问题就都迎刃而解了。
AST是源代码语法结构的一种抽象表示,代码对应的AST和代码风格无关,多写一个空格少写一个分号都没关系,通过AST来查找代码节点也更加可靠,不必担心错误匹配到别的代码,像eslint,webpack之类的工具都是通过分析AST来处理代码的。

JS的AST已经形成了一套规范,具体可以看这个文档

生成AST的工具也有很多,我选择的是acorn

找出输出语句

输出语句大多是直接给全局变量THREE赋值的,例如这样前言中说的Euler.js,我们期望将这样的代码:

THREE.Euler = function() { /* ... */ };

转换成:

const Euler = function() { /* ... */ };
export { Euler };

可以看到输出语句大都是THREE.XX = xx的形式,后面的xx可能是一个类、变量、函数或别的什么东西,总的来说它是一个赋值语句。
先抛开要处理的代码,我们来看一个简单的给属性赋值语句代码对应的AST是什么样的。

THREE.A = 1;

通过acorn.parse(code)可以得到AST:

{
  "type": "AssignmentExpression",
  "start": 1,
  "end": 12,
  "operator": "=",
  "left": {
    "type": "MemberExpression",
    "start": 1,
    "end": 8,
    "object": {
      "type": "Identifier",
      "start": 1,
      "end": 6,
      "name": "THREE"
    },
    "property": {
      "type": "Identifier",
      "start": 7,
      "end": 8,
      "name": "A"
    },
    "computed": false
  },
  "right": {
    "type": "Literal",
    "start": 11,
    "end": 12,
    "value": 1,
    "raw": "1"
  }
}

简单分析一下:
首先整个节点的type"AssignmentExpression",表示它是一个赋值表达式,里面的startend是源代码中对应的位置,leftright即表达式左边和右边的值,也就是被赋值的变量和赋值的值。
lefttype"MemberExpression",即成员表达式,也就是A.B的形式的代码,也可以看到它所属的object的名称为THREE
righttype"Literal",即字面量,其实我们并不关心right,它可能是字面量,也可能是函数、对象或别的东西。

到这里我们的目标就变得明确了,我们只需要找到所有的"AssignmentExpression",并且它的left"MemberExpression",且nameTHREE

接下来就可以处理所有代码了,遍历每个文件并得到它们的AST,然后使用acorn/walk遍历AST所有的节点,就可以知道每个文件都输出了什么。

walk.simple( ast, {
  AssignmentExpression: ( node ) => {
    if (node.left.type === 'MemberExpression' &&
      node.left.object.name === 'THREE') {
      const { start, end, property } = node.left;
      code.overwrite( start, end, `const ${property.name}` );  // 将THREE.XX = xx替换为const XX = xx
      exportVars.push(property.name);  // 将输出的变量保存,最后export它们
    }
  }
})

这样最后我们得到了所有的输出变量,就可以在文件末尾export它们。

处理依赖

除了找到输出的变量,我们还需要处理文件的依赖。值得高兴的是THREE所有文件都没有任何外部依赖,所有的依赖情况只有两种:
1.依赖three.js的核心库
2.依赖别的需要转化的文件

比如文件中有这样一段代码

const v = new THREE.Vector3();
const loader = new THREE.OBJLoader();

我们期望的转化后的文件应该是这样:

import { Vector3 } from 'three';
import { OBJLoader } from '../loader/OBJLoader.js';
const v = new Vector3();
const loader = new OBJLoader();

我们先找出代码中所有有依赖的地方,这两种依赖情况都是获取THREE中的一个值,所以只要像处理输出语句那样找到所有nameTHREEMemberExpression节点就可以了。

walk.simple( ast, {
  MemberExpression: node => {
    const { object, property } = node;
    if ( object.name === 'THREE' && property.type === 'Identifier' ) {
      code.overwrite(object.start, object.end + 1, ''); // 将代码中的THREE.XX 替换为 XX
      dependences.push( property.name );  // 得到依赖
    }
  }
})

得到所有依赖的名称后,通过判断three的核心库中是否包含这个值,就可以知道它是位于three中还是别的文件中,然后通过计算文件之间的相对位置,可以得到依赖文件的地址。

后话

转换实际情况要更加复杂一点,但是基本都可以通过AST来做正确的替换,通过这种方式我处理了将近300个文件,只有很少的一部分需要再手动修改一下。
另外three.js目前实现类的方式都还是ES5时代的function的方式,后面会通过各种方式来将它们批量转换成ES6的class,这中间肯定也需要用到AST。

相关代码:

使用Vue 3.0做JSX(TSX)风格的组件开发

前言

我日常工作都是使用React来做开发,但是我对React一直不是很满意,特别是在推出React Hooks以后。

不可否认React Hooks极大地方便了开发者,但是它又有非常多反直觉的地方,让我难以接受。所以在很长一段时间,我都在尝试寻找React的替代品,我尝试过不少别的前端框架,但都有各种各样的问题或限制。

在看到了Vue 3.0 Composition-API的设计,确实有眼前一亮的感觉,它既保留了React Hooks的优点,又没有反复声明销毁的问题,而Vue一直都是支持JSX语法的,3.0对TypeScript的支持又非常好,所以我开始尝试用Vue + TSX来做开发。

Vue 3.0已经发布了alpha版本,可以通过以下命令来安装:

npm install vue@next --save

简单示例

先来看看用Vue3.0 + TSX写一个组件是什么什么样子的。

实现一个Input组件:

import { defineComponent } from 'vue';

interface InputProps {
  value: string;
  onChange: (value: string) => void;
}
const Input = defineComponent({
  setup(props: InputProps) {
    const handleChange = (event: KeyboardEvent) => {
      props.onChange(event.target.value);
    }

    return () => (
      <input value={props.value} onInput={handleChange} />
    )
  }
})

可以看到写法和React非常相似,和React不同的是,一些内部方法,例如handleChange,不会在每次渲染时重复定义,而是在setup这个准备阶段完成,最后返回一个“函数组件”。

这算是解决了React Hooks非常大的一个痛点,比React Hooks那种重复声明的方式要舒服多了。

Vue 3.0对TS做了一些增强,不需要像以前那样必须声明props,而是可以通过TS类型声明来完成。

这里的defineComponent没有太多实际用途,主要是为了实现让ts类型提示变得友好一点。

Babel插件

为了能让上面那段代码跑起来,还需要有一个Babel插件来转换上文中的JSX,Vue 3.0相比2.x有一些变化,不能再使用原来的vue-jsx插件。

我们都知道JSX(TSX)实际上是语法糖,例如在React中,这样一段代码:

const input = <input value="text" />

实际上会被babel插件转换为下面这行代码:

const input = React.createElement('input', { value: 'text' });

Vue 3.0也提供了一个对应React.createElement的方法h。但是这个h方法又和vue 2.0以及React都有一些不同。

例如这样一段代码:

<div class={['foo', 'bar']} style={{ margin: '10px' }} id="foo" onClick={foo} />

在vue2.0中会转换成这样:

h('div', {
  class: ['foo', 'bar'],
  style: { margin: '10px' }
  attrs: { id: 'foo' },
  on: { click: foo }
})

可以看到vue会将传入的属性做一个分类,会分为classstyleattrson等不同部分。这样做非常繁琐,也不好处理。

在vue 3.0中跟react更加相似,会转成这样:

h('div', {
  class: ['foo', 'bar'],
  style: { margin: '10px' }
  id: 'foo',
  onClick: foo
})

基本上是传入什么就是什么,没有做额外的处理。

当然和React.createElement相比也有一些区别:

  • 子节点不会作为以children这个名字在props中传入,而是通过slots去取,这个下文会做说明。
  • 多个子节点是以数组的形式传入,而不是像React那样作为分开的参数

所以只能自己动手来实现这个插件,我是在babel-plugin-transform-react-jsx的基础上修改的,并且自动注入了h方法。

实际使用

在上面的工作完成以后,我们可以真正开始做开发了。

渲染子节点

上文说到,子节点不会像React那样作为children这个prop传递,而是要通过slots去取:

例如实现一个Button组件

// button.tsx
import { defineComponent } from 'vue';
import './style.less';

interface ButtonProps {
  type: 'primary' | 'dashed' | 'link'
}
const Button = defineComponent({
  setup(props: ButtonProps, { slots }) {
    return () => (
      <button class={'btn', `btn-${props.type}`}>
        {slots.default()}
      </button>
    )
  }
})

export default Button;

然后我们就可以使用它了:

import { createApp } from 'vue';
import Button from './button';

// vue 3.0也支持函数组件
const App = () => <Button>Click Me!</Button>

createApp().mount(App, '#app');

渲染结果:
image

Reactive

配合vue 3.0提供的reactive,不需要主动通知Vue更新视图,直接更新数据即可。

例如一个点击计数的组件Counter:

import { defineComponent, reactive } from 'vue';

const Counter = defineComponent({
  setup() {
    const state = reactive({ count: 0 });
    const handleClick = () => state.count++;
    return () => (
      <button onClick={handleClick}>
        count: {state.count}
      </button>
    )
  }
});

渲染结果:
Kapture 2020-01-14 at 13 15 22

这个Counter组件如果用React Hooks来写:

import React, { useState } from 'react';

const Counter = () => {
  const [count, setCount] = useState(0);
  const handleClick = () => setCount(count + 1);
  return (
    <button onClick={handleClick}>
      count: {count}
    </button>
  )
}

对比之下可以发现Vue 3.0的优势:

在React中,useState和定义handleClick的代码会在每次渲染时都执行,而Vue定义的组件重新渲染时只会执行setup中最后返回的渲染方法,不会重复执行上面的那部分代码。

而且在Vue中,只需要更新对应的值即可触发视图更新,不需要像React那样调用setCount

当然Vue的这种定义组件的方式也带来了一些限制setup的参数props是一个reactive对象,不要对它进行解构赋值,使用时要格外注意这一点:

例如实现一个简单的展示内容的组件:

// 错误示例
import { defineComponent, reactive } from 'vue';

interface LabelProps {
  content: string;
}
const Label = defineComponent({
  setup({ content }: LabelProps) {
    return () => <span>{content}</span>
  }
})

这样写是有问题的,我们在setup的参数中直接对props做了解构赋值,写成了{ content }这样在后续外部更新传入的content时,组件是不会更新的,因为破坏了props的响应机制。以后可以通过eslint之类的工具来避免这种写法。

正确的写法是在返回的方法里再对props做解构赋值:

import { defineComponent, reactive } from 'vue';

interface LabelProps {
  content: string;
}
const Label = defineComponent({
  setup(props: LabelProps) {
    return () => {
      const { content } = props;  // 在这里对props做解构赋值
      return <span>{content}</span>;
    }
  }
})

生命周期方法

在Vue 3.0中使用生命周期方法也非常简单,直接将对应的方法import进来即可使用。

import { defineComponent, reactive, onMounted } from 'vue';

interface LabelProps {
  content: string;
}
const Label = defineComponent({
  setup(props: LabelProps) {
    
    onMounted(() => { console.log('mounted!'); });
  
    return () => {
      const { content } = props;
      return <span>{content}</span>;
    }
  }
})

vue 3.0对tree-shaking非常友好,所有API和内置组件都支持tree-shaking。

如果你所有地方都没有用到onMounted,支持tree-shaking的打包工具会自动将起去掉,不会打进最后的包里。

指令和过渡效果

Vue 3.0还提供了一系列组件和方法,来使JSX也能使用模板语法的指令和过渡效果。

使用Transition在显示/隐藏内容块时做过渡动画:

import { defineComponent, ref, Transition } from 'vue';
import './style.less';

const App = defineComponent({
  setup() {
    const count = ref(0);
    const handleClick = () => {
      count.value ++;
    }

    return () => (
      <div>
        <button onClick={handleClick}>click me!</button>
        <Transition name="slide-fade">
          {count.value % 2 === 0 ?
            <h1>count: {count.value}</h1>
          : null}
        </Transition>
      </div>
    )
  }
})
// style.less
.slide-fade-enter-active {
  transition: all .3s ease;
}
.slide-fade-leave-active {
  transition: all .8s cubic-bezier(1.0, 0.5, 0.8, 1.0);
}
.slide-fade-enter, .slide-fade-leave-to {
  transform: translateX(10px);
  opacity: 0;
}

渲染结果:
Kapture 2020-01-14 at 12 25 41

也可以通过withDirectives来使用各种指令,例如实现模板语法v-show的效果:

import { defineComponent, ref, Transition, withDirectives, vShow } from 'vue';
import './style.less';

const App = defineComponent({
  setup() {
    const count = ref(0);
    const handleClick = () => {
      count.value ++;
    }

    return () => (
      <div >
        <button onClick={handleClick}>toggle</button>
        <Transition name="slide-fade">
          {withDirectives(<h1>Count: {count.value}</h1>, [[
            vShow, count.value % 2 === 0
          ]])}
        </Transition>
      </div>
    )
  }
})

这样写起来有点繁琐,应该可以通过babel-jsx插件来实现下面这种写法:

<h1 vShow={count.value % 2 === 0}>Count: {count.value}</h1>

优缺点

在我看来Vue 3.0 + TSX完全可以作为React的替代,它既保留了React Hooks的优点,又避开了React Hooks的种种问题。

但是这种用法也有一个难以忽视的问题:它没办法获得Vue 3.0编译阶段的优化。

Vue 3.0通过对模板的分析,可以做一些前期优化,而JSX语法是难以做到的。

例如“静态树提升”优化:

如下一段模板(这是模板,并非JSX):

<template>
 <div>
   <span>static</span>
   <span>{{ dynamic }}</span>
 </div>
</template>

如果不做任何优化,那么编译后得到的代码应该是这样子:

render() {
 return h('div', [
   h('span', 'static'),
   h('span', this.dynamic)
 ]);
}

那么每次重新渲染时,都会执行3次h方法,虽然未必会触发真正的DOM更新,但这也是一部分开销。

通过观察,我们知道h('span', 'static')这段代码传入的参数始终都不会有变化,它是静态的,而只有h('span', this.dynamic)这段才会根据dynamic的值变化。

在Vue 3.0中,编译器会自动分析出这种区别,对于静态的节点,会自动提升到render方法外部,避免重复执行。

Vue 3.0编译后的代码:

const __static1 = h('span', 'static');

render() {
   return h('div', [
       __static1,
       h('span', this.dynamic)
    ])     
}

这样每次渲染时就只会执行两次h。换言之,经过静态树提升后,Vue 3.0渲染成本将只会和动态节点的规模相关,静态节点将会被复用。

除了静态树提升,还有很多别的编译阶段的优化,这些都是JSX语法难以做到的,因为JSX语法本质上还是在写JS,它没有任何限制,强行提升它会破坏JS执行的上下文,所以很难做出这种优化(也许配合prepack可以做到)。

考虑到这一点,如果你是在实现一个对性能要求较高的基础组件库,那模板语法仍然是首选。

另外JSX也没办法做ref自动展开,使得refreactive在使用上没有太大区别。

后话

我个人对Vue 3.0是非常满意的,无论是对TS的支持,还是新的Composition API,如果不限制框架的话,那Vue以后肯定是我的首选。


更新:

本文中通过TS的interface声明props类型的依赖vue3的Optional props decalration,但后续版本中这个功能被废除了,原因可以查看#154, 在#1155中也有一些替代方案的讨论

从零开始实现一个React(一):JSX和虚拟DOM

前言

React是前端最受欢迎的框架之一,解读其源码的文章非常多,但是我想从另一个角度去解读React:从零开始实现一个React,从API层面实现React的大部分功能,在这个过程中去探索为什么有虚拟DOM、diff、为什么setState这样设计等问题。

提起React,总是免不了和Vue做一番对比

Vue的API设计非常简洁,但是其实现方式却让人感觉是“魔法”,开发者虽然能马上上手,但其原理却很难说清楚。

相比之下React的设计哲学非常简单,虽然有很多需要自己处理的细节问题,但它没有引入任何新的概念,相对更加的干净和简单。

关于jsx

在开始之前,我们有必要搞清楚一些概念。

我们来看一下这样一段代码:

const title = <h1 className="title">Hello, world!</h1>;

这段代码并不是合法的js代码,它是一种被称为jsx的语法扩展,通过它我们就可以很方便的在js代码中书写html片段。

本质上,jsx是语法糖,上面这段代码会被babel转换成如下代码

const title = React.createElement(
    'h1',
    { className: 'title' },
    'Hello, world!'
);

你可以在babel官网提供的在线转译测试jsx转换后的代码,这里有一个稍微复杂一点的例子

准备工作

为了集中精力编写逻辑,在代码打包工具上选择了最近火热的零配置打包工具parcel,需要先安装parcel:

npm install -g parcel-bundler

接下来新建index.jsindex.html,在index.html中引入index.js

当然,有一个更简单的方法,你可以直接下载这个仓库的代码:

https://github.com/hujiulong/simple-react/tree/chapter-1

注意一下babel的配置
.babelrc

{
    "presets": ["env"],
    "plugins": [
        ["transform-react-jsx", {
            "pragma": "React.createElement"
        }]
    ]
}

这个transform-react-jsx就是将jsx转换成js的babel插件,它有一个pragma项,可以定义jsx转换方法的名称,你也可以将它改成h(这是很多类React框架使用的名称)或别的。

准备工作完成后,我们可以用命令parcel index.html将它跑起来了,当然,现在它还什么都没有。

React.createElement和虚拟DOM

前文提到,jsx片段会被转译成用React.createElement方法包裹的代码。所以第一步,我们来实现这个React.createElement方法

从jsx转译结果来看,createElement方法的参数是这样:

createElement( tag, attrs, child1, child2, child3 );

第一个参数是DOM节点的标签名,它的值可能是divh1span等等
第二个参数是一个对象,里面包含了所有的属性,可能包含了classNameid等等
从第三个参数开始,就是它的子节点

我们对createElement的实现非常简单,只需要返回一个对象来保存它的信息就行了。

function createElement( tag, attrs, ...children ) {
    return {
        tag,
        attrs,
        children
    }
}

函数的参数 ...children使用了ES6的rest参数,它的作用是将后面child1,child2等参数合并成一个数组children。

现在我们来试试调用它

// 将上文定义的createElement方法放到对象React中
const React = {
    createElement
}

const element = (
    <div>
        hello<span>world!</span>
    </div>
);
console.log( element );

打开调试工具,我们可以看到输出的对象和我们预想的一致

1

我们的createElement方法返回的对象记录了这个DOM节点所有的信息,换言之,通过它我们就可以生成真正的DOM,这个记录信息的对象我们称之为虚拟DOM

ReactDOM.render

接下来是ReactDOM.render方法,我们再来看这段代码

ReactDOM.render(
    <h1>Hello, world!</h1>,
    document.getElementById('root')
);

经过转换,这段代码变成了这样

ReactDOM.render(
    React.createElement( 'h1', null, 'Hello, world!' ),
    document.getElementById('root')
);

所以render的第一个参数实际上接受的是createElement返回的对象,也就是虚拟DOM
而第二个参数则是挂载的目标DOM

总而言之,render方法的作用就是将虚拟DOM渲染成真实的DOM,下面是它的实现:

function render( vnode, container ) {
    
    // 当vnode为字符串时,渲染结果是一段文本
    if ( typeof vnode === 'string' ) {
        const textNode = document.createTextNode( vnode );
        return container.appendChild( textNode );
    }

    const dom = document.createElement( vnode.tag );

    if ( vnode.attrs ) {
        Object.keys( vnode.attrs ).forEach( key => {
            const value = vnode.attrs[ key ];
             setAttribute( dom, key, value );    // 设置属性
        } );
    }

    vnode.children.forEach( child => render( child, dom ) );    // 递归渲染子节点

    return container.appendChild( dom );    // 将渲染结果挂载到真正的DOM上
}

设置属性需要考虑一些特殊情况,我们单独将其拿出来作为一个方法setAttribute

function setAttribute( dom, name, value ) {
    // 如果属性名是className,则改回class
    if ( name === 'className' ) name = 'class';

    // 如果属性名是onXXX,则是一个事件监听方法
    if ( /on\w+/.test( name ) ) {
        name = name.toLowerCase();
        dom[ name ] = value || '';
    // 如果属性名是style,则更新style对象
    } else if ( name === 'style' ) {
        if ( !value || typeof value === 'string' ) {
            dom.style.cssText = value || '';
        } else if ( value && typeof value === 'object' ) {
            for ( let name in value ) {
                // 可以通过style={ width: 20 }这种形式来设置样式,可以省略掉单位px
                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函数时,不会清除原来的内容。所以我们将其附加到ReactDOM对象上时,先清除一下挂载目标DOM的内容:

const ReactDOM = {
    render: ( vnode, container ) => {
        container.innerHTML = '';
        return render( vnode, container );
    }
}

渲染和更新

到这里我们已经实现了React最为基础的功能,可以用它来做一些事了。

我们先在index.html中添加一个根节点

<div id="root"></div>

我们先来试试官方文档中的Hello,World

ReactDOM.render(
    <h1>Hello, world!</h1>,
    document.getElementById('root')
);

可以看到结果:
2

试试渲染一段动态的代码,这个例子也来自官方文档

function tick() {
    const element = (
        <div>
            <h1>Hello, world!</h1>
            <h2>It is {new Date().toLocaleTimeString()}.</h2>
        </div>
      );
    ReactDOM.render(
        element,
        document.getElementById( 'root' )
    );
}

setInterval( tick, 1000 );

可以看到结果:
2

后话

这篇文章中,我们实现了React非常基础的功能,也了解了jsx和虚拟DOM,下一篇文章我们将实现非常重要的组件功能。

最后留下一个小问题
在定义React组件或者书写React相关代码,不管代码中有没有用到React这个对象,我们都必须将其import进来,这是为什么?

例如:

import React from 'react';    // 下面的代码没有用到React对象,为什么也要将其import进来
import ReactDOM from 'react-dom';

ReactDOM.render( <App />, document.getElementById( 'editor' ) );

不知道答案的同学再仔细看看这篇文章哦

从零开始实现React系列

React是前端最受欢迎的框架之一,解读其源码的文章非常多,但是我想从另一个角度去解读React:从零开始实现一个React,从API层面实现React的大部分功能,在这个过程中去探索为什么有虚拟DOM、diff、为什么setState这样设计等问题。

整个系列大概会有四篇左右,我每周会更新一到两篇,我会第一时间在github上更新,有问题需要探讨也请在github上回复我~

博客地址: https://github.com/hujiulong/blog
关注点star,订阅点watch

下一篇文章

从零开始实现React(二):组件和生命周期

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.