叔叔张

叔叔张 查看完整档案

杭州编辑南京财经大学  |  计算机 编辑阿里巴巴  |  前端码农 编辑 lpgray.vipsinaapp.com 编辑
编辑

可文艺、可二逼、不识好歹的懒前端。

个人动态

叔叔张 赞了文章 · 2018-08-21

以太坊源码分析--MPT树

MPT(Merkle Patricia Tries)是以太坊中存储区块数据的核心数据结构,它Merkle Tree和Patricia Tree融合一个树形结构,理解MPT结构对之后学习以太坊区块header以及智能合约状态存储结构的模块源码很有帮助。

首先来看下Merkle树:

1533192383802
它的叶子是数据块的hash,从图中可以看出非叶子节点是其子节点串联字符串的hash,底层数据的任何变动都会影响父节点,这棵树的Merkle Root代表对底层所有数据的“摘要”。
这样的树有一个很大的好处,比如我们把交易信息写入这样的树形结构,当需要证明一个交易是否存在这颗树中的时候,就不需要重新计算所有交易的hash值。比如证明图中Hash 1-1,我们可以借助Hash 1-0重新计算出Hash 1,然后再借助Hash 0重新计算出Top Hash,这样就可以根据算出来的Top Hash和原来的Top Hash是否一样,如果一样的话那么Hash 1-1就属于这棵树。
所以想象一下,我们将这个Top Hash储存在区块头中,那么有了区块头就可以对区块信息进行验证了。同时 Hash 计算的过程可以十分快速,预处理可以在短时间内完成。利用Merkle树结构能带来巨大的比较性能提升。

再来看下Patricia树:

1533194708638
从它的名字压缩前缀树再结合上图就可以猜出来Patricia树的特点了,这种树形结构比将每一个字符作为一个节点的普通trie树形结构,它的键值可以使用多个字符,降低了树的高度,也节省了空间,再看个例子:
1533195730622
图中可以很容易看出数中所存储的键值对:

  • 6c0a5c71ec20bq3w => 5
  • 6c0a5c71ec20CX7j => 27
  • 6c0a5c71781a1FXq => 18
  • 6c0a5c71781a9Dog => 64
  • 6c0a8f743b95zUfe => 30
  • 6c0a8f743b95jx5R => 2
  • 6c0a8f740d16y03G => 43
  • 6c0a8f740d16vcc1 => 48

以太坊中的MPT:

在以太坊中MPT的节点的规格主要有一下几个:

  • NULL 空节点,简单的表示空,在代码中是一个空串
  • Nibble 它是key的基本单元,是一个四元组(四个bit位的组合例如二进制表达的0010就是一个四元组)
  • Extension 扩展节点有两个元素,一个是key值,还有一个是hash值,这个hash值指向下一个节点
  • Branch 分支节点有17个元素,回到Nibble,四元组是key的基本单元,四元组最多有16个值。所以前16个必将落入到在其遍历中的键的十六个可能的半字节值中的每一个。第17个是存储那些在当前结点结束了的节点(例如, 有三个key,分别是 (abc ,abd, ab) 第17个字段储存了ab节点的值)
  • Leaf 叶子节点只有两个元素,分别为key和value

这里还有一些知识点需要了解的,为了将MPT树存储到数据库中,同时还可以把MPT树从数据库中恢复出来,对于Extension和Leaf的节点类型做了特殊的定义:如果是一个扩展节点,那么前缀为0,这个0加在key前面。如果是一个叶子节点,那么前缀就是1。同时对key的长度就奇偶类型也做了设定,如果是奇数长度则标示1,如果是偶数长度则标示0。

以太坊中主要有一下几个地方用了MPT树形结构:

  • State Trie 区块头中的状态树

    • key => sha3(以太坊账户地址address)
    • value => rlp(账号内容信息account)
  • Transactions Trie 区块头中的交易树

    • key => rlp(交易的偏移量 transaction index)
    • 每个块都有各自的交易树,且不可更改
  • Receipts Trie 区块头中的收据树

    • key = rlp(交易的偏移量 transaction index)
    • 每个块都有各自的交易树,且不可更改
  • Storage Trie 存储树

    • 存储只能合约状态
    • 每个账号有自己的Storage Trie

1533199123752
这两个区块头中,state root,tx root receipt root分别存储了这三棵树的树根,第二个区块显示了当账号175的数据变更(27 -> 45)的时候,只需要存储跟这个账号相关的部分数据,而且老的区块中的数据还是可以正常访问。

MPT树种还有一个重要的概念一个特殊的十六进制前缀(hex-prefix, HP)编码来对key编码,我们先来了解一下编码定义规则,源码实现后面再分析:

  • RAW 原始编码,对输入不做任何变更
  • HEX 十六进制编码

    • RAW编码输入的每个字符分解为高4位和低4位
    • 如果是叶子节点,则在最后加上Hex值0x10表示结束
    • 如果是分支节点不附加任何Hex值
比如key=>"bob",b的ASCII十六进制编码为0x62,o的ASCII十六进制编码为0x6f,分解成高四位和第四位,16表示终结 0x10,最终编码结果为[6 2 6 15 6 2 16],
  • HEX-Prefix 十六进制前缀编码

    • 输入key结尾为0x10,则去掉这个终止符
    • key之前补一个四元组这个Byte第0位区分奇偶信息,第1位区分节点类型
    • 如果输入key的长度是偶数,则再添加一个四元组0x0在flag四元组后
    • 将原来的key内容压缩,将分离的两个byte以高四位低四位进行合并
十六进制前缀编码相当于一个逆向的过程,比如输入的是[6 2 6 15 6 2 16],根据第一个规则去掉终止符16。根据第二个规则key前补一个四元组,从右往左第一位为1表示叶子节点,从右往左第0位如果后面key的长度为偶数设置为0,奇数长度设置为1,那么四元组0010就是2。根据第三个规则,添加一个全0的补在后面,那么就是20.根据第三个规则内容压缩合并,那么结果就是[0x20 0x62 0x6f 0x62]

官方有一个详细的结构的示例:
worldstatetrie -1-

下面再用一个图像化的示例来加深一下对上面的MPT规则的理解

key的16进制keyvalue
<64 6f>doverb
<64 6f 67>dogpuppy
<64 6f 67 65>dogecoin
<68 6f 72 73 65>horsestallion

WechatIMG83

三种编码格式互相转换的代码实现

Compact就是上面说的HEX-Prefix,keybytes为按完整字节(8bit)存储的正常信息,hex为按照半字节nibble(4bit)储存信息的格式。
go-ethereum/trie/encoding:

package trie

func hexToCompact(hex []byte) []byte {
    terminator := byte(0)
    if hasTerm(hex) { //检查是否有结尾为0x10 => 16
        terminator = 1 //有结束标记16说明是叶子节点
        hex = hex[:len(hex)-1] //去除尾部标记
    }
    buf := make([]byte, len(hex)/2+1) // 字节数组
    
    buf[0] = terminator << 5 // 标志byte为00000000或者00100000
    //如果长度为奇数,添加奇数位标志1,并把第一个nibble字节放入buf[0]的低四位
    if len(hex)&1 == 1 {
        buf[0] |= 1 << 4 // 奇数标志 00110000
        buf[0] |= hex[0] // 第一个nibble包含在第一个字节中 0011xxxx
        hex = hex[1:]
    }
    //将两个nibble字节合并成一个字节
    decodeNibbles(hex, buf[1:])
    return buf
}
//compact编码转化为Hex编码
func compactToHex(compact []byte) []byte {
    base := keybytesToHex(compact)
    base = base[:len(base)-1]
     // apply terminator flag
    // base[0]包括四种情况
    // 00000000 扩展节点偶数位
    // 00000001 扩展节点奇数位
    // 00000010 叶子节点偶数位
    // 00000011 叶子节点奇数位

    // apply terminator flag
    if base[0] >= 2 {
       //如果是叶子节点,末尾添加Hex标志位16
        base = append(base, 16)
    }
    // apply odd flag
    //如果是偶数位,chop等于2,否则等于1
    chop := 2 - base[0]&1
    return base[chop:]
}
// 将keybytes 转成十六进制
func keybytesToHex(str []byte) []byte {
    l := len(str)*2 + 1
     //将一个keybyte转化成两个字节
    var nibbles = make([]byte, l)
    for i, b := range str {
        nibbles[i*2] = b / 16
        nibbles[i*2+1] = b % 16
    }
    //末尾加入Hex标志位16
    nibbles[l-1] = 16
    return nibbles
}

// 将十六进制的bibbles转成key bytes,这只能用于偶数长度的key
func hexToKeybytes(hex []byte) []byte {
    if hasTerm(hex) {
        hex = hex[:len(hex)-1]
    }
    if len(hex)&1 != 0 {
        panic("can't convert hex key of odd length")
    }
    key := make([]byte, (len(hex)+1)/2)
    decodeNibbles(hex, key)
    return key
}

func decodeNibbles(nibbles []byte, bytes []byte) {
    for bi, ni := 0, 0; ni < len(nibbles); bi, ni = bi+1, ni+2 {
        bytes[bi] = nibbles[ni]<<4 | nibbles[ni+1]
    }
}

// 返回a和b的公共前缀的长度
func prefixLen(a, b []byte) int {
    var i, length = 0, len(a)
    if len(b) < length {
        length = len(b)
    }
    for ; i < length; i++ {
        if a[i] != b[i] {
            break
        }
    }
    return i
}

// 十六进制key是否有结束标志符
func hasTerm(s []byte) bool {
    return len(s) > 0 && s[len(s)-1] == 16
}

以太坊中MTP数据结构

上面已经分析了以太坊的key的编码方式,接下来我们来看以太坊中MPT树的数据结构,在分析trie的数据结构前,我们先来了解一下node的定义:
trie/node.go

type node interface {
    fstring(string) string
    cache() (hashNode, bool)
    canUnload(cachegen, cachelimit uint16) bool
}

type (
    fullNode struct {  //分支节点
        Children [17]node // Actual trie node data to encode/decode (needs custom encoder)
        flags    nodeFlag
    }
    shortNode struct { 
        Key   []byte
        Val   node
        flags nodeFlag
    }
    hashNode  []byte
    valueNode []byte
)

上面代码中定义了四个struct,就是node的四种类型:

  • fullNode -> 分支节点,它有一个容量为17的node数组成员变量Children,数组中前16个空位分别对应16进制(hex)下的0-9a-f,这样对于每个子节点,根据其key值16进制形式下的第一位的值,就可挂载到Children数组的某个位置,fullNode本身不再需要额外key变量;Children数组的第17位,留给该fullNode的数据部分。这和我们上面说的Branch 分支节点的规格一致的。
  • shortNode,key是一个任意长度的字符串(字节数组[]byte),体现了PatriciaTrie的特点,通过合并只有一个子节点的父节点和其子节点来缩短trie的深度,结果就是有些节点会有长度更长的key。

    • -> 扩展节点,Val指向分支节点或者叶子节点
    • -> 叶子节点,Val为rlp编码数据,key为该数据的hash
  • valueNode -> MPT的叶子节点。字节数组[]byte的一个别名,不带子节点。使用中valueNode就是所携带数据部分的RLP哈希值,长度32byte,数据的RLP编码值作为valueNode的匹配项存储在数据库里。
  • hashNode -> 字符数组[]byte的一个别名,存放32byte的哈希值,他是fullNode或者shortNode对象的RLP哈希值

来看下trie的结构以及对trie的操作可能对上面各个node的类型使用可能会更清晰一点,我们来接着看下trie的结构定义
trie/trie.go:

type Trie struct {
    db           *Database // 用levelDB做KV存储
    root         node     //当前根节点
    originalRoot common.Hash //启动加载时候的hash,可以从db中恢复出整个trie

    cachegen, cachelimit uint16 // cachegen 缓存生成值,每次Commit会+1
}

这里的cachegen缓存生成值会被附加在node节点上面,如果当前的cachegen-cachelimit参数大于node的缓存生成,那么node会从cache里面卸载,以便节约内存。一个缓存多久没被时候用就会被从缓存中移除,看起来和redis等一些LRU算法的cache db很像。

Trie的初始化:

func New(root common.Hash, db *Database) (*Trie, error) {
    if db == nil {
        panic("trie.New called without a database")
    }
    trie := &Trie{
        db:           db,
        originalRoot: root,
    }
    if (root != common.Hash{}) && root != emptyRoot {
       // 如果hash不是空值,从数据库中加载一个已经存在的树
        rootnode, err := trie.resolveHash(root[:], nil)
        if err != nil {
            return nil, err
        }
        trie.root = rootnode //根节点为找到的trie
    }
    //否则返回新建一个树
    return trie, nil
}

这里的trie.resolveHash就是加载整课树的方法,还有传入的root common.Hash hash是一个将hex编码转为原始hash的32位byte[] (common.HexToHash()),来看下如何通过这个hash来找到整个trie的:

func (t *Trie) resolveHash(n hashNode, prefix []byte) (node, error) {
    cacheMissCounter.Inc(1) //没执行一次计数器+1
   //上面说过了,n是一个32位byte[]
    hash := common.BytesToHash(n)
  //通过hash从db中取出node的RLP编码内容
    enc, err := t.db.Node(hash)
    if err != nil || enc == nil {
        return nil, &MissingNodeError{NodeHash: hash, Path: prefix}
    }
    return mustDecodeNode(n, enc, t.cachegen), nil
}

mustDecodeNode中调用了decodeNode,这个方法通过RLP的list长度来判断该编码内容属于上面节点,如果是两个字段则为shortNode,如果是17个字段则为fullNode,然后再调用各自的decode解析函数

func decodeNode(hash, buf []byte, cachegen uint16) (node, error) {
    if len(buf) == 0 {
        return nil, io.ErrUnexpectedEOF
    }
    elems, _, err := rlp.SplitList(buf) //将buf拆分为列表的内容以及列表后的任何剩余字节。
    if err != nil {
        return nil, fmt.Errorf("decode error: %v", err)
    }
    switch c, _ := rlp.CountValues(elems); c {
    case 2:
        n, err := decodeShort(hash, elems, cachegen) //decode shortNode
        return n, wrapError(err, "short")
    case 17:
        n, err := decodeFull(hash, elems, cachegen) //decode fullNode
        return n, wrapError(err, "full")
    default:
        return nil, fmt.Errorf("invalid number of list elements: %v", c)
    }
}

decodeShort函数中通过key是否含有结束标识符来判断是叶子节点还是扩展节点,这个我们在上面的编码部分已经讲过,有结束标示符则是叶子节点,再通过rlp.SplitString解析出val生成一个叶子节点shortNode返回。没有结束标志符则为扩展节点,通过decodeRef解析并生成一个shortNode返回。

func decodeShort(hash, elems []byte, cachegen uint16) (node, error) {
    kbuf, rest, err := rlp.SplitString(elems) //将elems填入RLP字符串的内容以及字符串后的任何剩余字节。
    if err != nil {
        return nil, err
    }
    flag := nodeFlag{hash: hash, gen: cachegen}
    key := compactToHex(kbuf)
    if hasTerm(key) {
        // value node
        val, _, err := rlp.SplitString(rest)
        if err != nil {
            return nil, fmt.Errorf("invalid value node: %v", err)
        }
        return &shortNode{key, append(valueNode{}, val...), flag}, nil
    }
    r, _, err := decodeRef(rest, cachegen)
    if err != nil {
        return nil, wrapError(err, "val")
    }
    return &shortNode{key, r, flag}, nil
}

继续看下decodeRef主要做了啥操作:

func decodeRef(buf []byte, cachegen uint16) (node, []byte, error) {
    kind, val, rest, err := rlp.Split(buf)
    if err != nil {
        return nil, buf, err
    }
    switch {
    case kind == rlp.List:
        // 'embedded' node reference. The encoding must be smaller
        // than a hash in order to be valid.
        if size := len(buf) - len(rest); size > hashLen {
            err := fmt.Errorf("oversized embedded node (size is %d bytes, want size < %d)", size, hashLen)
            return nil, buf, err
        }
        n, err := decodeNode(nil, buf, cachegen)
        return n, rest, err
    case kind == rlp.String && len(val) == 0:
        // empty node
        return nil, rest, nil
    case kind == rlp.String && len(val) == 32:
        return append(hashNode{}, val...), rest, nil
    default:
        return nil, nil, fmt.Errorf("invalid RLP string size %d (want 0 or 32)", len(val))
    }
}

这段代码比较清晰,通过rlp.Split后返回的类型做不同的处理,如果是list,调用decodeNode解析,如果是空节点返回空,如果是一个32位hash值返回hashNode,decodeFull:

func decodeFull(hash, elems []byte, cachegen uint16) (*fullNode, error) {
    n := &fullNode{flags: nodeFlag{hash: hash, gen: cachegen}}
    for i := 0; i < 16; i++ {
        cld, rest, err := decodeRef(elems, cachegen)
        if err != nil {
            return n, wrapError(err, fmt.Sprintf("[%d]", i))
        }
        n.Children[i], elems = cld, rest
    }
    val, _, err := rlp.SplitString(elems)
    if err != nil {
        return n, err
    }
    if len(val) > 0 {
        n.Children[16] = append(valueNode{}, val...)
    }
    return n, nil
}

再回到Trie结构体中的cachegen, cachelimit,Trie树每次Commit时cachegen都会+1,这两个参数是cache的控制参数,为了弄清楚Trie的缓存机制,我们来看下Commit具体是干嘛的:

func (t *Trie) Commit(onleaf LeafCallback) (root common.Hash, err error) {
    if t.db == nil {
        panic("commit called on trie with nil database")
    }
    hash, cached, err := t.hashRoot(t.db, onleaf)
    if err != nil {
        return common.Hash{}, err
    }
    t.root = cached
    t.cachegen++
    return common.BytesToHash(hash.(hashNode)), nil //返回所指向的node的未编码的hash
}
//返回trie.root所指向的node的hash以及每个节点都带有各自hash的trie树的root。
func (t *Trie) hashRoot(db *Database, onleaf LeafCallback) (node, node, error) {
    if t.root == nil {
        return hashNode(emptyRoot.Bytes()), nil, nil
    }
    h := newHasher(t.cachegen, t.cachelimit, onleaf)
    defer returnHasherToPool(h)
    return h.hash(t.root, db, true)//为每个节点生成一个未编码的hash
}

Commit目的,是将trie树中的key转为Compact编码,为每个节点生成一个hash,它就是为了确保后续能正常将变动的数据提交到db.

那么这个cachegen是怎么放到该节点中的,当trie树在节点插入的时候,会把当前trie的cachegen放入到该节点中,看下trie的insert方法:

//n -> trie当前插入节点
//prefix -> 当前匹配到的key的公共前缀
//key -> 待插入数据当前key中剩余未匹配的部分,完整的key=prefix+key
//value -> 待插入数据本身
//返回 -> 是否改变树,插入完成后子树根节点,error
func (t *Trie) insert(n node, prefix, key []byte, value node) (bool, node, error) {
    if len(key) == 0 {
        if v, ok := n.(valueNode); ok {
            return !bytes.Equal(v, value.(valueNode)), value, nil
        }
        //如果key长度为0,那么说明当前节点中新增加的节点和当前节点数据一样,认为已经新增过了就直接返回
        return true, value, nil
    }
    switch n := n.(type) {
    case *shortNode:
        matchlen := prefixLen(key, n.Key) // 返回公共前缀长度
        
        if matchlen == len(n.Key) {
        //如果整个key匹配,请按原样保留此节点,并仅更新该值。
            dirty, nn, err := t.insert(n.Val, append(prefix, key[:matchlen]...), key[matchlen:], value)
            if !dirty || err != nil {
                return false, n, err
            }
            return true, &shortNode{n.Key, nn, t.newFlag()}, nil
        }
        //否则在它们不同的索引处分支出来
        branch := &fullNode{flags: t.newFlag()}
        var err error
        _, branch.Children[n.Key[matchlen]], err = t.insert(nil, append(prefix, n.Key[:matchlen+1]...), n.Key[matchlen+1:], n.Val)
        if err != nil {
            return false, nil, err
        }
        _, branch.Children[key[matchlen]], err = t.insert(nil, append(prefix, key[:matchlen+1]...), key[matchlen+1:], value)
        if err != nil {
            return false, nil, err
        }
        //如果它在索引0处出现则用该branch替换shortNode
        if matchlen == 0 {
            return true, branch, nil
        }
        // Otherwise, replace it with a short node leading up to the branch.
        return true, &shortNode{key[:matchlen], branch, t.newFlag()}, nil

    case *fullNode:
        dirty, nn, err := t.insert(n.Children[key[0]], append(prefix, key[0]), key[1:], value)
        if !dirty || err != nil {
            return false, n, err
        }
        n = n.copy()
        n.flags = t.newFlag()
        n.Children[key[0]] = nn
        return true, n, nil

    case nil:
    //在空trie中添加一个节点,就是叶子节点,返回shortNode。
        return true, &shortNode{key, value, t.newFlag()}, nil

    case hashNode:
        rn, err := t.resolveHash(n, prefix)//恢复一个存储在db中的node
        if err != nil {
            return false, nil, err
        }
        dirty, nn, err := t.insert(rn, prefix, key, value) //递归调用
        if !dirty || err != nil {
            return false, rn, err
        }
        return true, nn, nil

    default:
        panic(fmt.Sprintf("%T: invalid node: %v", n, n))
    }

Trie树的插入,这是一个递归调用的方法,从根节点开始,一直往下找,直到找到可以插入的点,进行插入操作。

  • 如果当前的根节点叶子节点shortNode,首先计算公共前缀

    • 如果公共前缀就等于key,那么说明这两个key是一样的,如果value也一样的(dirty == false),那么返回错误。 如果没有错误就更新shortNode的值然后返回。
    • 如果公共前缀不完全匹配,那么就需要把公共前缀提取出来形成一个独立的节点(扩展节点),扩展节点后面连接一个branch节点,branch节点后面看情况连接两个short节点。首先构建一个branch节点(branch := &fullNode{flags: t.newFlag()}),然后再branch节点的Children位置调用t.insert插入剩下的两个short节点。这里有个小细节,key的编码是HEX encoding,而且末尾带了一个终结符。考虑我们的根节点的key是abc0x16,我们插入的节点的key是ab0x16。下面的branch.Children[key[matchlen]]才可以正常运行,0x16刚好指向了branch节点的第17个孩子。如果匹配的长度是0,那么直接返回这个branch节点,否则返回shortNode节点作为前缀节点。
  • 如果节点类型是nil(一颗全新的Trie树的节点就是nil的),这个时候整颗树是空的,直接返回shortNode{key, value, t.newFlag()}, 这个时候整颗树的跟就含有了一个shortNode节点。
  • 如果当前的节点是fullNode(也就是branch节点),那么直接往对应的孩子节点调用insert方法,然后把对应的孩子节点指向新生成的节点。
  • 如果当前节点是hashNode, hashNode的意思是当前节点还没有加载到内存里面来,还是存放在数据库里面,那么首先调用 t.resolveHash(n, prefix)来加载到内存,然后对加载出来的节点调用insert方法来进行插入。

接下来看如何遍历Trie树从Trie中获取数据,根据key获取的value过程:

func (t *Trie) TryGet(key []byte) ([]byte, error) {
    key = keybytesToHex(key)
    value, newroot, didResolve, err := t.tryGet(t.root, key, 0)
    if err == nil && didResolve {
        t.root = newroot
    }
    return value, err
}

func (t *Trie) tryGet(origNode node, key []byte, pos int) (value []byte, newnode node, didResolve bool, err error) {
    switch n := (origNode).(type) {
    case nil: 
       // 空树
        return nil, nil, false, nil
    case valueNode:
       // 就是要查找的叶子节点数据
        return n, n, false, nil
    case *shortNode: 
        if len(key)-pos < len(n.Key) || !bytes.Equal(n.Key, key[pos:pos+len(n.Key)]) {
            // key在trie中不存在
            return nil, n, false, nil
        }
        value, newnode, didResolve, err = t.tryGet(n.Val, key, pos+len(n.Key))
        if err == nil && didResolve {
            n = n.copy()
            n.Val = newnode
            n.flags.gen = t.cachegen
        }
        return value, n, didResolve, err
    case *fullNode:
        value, newnode, didResolve, err = t.tryGet(n.Children[key[pos]], key, pos+1)
        if err == nil && didResolve {
            n = n.copy()
            n.flags.gen = t.cachegen
            n.Children[key[pos]] = newnode
        }
        return value, n, didResolve, err
    case hashNode:
       // hashNodes时候需要去db中获取
        child, err := t.resolveHash(n, key[:pos])
        if err != nil {
            return nil, n, true, err
        }
        value, newnode, _, err := t.tryGet(child, key, pos)
        return value, newnode, true, err
    default:
        panic(fmt.Sprintf("%T: invalid node: %v", origNode, origNode))
    }
}

tryGet(origNode node, key []byte, pos int)方法提供三个参数,起始的node,hash key,还有当前hash匹配的位置,didResolve用来判断trie树是否发生变化,根据hashNode去db中获取该node值,获取到后,需要更新现有的trie,didResolve就会发生变化。

关于Trie的UpdateDelete就不分析了,在trie包中还有其他的功能,我们来大略看下主要是干嘛的不做详细解读了:

  • databases.go trie数据结构和磁盘数据库之间的一个写入层,方便trie中节点的插入删除操作
  • iterator.go 遍历Trie的键值迭代器
  • proof.go Trie树的默克尔证明,Prove方法获取指定Key的proof证明, proof证明是从根节点到叶子节点的所有节点的hash值列表。 VerifyProof方法,接受一个roothash值和proof证明和key来验证key是否存在。
  • security_trie.go 加密了的trie实现

转载请注明: 转载自Ryan是菜鸟 | LNMP技术栈笔记

如果觉得本篇文章对您十分有益,何不 打赏一下

谢谢打赏

本文链接地址:以太坊源码分析--MPT 树

查看原文

赞 13 收藏 4 评论 0

叔叔张 赞了文章 · 2018-08-21

以太坊源码分析--MPT树

MPT(Merkle Patricia Tries)是以太坊中存储区块数据的核心数据结构,它Merkle Tree和Patricia Tree融合一个树形结构,理解MPT结构对之后学习以太坊区块header以及智能合约状态存储结构的模块源码很有帮助。

首先来看下Merkle树:

1533192383802
它的叶子是数据块的hash,从图中可以看出非叶子节点是其子节点串联字符串的hash,底层数据的任何变动都会影响父节点,这棵树的Merkle Root代表对底层所有数据的“摘要”。
这样的树有一个很大的好处,比如我们把交易信息写入这样的树形结构,当需要证明一个交易是否存在这颗树中的时候,就不需要重新计算所有交易的hash值。比如证明图中Hash 1-1,我们可以借助Hash 1-0重新计算出Hash 1,然后再借助Hash 0重新计算出Top Hash,这样就可以根据算出来的Top Hash和原来的Top Hash是否一样,如果一样的话那么Hash 1-1就属于这棵树。
所以想象一下,我们将这个Top Hash储存在区块头中,那么有了区块头就可以对区块信息进行验证了。同时 Hash 计算的过程可以十分快速,预处理可以在短时间内完成。利用Merkle树结构能带来巨大的比较性能提升。

再来看下Patricia树:

1533194708638
从它的名字压缩前缀树再结合上图就可以猜出来Patricia树的特点了,这种树形结构比将每一个字符作为一个节点的普通trie树形结构,它的键值可以使用多个字符,降低了树的高度,也节省了空间,再看个例子:
1533195730622
图中可以很容易看出数中所存储的键值对:

  • 6c0a5c71ec20bq3w => 5
  • 6c0a5c71ec20CX7j => 27
  • 6c0a5c71781a1FXq => 18
  • 6c0a5c71781a9Dog => 64
  • 6c0a8f743b95zUfe => 30
  • 6c0a8f743b95jx5R => 2
  • 6c0a8f740d16y03G => 43
  • 6c0a8f740d16vcc1 => 48

以太坊中的MPT:

在以太坊中MPT的节点的规格主要有一下几个:

  • NULL 空节点,简单的表示空,在代码中是一个空串
  • Nibble 它是key的基本单元,是一个四元组(四个bit位的组合例如二进制表达的0010就是一个四元组)
  • Extension 扩展节点有两个元素,一个是key值,还有一个是hash值,这个hash值指向下一个节点
  • Branch 分支节点有17个元素,回到Nibble,四元组是key的基本单元,四元组最多有16个值。所以前16个必将落入到在其遍历中的键的十六个可能的半字节值中的每一个。第17个是存储那些在当前结点结束了的节点(例如, 有三个key,分别是 (abc ,abd, ab) 第17个字段储存了ab节点的值)
  • Leaf 叶子节点只有两个元素,分别为key和value

这里还有一些知识点需要了解的,为了将MPT树存储到数据库中,同时还可以把MPT树从数据库中恢复出来,对于Extension和Leaf的节点类型做了特殊的定义:如果是一个扩展节点,那么前缀为0,这个0加在key前面。如果是一个叶子节点,那么前缀就是1。同时对key的长度就奇偶类型也做了设定,如果是奇数长度则标示1,如果是偶数长度则标示0。

以太坊中主要有一下几个地方用了MPT树形结构:

  • State Trie 区块头中的状态树

    • key => sha3(以太坊账户地址address)
    • value => rlp(账号内容信息account)
  • Transactions Trie 区块头中的交易树

    • key => rlp(交易的偏移量 transaction index)
    • 每个块都有各自的交易树,且不可更改
  • Receipts Trie 区块头中的收据树

    • key = rlp(交易的偏移量 transaction index)
    • 每个块都有各自的交易树,且不可更改
  • Storage Trie 存储树

    • 存储只能合约状态
    • 每个账号有自己的Storage Trie

1533199123752
这两个区块头中,state root,tx root receipt root分别存储了这三棵树的树根,第二个区块显示了当账号175的数据变更(27 -> 45)的时候,只需要存储跟这个账号相关的部分数据,而且老的区块中的数据还是可以正常访问。

MPT树种还有一个重要的概念一个特殊的十六进制前缀(hex-prefix, HP)编码来对key编码,我们先来了解一下编码定义规则,源码实现后面再分析:

  • RAW 原始编码,对输入不做任何变更
  • HEX 十六进制编码

    • RAW编码输入的每个字符分解为高4位和低4位
    • 如果是叶子节点,则在最后加上Hex值0x10表示结束
    • 如果是分支节点不附加任何Hex值
比如key=>"bob",b的ASCII十六进制编码为0x62,o的ASCII十六进制编码为0x6f,分解成高四位和第四位,16表示终结 0x10,最终编码结果为[6 2 6 15 6 2 16],
  • HEX-Prefix 十六进制前缀编码

    • 输入key结尾为0x10,则去掉这个终止符
    • key之前补一个四元组这个Byte第0位区分奇偶信息,第1位区分节点类型
    • 如果输入key的长度是偶数,则再添加一个四元组0x0在flag四元组后
    • 将原来的key内容压缩,将分离的两个byte以高四位低四位进行合并
十六进制前缀编码相当于一个逆向的过程,比如输入的是[6 2 6 15 6 2 16],根据第一个规则去掉终止符16。根据第二个规则key前补一个四元组,从右往左第一位为1表示叶子节点,从右往左第0位如果后面key的长度为偶数设置为0,奇数长度设置为1,那么四元组0010就是2。根据第三个规则,添加一个全0的补在后面,那么就是20.根据第三个规则内容压缩合并,那么结果就是[0x20 0x62 0x6f 0x62]

官方有一个详细的结构的示例:
worldstatetrie -1-

下面再用一个图像化的示例来加深一下对上面的MPT规则的理解

key的16进制keyvalue
<64 6f>doverb
<64 6f 67>dogpuppy
<64 6f 67 65>dogecoin
<68 6f 72 73 65>horsestallion

WechatIMG83

三种编码格式互相转换的代码实现

Compact就是上面说的HEX-Prefix,keybytes为按完整字节(8bit)存储的正常信息,hex为按照半字节nibble(4bit)储存信息的格式。
go-ethereum/trie/encoding:

package trie

func hexToCompact(hex []byte) []byte {
    terminator := byte(0)
    if hasTerm(hex) { //检查是否有结尾为0x10 => 16
        terminator = 1 //有结束标记16说明是叶子节点
        hex = hex[:len(hex)-1] //去除尾部标记
    }
    buf := make([]byte, len(hex)/2+1) // 字节数组
    
    buf[0] = terminator << 5 // 标志byte为00000000或者00100000
    //如果长度为奇数,添加奇数位标志1,并把第一个nibble字节放入buf[0]的低四位
    if len(hex)&1 == 1 {
        buf[0] |= 1 << 4 // 奇数标志 00110000
        buf[0] |= hex[0] // 第一个nibble包含在第一个字节中 0011xxxx
        hex = hex[1:]
    }
    //将两个nibble字节合并成一个字节
    decodeNibbles(hex, buf[1:])
    return buf
}
//compact编码转化为Hex编码
func compactToHex(compact []byte) []byte {
    base := keybytesToHex(compact)
    base = base[:len(base)-1]
     // apply terminator flag
    // base[0]包括四种情况
    // 00000000 扩展节点偶数位
    // 00000001 扩展节点奇数位
    // 00000010 叶子节点偶数位
    // 00000011 叶子节点奇数位

    // apply terminator flag
    if base[0] >= 2 {
       //如果是叶子节点,末尾添加Hex标志位16
        base = append(base, 16)
    }
    // apply odd flag
    //如果是偶数位,chop等于2,否则等于1
    chop := 2 - base[0]&1
    return base[chop:]
}
// 将keybytes 转成十六进制
func keybytesToHex(str []byte) []byte {
    l := len(str)*2 + 1
     //将一个keybyte转化成两个字节
    var nibbles = make([]byte, l)
    for i, b := range str {
        nibbles[i*2] = b / 16
        nibbles[i*2+1] = b % 16
    }
    //末尾加入Hex标志位16
    nibbles[l-1] = 16
    return nibbles
}

// 将十六进制的bibbles转成key bytes,这只能用于偶数长度的key
func hexToKeybytes(hex []byte) []byte {
    if hasTerm(hex) {
        hex = hex[:len(hex)-1]
    }
    if len(hex)&1 != 0 {
        panic("can't convert hex key of odd length")
    }
    key := make([]byte, (len(hex)+1)/2)
    decodeNibbles(hex, key)
    return key
}

func decodeNibbles(nibbles []byte, bytes []byte) {
    for bi, ni := 0, 0; ni < len(nibbles); bi, ni = bi+1, ni+2 {
        bytes[bi] = nibbles[ni]<<4 | nibbles[ni+1]
    }
}

// 返回a和b的公共前缀的长度
func prefixLen(a, b []byte) int {
    var i, length = 0, len(a)
    if len(b) < length {
        length = len(b)
    }
    for ; i < length; i++ {
        if a[i] != b[i] {
            break
        }
    }
    return i
}

// 十六进制key是否有结束标志符
func hasTerm(s []byte) bool {
    return len(s) > 0 && s[len(s)-1] == 16
}

以太坊中MTP数据结构

上面已经分析了以太坊的key的编码方式,接下来我们来看以太坊中MPT树的数据结构,在分析trie的数据结构前,我们先来了解一下node的定义:
trie/node.go

type node interface {
    fstring(string) string
    cache() (hashNode, bool)
    canUnload(cachegen, cachelimit uint16) bool
}

type (
    fullNode struct {  //分支节点
        Children [17]node // Actual trie node data to encode/decode (needs custom encoder)
        flags    nodeFlag
    }
    shortNode struct { 
        Key   []byte
        Val   node
        flags nodeFlag
    }
    hashNode  []byte
    valueNode []byte
)

上面代码中定义了四个struct,就是node的四种类型:

  • fullNode -> 分支节点,它有一个容量为17的node数组成员变量Children,数组中前16个空位分别对应16进制(hex)下的0-9a-f,这样对于每个子节点,根据其key值16进制形式下的第一位的值,就可挂载到Children数组的某个位置,fullNode本身不再需要额外key变量;Children数组的第17位,留给该fullNode的数据部分。这和我们上面说的Branch 分支节点的规格一致的。
  • shortNode,key是一个任意长度的字符串(字节数组[]byte),体现了PatriciaTrie的特点,通过合并只有一个子节点的父节点和其子节点来缩短trie的深度,结果就是有些节点会有长度更长的key。

    • -> 扩展节点,Val指向分支节点或者叶子节点
    • -> 叶子节点,Val为rlp编码数据,key为该数据的hash
  • valueNode -> MPT的叶子节点。字节数组[]byte的一个别名,不带子节点。使用中valueNode就是所携带数据部分的RLP哈希值,长度32byte,数据的RLP编码值作为valueNode的匹配项存储在数据库里。
  • hashNode -> 字符数组[]byte的一个别名,存放32byte的哈希值,他是fullNode或者shortNode对象的RLP哈希值

来看下trie的结构以及对trie的操作可能对上面各个node的类型使用可能会更清晰一点,我们来接着看下trie的结构定义
trie/trie.go:

type Trie struct {
    db           *Database // 用levelDB做KV存储
    root         node     //当前根节点
    originalRoot common.Hash //启动加载时候的hash,可以从db中恢复出整个trie

    cachegen, cachelimit uint16 // cachegen 缓存生成值,每次Commit会+1
}

这里的cachegen缓存生成值会被附加在node节点上面,如果当前的cachegen-cachelimit参数大于node的缓存生成,那么node会从cache里面卸载,以便节约内存。一个缓存多久没被时候用就会被从缓存中移除,看起来和redis等一些LRU算法的cache db很像。

Trie的初始化:

func New(root common.Hash, db *Database) (*Trie, error) {
    if db == nil {
        panic("trie.New called without a database")
    }
    trie := &Trie{
        db:           db,
        originalRoot: root,
    }
    if (root != common.Hash{}) && root != emptyRoot {
       // 如果hash不是空值,从数据库中加载一个已经存在的树
        rootnode, err := trie.resolveHash(root[:], nil)
        if err != nil {
            return nil, err
        }
        trie.root = rootnode //根节点为找到的trie
    }
    //否则返回新建一个树
    return trie, nil
}

这里的trie.resolveHash就是加载整课树的方法,还有传入的root common.Hash hash是一个将hex编码转为原始hash的32位byte[] (common.HexToHash()),来看下如何通过这个hash来找到整个trie的:

func (t *Trie) resolveHash(n hashNode, prefix []byte) (node, error) {
    cacheMissCounter.Inc(1) //没执行一次计数器+1
   //上面说过了,n是一个32位byte[]
    hash := common.BytesToHash(n)
  //通过hash从db中取出node的RLP编码内容
    enc, err := t.db.Node(hash)
    if err != nil || enc == nil {
        return nil, &MissingNodeError{NodeHash: hash, Path: prefix}
    }
    return mustDecodeNode(n, enc, t.cachegen), nil
}

mustDecodeNode中调用了decodeNode,这个方法通过RLP的list长度来判断该编码内容属于上面节点,如果是两个字段则为shortNode,如果是17个字段则为fullNode,然后再调用各自的decode解析函数

func decodeNode(hash, buf []byte, cachegen uint16) (node, error) {
    if len(buf) == 0 {
        return nil, io.ErrUnexpectedEOF
    }
    elems, _, err := rlp.SplitList(buf) //将buf拆分为列表的内容以及列表后的任何剩余字节。
    if err != nil {
        return nil, fmt.Errorf("decode error: %v", err)
    }
    switch c, _ := rlp.CountValues(elems); c {
    case 2:
        n, err := decodeShort(hash, elems, cachegen) //decode shortNode
        return n, wrapError(err, "short")
    case 17:
        n, err := decodeFull(hash, elems, cachegen) //decode fullNode
        return n, wrapError(err, "full")
    default:
        return nil, fmt.Errorf("invalid number of list elements: %v", c)
    }
}

decodeShort函数中通过key是否含有结束标识符来判断是叶子节点还是扩展节点,这个我们在上面的编码部分已经讲过,有结束标示符则是叶子节点,再通过rlp.SplitString解析出val生成一个叶子节点shortNode返回。没有结束标志符则为扩展节点,通过decodeRef解析并生成一个shortNode返回。

func decodeShort(hash, elems []byte, cachegen uint16) (node, error) {
    kbuf, rest, err := rlp.SplitString(elems) //将elems填入RLP字符串的内容以及字符串后的任何剩余字节。
    if err != nil {
        return nil, err
    }
    flag := nodeFlag{hash: hash, gen: cachegen}
    key := compactToHex(kbuf)
    if hasTerm(key) {
        // value node
        val, _, err := rlp.SplitString(rest)
        if err != nil {
            return nil, fmt.Errorf("invalid value node: %v", err)
        }
        return &shortNode{key, append(valueNode{}, val...), flag}, nil
    }
    r, _, err := decodeRef(rest, cachegen)
    if err != nil {
        return nil, wrapError(err, "val")
    }
    return &shortNode{key, r, flag}, nil
}

继续看下decodeRef主要做了啥操作:

func decodeRef(buf []byte, cachegen uint16) (node, []byte, error) {
    kind, val, rest, err := rlp.Split(buf)
    if err != nil {
        return nil, buf, err
    }
    switch {
    case kind == rlp.List:
        // 'embedded' node reference. The encoding must be smaller
        // than a hash in order to be valid.
        if size := len(buf) - len(rest); size > hashLen {
            err := fmt.Errorf("oversized embedded node (size is %d bytes, want size < %d)", size, hashLen)
            return nil, buf, err
        }
        n, err := decodeNode(nil, buf, cachegen)
        return n, rest, err
    case kind == rlp.String && len(val) == 0:
        // empty node
        return nil, rest, nil
    case kind == rlp.String && len(val) == 32:
        return append(hashNode{}, val...), rest, nil
    default:
        return nil, nil, fmt.Errorf("invalid RLP string size %d (want 0 or 32)", len(val))
    }
}

这段代码比较清晰,通过rlp.Split后返回的类型做不同的处理,如果是list,调用decodeNode解析,如果是空节点返回空,如果是一个32位hash值返回hashNode,decodeFull:

func decodeFull(hash, elems []byte, cachegen uint16) (*fullNode, error) {
    n := &fullNode{flags: nodeFlag{hash: hash, gen: cachegen}}
    for i := 0; i < 16; i++ {
        cld, rest, err := decodeRef(elems, cachegen)
        if err != nil {
            return n, wrapError(err, fmt.Sprintf("[%d]", i))
        }
        n.Children[i], elems = cld, rest
    }
    val, _, err := rlp.SplitString(elems)
    if err != nil {
        return n, err
    }
    if len(val) > 0 {
        n.Children[16] = append(valueNode{}, val...)
    }
    return n, nil
}

再回到Trie结构体中的cachegen, cachelimit,Trie树每次Commit时cachegen都会+1,这两个参数是cache的控制参数,为了弄清楚Trie的缓存机制,我们来看下Commit具体是干嘛的:

func (t *Trie) Commit(onleaf LeafCallback) (root common.Hash, err error) {
    if t.db == nil {
        panic("commit called on trie with nil database")
    }
    hash, cached, err := t.hashRoot(t.db, onleaf)
    if err != nil {
        return common.Hash{}, err
    }
    t.root = cached
    t.cachegen++
    return common.BytesToHash(hash.(hashNode)), nil //返回所指向的node的未编码的hash
}
//返回trie.root所指向的node的hash以及每个节点都带有各自hash的trie树的root。
func (t *Trie) hashRoot(db *Database, onleaf LeafCallback) (node, node, error) {
    if t.root == nil {
        return hashNode(emptyRoot.Bytes()), nil, nil
    }
    h := newHasher(t.cachegen, t.cachelimit, onleaf)
    defer returnHasherToPool(h)
    return h.hash(t.root, db, true)//为每个节点生成一个未编码的hash
}

Commit目的,是将trie树中的key转为Compact编码,为每个节点生成一个hash,它就是为了确保后续能正常将变动的数据提交到db.

那么这个cachegen是怎么放到该节点中的,当trie树在节点插入的时候,会把当前trie的cachegen放入到该节点中,看下trie的insert方法:

//n -> trie当前插入节点
//prefix -> 当前匹配到的key的公共前缀
//key -> 待插入数据当前key中剩余未匹配的部分,完整的key=prefix+key
//value -> 待插入数据本身
//返回 -> 是否改变树,插入完成后子树根节点,error
func (t *Trie) insert(n node, prefix, key []byte, value node) (bool, node, error) {
    if len(key) == 0 {
        if v, ok := n.(valueNode); ok {
            return !bytes.Equal(v, value.(valueNode)), value, nil
        }
        //如果key长度为0,那么说明当前节点中新增加的节点和当前节点数据一样,认为已经新增过了就直接返回
        return true, value, nil
    }
    switch n := n.(type) {
    case *shortNode:
        matchlen := prefixLen(key, n.Key) // 返回公共前缀长度
        
        if matchlen == len(n.Key) {
        //如果整个key匹配,请按原样保留此节点,并仅更新该值。
            dirty, nn, err := t.insert(n.Val, append(prefix, key[:matchlen]...), key[matchlen:], value)
            if !dirty || err != nil {
                return false, n, err
            }
            return true, &shortNode{n.Key, nn, t.newFlag()}, nil
        }
        //否则在它们不同的索引处分支出来
        branch := &fullNode{flags: t.newFlag()}
        var err error
        _, branch.Children[n.Key[matchlen]], err = t.insert(nil, append(prefix, n.Key[:matchlen+1]...), n.Key[matchlen+1:], n.Val)
        if err != nil {
            return false, nil, err
        }
        _, branch.Children[key[matchlen]], err = t.insert(nil, append(prefix, key[:matchlen+1]...), key[matchlen+1:], value)
        if err != nil {
            return false, nil, err
        }
        //如果它在索引0处出现则用该branch替换shortNode
        if matchlen == 0 {
            return true, branch, nil
        }
        // Otherwise, replace it with a short node leading up to the branch.
        return true, &shortNode{key[:matchlen], branch, t.newFlag()}, nil

    case *fullNode:
        dirty, nn, err := t.insert(n.Children[key[0]], append(prefix, key[0]), key[1:], value)
        if !dirty || err != nil {
            return false, n, err
        }
        n = n.copy()
        n.flags = t.newFlag()
        n.Children[key[0]] = nn
        return true, n, nil

    case nil:
    //在空trie中添加一个节点,就是叶子节点,返回shortNode。
        return true, &shortNode{key, value, t.newFlag()}, nil

    case hashNode:
        rn, err := t.resolveHash(n, prefix)//恢复一个存储在db中的node
        if err != nil {
            return false, nil, err
        }
        dirty, nn, err := t.insert(rn, prefix, key, value) //递归调用
        if !dirty || err != nil {
            return false, rn, err
        }
        return true, nn, nil

    default:
        panic(fmt.Sprintf("%T: invalid node: %v", n, n))
    }

Trie树的插入,这是一个递归调用的方法,从根节点开始,一直往下找,直到找到可以插入的点,进行插入操作。

  • 如果当前的根节点叶子节点shortNode,首先计算公共前缀

    • 如果公共前缀就等于key,那么说明这两个key是一样的,如果value也一样的(dirty == false),那么返回错误。 如果没有错误就更新shortNode的值然后返回。
    • 如果公共前缀不完全匹配,那么就需要把公共前缀提取出来形成一个独立的节点(扩展节点),扩展节点后面连接一个branch节点,branch节点后面看情况连接两个short节点。首先构建一个branch节点(branch := &fullNode{flags: t.newFlag()}),然后再branch节点的Children位置调用t.insert插入剩下的两个short节点。这里有个小细节,key的编码是HEX encoding,而且末尾带了一个终结符。考虑我们的根节点的key是abc0x16,我们插入的节点的key是ab0x16。下面的branch.Children[key[matchlen]]才可以正常运行,0x16刚好指向了branch节点的第17个孩子。如果匹配的长度是0,那么直接返回这个branch节点,否则返回shortNode节点作为前缀节点。
  • 如果节点类型是nil(一颗全新的Trie树的节点就是nil的),这个时候整颗树是空的,直接返回shortNode{key, value, t.newFlag()}, 这个时候整颗树的跟就含有了一个shortNode节点。
  • 如果当前的节点是fullNode(也就是branch节点),那么直接往对应的孩子节点调用insert方法,然后把对应的孩子节点指向新生成的节点。
  • 如果当前节点是hashNode, hashNode的意思是当前节点还没有加载到内存里面来,还是存放在数据库里面,那么首先调用 t.resolveHash(n, prefix)来加载到内存,然后对加载出来的节点调用insert方法来进行插入。

接下来看如何遍历Trie树从Trie中获取数据,根据key获取的value过程:

func (t *Trie) TryGet(key []byte) ([]byte, error) {
    key = keybytesToHex(key)
    value, newroot, didResolve, err := t.tryGet(t.root, key, 0)
    if err == nil && didResolve {
        t.root = newroot
    }
    return value, err
}

func (t *Trie) tryGet(origNode node, key []byte, pos int) (value []byte, newnode node, didResolve bool, err error) {
    switch n := (origNode).(type) {
    case nil: 
       // 空树
        return nil, nil, false, nil
    case valueNode:
       // 就是要查找的叶子节点数据
        return n, n, false, nil
    case *shortNode: 
        if len(key)-pos < len(n.Key) || !bytes.Equal(n.Key, key[pos:pos+len(n.Key)]) {
            // key在trie中不存在
            return nil, n, false, nil
        }
        value, newnode, didResolve, err = t.tryGet(n.Val, key, pos+len(n.Key))
        if err == nil && didResolve {
            n = n.copy()
            n.Val = newnode
            n.flags.gen = t.cachegen
        }
        return value, n, didResolve, err
    case *fullNode:
        value, newnode, didResolve, err = t.tryGet(n.Children[key[pos]], key, pos+1)
        if err == nil && didResolve {
            n = n.copy()
            n.flags.gen = t.cachegen
            n.Children[key[pos]] = newnode
        }
        return value, n, didResolve, err
    case hashNode:
       // hashNodes时候需要去db中获取
        child, err := t.resolveHash(n, key[:pos])
        if err != nil {
            return nil, n, true, err
        }
        value, newnode, _, err := t.tryGet(child, key, pos)
        return value, newnode, true, err
    default:
        panic(fmt.Sprintf("%T: invalid node: %v", origNode, origNode))
    }
}

tryGet(origNode node, key []byte, pos int)方法提供三个参数,起始的node,hash key,还有当前hash匹配的位置,didResolve用来判断trie树是否发生变化,根据hashNode去db中获取该node值,获取到后,需要更新现有的trie,didResolve就会发生变化。

关于Trie的UpdateDelete就不分析了,在trie包中还有其他的功能,我们来大略看下主要是干嘛的不做详细解读了:

  • databases.go trie数据结构和磁盘数据库之间的一个写入层,方便trie中节点的插入删除操作
  • iterator.go 遍历Trie的键值迭代器
  • proof.go Trie树的默克尔证明,Prove方法获取指定Key的proof证明, proof证明是从根节点到叶子节点的所有节点的hash值列表。 VerifyProof方法,接受一个roothash值和proof证明和key来验证key是否存在。
  • security_trie.go 加密了的trie实现

转载请注明: 转载自Ryan是菜鸟 | LNMP技术栈笔记

如果觉得本篇文章对您十分有益,何不 打赏一下

谢谢打赏

本文链接地址:以太坊源码分析--MPT 树

查看原文

赞 13 收藏 4 评论 0

叔叔张 赞了文章 · 2017-08-22

机器学习初体验

刚看《机器学习系统设计》,边看边理解形成了以下心得。
机器学习其实不是新的技术,前两年它的名字叫数据挖掘或预测分析。和统计学的关联非常大,统计学是研究现有的大量数据,来帮助人更好的理解数据。机器学习其实也是类似的过程。机器根据现有的大量训练数据,计算出指定特征的最优参数,得到模型,然后用测试数据对模型进行验证,验证符合一定的准确率条件就得到一个可以用于生产的模型。

概览

机器学习的处理对象是数据,这些数据一般从应用采集而来,采集的过程,机器学习是不关注的。机器学习的入口是就是采集到的一堆数据,一般还有对元数据的介绍和相关的背景知识。
前面说的比较抽象,这节用一张图来描述机器学习的主要工作流程。
clipboard.png

理解数据

有了元数据,只知道这些数据column的领域,但对数据的特点并不了解,所以第一步就是读取数据,让数据到程序的数据结构中来,通过工具我们可以把玩数据,进而使用工具去分析数据的分布,掌握数据的特点,另外采集的数据中有些异常这也是非常正常的,清洗工作不可避免。
这个过程是一个反复的过程,需要通过多次探索,才能对数据有一定深度的理解。

机器学习

机器学习部分并不是完全的机器去学,数据特征的识别、模型的确定、算法的选择都是数据科学家要干的事情。选定算法后,我们可能发现,初始的数据不能满足算法处理的需要。所以为适配算法可能还需要对数据进行提炼。
数据提炼的过程是体现数据科学家价值的美好时刻,一个简单算法在提炼后数据上的表现,能够超过一个复杂算法在原始数据上的效果。
而机器学习的机器部分,就是让机器根据数据科学家设定的路径进行处理,然后得到模型。这部分工作是比较适合机器去做的。

评估

一个模型好不好,评估条件很重要。评估往往和具体的场景有关,有时我们比较关注处理效率,有时我们更关注准确率,有时错误的判断对目标影响很大。比如垃圾邮件识别的场景,把一个正确邮件删除的影响要大于把错误的放过一个垃圾邮件的影响。

查看原文

赞 1 收藏 13 评论 1

叔叔张 发布了文章 · 2017-08-16

对近期前端圈口水之争的一些思考

写在前面

1.大漠穷秋同学以略显偏激的ng对比vue一文引起网络上的口诛笔伐,最终以致歉信辞职信告终
2.知乎上未知姓名同学回答为什么使用React的问题,其中夹杂着一些对vue的个人观点,引来了vue作者的讨伐

以上两点恰好同时出现在了我的视线中,由此我想站在一个做业务开发的前端视角来思考,当我们在分析一个框架适不适合一个项目时,我需要以怎样的维度来分析?

首先讲点题外给各位,作为吃瓜群众,我是很开心的,因为我所处的前端圈子比较狂躁,起码比没新闻强,说明我们前端正在蓬勃发展,处在其中会让我有一种兴奋感。其实关于网络上的争吵大家不用太较真,某些人的观点和看法仅仅是商业行为,前端圈也不能说混乱,因为我们处的大环境就是商业社会,大家都是趋利而已,任何环境都一样,前端也不例外。

不多说了,进入主题。

进行前端技术选型我所思考的几个维度

  • 法律风险

  • 稳定性

  • 上手难度及社区生态

  • 开发工具及流程闭环

  • 可维护性及可扩展性

  • 团队合作并行提效

  • 与新标准的融合度

  • 方便调优及监控

法律风险

这一个因素可能小公司会忽略,但请千万不要,因为法律上出了问题,往往对你的团队带来的经济损失是超大的,尤其是做企业级的产品,法律风险永远是要考虑的第一要素。前段时间Apache 软件基金会(ASF, Apache Software Foundation)将『BSD+专利许可证』列为 Category-X License,这个意思是说,所有 License 为 『BSD+专利许可证』的软件都会受到影响,大概的意思就是『BSD+专利许可证』不被ASF官方认可,可能会有潜在的法律风险,这一举措对 Facebook 旗下的软件影响巨大,特别是对于前端领域的 React 框架,因此,这个措施引发了一些用 React 的大厂的关注,我厂的法务部也在第一时间介入研究这个举措的影响度,最终的解决方案目前还不清晰,不过React官方还是表态不会修改 License,ASF 仍旧把 Facebook 这个 License 列为非官方推荐的 License。如果对法律层面不清晰,引发了著作权官司,对于阿里这个航空母舰会面临多少钱的诉讼,即使不会立刻引发诉讼,但肯定要做技术替换,这需要投入多少成本都是不可估量的。在前公司,设计海报时使用了方正字体也接到过诉讼,这些其实都属于法律层面需要注意的。总而言之,在做技术选型时,我首先考虑的就是所用的框架的 License 是不是存在法律风险。

稳定性

当一个框架已经超过了1.0版,或者说已经fix掉很多bug(从github的issue记录可以查看),这个时候可以理解为这个框架的稳定性是相对较好的。稳定,是一个软件系统的生死线,如果一个系统连基本的稳定都没有,这个软件系统可以说是一击即溃,对于开源框架的技术选型稳定性也是至关重要的一环。

上手难度及社区生态

其实上手的难度是因人而异的,对于我所负责的采购业务团队,外包同学可能比较多而且离职率也高,为了业务的高效运转,做技术选型时上手难度不得不做为一个因素来考量。比如我在采购平台的前端开发中,引入基本的构建工具和组件库后一直想要引入数据流框架,但是发现Flux的思想以及跟React之间的关系,确实不便于新人去理解,所以我迟迟没有引入数据流,大部分场景还是鼓励大家手写 fetch + setState,这样迭代了将近1年,发现技术上的咨询确实很少,因为引入的新概念少,新人在理解业务之后能够快速上手。社区生态其实就是看一下 stackoverflow 或者 segmentfalut 上问题的 tag 数,当使用这个技术时能否通过搜索引擎快速查找到解决方案,这个是衡量这个技术是否社区活跃的重要因素,这个也有助于效率。

开发工具及流程闭环

这个因素的意思是,当你选用某一个技术时,是不是配套的工具很多很方便,开发的流程能不能够基于这些工具完成一个健康的闭环,也就是说在使用这个技术之后,它附带的脚手架、构建、压缩、单测等工具能不能够覆盖你一个项目的整个生命周期,从设计、开发、测试、发布、运维等各方面都能覆盖到。比如 vue 和 angular 这方面就比 React 要强一些,React 主要 focus 视图层,而 vue 官方就会直接推荐一整套解决方案,从路由、脚手架等,不过 React 后来也加入了一些官方的东西,比如 create-react-app 这个脚手架的引入。

可维护性及可扩展性

这个概念可能会让人感觉到比较虚。举个例子,你使用 jQuery 开发了一个 app,过了两年 React 开始上马你们的业务了,这个使用 jQuery 所写的代码还能不能继续维护甚至基于 React 技术进行扩展。这个因素其实还是跟人相关,厉害的人写得代码是有设计感在里面的,是有模式的,无论未来更换什么样的框架,但代码的核心思想在那,代码结构就在那,API设计的好,即使你过去是用jQuery写得 dialog,但这个 dialog 必须要支持后续的 React 风格,可能仅仅需要一层简单的封装就可以搞定了,所以技术的维护性和扩展性,在框架那一层是大多是没有问题的,关键还是写代码的人,所以你团队代码的API设计、模式分层要把控好。

团队合作并行提效

这个点思考的是这样的场景。比如,某一天你的业务突然紧急起来,使用这个框架能不能够让你达到投入越多的人,就能在保证质量的前提下更快的完成任务。在这个点我感觉 React 做得足够优秀,因为 React 的概念很少,API也精简,大家都是通过组件化的思维模式来写代码,当业务繁忙的时候,本来是两个人负责20个组件,可能通过简单沟通之后再投入2个人,就是四个人负责20个组件,这样就能够通过加人来达到提升速度的目的,相比较之下 vue 和 angularjs 在这个方面就不如 React。

与新标准的融合度

业务是发展的,技术是面向未来的。你所用的技术是否跟业界新规范有一定的融合这一点比较重要,直接关系着未来你的项目是否能够方便升级并且不会带来很多潜在的bug,比如 React 原本是 createClass,后来就推崇使用 ES6 的继承机制,这就是一个很好的趋势,说明这个框架是面向未来的,其实在前端领域很多框架这一点做得都不错。但是如果做不到这一点,我会觉得这个框架前途未卜。

方便调优及监控

其实这个点是比较细的,落地到技术细节来说就是这个框架在设计的时候有没有考虑一些 hook 机制或者中间件机制,方不方便你在一些关键的节点插入监控脚本来获知当前的性能数据以及达到性能优化的目的,其实这个属于优化的范畴,基本上是在软件工程的中后期才会涉及,有一句话我认为非常有道理,『过早的优化是万恶之源』,其实性能因素并不适合过早的去考虑,它往往是随着工程以及业务的发展逐步引入的,过早的因为考虑性能而降低了你项目的效率其实是不明智的,因为你所考虑的性能问题不一定以后就会出现,但也不能说不考虑性能,你应该为未来优化埋下一些小种子。

以上就是我所考虑的几个技术选型的维度,也希望对于技术选型有自己独特见解的同学跟我交流。

查看原文

赞 4 收藏 5 评论 5

叔叔张 发布了文章 · 2017-02-19

聊聊 node.js 中各种 dependency

node 项目中常见 dependencydevDependencypeerDependency,平时开发的时候总是遇到,但就是没细了解过它们之间的异同,今天简单深入一下,记录下来。

首先看下方的图,project-main 的 dependency 是 package-a,package-a 的 devDependency 是 package-a-1,此外,project-main 也有一个 devDependency 是 package-b:

├── project-main
    ├── package-a (dependency)
    │   └── package-a-1 (devDependency)
    └── package-b (devDependency)
// package.json
{
  "name": "project-main",
  "dependencies": {
    "package-a": "^1.0.0"
  },
  "devDependencies": {
    "package-b": "^1.0.0"
  }
}

在 project-main 下执行 npm install 之后,package-a 和 package-b 都会被安装,但 package-a-1 不会被安装,所以你在 project-main 的 node_modules 文件夹下找不到 package-c。

dependency 与 devDependency 的异同

这是 dependencydevDependency 的不同点之一。项目依赖的 package 的 devDependency 不会被安装,但自身的 devDependency 会被安装,而所有的 dependency 都会被安装。如果不想安装自身的 devDependency 就使用 npm install --production 这个指令来,这样的话 对于 project-main 来说,它的 devDependency 也不会被安装了。

所以,在开发一个 node 包时,要注意区分什么时候用 dependencies 什么时候用 devDependencies,一般做测试、打包、ES6转ES5此类的工作所依赖的库就使用 devDependencies,而正常功能所依赖的包就使用 dependencies 声明。

> npm install react --save  // 做为 dependencies 安装
> npm install eslint --save-dev // 做为 devDependencies 安装

peerDependencies

还是拿上面的例子来说,假如 project-main 依赖的 package-a 的 package.json 中声明了 peerDependency 是 package-apeer@^1.0.0,而 project-main 中没有任何 package-apeer 的配置,此时在 project-main 下使用 npm3 执行 npm install,控制台就会告警 UNMET PEER DEPENDENCY package-apeer@^1.0.0,意思就是说使用到 package-a 的项目必须安装同时安装 package-apeer@^1.0.0 ,否则程序就可能会有异常,而在 npm@1npm@2 下,就不会报错而是自动把 package-apeer@^1.0.0 安装上,因为很多用户反应这样很困惑,我没声明这个包,你为什么要给我安装呢?所以在 npm@3 中这个 peerDependencies 如果没装就变成了控制台告警。

其它的 dependency

其实 node 还有另外两种 dependency 配置。

bundleDependencies

它还有一个别名,bundledDependencies,这个配置的作用如下:

对于下面这个包 package-a

{
  "name": "package-a",
  "dependencies": {
    "react": "^15.0.0",
    "core-js": "^2.0.0",
    "lodash": "^4.0.0"
  },
  "bundleDependencies": [
    "react",
    "core-js"
  ]
}

在你的项目中使用 npm@3 安装 package-a 之后,项目的 node_modules 的文件结构:

├── node_modules
    ├── package-a
    │   └── react
    │   └── core-js
    └── loadsh

bundleDependencies 的作用就是在用户安装了 package-a 之后,将 package-a 所声明的依赖包汇总到 package-a 自身的 node_modules 下,便于用户管理,如果 package-a 中没有配置 bundleDepencies,在安装了 package-a 的项目下 node_modules 就会长这样:

├── node_modules
    ├── package-a
    ├── react
    ├── core-js
    └── loadsh

optionalDependencies

如果你的node项目依赖了一个包 package-optional,假如这个 package-optional 没有安装,你仍然想让程序正常执行,这个时候 optionalDependencies 就非常适合你这个需求,optionalDependencies 跟 dependencies 声明方式完全一致,而且一个依赖如果同时在 dependencies 和 optionalDependencies 中声明,option 还会覆盖 dependency 的声明。如果 package-optional 这个包是可选的,在代码中就可以这样写了:

try {
    var pkgOpt = require('package-optional');
} catch (e) {
    pkgOpt = null;
}
console.log(pkgOpt);

结语

node package 的依赖管理在如今的前端工程化时代背景下变得尤为重要,构建优雅可维护的 node_modules 结构是值得探讨的一个话题,希望今天本文能对你有所帮助和启发。

查看原文

赞 18 收藏 16 评论 4

叔叔张 发布了文章 · 2016-09-01

分享一个 12306命令行工具 JavaScript 版

闲来无事,看到有个小伙伴用 python 实现了一个 12306 查询工具,于是乎萌生了用 JavaScript 写的冲动。现在查询功能有了,使用方法在这。

小玩意很简单,感觉没什么好总结的点,大家如果有兴趣,我可以写个简单的 tutorial 和思路分享。

查看原文

赞 0 收藏 10 评论 0

叔叔张 赞了文章 · 2016-08-08

如何在 React 中做到 jQuery-free

前言

前些天在订阅的公众号中看到了以前阮一峰老师写过的一篇文章,「如何做到 jQuery-free?」。这篇文章讨论的问题,在今天来看仍不过时,其中的一些点的讨论主要是面向新内核现代浏览器的标准 DOM API,很可惜的是在目前的开发环境下,我们仍然无法完全抛弃 IE,大部分情况下我们至少还要兼容到 IE 8,这一点使我们无法充分践行文章中提到的一些点,而本文也正是首次启发,顺着阮老师文章的思路来讨论如何在 React 中实战 IE8-compatible 的 jQuery-free。

首先我们仍要说的是,jQuery 是现在最流行的 JavaScript 工具库。在 W3techs 的统计中,目前全世界 70.6% 的网站在使用他,而 React 甚至还不到 0.1%,但 React 一个值得注意的趋势是,他在目前顶级流量网站中的使用率是最高的,比例达到了 16%。这一趋势也表明了目前整个前端界的技术趋势,但 70.6% 的数字也在告诉我们,jQuery 在 JS 库中的王者地位,即使使用了React,也可能因为各种各样的原因,还要和 jQuery 来配合使用。但 React 本身的体积已经让我们对任何一个重库产生了不适反应,为了兼容 IE8,我们仍然需要使用 1.x 的 jQuery 版本,但当时设计上的缺陷使得我们无法像 lodash 那样按需获取。而 React 和 jsx 的强大,又使得我们不需要了 jQuery 的大部分功能。从这个角度来看,他臃肿的体积让开发者更加难以忍受,jQuery-free 势在必行。

下面就顺着阮老师当年的思路,来讨论如何使用 React 自带的强大功能,和一些良心第三方库屏蔽兼容性,来取代 jQuery 的主要功能,做到 jQuery-free。

(注:React 15.x 版本已经不再兼容 IE8,因此本文讨论的 React 仍是 0.14.x 的版本,同时为了易于理解,本文也基本上以 ES6 class 的方式来声明组件,而不采用 pure function。)

一、选取 DOM 元素

在 jQuery 中,我们已经熟悉了使用 sizzle 选择器来完成 DOM 元素的选取。而在 React 中,我们可以使用 ref 来更有针对性的获取元素。

import React from 'react';
class Demo extends React.Compoent {

    getDomNode() {
        return this.refs.root; // 获取 Dom Node
    }
    render() {
        return (
            <div ref="root">just a demo</div>
        );
    }
}

这是最简单的获取 node 的方式,如果有多层结构嵌套呢?没有关系。

import React from 'react';
class Demo extends React.Compoent {

    getRootNode() {
        return this.refs.root; // 获取根节点 Dom Node
    }
    getLeafNode() {
        return this.refs.leaf; // 获取叶节点 Dom Node
    }
    render() {
        return (
            <div ref="root">
                <div ref="leaf">just a demo</div>
            </div>
        );
    }
}

如果是组件和组件嵌套呢?也没关系,父组件仍然可以拿到子组件的根节点。

import React from 'react';
import ReactDOM from 'react-dom';
class Sub extends React.Compoent {
    render() {
        return (
            <div>a sub component</div>
        );
    }
}
class Demo extends React.Compoent {

    getDomNode() {
        return this.refs.root; // 获取 Dom Node
    }
    
    getSubNode() {
        return ReactDOM.findDOMNode(this.refs.sub); // 获取子组件根节点
    }
    render() {
        return (
            <div ref="root">
                <Sub ref="sub" />
            </div>
        );
    }
}

上面使用了比较易懂的 API 来解释 Ref 的用法,但里面包含了一些现在 React 不太推荐和即将废弃的方法,如果用 React 推荐的写法,我们可以这样写。

import React from 'react';
import ReactDOM from 'react-dom';
class Sub extends React.Compoent {
    getDomNode() {
        return this.rootNode;
    }
    render() {
        return (
            <div ref={(c) => this.rootNode = c}>a sub component</div>
        );
    }
}
class Demo extends React.Compoent {

    getDomNode() {
        return this.rootNode; // 获取 Dom Node
    }
    
    getSubNode() {
        return this.sub.getDomNode(); // 获取子组件根节点
    }
    render() {
        return (
            <div ref={(c) => this.rootNode = c}>
                <Sub ref={(c) => this.sub = c} />
            </div>
        );
    }
}

有人可能会问,那子组件怎么拿父组件的 Dom Node 呢,从 React 的单向数据流角度出发,遇到这种情况我们应该通过回调通知给父组件,再由父组件自行判断如何修改 Node,其实父组件拿子组件的 Node 情况也很少,大多数情况下我们是通过 props 传递变化给子组件,获取子组件 Node,更多的情况下是为了避开大量重新渲染去修改一些Node的属性(比如 scrollLeft)。

二、DOM 操作

jQuery 中提供了丰富的操作方法,但一个个操作 DOM 元素有的时候真的很烦人并且容易出错。React 通过数据驱动的思想,通过改变 view 对应的数据,轻松实现 DOM 的增删操作。

class Demo extends React.Compoent {
    constructor(props) {
        super(props);
        this.state = {
            list: [1, 2, 3],
        };
        this.addItemFromBottom = this.addItemFromBottom.bind(this);
        this.addItemFromTop = this.addItemFromTop.bind(this);
        this.deleteItem = this.deleteItem.bind(this);
    }
    
    addItemFromBottom() {
        this.setState({
            list: this.state.list.concat([4]),
        });
    }
    
    addItemFromTop() {
        this.setState({
            list: [0].concat(this.state.list),
        });
    }
    
    deleteItem() {
        const newList = [...this.state.list];
        newList.pop();
        this.setState({
            list: newList,
        });
    }
    
    render() {
        return (
            <div>
                {this.state.list.map((item) => <div>{item}</div>)}
                <button onClick={this.addItemFromBottom}>尾部插入 Dom 元素</button>
                <button onClick={this.addItemFromTop}>头部插入 Dom 元素</button>
                <button onClick={this.deleteItem}>删除 Dom 元素</button>
            </div>
        );
    }
}

三、事件的监听

React 通过根节点代理的方式,实现了一套很优雅的事件监听方案,在组件 unmount 时也不需要自己去处理内存回收相关的问题,非常的方便。

import React from 'react';
class Demo extends React.Component {
    constructor(props) {
        super(props);
        this.handleClick = this.handleClick.bind(this);
    }
    handleClick() {
        alert('我是弹窗');
    }
    render() {
        return (
            <div onClick={this.handleClick}>点击我弹出弹框</div>
        );
    }
}

这里有一个小细节就是 bind 的时机,bind 是为了保持相应函数的上下文,虽然也可以在 onClick 那里 bind,但这里选择在 constructor 里 bind 是因为前者会在每次 render 的时候都进行一次 bind,返回一个新函数,是比较消耗性能的做法。

但 React 的事件监听,毕竟只能监听至 root component,而我们在很多时候要去监听 window/document 上的事件,如果 resize、scroll,还有一些 React 处理不好的事件,比如 scroll,这些都需要我们自己来解决。事件监听为了屏蔽差异性需要做很多的工作,这里像大家推荐一个第三方库来完成这部分的工作,add-dom-event-listener,用法和原生的稍有区别,是因为这个库并不旨在做 polyfill,但用法还是很简单。

var addEventListener = require('add-dom-event-listener');
var handler = addEventListener(document.body, 'click', function(e){
  console.log(e.target); // works for ie
  console.log(e.nativeEvent); // native dom event
});
handler.remove(); // detach event listener

另一个选择是 bean,达到了 IE6+ 级别的兼容性。

四、事件的触发

和事件监听一样,无论是 Dom 事件还是自定义事件,都有很优秀的第三方库帮我们去处理,如果是 DOM 事件,推荐 bean,如果是自定义事件的话,推荐 PubSubJS

五、document.ready

React 作为一个 view 层框架,通常情况下页面只有一个用于渲染 React 页面组件的根节点 div,因此 document.ready,只需把脚本放在这个 div 后面执行即可。而对于渲染完成后的回调,我们可以使用 React 提供的 componentDidMount 生命周期。

import React from 'react';
class Demo extends React.Component {
    constructor(props) {
        super(props);
    }
    
    componentDidMount() {
        doSomethingAfterRender(); // 在组件渲染完成后执行一些操作,如远程获取数据,检测 DOM 变化等。
    }
    render() {
        return (
            <div>just a demo</div>
        );
    }
}

六、attr 方法

jQuery 使用 attr 方法,获取 Dom 元素的属性。在 React 中也可以配合 Ref 直接读取 DOM 元素的属性。

import React from 'react';
class Demo extends React.Component {
    constructor(props) {
        super(props);
    }
    
    componentDidMount() {
        this.rootNode.scrollLeft = 10; // 渲染后将外层的滚动调至 10px
    }
    render() {
        return (
            <div 
                ref={(c) => this.rootNode = c} 
                style={{ width: '100px', overflow: 'auto' }}
            > 
                <div style={{ width: '1000px' }}>just a demo</div>
            </div>
        );
    }
}

但是,在大部分的情况下,我们完全不需要做,因为 React 的单向数据流和数据驱动渲染,我们可以不通过 DOM,轻松拿到和修改大部分我们需要的 DOM 属性。

import React from 'react';
class Demo extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            link: '//www.taobao.com',
        };
        this.getLink = this.getLink.bind(this);
        this.editLink = this.editLink.bind(this);
    }
    
    getLink() {
        alert(this.state.link);
    }
    
    editLink() {
        this.setState({
            link: '//www.tmall.com',
        });
    }
    
    render() {
        return (
            <div>
                <a href={this.state.link}>跳转链接</a>
                <button onClick={this.getLink}>获取链接</button>
                <button onClick={this.editLink}>修改链接</button>
            </div>
        );
    }
    
}

七、addClass/removeClass/toggleClass

在 jQuery 的时代,我们通常靠获取 Dom 元素后,再 addClass/removeClass 来改变外观。在 React 中通过数据驱动和第三库 classnames 修改样式从未如此轻松。

.fn-show {
    display: block;
}
.fn-hide {
    display: none;
}
import React from 'react';
import classnames from 'classnames';
class Demo extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            show: true,
        };
        this.changeShow = this.changeShow.bind(this);
    }
    
    changeShow() {
        this.setState({
            show: !this.state.show, 
        });
    }
    
    render() {
        return (
            <div>
                <a 
                    href="//www.taobao.com" 
                    className={classnames({
                        'fn-show': this.state.show,
                        'fn-hide': !this.state.show,
                    })}
                >
                    跳转链接
                </a>
                <button onClick={this.changeShow}>改变现实状态</button>
            </div>
        );
    }
    
}

八、css

jQuery 的 css 方法用于设置 DOM 元素的 style 属性,在 React 中,我们可以直接设置 DOM 的 style 属性,如果想改变,和上面的 class 一样,用数据去驱动。

import React from 'react';
class Demo extends React.Component {
    constructor() {
        super(props);
        this.state = {
            backgorund: 'white',
        };
        this.handleClick = this.handleClick.bind(this);
    }
    
    handleClick() {
        this.setState({
            background: 'black',
        });
    }
    
    render() {
        return (
            <div 
                style={{
                    background: this.state.background,
                }}
            >
                just a demo
                <button>change Background Color</button>
            </div>
        );
    }
}

九、数据存储

比起 jQuery,React 反而是更擅长管理数据,我们没有必要像 jQuery 时那样将数据放进 Dom 元素的属性里,而是利用 state 或者 内部变量(this.xxx) 来保存,在整个生命周期,我们都可以拿到这些数据进行比较和修改。

十、Ajax

Ajax 确实是在处理兼容性问题上一块令人比较头疼的地方,要兼容各种形式的 Xhr 不说,还有 jsonp 这个不属于 ajax 的功能也要同时考虑,好在已经有了很好的第三方库帮我们解决了这个问题,这里向大家推荐 natty-fetch,一个兼容 IE8 的fetch 库,在 API 设计上向 fetch 标准靠近,而又保留了和 jQuery 类似的接口,熟悉 $.ajax 应该可以很快的上手。

十一、动画

React 在动画方面提供了一个插件 ReactCSSTransitionGroup,和它的低级版本 ReactTransitionGroup,注意这里的低级并不是退化版本,而是更加基础的暴露更多 API 的版本。
这个插件的灵感来自于 Angular 的 ng-animate,在设计思路上也基本保持一致。通过指定 Transition 的类名,比如 example ,在元素进场和退场的时候分别加上对应的类名,以实现 CSS3 动画。例如本例中,进场会添加 example-enterexample-enter-active 到对应的元素 ,而在退场 example-leaveexample-leave-active 类名。当然你也可以指定不同的进场退场类名。而对应入场,React 也区分了两种类型,一种是 ReactCSSTransitionGroup 第一次渲染时(appear),而另一种是 ReactCSSTransitionGroup 已经渲染完成后,有新的元素插入进来(enter),这两种进场可以使用 prop 进行单独配置,禁止或者修改超时时长。具体的例子,在上面给出的链接中有详细的例子和说明,因此本文不再赘述。

但这个插件最多只提供了做动画的方案,如果我想在动画进行的过程中做一些其他事情呢?他就无能为力了,这时候就轮到 ReactTransitionGroup 出场了。ReactTransitionGroup 为他包裹的动画元素提供了六种新的生命周期:componentWillAppear(callback), componentDidAppear(), componentWillEnter(callback), componentDidEnter(), componentWillLeave(callback), componentDidLeave()。这些 hook 可以帮助我们完成一些随着动画进行需要做的其他事。

但官方提供的插件有一个不足点,动画只是在进场和出场时进行的,如果我的组件不是 mount/unmount,而只是隐藏和显示怎么办?这里推荐一个第三方库:rc-animate,从 API 设计上他基本上是延续了 ReactCSSTransitionGroup 的思路,但是通过引入 showProp 这一属性,使他可以 handle 组件显示隐藏这一情况下的出入场动画(只要将组件关于 show/hide 的属性传给 showProp 即可),同时这个库也提供自己的 hook,来实现 appear/enter/leave 时的回调。

如果你说我并不满足只是进场和出场动画,我要实现类似鼠标拖动时的实时动画,我需要的是一个 js 动画库,这里向大家推荐一个第三方库:react-motion , react-motion 一个很大的特点是,有别以往使用贝塞尔曲线来定义动画节奏,引入了刚度和阻尼这些弹簧系数来定义动画,按照作者的说法,与其纠结动画时长和很难掌握的贝塞尔表示法,通过不断调整刚度和阻尼来调试出最想要的弹性效果才是最合理的。Readme 里提供了一系列的很炫的动画效果,比如这个 draggable list。Motion 通过指定 defaultStyle、style,传回给子组件正在变化中的 style,从而实现 js 动画。

<Motion defaultStyle={{x: 0}} style={{x: spring(10)}}>
  {interpolatingStyle => <div style={interpolatingStyle} />}
</Motion>

总结

本文的灵感来源于阮老师两年前的文章,这篇的实战意义更大于对未来技术的展望,希望能够给各位正在使用 React 开发系统的同学们一点启发。

参考链接

最后

惯例地来宣传一下团队开源的 React PC 组件库 UXCore ,上面提到的点,在我们的组件优化过程中(如 table)都有体现,欢迎大家一起讨论,也欢迎在我们的 SegmentFault 专题下进行提问讨论。

uxcore

查看原文

赞 10 收藏 64 评论 5

叔叔张 赞了文章 · 2016-06-22

开发工具心得:如何 10 倍提高你的 Webpack 构建效率

0. 前言

babel+webpack+es6+react

图1:ES6 + Webpack + React + Babel

webpack 是个好东西,和 NPM 搭配起来使用管理模块实在非常方便。而 Babel 更是神一般的存在,让我们在这个浏览器尚未全面普及 ES6 语法的时代可以先一步体验到新的语法带来的便利和效率上的提升。在 React 项目架构中这两个东西基本成为了标配,但 commonjs 的模块必须在使用前经过 webpack 的构建(后文称为 build)才能在浏览器端使用,而每次修改也都需要重新构建(后文称为 rebuild)才能生效,如何提高 webpack 的构建效率成为了提高开发效率的关键之一。

1. Webpack 的构建流程

在开始正式的优化之前,让我们先回顾一下 Webpack 的构建流程,有哪些关键步骤,只有了解了这些,我们才能分析出哪些地方有优化的可能性。
webpack official

图2:webpack is a module bundler.

首先,我们来看看官方对于 Webpack 的理念阐释,webapck 把所有的静态资源都看做是一个 module,通过 webpack,将这些 module 组成到一个 bundle 中去,从而实现在页面上引入一个 bundle.js,来实现所有静态资源的加载。所以详细一点看,webpack 应该是这样的:

图3:Every static asset should be able to be a module --webpack

通过 loader,webpack 可以把各种非原生 js 的静态资源转换成 JavaScript,所以理论上任何一种静态资源都可以成为一个 module。
当然 webpack 还有很多其他好玩的特性,但不是本文的重点因此不铺开进行说明了。了解了上述的过程,我们就可以根据这些过程的前后处理进行对应的优化,接下来我们会针对 build 和 rebuild 的过程给与相应的意见。

2. RESOLVE

我们先从解析模块路径和分析依赖讲起,有人可能觉得这无所谓,但当项目应用依赖的模块越来越多,越来越重时,项目越来越大,文件和文件夹越来越多时,这个过程就变得越来越关乎性能。

2.1 减小 Webpack 覆盖的范围

build +, rebuild +

webpack 默认会去寻找所有 resolve.root 下的模块,但是有些目录我们是可以明确告知 webpack 不要管这里,从而减轻 webpack 的工作量。这时会用到 module.noParse 参数。

2.2 Resolove.root VS Resolove.moduledirectories

build +, rebuild +

rootmoduledirectories 如果只从用法上来看,似乎是可以互相替代的。但因为 moduledirectories 从设计上是取相对路径,所以比起 root ,所以会多 parse 很多路径。

resolve: {
    root: path.resolve('src/node_modules'),
    extensions: ['', '.js', '.jsx']
},
resolve: {
    modulesDirectories: ['node_modules', './src'],
    extensions: ['', '.js', '.jsx']
},

上面的配置,只会解析

./src/node_modules/a

==== 此处有修改 2016/09/10 感谢 @lili_21 ====

而下面的配置会解析

/some/folder/structure/node_modules/a
/some/folder/structure/src/a
/some/folder/node_modules/a
/some/folder/src/a
/some/node_modules/a
/some/src/a
/node_modules/a
/src/a 

大部分的情况下使用 root 即可,只有在有很复杂的路径下,才考虑使用 moduledirectories,这可以明显提高 webpack 的构建性能。这个 issue 也很详细地讨论了这个问题。

3. LOADERS

webpack 官方和社区为我们提供了各种各样 loader 来处理各种类型的文件,这些 loader 的配置也直接影响了构建的性能。

3.1 Babel-loader: 能者少劳

build ++, rebuild ++

以 babel-loader 为例,我们在开发 React 项目时很可能会使用到了 ES6 或者 jsx 的语法,因此使用到 babel-loader 的情况很多,最简单的情况下我们可以这样配置,让所有的 js/jsx 通过 babel-loader:

module: {
    loaders: [
      {
          test: /\.js(x)*$/,
          loader: 'babel-loader',
          query: {
              presets: ['react', 'es2015-ie', 'stage-1']
          }
      }
    ]
}

上面这样的做法当然是 ok 的,但是对于很多的 npm 包来说,他们完全没有经过 babel 的必要(成熟的 npm 包会在发布前将自己 es5,甚至 es3 化),让这些包通过 babel 会带来巨大的性能负担,毕竟 babel6 要经过几十个插件的处理,虽然 babel-loader 强大,但能者多劳的这种保守的想法却使得 babel-loader 成为了整个构建的性能瓶颈。所以我们可以使用 exclude,大胆地屏蔽掉 npm 里的包,从而使整包的构建效率飞速提高。

module: {
    loaders: [
      {
          test: /\.js(x)*$/,
          loader: 'babel-loader',
          exclude: function(path) {
              // 路径中含有 node_modules 的就不去解析。
              var isNpmModule = !!path.match(/node_modules/);
              return isNpmModule;
          },
          query: {
              presets: ['react', 'es2015-ie', 'stage-1']
          }
      }
    ]
}

甚至,在我们十分确信的情况下,使用 include 来限定 babel 的使用范围,进一步提高效率。

var path = require('path');
module.exports = {
    module: {
        loaders: [
          {
              test: /\.js(x)*$/,
              loader: 'babel-loader',
              include: [
                // 只去解析运行目录下的 src 和 demo 文件夹
                path.join(process.cwd(), './src'),
                path.join(process.cwd(), './demo')
              ],
              query: {
                  presets: ['react', 'es2015-ie', 'stage-1']
              }
          }
        ]
    }
}

4. PLUGINS

webpack 官方和社区为我们提供了很多方便的插件,有些插件为我们开发和生产带来了很多的便利,但是不合适地使用插件也会拖慢 webpack 的构建效率,而有些插件虽然不会为我们的开发上直接提供便利,但使用他们却可以帮助我们提高 webpack 的构建效率,这也是本文会提到的。

4.1 SourceMaps

build +

SourceMaps 是一个非常实用的功能,可以让我们在 chrome debug 时可以不用直接看已经 bundle 过的 js,而是直接在源代码上进行查看和调试,但完美的 SourceMaps 是很慢的,webpack 官方提供了七种 sourceMap 模式共大家选择,性能对比如下:

devtoolbuild speedrebuild speedproduction supportedquality
eval++++++nogenerated code
cheap-eval-source-map+++notransformed code (lines only)
cheap-source-map+oyestransformed code (lines only)
cheap-module-eval-source-mapo++nooriginal source (lines only)
cheap-module-source-mapo-yesoriginal source (lines only)
eval-source-map--+nooriginal source
source-map----yesoriginal source

具体各自的区别请参考 https://github.com/webpack/do... ,我们这里推荐使用 cheap-source-map,也就是去掉了column mapping 和 loader-sourceMap(例如 jsx to js) 的 sourceMap,虽然带上 eval 参数的可以快更多,但是这种 sourceMap 只能看,不能调试,得不偿失。

4.2 OPTIMIZATION

build ++,rebuild ++

webpack 提供了一些可以优化浏览器端性能的优化插件,如UglifyJsPlugin,OccurrenceOrderPlugin 和 DedupePlugin,都很实用,也都在消耗构建性能(UglifyJsPlugin 非常耗性能),如果你是在开发环境下,这些插件最好都不要使用,毕竟脚本大一些,跑的慢一些这些比起每次构建要耗费更多时间来说,显然还是后者更会消磨开发者的耐心,因此,只在正产环境中使用 OPTIMIZATION。

4.3 CommonsChunk

rebuild +

当你的 webpack 构建任务中有多个入口文件,而这些文件都 require 了相同的模块,如果你不做任何事情,webpack 会为每个入口文件引入一份相同的模块,显然这样做,会使得相同模块变化时,所有引入的 entry 都需要一次 rebuild,造成了性能的浪费,CommonsChunkPlugin 可以将相同的模块提取出来单独打包,进而减小 rebuild 时的性能消耗。这里有一篇很通俗易懂的使用方法:http://webpack.toobug.net/zh-... ,感兴趣的朋友不妨一试。

4.4 DLL & DllReference

build +++, rebuild +++

除了正在开发的源代码之外,通常还会引入很多第三方 NPM 包,这些包我们不会进行修改,但是仍然需要在每次 build 的过程中消耗构建性能,那有没有什么办法可以减少这些消耗呢?DLLPlugin 就是一个解决方案,他通过前置这些依赖包的构建,来提高真正的 build 和 rebuild 的构建效率。
鉴于现有的资料对于这两个插件的解释都不是很清楚,笔者这里翻译了一篇日本同学的文章,通过一个简单的例子来说明一下这两个插件的用法。我们举例,把 react 和 react-dom 打包成为 dll bundle。
首先,我们来写一个 DLLPlugin 的 config 文件。

webpack.dll.config.js

const path = require('path');
const webpack = require('webpack');

module.exports = {
  entry: {
    vendor: ['react', 'react-dom']
  },
  output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name].dll.js',
    /**
     * output.library
     * 将会定义为 window.${output.library}
     * 在这次的例子中,将会定义为`window.vendor_library`
     */
    library: '[name]_library'
  },
  plugins: [
    new webpack.DllPlugin({
      /**
       * path
       * 定义 manifest 文件生成的位置
       * [name]的部分由entry的名字替换
       */
      path: path.join(__dirname, 'dist', '[name]-manifest.json'),
      /**
       * name
       * dll bundle 输出到那个全局变量上
       * 和 output.library 一样即可。 
       */
      name: '[name]_library'
    })
  ]
};

执行 webpack 后,就会在 dist 目录下生成 dll bundle 和对应的 manifest 文件

$ ./node_modules/.bin/webpack --config webpack.dll.config.js
Hash: 36187493b1d9a06b228d
Version: webpack 1.13.1
Time: 860ms
        Asset    Size  Chunks             Chunk Names
vendor.dll.js  699 kB       0  [emitted]  vendor
   [0] dll vendor 12 bytes {0} [built]
    + 167 hidden modules

$ ls dist
./                    vendor-manifest.json
../                   vendor.dll.js

manifest 文件的格式大致如下,由包含的 module 和对应的 id 的键值对构成。

cat dist/vendor-manifest.json
{
  "name": "vendor_library",
  "content": {
    "./node_modules/react/react.js": 1,
    "./node_modules/react/lib/React.js": 2,
    "./node_modules/process/browser.js": 3,
    "./node_modules/object-assign/index.js": 4,
    "./node_modules/react/lib/ReactChildren.js": 5,
    "./node_modules/react/lib/PooledClass.js": 6,
    "./node_modules/fbjs/lib/invariant.js": 7,
...

好,接下来我们通过 DLLReferencePlugin 来使用刚才生成的 DLL Bundle。

首先我们写一个只去 require react,并通过 console.log 吐出的 index.js

var React = require('react');
var ReactDOM = require('react-dom');
console.log("dll's React:", React);
console.log("dll's ReactDOM:", ReactDOM);

再写一个不参考 Dll Bundle 的普通 webpack config 文件。

webpack.conf.js

const path = require('path');
const webpack = require('webpack');

module.exports = {
  entry: {
    'dll-user': ['./index.js']
  },
  output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name].bundle.js'
  }
};

执行 webpack,会在 dist 下生成 dll-user.bundle.js,约 700K,耗时 801ms。

$ ./node_modules/.bin/webpack
Hash: d8cab39e58c13b9713a6
Version: webpack 1.13.1
Time: 801ms
             Asset    Size  Chunks             Chunk Names
dll-user.bundle.js  700 kB       0  [emitted]  dll-user
   [0] multi dll-user 28 bytes {0} [built]
   [1] ./index.js 145 bytes {0} [built]
    + 167 hidden modules

接下来,我们加入 DLLReferencePlugin

webpack.conf.js

const path = require('path');
const webpack = require('webpack');

module.exports = {
  entry: {
    'dll-user': ['./index.js']
  },
  output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name].bundle.js'
  },
  // ----在这里追加----
  plugins: [
    new webpack.DllReferencePlugin({
      context: __dirname,
      /**
       * 在这里引入 manifest 文件
       */
      manifest: require('./dist/vendor-manifest.json')
    })
  ]
  // ----在这里追加----
};
./node_modules/.bin/webpack
Hash: 3bc7bf760779b4ca8523
Version: webpack 1.13.1
Time: 70ms
             Asset     Size  Chunks             Chunk Names
dll-user.bundle.js  2.01 kB       0  [emitted]  dll-user
   [0] multi dll-user 28 bytes {0} [built]
   [1] ./index.js 145 bytes {0} [built]
    + 3 hidden modules

结果是非常惊人的,只有2.01K,耗时 70 ms,无疑大大提高了 build 和 rebuild 的效率。实际放到页面上看下是否可行。

<body>
  <script data-original="dist/vendor.dll.js"></script>
  <script data-original="dist/dll-user.bundle.js"></script>
</body>

因为 Dll bundle 在依赖安装完毕后就可以进行了,我们可以在第一次执行 dev server 前执行一次 dll bundle 的 webapck 任务。

4.4.1 和 external 的比较

有人会说,这个和 用 webpackexternals 配置把 require 的 module 指向全局变量有点像啊。

const path = require('path');
const webpack = require('webpack');

module.exports = {
  entry: {
    'ex': ['./index.js']
  },
  output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name].bundle.js'
  },
  externals: {
    // require('react')はwindow.Reactを使う
    'react': 'React',
    // require('react-dom')はwindow.ReactDOMを使う
    'react-dom': 'ReactDOM'
  }
};
<body>
  <script data-original="dist/react.min.js"></script>
  <script data-original="dist/react-dom.min.js"></script>
  <script data-original="dist/ex.bundle.js"></script>
</body>

这里有两个主要的区别:

  1. 像是 react 这种已经打好了生产包的使用 externals 很方便,但是也有很多 npm 包是没有提供的,这种情况下 DLLBundle 仍可以使用。

  2. 如果只是引入 npm 包一部分的功能,比如 require('react/lib/React') 或者 require('lodash/fp/extend') ,这种情况下 DLLBundle 仍可以使用。

  3. 当然如果只是引用了 react 这类的话,externals 因为配置简单所以也推荐使用。

4.5 HappyPack

build +, rebuild +

webpack 的长时间构建搞的大家都很 unhappy。于是 @amireh 想到了一个点子,既然 loader 默认都是一个进程在跑,那是否可以让 loader 多进程去处理文件呢?

happyPack 的文档写的很易懂,这里就不再赘述,happyPack 不仅利用了多进程,同时还利用缓存来使得 rebuild 更快。下面是插件作者给出的性能数据:

For the main repository I tested on, which had around 3067 modules, the build time went down from 39 seconds to a whopping ~10 seconds when there was yet no

  1. Successive builds now take between 6 and 7 seconds.

Here's a rundown of the various states the build was performed in:

Elapsed (ms)Happy?Cache enabled?Cache present?Using DLLs?
39851NON/AN/ANO
37393NON/AN/AYES
14605YESNON/ANO
13925YESYESNONO
11877YESYESYESNO
9228YESNON/AYES
9597YESYESNOYES
6975YESYESYESYES

The builds above were run on Linux over a machine with 12 cores.

5. 其他

上面我们针对 webpack 的 resolve、loader 和 plugin 的过程给出了相应的优化意见,除了这些哪些优化点呢?其实有些优化贯穿在这个流程中,比如缓存和文件 IO。

5.1 Cache

无论在何种性能优化中,缓存总是必不可少的一部分,毕竟每次变动都只影响很小的一部分,如果能够缓存住那些没有变动的部分,直接拿来使用,自然会事半功倍,在 webpack 的整个构建过程中,有多个地方提供了缓存的机会,如果我们打开了这些缓存,会大大加速我们的构建,尤其是 rebuild 的效率。

5.1.1 webpack.cache

rebuild +

webpack 自身就有 cache 的配置,并且在 watch 模式下自动开启,虽然效果不是最明显的,但却对所有的 module 都有效。

5.1.2 babel-loader.cacheDirectory

rebuild ++

babel-loader 可以利用系统的临时文件夹缓存经过 babel 处理好的模块,对于 rebuild js 有着非常大的性能提升。

5.1.3 HappyPack.cache

build +, rebuild +

上面提到的 happyPack 插件也同样提供了 cache 功能,默认是以 .happypack/cache--[id].json 的路径进行缓存。因为是缓存在当前目录下,所以他也可以辅助下次 build 时的效率。

5.2 FileSystem

默认的情况下,构建好的目录一定要输出到某个目录下面才能使用,但 webpack 提供了一种很棒的读写机制,使得我们可以直接在内存中进行读写,从而极大地提高 IO 的效率,开启的方法也很简单。

var MemoryFS = require("memory-fs");
var webpack = require("webpack");

var fs = new MemoryFS();
var compiler = webpack({ ... });
compiler.outputFileSystem = fs;
compiler.run(function(err, stats) {
  // ...
  var fileContent = fs.readFileSync("...");
});

当然,我们还可以通过 webpackDevMiddleware 更加无缝地就接入到 dev server 中,例如我们以 express 作为静态 server 的例子。

var compiler = webpack(webpackCfg);

var webpackDevMiddlewareInstance = webpackDevMiddleware(compiler, {
   // webpackDevMiddleware 默认使用了 memory-fs
   publicPath: '/dist',
   aggregateTimeout: 300, // wait so long for more changes
   poll: true, // use polling instead of native watchers
   stats: {
       chunks: false
   }
});

var app = express();
app.use(webpackDevMiddlewareInstance);
app.listen(xxxx, function(err) {
   console.log(colors.info("dev server start: listening at " + xxxx));
   if (err) {
     console.error(err);
   }
}

6. 总结

上面我们从 webpack 构建的各个部分,给出了相应的优化策略,如果你的项目中能够将其完全贯彻起来,10 倍提速不是梦想。这些优化也同样应用到了我们团队的 react 项目中,https://github.com/uxcore/uxcore ,欢迎一起来讨论 webpack 的效率优化方案。

7. 参考文章

本文作者 eternalsky,始发于团队微信公众号 猿猿相抱 和个人博客 空の屋敷,转载请保留作者信息。

查看原文

赞 73 收藏 331 评论 9

叔叔张 赞了文章 · 2016-05-31

React 移动 web 极致优化

原文地址:https://github.com/lcxfs1991/...

最近一个季度,我们都在为手Q家校群做重构优化,将原有那套问题不断的框架换掉。经过一些斟酌,决定使用react 进行重构。选择react,其实也主要是因为它具有下面的三大特性。

React的特性

1. Learn once, write anywhere

学习React的好处就是,学了一遍之后,能够写web, node直出,以及native,能够适应各种纷繁复杂的业务。需要轻量快捷的,直接可以用Reactjs;需要提升首屏时间的,可以结合React Server Render;需要更好的性能的,可以上React Native。

但是,这其实暗示学习的曲线非常陡峭。单单是Webpack+ React + Redux就已够一个入门者够呛,更何况还要兼顾直出和手机客户端。不是一般人能hold住所有端。

2. Virtual Dom

Virtual Dom(下称vd)算是React的一个重大的特色,因为Facebook宣称由于vd的帮助,React能够达到很好的性能。是的,Facebook说的没错,但只说了一半,它说漏的一半是:“除非你能正确的采用一系列优化手段”。

3. 组件化

另一个被大家所推崇的React优势在于,它能令到你的代码组织更清晰,维护起来更容易。我们在写的时候也有同感,但那是直到我们踩了一些坑,并且渐渐熟悉React+ Redux所推崇的那套代码组织规范之后。

那么?

上面的描述不免有些先扬后抑的感觉,那是因为往往作为React的刚入门者,都会像我们初入的时候一样,对React满怀希望,指意它帮我们做好一切,但随着了解的深入,发现需要做一些额外的事情来达到我们的期待。

对React的期待

初学者对React可能满怀期待,觉得React可能完爆其它一切框架,甚至不切实际地认为React可能连原生的渲染都能完爆——对框架的狂热确实会出现这样的不切实际的期待。让我们来看看React的官方是怎么说的。React官方文档在Advanced Performanec这一节,这样写道:

One of the first questions people ask when considering React for a project is whether their application will be as fast and responsive as an equivalent non-React version

显然React自己也其实只是想尽量达到跟非React版本相若的性能。React在减少重复渲染方面确实是有一套独特的处理办法,那就是vd,但显示在首次渲染的时候React绝无可能超越原生的速度,或者一定能将其它的框架比下去。因此,我们在做优化的时候,可的期待的东西有:

  • 首屏时间可能会比较原生的慢一些,但可以尝试用React Server Render (又称Isomorphic)去提高效率

  • 用户进行交互的时候,有可能会比原生的响应快一些,前提是你做了一些优化避免了浪费性能的重复渲染。

以手Q家校群功能页React重构优化为例

手Q家校群功能页主要由三个页面构成,分别是列表页、布置页和详情页。列表页已经重构完成并已发布,布置页已重构完毕准备提测,详情页正在重构。与此同时我们已完成对列表页的同构直出优化,并已正在做React Native优化的铺垫。

这三个页面的重构其实覆盖了不少页面的案例,所以还是蛮有代表性的,我们会将重构之中遇到的一些经验穿插在文章里论述。

在手Q家校群重构之前,其实我们已经做了一版PC家校群。当时将native的页面全部web化,直接就采用了React比较常用的全家桶套装:

  • 构建工具 => gulp + webpack

  • 开发效率提升 => redux-dev-tools + hot-reload

  • 统一数据管理=> redux

  • 性能提升 => immutable + purerender

  • 路由控制器 => react-router(手Q暂时没采用)

为什么我们在优化的时候主要讲手Q呢?毕竟PC的性能在大部份情况下已经很好,在PC上一些存在的问题都被PC良好的性能掩盖下去。手机的性能不如PC,因此有更多有价值的东西深挖。开发的时候我就跟同事开玩笑说:“没做过手机web优化的都真不好意思说自己做过性能优化啊“。

构建针对React做的优化

我在《性能优化三部曲之一——构建篇》提出,“通过构建,我们可以达成开发效率的提升,以及对项目最基本的优化”。在进行React重构优化的过程中,构建对项目的优化作用必不可少。在本文暂时不赘述,我另外开辟了一篇《webpack使用优化(react篇)》进行具体论述。

开发效率提升工具

1

在PC端使用Redux的时候,我们都很喜欢使用Redux-Devtools来查看Redux触发的action,以及对应的数据变化。PC端使用的时候,我们习惯摆在右边。但移动端的屏幕较少,因此家校群项目使用的时候放在底部,而且由于性能问题,我们在constant里设一个debug参数,然后在chrome调试时打开,移动端非必须的时候关闭。否则,它会导致移动web的渲染比较低下。

数据管理及性能优化

Redux统一管理数据

这一部份算是重头戏吧。React作为View层的框架,已经通过vd帮助我们解决重复渲染的问题。但vd是通过看数据的前后差异去判断是否要重复渲染的,但React并没有帮助我们去做这层比较。因此我们需要使用一整套数据管理工具及对应的优化方法去达成。在这方法,我们选择了Redux。

Redux整个数据流大体可以用下图来描述:

2

Redux这个框架的好处在于能够统一在自己定义的reducer函数里面去进行数据处理,在View层中只需要通过事件去处触发一些action就可以改变地应的数据,这样能够使数据处理和dom渲染更好地分离,而避免手动地去设置state。

在重构的时候,我们倾向于将功能类似的数据归类到一起,并建立对应的reducer文件对数据进行处理。如下图,是手Q家校群布置页的数据结构。有些大型的SPA项目可能会将初始数据分开在不同的reducer文件里,但这里我们倾向于归到一个store文件,这样能够清晰地知道整个文件的数据结构,也符合Redux想统一管理数据的想法。然后数据的每个层级与reducer文件都是一一对应的关系。

3

重复渲染导致卡顿

这套React + Redux的东西在PC家校群页面上用得很欢乐, 以至于不用怎么写shouldComponentUpdate都没遇到过什么性能问题。但放到移动端上,我们在列表页重构的时候就马上遇到卡顿的问题了。

什么原因呢?是重复渲染导致的!!!!!!

说好的React vd可以减少重复渲染呢?!!!

请别忘记前提条件!!!!

你可以在每个component的render里,放一个console.log("xxx component")。然后触发一个action,在优化之前,几乎全部的component都打出这个log,表明都重复渲染了。

React性能的救星Immutablejs

4
(网图,引用的文章太多以致于不知道哪篇才是出处)

上图是React的生命周期,还没熟悉的同学可以去熟悉一下。因为其中的shouldComponentUpdate是优化的关键。React的重复渲染优化的核心其实就是在shouldComponentUpdate里面做数据比较。在优化之前,shouldComponentUpdate是默认返回true的,这导致任何时候触发任何的数据变化都会使component重新渲染。这必然会导致资源的浪费和性能的低下——你可能会感觉比较原生的响应更慢。

这时你开始怀疑这世界——是不是Facebook在骗我。

当时遇到这个问题我的开始翻阅文档,也是在Facebook的Advanced Performance一节中找到答案:Immutablejs。这个框架已被吹了有一年多了吧,吹这些框架的人理解它的原理,但不一定实践过——因为作为一线移动端开发者,打开它的github主页看dist文件,50kb,我就已经打退堂鼓了。只是遇到了性能问题,我们才再认真地去了解一遍。

Immutable这个的意思就是不可变,Immutablejs就是一个生成数据不可变的框架。一开始你并不理解不可变有什么用。最开始的时候Immutable这种数据结构是为了解决数据锁的问题,而对于js,就可以借用来解决前后数据比较的问题——因为同时Immutablejs还提供了很好的数据比较方法——Immutable.is()。小结一下就是:

  • Immutablejs本身就能生成不可变数据,这样就不需要开发者自己去做数据深拷贝,可以直接拿prevProps/prevState和nextProps/nextState来比较。

  • Immutable本身还提供了数据的比较方法,这样开发者也不用自己去写数据深比较的方法。

说到这里,已万事俱备了。那东风呢?我们还欠的东风就是应该在哪里写这个比较。答案就是shouldComponentUpdate。这个生命周期会传入nextProps和nextState,可以跟component当前的props和state直接比较。这个就可以参考pure-render的做法,去重写shouldComponentUpdate,在里面写数据比较的逻辑。

其中一位同事polarjiang利用Immutablejs的is方法,参考pure-render-decorator写了一个immutable-pure-render-decorator

那具体怎么使用immutable + pure-render呢?

对于immutable,我们需要改写一下reducer functions里面的处理逻辑,一律换成Immutable的api。

至于pure-render,若是es5写法,可以用使mixin;若是es6/es7写法,需要使用decorator,在js的babel loader里面,新增plugins: [‘transform-decorators-legacy’]。其es6的写法是

@pureRender
export default class List extends Component { ... }

Immutablejs带来的一些问题

不重新渲染

你可能会想到Immutable能减少无谓的重新渲染,但可能没想过会导致页面不能正确地重新渲染。目前列表页在老师进入的时候是有2个tab的,tab的切换会让列表也切换。目前手Q的列表页学习PC的列表页,两个列表共用一套dom结构(因为除了作业布置者名字之外,两个列表一模一样)。上了Immutablejs之后,当碰巧“我发布的“列表和”全部“列表开头的几个作业都是同一个人布置的时候,列表切换就不重新渲染了。

引入immutable和pureRender后,render里的JSX注意一定不要有同样的key(如两个列表,有重复的数据,此时以数据id来作为key就不太合适,应该要用数据id + 列表类型作为key),会造成不渲染新数据情况。列表页目前的处理办法是将key值换成id + listType。

4
(列表页两个列表的切换)

这样写除了保证在父元素那一层知晓数据(key值)不同需要重新渲染之外,也保证了React底层渲染知道这是两组不同的数据。在React源文件里有一个ReactChildReconciler.js主要是写children的渲染逻辑。其中的updateChildren里面有具体如何比较前后children,然后再决定是否要重新渲染。在比较的时候它调用了shouldUpdateReactComponent方法。我们看到它有对key值做比较。在两个列表中有不同的key,在数据相似的情况下,能保证两者切换的时候能重新渲染。

function shouldUpdateReactComponent(prevElement, nextElement) {
  var prevEmpty = prevElement === null || prevElement === false;
  var nextEmpty = nextElement === null || nextElement === false;
  if (prevEmpty || nextEmpty) {
    return prevEmpty === nextEmpty;
  }

  var prevType = typeof prevElement;
  var nextType = typeof nextElement;
  if (prevType === 'string' || prevType === 'number') {
    return nextType === 'string' || nextType === 'number';
  } else {
    return nextType === 'object' && prevElement.type === nextElement.type && prevElement.key === nextElement.key;
  }
}

Immutablejs太大了

上文也提到Immutablejs编译后的包也有50kb。对于PC端来说可能无所谓,网速足够快,但对于移动端来说压力就大了。有人写了个seamless-immutable,算是简易版的Immutablejs,只有2kb,只支持Object和Array。

但其实数据比较逻辑写起来也并不难,因此再去review代码的时候,我决定尝试自己写一个,也是这个决定让我发现了更多的奥秘。

针对React的这个数据比较的深比较deepCompare,要点有2个:

  • 尽量使传入的数据扁平化一点

  • 比较的时候做一些限制,避免溢出栈

先上一下列表页的代码,如下图。这里当时是学习了PC家校群的做法,将component作为props传入。这里的<Scroll>封装的是滚动检测的逻辑,而<List>则是列表页的渲染,<Empty>是列表为空的时候展示的内容,<Loading>是列表底部加载的显示横条。

5

针对deepCompare的第1个要点,扁平化数据,我们很明显就能定位出其中一个问题了。例如<Empty>,我们传入了props.hw,这个props包括了两个列表的数据。但这样的结构就会是这样

props.hw = {
    listMine: [
        {...}, {...}, ...
    ],
    listAll: [
        {...}, {...}, ...
    ],
}

但如果我们提前在传入之前判断当前在哪个列表,然后传入对应列表的数量,则会像这样:
props.hw = 20;

两者比较起来,显示是后者简单得多。

针对deepCompare第2点,限制比较的条件。首先让我们想到的是比较的深度。一般而言,对于Object和Array数据,我们都需要递归去进行比较,出于性能的考虑,我们都会限制比较的深度。

除此之外,我们回顾一下上面的代码,我们将几个React component作为props传进去了,这会在shouldComponentUpdate里面显示出来。这些component的结构大概如下:

6

$$typeof // 类型
_owner // 父组件
_self: // 仅开发模式出现
_source: //  仅开发模式出现
_store //  仅开发模式出现
key // 组件的key属性值
props // 从传入的props
ref // 组件的ref属性值
type 本组件ReactComponent

因此,针对component的比较,有一些是可以忽略的,例如$$typeof, _store, _self, _source, _ownertype这个比较复杂,可以比较,但仅限于我们定好的比较深度。如果不做这些忽略,这个深比较将会比较消耗性能。关于这个deepCompare的代码,我放在了pure-render-deepCompare-decorator

不过其实,将component当作props传入更为灵活,而且能够增加组件的复用性,但从上面看来,是比较消耗性能的。看了官方文档之后,我们尝试换种写法,主要就是采用<Scroll>包裹<List>的做法,然后用this.props.children在<Scroll>里面渲染,并将<Empty>, <Loading>抽出来。

7

8

本以为React可能会对children这个props有什么特殊处理,但它依然是将children当作props,传入shouldComponentUpdate,这就迫使父元素<Scroll>要去判断是否要重新渲染,进而跳到子无素<List>再去判断是否进一步进行渲染。

<Scroll>究竟要不要去做这重判断呢?针对列表页这种情况,我们觉得可以暂时不做,由于<Scroll>包裹的元素不多,<Scroll>可以先重复渲染,然后再交由子元素<List>自己再去判断。这样我们对pure-render-deepCompare-decorator要进行一些修改,当轮到props.children判断的时候,我们要求父元素直接重新渲染,这样就能交给子元素去做下一步的处理。

如果<Scroll>包裹的只有<List>还好,如果还有像<Empty>, <Loading>甚至其它更多的子元素,那<Scroll>重新渲染会触发其它子元素去运算,判断自己是否要做重新渲染,这就造成了浪费。react的官方论坛上已经有人提出,React的将父子元素的重复渲染的决策都放在shouldComponentUpdate,可能导致了耦合Shouldcomponentupdate And Children

lodash.merge可以解决大部份场景

此段更新于2016年6月30日
由于immutable的大小问题一直萦绕头上,久久不得散去,因此再去找寻其它的方案。后面决定尝试一下lodash.merge,并用上之前自己写的pureRender。在渲染性能上还可以接受,在仅比immutable差一点点(后面会披露具体数据),但却带来了30kb的减包。

性能优化小Tips

这里归纳了一些其它性能优化的小Tips

请慎用setState,因其容易导致重新渲染

既然将数据主要交给了Redux来管理,那就尽量使用Redux管理你的数据和状态state,除了少数情况外,别忘了shouldComponentUpdate也需要比较state。

请将方法的bind一律置于constructor

Component的render里不动态bind方法,方法都在constructor里bind好,如果要动态传参,方法可使用闭包返回一个最终可执行函数。如:showDelBtn(item) { return (e) => {}; }。如果每次都在render里面的jsx去bind这个方法,每次都要绑定会消耗性能。

请只传递component需要的props

传得太多,或者层次传得太深,都会加重shouldComponentUpdate里面的数据比较负担,因此,也请慎用spread attributes(<Component {...props} />)。

请尽量使用const element

这个用法是工业聚在React讨论微信群里教会的,我们可以将不怎么变动,或者不需要传入状态的component写成const element的形式,这样能加快这个element的初始渲染速度。

路由控制与拆包

当项目变得更大规模与复杂的时候,我们需要设计成SPA,这时路由管理就非常重要了,这使特定url参数能够对应一个页面。

9

PC家校群整个设计是一个中型的SPA,当js bundle太大的时候,需要拆分成几个小的bundle,进行异步加载。这时可以用到webpack的异步加载打包功能,require。

10

在重构手Q家校群布置页的时候,我们有不少的浮层,列表有布置页内容主浮层、同步到多群浮层、科目管理浮层以及指定群成员浮层。这些完全可以使用react-router进行管理。但是由于当时一早使用了Immutablejs,js bundle已经比较大,我们就不打算使用react-router了。但后面仍然发现包比重构前要大一些,因此为了保证首屏时间不慢于重构前,我们希望在不用react-router的情况下进行分包,其实也并不难,如下面2幅图:

12

11

首先在切换浮层方法里面,使用require.ensure,指定要加载哪个包。
在setComponent方法里,将component存在state里面。
在父元素的渲染方法里,当state有值的时候,就会自动渲染加载回来的component。

性能数据

首屏可交互时间

目前只有列表页发布外网了,我们比较了优化前后的首屏可交互时间,分别有18%和5.3%的提升。

13

14

渲染FPS

更新于2016年7月2日

Android

React重构后第一版,当时还没做任何的优化,发现平均FPS只有22(虽然Android的肉眼感受不出来),而后面使用Immutable或者Lodash.merge都非常接近,能达到42或以上。而手机QQ可接受的FPS最少值是30FPS。因此使用Immutable和Lodash.merge的优化还是相当明显的。

  • 重构后第一版
    before rebuild

  • Immutable
    Immutable

  • Lodash.merge
    Lodash.merge

iOS

在iOS上的fps差距尤为明显。重构后第一版,拉了大概5屏之后,肉眼会有卡顿的感觉,拉到了10屏之后,数据开始掉到了20多30。而Immutable和Lodash.merge则大部份时间保持在50fps以上,很多时候还能达到非常流畅的60fps。

  • 重构后第一版
    before rebuild

  • Immutable
    Immutable

  • Lodash.merge
    Lodash.merge

Chrome模拟器

用Chrome模拟器也能看出一些端倪。在Scripting方面,Immutable和Lodash.merge的耗时是最少的,约700多ms,而重构后的第一版则需要1220ms。Lodash.merge在rendering和painting上则没占到优势,但Immutable则要比其它两个要少30% - 40%。由于测试的时候是在PC端,PC端的性能又极好,所以不管是肉眼,还是数据,对于不是很复杂的需求,总体的渲染性能看不出非常明显的差距。

  • 重构后第一版
    before rebuild

  • Immutable
    Immutable

  • Lodash.merge
    Lodash.merge

从上面的数据看来,在移动端使用Immutable和Lodash.merge相对于不用,会有较大的性能优势,但Immutable相对于Lodash.merge在我们需求情景下暂时没看出明显的优势,笔者估计可能是由于项目数据规模不大,结构不复杂,因此Immutable的算法优势并没有充分发挥出来。

测试注明

Android端测试FPS是使用了腾讯开发的GT随身调。而iOS则使用了Macbook里xCode自带的instrument中的animation功能。Chrome模拟器则使用了Chrome的timeline。测试的方式是匀速滚动列表,拉出数据进行渲染。

React性能优化军规

我们在开发的过程中,将上面所论述的内容,总结成一个基本的军规,铭记于心,就可以保证React应用的性能不至于太差。

渲染相关

  • 提升级项目性能,请使用immutable(props、state、store)

  • 请pure-render-decorator与immutablejs搭配使用

  • 请慎用setState,因其容易导致重新渲染

  • 谨慎将component当作props传入

  • 请将方法的bind一律置于constructor

  • 请只传递component需要的props,避免其它props变化导致重新渲染(慎用spread attributes)

  • 请在你希望发生重新渲染的dom上设置可被react识别的同级唯一key,否则react在某些情况可能不会重新渲染。

  • 请尽量使用const element

tap事件

1. 简单的tap事件,请使用react-tap-event-plugin

开发环境时,最好引入webpack的环境变量(仅在开发环境中初始化),在container中初始化。生产环境的时候,请将plugin跟react打包到一起(需要打包在一起才能正常使用,因为plugin对react有好多依赖),外链引入。

目前参考了这个项目的打包方案:

2. 复杂的tap事件,建议使用tap component

家校群列表页的每个作业的tap交互都比较复杂,出了普通的tap之外,还需要long tap和swipe。因此我们只好自己封装了一个tap component

Debug相关

  • 移动端请慎用redux-devtools,易造成卡顿

  • Webpack慎用devtools的inline-source-map模式
    使用此模式会内联一大段便于定位bug的字符串,查错时可以开启,不是查错时建议关闭,否则开发时加载的包会非常大。

其它

  • 慎用太新的es6语法。
    Object.assign等较新的类库避免在移动端上使用,会报错。

Object.assign目前使用object-assign包。或者使用babel-plugin-transform-object-assign插件。会转换成一个extends的函数:

var _extends = ...;

_extends(a, b);

如有错误,请斧正!

查看原文

赞 12 收藏 78 评论 1

叔叔张 发布了文章 · 2016-05-29

应该了解的 Web 图标解决方案


A picture is worth a thousand words, 一图胜千言。 没错,从 Web 诞生的那天开始,图标就成为视觉层面不可或缺的一个元素,在一个 Web 页面中,一个图标不仅仅能从视觉上带来优雅感,更重要的是,它对此处的功能起到了点睛之笔的作用,它会使得用户更容易理解你的产品。那么,在我们当下的 Web 前端开发中,最常见的图标解决方案有哪些呢?大概是三种,图片、IconFont 和 Svg。图片就不说了,就是整一坨小的 png 图片作为图标,最终把他们合在一个图片里,此种技术还有一个好听的名字 CSS Sprites,国人称为 雪碧图,此种方案还是 Web 前端性能优化军规之一,降低 http 请求数来达到提速的目的。

图片咱们今天不说了,没啥意思。咱们今天聊聊 IconFont 和 inline SVG,然后把这两个方案的优劣进行一个对比,然后再介绍介绍常见的 IconFont 库及 inline SVG 的库,最后再展示一个小 Demo 给大家看一看具体在页面上 IconFont 和 Svg 有什么不同。

IconFont 介绍

IconFont 使用的技术是 CSS 自定义字体,用户可以把图标集合打包成字体文件 ( 如何打包,可使用 iconfont.cn ),然后通过 @font-face 来自定义一个字体,最后通过设置 font-family 以及通过使用图标字体的 unicode编码 来使用图标。

在 CSS 里声明字体,编写 unicode 编码对应的图标:

@font-face {
  font-family: 'FontAwesome';
  src: url('../fonts/fontawesome-webfont.eot?v=4.6.3');
  src: url('../fonts/fontawesome-webfont.eot?#iefix&v=4.6.3') format('embedded-opentype'),         
        url('../fonts/fontawesome-webfont.woff2?v=4.6.3') format('woff2'), 
        url('../fonts/fontawesome-webfont.woff?v=4.6.3') format('woff'), 
        url('../fonts/fontawesome-webfont.ttf?v=4.6.3') format('truetype'), 
        url('../fonts/fontawesome-webfont.svg?v=4.6.3#fontawesomeregular') format('svg');
  font-weight: normal;
  font-style: normal;
}

.fa {
 font-family: 'FontAwesome';
 display: inline-block;
}

.fa-icon:after {
  content: '\f00c'
}

在 HTML 里这么写就可以了:

<i class="fa-icon"></i>

IconFont 有大量的开源解决方案,而且有很多现成的图标,比较具有代表性的如下:

  • FontAwesome,具备完善大量的图标库,对于定制化程度不高的项目,可以直接拿过来用

  • Iconfont.cn,阿里的解决方案,不但有现成的图标供你选择,还可以上传自己的图标来制作 IconFont

IconFont 的最大的好处就是浏览器兼容性好(IE6+),可以通过 CSS 来控制图标大小、颜色。

inline SVG 介绍

使用 IconFont 是把已有的矢量文件(通常是很多 .svg 文件)打包成字体文件,而 inline SVG 则是把 .svg 文件合并成一个大的 .svg 文件,然后在 HTML 中引用这个文件即可,具体步骤参考下面。

合并 svg

在这里搞了三个 svg 文件,准备把他们合并在一起:

<img style="display:block;width:100%;float:none;" data-original="http://ww4.sinaimg.cn/large/8df27f17gw1f4b11i2l79j20xe0i0acj.jpg"/>

SVG Symbol

我这里使用的是 svg-symbol 方案来合并 svg。

还有一个合并方法是 SVG defs,这个比 SVG Symbol 要鸡肋很多,在此就不介绍了。

通过使用 gulp-svg-symbols 来把 svg 文件合并:

var gulp       = require('gulp');
var svgSymbols = require('gulp-svg-symbols');

gulp.task('sprites', function () {
  return gulp.src('assets/svg/*.svg')
    .pipe(svgSymbols())
    .pipe(gulp.dest('assets'));
});

最终得到的 svg 文件:

<svg xmlns="http://www.w3.org/2000/svg" style="width:0; height:0; visibility:hidden;">
    <symbol id="circle" viewBox="0 0 200 200">
      <g class="transform-group">
        <g transform="scale(0.1953125, 0.1953125)">
          <path d="..." fill="#272636"/>
        </g>
      </g>
    </symbol>
    <symbol id="password" viewBox="0 0 200 200">
      <g class="transform-group">
        <g transform="scale(0.1953125, 0.1953125)">
          <path d="..." fill="#272636"/>
        </g>
      </g>
    </symbol>
    <symbol id="profile" viewBox="0 0 200 200">
      <g class="transform-group">
        <g transform="scale(0.1953125, 0.1953125)">
          <path d="..." fill="#272636"/>
        </g>
      </g>
    </symbol>
</svg>

使用方法

在 HTML 文件中声明 svg,然后通过 <svg><use xlink:href="#id" /></svg> 来使用:

<svg xmlns="http://www.w3.org/2000/svg" style="width:0; height:0; visibility:hidden;">
    <symbol id="circle" viewBox="0 0 200 200">
      <g class="transform-group">
        <g transform="scale(0.1953125, 0.1953125)">
          <path d="..." fill="#272636"/>
        </g>
      </g>
    </symbol>
    <symbol id="password" viewBox="0 0 200 200">
      <g class="transform-group">
        <g transform="scale(0.1953125, 0.1953125)">
          <path d="..." fill="#272636"/>
        </g>
      </g>
    </symbol>
    <symbol id="profile" viewBox="0 0 200 200">
      <g class="transform-group">
        <g transform="scale(0.1953125, 0.1953125)">
          <path d="..." fill="#272636"/>
        </g>
      </g>
    </symbol>
</svg>

<svg class="icon"><use xlink:href="#profile" /></svg>
<svg class="icon"><use xlink:href="#password" /></svg>
<svg class="icon"><use xlink:href="#circle" /></svg>

你也可以通过 <svg><use xlink:href="http://cdn.com/assets/symbols.svg#id" /></svg> 来直接使用存储在 CDN 上的 svg 文件,如果感觉每个都要写 CDN 的地址太麻烦,则可以封装 JS 工具,统一维护,统一管理。

inline SVG 目前没有什么特别推荐的开源解决方案,一般情况下,图标都是自己的,自己通过工具打包就已经很方便了,而且很难通过纯 CSS 或 JS 来解决,因为它跟 HTML 的关联性太大了,即使是这样,还是推荐一个库给大家了解了解:

SVGInjector

IconFont 与 inline SVG 方案对比

浏览器兼容性

IconFontinline SVG
IE6+IE9+ , Android 3.0+ 移动端支持很好,现在可以使用

尺寸、颜色是否容易控制

IconFontinline SVG
浏览器会认为它是一个字体,因此只能使用 color 和 font-size 控制,而且尺寸特别不精细支持多色、局部颜色控制、控制尺寸使用 width 和 height

访问的稳定性

IconFontinline SVG
Font 在 CDN 上会有跨域问题;而且字体下载不下来是很常见的事;还有一些已知的Chrome的Bug ;貌似代理性质的浏览器,像 UC ,就不支持自定义 Font;一些浏览器拦截插件会拦截自定义字体......Svg很正常

语义化

IconFontinline SVG
根本不语义化,你要写多余没有意义的标签,对 SEO 很不利Svg 是图形,人家就是图形,而且 SVG Symbol 支持 title 和 description 属性,非常友好

用起来是否顺滑

IconFontinline SVG
自己生成 svg 然后使用工具打包成多个字体文件,然后用 unicode 对应使用SVG Symbol 使用打包工具生成 SVG 集合,直接通过 ID 使用

IconFont 与 SVG 的 Demo

请去我的CodePen

总结

如果,你的产品需要支持 IE8 及以下,还是推荐使用 IconFont ,因为使用 SVG Symbol 的话,你需要考虑在低端浏览器下的兼容性,常见的做法是,生成一些 png 的图片做 fallback,然后在低端浏览器下显示,把 svg 隐藏.....

如果,你只需要考虑 IE9+ 和 Android 3.0 + ,毫无疑问,inline SVG 是唯一选择!

查看原文

赞 9 收藏 48 评论 8

认证与成就

  • 获得 103 次点赞
  • 获得 7 枚徽章 获得 1 枚金徽章, 获得 1 枚银徽章, 获得 5 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

  • Django Blog

    个人技术博客,自学 django 开发的产物,日常撰写技术心得的小角落。

注册于 2013-12-26
个人主页被 857 人浏览