在这篇文章中,我们会尝试使 JavaScript 的类具有 Python 的风格:在方法内明确地传递和使用 self 参数,但是调用时自动传入实例参数。我们将使用描述符和 Proxy 来进行实现,在此过程中来学习属性描述符、Proxy、Reflect 的概念。

this 问题

this 是 JavaScript 中一个很重要有很迷惑人的东西,我们总是要时刻思考函数内的 this 到底是谁。随便翻一翻一些开发论坛,像 JavaScript 中的 this 指的是什么 是很高频的文章(实际上,我自己也写过)。那么为什么像 Python 就没有这个问题呢。

对于下边这个 Python 类:

class Dog:
    def __init__(self, name):
        self.name = name
    def bark(self):
        print(f'Wang! {self.name}')
tom = Dog('Tom')
tom.bark()
# Wang! Tom

bark = tom.bark
bark()
# Wang! Tom

snoopy = Dog('Snoopy')
snoopy.bark = bark
snoopy.bark()
# Wang! Tom

无论怎么调用 dog.bark, 函数内的 self 永远都是 tom

Explicit is better than implicit.

———— The Zen of Python

Python 中的 self 是明确传参,明确引用,所以我们在任何时候调用,self 都指向实例对象 tom。而 JavaScript 中 this 并没有明确传入而是依赖于调用时的对象,但是我们可以明确地引用,这就造成了 this 的混乱。

你可能会说:我调用 tom.bark() / bark() 的时候并没有把实例对象(tom)作为参数传递进去呀?原因就在于实例的方法是一个属性描述符而非定义时的那个函数对象。方法本身已经绑定了该实例,我们只用传递额外的参数,而方法会自动将实例对象传递给函数。

在 JavaScript 中,我们可以使用 class field 与箭头函数 => 来实现实现 this 的自动绑定,但本文的目的不是为了实现 this 绑定,而是在此过程中来学习了解 Proxy、Reflect 等概念。

什么是描述符

Python 中的描述符就是一个实现了__get__/__set__/__delete__方法的对象。JavaScript 中的描述符则可以具有 configurable enumerable value writable get set 等属性,分别构造出数据描述符/存取描述符

我们来使用描述符来使 bark 方法成为一个绑定了实例参数的方法。

class Dog {
    constructor(name) {
        this.name = name
    }
    bark(self) {
        console.log(`Wang! ${self.name}`)
    }
}

let tom = new Dog('Tom')

Object.defineProperty(tom, 'bark', {
    get() {
        return () => {
            return Dog.prototype.bark(tom)
        }
    }
})

tom.bark()
// Wang! Tom

let bark = tom.bark
bark()
// Wang! Tom

在我们读取 tom.bark 的时候,属性描述符的 getter 被执行,返回了一个新的函数,该函数不是我们在 Class 内定义的函数,而是一个包含了实例自由变量的闭包函数。当我们调用它的时候,它会自动把实例对象传递给 Dog.prototype.bark 方法。

用描述符(简单)实现了我们的目标,但是还有问题:

  • 如果有多个方法,需要对每个方法定义一次描述符,很麻烦。
  • 如果用户用 class field + 箭头函数的方法定义方法,name 方法将作为实例的属性,而非其原型的属性。Dog.prototype.bark 调用将会出错。如果我们在描述符内直接用 tom.bark(tom) 会导致重复无限递归查找:
    • tom.bark --getter--> tom.bark --getter--> tom.bark...

Proxy

现在我们来研究如何通过 Proxy 来实现。Proxy 就如其名字一样,可以代理(Proxy)对象的一些底层操作。

var p = new Proxy(target, handler);

Proxy 的关键在 handler,handler 对象有多个陷阱(trap)用来处理代理对象的各种操作,包括属性读取、被删除、对象被调用、被 new、被判断属性的存在等等。如果没有对相应的行为定义 trap,那么对代理对象的操作将被转发到目标对象身上。

现在我们来拦截所有对方法的访问:如果对象属性是一个函数,我们就返回一个绑定了实例对象的高阶函数。

class Dog {
    constructor(name) {
        this.name = name
    }
    bark(self) {
        console.log(`Wang! ${self.name}`)
    }
}

let methodHandler = {
    // target: 目标对象
    // property: 属性名
    // receiver: 代理对象
    get(target, property, receiver) {
        let value = Reflect.get(target, property)
        if (typeof value === 'function') {
            return (...args) => value(target, ...args)
        }
        return value
    }
}

let tom = new Dog('Tom')
let proxiedTom = new Proxy(tom, methodHandler)

proxiedTom.bark()
// Wang! Tom

let bark = proxiedTom.bark
bark()
// Wang! Tom

let snoopy = new Dog('Snoopy')
let proxiedSnoopy = new Proxy(snoopy, methodHandler)

proxiedSnoopy.bark()
// Wang! Snoopy

看起来我们不必为每个属性定义操作符了,而且我们调用 proxiedTom.bark 时不管 bark 方法在 tom 自己身上还是在其原型身上,都不会触发无限递归查找了:proxiedTom.bark --proxy--> Reflect.get(tom, 'bark') -> bark method

但是我们需要为每一个实例生成它的代理对象。可不可以在 new Dog 的时候就返回生成的代理对象呢?只需要代理 Dog 的 construct 行为即可。

class Dog {
    constructor(name) {
        this.name = name
    }
    bark(self) {
        console.log('Wang~', self.name)
    }
    eat(self, food) {
        console.log(self.name, 'eat', food)
    }
}

let constructHandler = {
    // target: 目标对象
    // argumentsList: 参数列表
    // newTarget: 最初被调用来构造的函数,如代理对象
    construct(target, argumentsList, newTarget) {
        // 构造实例对象
        let instance = Reflect.construct(target, argumentsList)
        // 代理并返回代理对象
        return new Proxy(instance, methodHandler)
    }
}

let ProxiedDog = new Proxy(Dog, constructHandler)

let tom = new ProxiedDog('Tom')

tom.bark()
// Wang~ Tom

let snoopy = new ProxiedDog('Snoopy')

snoopy.bark()
// Wang~ Snoopy

snoopy.eat('milk')
// Snoopy eat milk

snoopy.name
// 'Snoopy'

snoopy.tomsbark = tom.bark
snoopy.tomsbark()
// Wang~ Tom

那么 Reflect 又是什么?

Reflect 有一系列操作对象的静态方法。 每一个 handler trap 都对应一个 Reflect 操作,如 get trap 对应于 Reflect.get。defineProperty trap 对应于 Reflect.defineProperty

有一些 Reflect 方法实现的效果与 Object 对象上的方法一样,但是有一些不同。

Reflect.defineProperty 为例,它的效果与 Object.defineProperty 一样,但是 Reflect.defineProperty 会在操作成功是返回布尔值 trueObject.defineProperty 则返回传递给它的对象。

let tom = {
    name: 'tom'
}
let tommy = Object.defineProperty(tom, 'age', {
    get() {
        return 3
    }
})

tom === tommy
// true

let snoopy = {
    name: 'snoopy'
}
let result = Reflect.defineProperty(snoopy, 'age', {
    get() { 
        return 3
    }
})

result === true
// true

关于 Reflect 提供的方法与 Object 的方法的具体不同,可以参考这个表格

Reflect 与 Object 相比的另一个有用的地方是,在对象设置了 getter 的时候,Reflect 可以很好地处理这种情况。

// 比如我们有一个会员对象
let member = {
    _name: 'Han Solo',
    get name() {
        return this._name
    }
}

// 然后我们想用一个代理来实现会员匿名。

let anonymousMember = new Proxy(member, {
    get(target, property, receiver) {
        if (property === '_name') {
            return 'anonymous'
        }
        return target[property]
    }
})

当我们调用 anonymousMember.name,会输出什么?

anonymousMember.name --proxy--> member['name'] ---getter--> this._name -> member._name -> 'Han Solo'

所以我们没有拦截到对 member_name 属性的访问。

使用 Reflect 可以让我们指定 getter 内的 this 来实现对 _name 属性的真正拦截。

let anonymousMember = new Proxy(member, {
    get(target, property, receiver) {
        if (property === '_name') {
            return 'anonymous'
        }
        // 第三个参数 receiver 指定了 getter 调用时的 this,在此为我们的代理对象 anonymousMember
        return Reflect.get(target, property, receiver)
    }
})

anonymousMember.name
// 'anonymous'

// anonymousMember.name --proxy--> Reflect.get(member, 'name', anonymousMember) ---getter---> anonymousMember._name -> 'anonymous'

参考

前一段时间项目上有过一个需求,可以考虑通过自动给 React 组件添加属性来实现。虽然这是一个很反模式的方式并且最终否决了这个方案,但是还是尝试研究了一下如何实现 自动给 React 组件添加属性。更确切地说,是在 webpack 打包出来的最终文件内给 React 组件添加上自定义的 props

webpack 处理 React 文件(js/jsx)使用 babel-loader,babel 就是我们的 JavaScript 编译器,它接收我们的源代码作为输入,产出编译后的可运行于浏览器的目标代码作为输出。babel 支持插件(plugin),可以视作编译器前端与后端之间的中间件:前端根据源代码生成抽象语法树(AST)等,后端根据抽象语法树生成目标代码,而插件作为中间件则是在生成目标代码之前对抽象语法树做相应的修改。

基础概念

抽象语法树

@babel/parser 模块用来接收我们的源代码来生成抽象语法树,

import * as parser from '@babel/parser'

let code = 'let result = x + y'

let ast = parser.parse(code)

console.log(ast)

将会输出

Node {
  type: 'File',
  start: 0,
  end: 13,
  loc:
   SourceLocation {
     start: Position { line: 1, column: 0 },
     end: Position { line: 1, column: 13 } },
  program:
   Node {
     type: 'Program',
     start: 0,
     end: 13,
     loc: SourceLocation { start: [Position], end: [Position] },
     sourceType: 'script',
     interpreter: null,
     body: [ [Node] ],
     directives: [] },
  comments: [] }

如果我们打印出 ast.program.body,我们会得到:

[ Node {
    type: 'VariableDeclaration',
    start: 0,
    end: 18,
    loc: SourceLocation { start: [Position], end: [Position] },
    declarations: [ [Node] ],
    kind: 'let' } ]

可以看到抽象语法树是由一个个 Node 组成的,每个 Node 有很多属性,而 Node 还会有一些 body、left 之类的属性,这些属性的值的类型也可能是 Node。对抽象语法树的修改就是修改这些 Node 的值或者属性。

访问者模式

访问者模式是一种将算法与对象结构分离的软件设计模式。

这个模式的基本想法如下:首先我们拥有一个由许多对象构成的对象结构,这些对象的类都拥有一个accept方法用来接受访问者对象;访问者是一个接口,它拥有一个visit方法,这个方法对访问到的对象结构中不同类型的元素作出不同的反应;在对象结构的一次访问过程中,我们遍历整个对象结构,对每一个元素都实施accept方法,在每一个元素的accept方法中回调访问者的visit方法,从而使访问者得以处理对象结构的每一个元素。我们可以针对对象结构设计不同的实在的访问者类来完成不同的操作。

———— 维基百科

具体来说,我们的 AST 的每一个 Node 有一个 accept 方法,当我们用一个 visitor 来遍历我们的 AST 时,每遍历到一个 Node 就会调用这个 Node 的 accept 方法来 接待 这个 visitor,而在 accept 方法内,我们会回调 visitor 的 visit 方法。我们来用访问者模式来实现一个 旅行者访问城市景点 的逻辑。

*实际上 Node 是有两个方法,enter 和 exit,指遍历进入和离开 Node 的时候。通常访问者的 visit 方法会在 enter 内被调用。

// 旅游景点
class ScenicPoint {
    constructor(name) {
        this.name = name
    }
    // 景点的 accept 方法接收 visitor,函数内调用 visitor 的 visit 方法来 visit 景点的实例
    accept(visitor) {
        visitor.visit(this)
    }
}

class Park extends ScenicPoint { }

class Museum extends ScenicPoint { }

// 我们的城市
class City {
    constructor(name, scenicPointList) {
        this.name = name
        this.scenicPointList = scenicPointList
    }
    accept(visitor) {
        for (let scenicPoint of this.scenicPointList) {
            scenicPoint.accept(visitor)
        }
    }
}

// visitors: Alice 与 Bob
let Alice = {
    name: 'Alice',
    visit(scenicPoint) {
        if (scenicPoint instanceof Park) {
            console.log(`${scenicPoint.name} is a wonderful park~`)
        } else {
            console.log(`${this.name} visiting ${scenicPoint.name}`)
        }
    }
}
let Bob = {
    name: 'Bob',
    visit(scenicPoint) {
        if (!(scenicPoint instanceof Museum)) {
            console.log(`I want to go to some Museum.`)
        } else {
            console.log(`${scenicPoint.name} is a wonderful Museum~`)
        }
    }
}

let BeiJing = new City('BeiJing', [
    new ScenicPoint('八达岭长城'),
    new Park('玉渊潭公园'),
    new Museum('国家博物馆'),
])

我们用一个访问者来访问北京:

BeiJing.accept(Alice)
// Alice visiting 八达岭长城
// 玉渊潭公园 is a wonderful park~
// Alice visiting 国家博物馆

BeiJing.accept(Bob)
// I want to go to some Museum.
// I want to go to some Museum.
// 国家博物馆 is a wonderful Museum~

可以发现,我们可以自定义 visitor 的 visit 方法,针对不同的景点实现不同的逻辑。

返回到我们的 babel 插件,每一个 Node 都可以视为一个景点,而我们的插件则是一个 visitor,我们在插件内定义了针对不同 Node 类型的处理方式,当遍历到对应的节点时调用对应的方法进行处理。

插件编写

首先我们应该看我们的 React 元素是怎么创建出来的:

let element = <Button color="red" />

会被编译为:

var element = _react.default.createElement(Button, {
  color: "red"
})

可以看到组件属性作为 _react.default.createElement 的第二个参数,我们只需要修改这个参数值就可以了。

我们可以先看上述代码生成的 AST:

{ type: 'CallExpression',
  callee:
   { type: 'MemberExpression',
     object: { type: 'Identifier', name: 'React' },
     property: { type: 'Identifier', name: 'createElement' },
     computed: false,
     optional: null },
  arguments:
   [ Node {
       type: 'Identifier',
       start: 42,
       end: 48,
       loc: [SourceLocation],
       name: 'Button' },
     { type: 'ObjectExpression', properties: [Array] } ],
  typeAnnotation: undefined,
  typeParameters: undefined,
  returnType: undefined,
  start: 41,
  loc:
   SourceLocation {
     start: Position { line: 3, column: 14 },
     end: Position { line: 3, column: 36 } },
  end: 63,
  trailingComments: [],
  leadingComments: [],
  innerComments: [] }

可以发现 props 是这个 CallExpression 的 arguments 属性数组的第二个元素,而 props 自身是一个 ObjectExpression,我们只需要用我们的 props 对象来替换/合并原来的 ObjectExpression 即可。

思路已有,直接上最终代码:

// parser
import * as parser from '@babel/parser'
// 各种 Node 类型定义
import * as types from '@babel/types'
import traverse, { NodePath } from '@babel/traverse'

export default function() {
    return {
        // 我们的 visitor
        visitor: {
            // 针对函数调用的单独逻辑处理
            CallExpression(path: NodePath<types.CallExpression>, state) {
                // 我们只处理 React.createElement 函数调用
                let { callee } = path.node
                if (
                    !(
                        types.isMemberExpression(callee) &&
                        types.isIdentifier(callee.property) &&
                        callee.property.name === 'createElement'
                    )
                ) {
                    return
                }

                // 从第一个参数获取组件名称(Button)
                // 从第二个参数获取组件属性
                let [element, propsExpression] = path.node.arguments
                let elementType: string
                if (types.isStringLiteral(element)) {
                    elementType = element.value
                } else if (types.isIdentifier(element)) {
                    elementType = element.name
                }

                // 我们的插件支持自定义选项,针对不同的组件类型传入不同的额外自定义属性
                const options: Object = state.opts
                let extraProps: Object | undefined = options[elementType]

                // 如果没有针对次组件类型的额外参数,我们的插件什么都不做
                if (!extraProps) {
                    return
                }

                // 否则,我们利用 parser.parseExpression 方法以及我们的自定义属性生成一个 ObjectExpression
                let stringLiteral = JSON.stringify(extraProps)
                let extraPropsExpression = parser.parseExpression(stringLiteral)

                // 如果组件原本 props 为空,我们直接将我们的自定义属性作为属性参数
                if (types.isNullLiteral(propsExpression)) {
                    path.node.arguments[1] = extraPropsExpression
                } else {
                // 否则,我们将我们的自定义属性与原属性进行合并
                    path.node.arguments[1] = types.objectExpression(
                        (<types.ObjectExpression>propsExpression).properties.concat(
                            (<types.ObjectExpression>extraPropsExpression).properties,
                        ),
                    )
                }
            },
        },
    }
}

然后我们修改 babel 的配置文件启用我们的插件:

// babel.config.js
const plugins = [
    [
        'babel-plugin-react-auto-props',
        {
            'Button': {
                size: 'small',
            },
        },
    ],
]

module.exports = { presets, plugins }

然后我们的源代码为:

let button = <Button type="primary" />

使用 babel 编译后的结果为:

var button = _react.default.createElement(Button, {
  type: "primary",
  "size": "small"
});

可以看到属性已经自动添加成功。

我应该使用这个插件吗?

这个插件已经发布到 npm,不过请注意,该插件只是一个实验性的插件,而且它的结果很反模式。如果在项目中确实需要为组件自动添加属性,请使用特例关系组件

class SmallButton extends Component {
    render() {
        return <Button {...this.props} size="small" />
    }
}

如果只是想尝试,可以通过 npm 安装:

npm install -D babel-plugin-react-auto-props

RSA 加密算法是目前广泛使用的一种非对称加密算法。RSA 加密算法的可靠性依赖于极大整数因数分解的困难度。本文主要根据 RSA 加密算法实现一个简单版本的 Python 代码实现,包括私钥、公钥生成与加密、解密方法。

RSA 加密算法概述

RSA 加密算法为非对称加密算法,意味着加密和解密使用不同的秘钥,称为 公钥私钥 ,公钥可以任意分发并用于加密,私钥需妥善保存用于解密。

公钥

公钥由两个数 E、N 组成,使用公钥加密时运算

密文 = (明文 ** E) % N

私钥

私钥由两个数 D、N 组成,使用私钥解密时运算

明文 = (密文 ** D) % N

所以生成 RSA 加密算法的秘钥就是生成 E、D、N 这三个数。生成过程大致如下:

  1. 生成两个大质数 p 和 q,计算 N = p*q
  2. 求得 L = (p-1) * (q-1)
  3. 选择一个大于 1 小于 L 的正整数 E,使 E 与 L 互质
  4. 求得 E 关于 L 的模逆元,即为 D,D 小于 L

实现

质数生成函数

为了生成质数,我们需要判断一个数是否是质数,我们可以使用数学上的费马小定理

如果 a 是一个整数,n 是一个质数,那么 a ** n ≡ a (mod n)

———— 费马小定理

符合费马小定理是一个数是质数的必要而非充分条件,所以为了判断一个数 p 是否是质数,通常需要使用 k 个不同的 a 值来进行验证,称为费马素性检验,如果随机出来的 a 值都符合费马小定理,我们认为该数是一个质数。

质数判断函数:

import random

# 使用费马小定理检查一个数 n 是不是质数
def is_prime(n):
    if n < 2:
        return False

    # 100次 费马素性检测
    for _ in range(100):
        a = random.randint(2, n-1)
        if pow(a, n, n) != a:
            return False
    return True

质数生成函数:

# 生成一个随机的大质数(介于 10^100 与 10^110 之间)
def generate_prime():
    while True:
        expect_number = random.randint(1E100, 1E110)
        if is_prime(expect_number):
            break
    return expect_number

生成 p、q、N、L:

p = generate_prime()
q = generate_prime()

N = p*q

L = (p-1) * (q-1)

生成 E:

根据算法,E 是介于 1 与 L 之间与 L 互质的数(最大公约数为 1):

import math

# E 的随机生成函数
def generate_e():
    while True:
        expect_e = random.randint(2, L)
        if math.gcd(expect_e, L) == 1:
            break
    return expect_e

E = generate_e()

生成 D:

根据算法,D 是 E 关于 L 的模逆元,也就是:

E * D ≡ 1 (mod L)

我们当然可以从 1 开始一直试到 L 来寻找 D,但是在 D 很大大的情况下计算量是非常大的。这里我们使用扩展欧几里得算法(以下代码出自该 wiki 条目):

# 扩展欧几里得算法求模逆元
def ext_euclid(E, L):
     if L == 0:
         return 1, 0, E
     else:
         x, y, q = ext_euclid(L, E % L) # q = gcd(E, L) = gcd(L, E%L)
         x, y = y, (x - (E // L) * y)
         return x, y, q

D = ext_euclid(E, L)[0]

加密与解密算法

至此,我们的秘钥已经生成完成:公钥 (N, E)、私钥:(N, D)。

# 加密算法:
# 密文 = 明文 ** E % N
def encrypt(message):
    return pow(message, E, N)

# 解密算法
# 明文 = 密文 ** D % N
def decrypt(secret):
    return pow(secret, D, N)

下边为我们根据上述算法生成的一对秘钥:

公钥 (N, E) = (
    4035201027052526716476770815301885547930386611424746846836729207720402684586903067390911032635700387807809913761690633108036479608875715316392163632899103649241282491070030290136720168432012867861620750453121897352765277, 
    3008241542036098467083169586257729095679728537524058797442421603938590337844782185494379049491888737248445705876804690371511766177598271941925223485143156067146179224661664800047568667114630699761924649833705169821421939
)

私钥:(N, D) = (
    4035201027052526716476770815301885547930386611424746846836729207720402684586903067390911032635700387807809913761690633108036479608875715316392163632899103649241282491070030290136720168432012867861620750453121897352765277, 
    263104268796040141692115220957372648791994375662679669674392390239402717514924248311933394103742807630673302384124516817979074152829180217297193870626824603082561775343291294186200630148873101176593862045968382744834439
)

我们尝试一次加密与解密:

message = 3141592653

secret = encrypt(message)
print(secret)
# 1926350294561951470341018025045828648112140614621362576015267907023473556254498224885881403355559114529772490367097950170850223416554454126315058562501008274952527345854593658423828584546446402279730866190911413291563786

decrypted_message = decrypt(secret)
print(decrypted_message)
# 3141592653

message == decrypted_message
# True

解密后的明文和开始的明文相同,加密解密成功。

软件开发离不开开源项目,自己单干无异闭门造车,所以我们需要别人的库/工具(package)帮助我们完成工作。我们需要的,是尽可能轻松地集成到我们的项目内,并且能很好地解决我们的需求而不引起其他问题。我们以以下三个方面来考虑是否选择一个库:

  • 功能性:可以解决我们的问题
  • 可靠性:不会因为引入它而引起其他问题
  • 复杂性:集成尽可能简单

功能性是我们的根本目的,但是不应该仅仅止于此。不能简单地只是因为一个库能够解决我们的问题我们就选它

1. 我们可能不需要一个 package

module_count

这张图反映的是 npm 包含的库的数量对比 Java、Python 库的数量,可以看到 npm 库的数量非常之多。但是有多少是真是有用的呢。

这个库:is-odd,在目前每周有 849,241 次下载,在 GitHub 上有 622,528 个其他仓库依赖于它。但是它的作用,就是和它的名字一样,用来检查一个数字是不是奇数,它的源代码不到 20 行。

这是一个例子,告诉我们在实现很简单的情况下,我们完全可以自己实现,而不必去借助外部的库来帮我们实现。

GitHub 上有一些项目,专门用来总结“你可能不需要 XXX”,比如:

我们再以 moment 为例,moment 是一个非常强大,同时也非常 API 友好的项目,它能够方便地解决我们的问题。但是如果我们统计一下,就会发现,我们最常用到的 moment 功能,可能不超过 5 个。但是 moment 打包体积有 329KB,即使 GZip 压缩之后,仍然有 69.6KB。而那几个最常用的功能如果我们自己来实现的话,可能体积会缩小几十到上百倍,这时候更好的方案可能就是不引入 moment(当然这只是理想情况,比如我们用到了 antd,antd 的一些组件会依赖于 moment,那我们还是需要引入 moment)。

2. 当我们确实需要一个 package 时,我们应该考虑的因素

module_react

可靠性

一个常见且简单的方法是看它的 GitHub Star 数,但是我们会发现这个评价标准是非常片面的。我们应该从以下这些方面来评估其可靠性:

更新日期(越近越好)

上图中,react 的 上次提交时间是 Latest commit 45acbdc 7 hours ago,说明它的维护很及时。而如果一个项目的最近更新日期是 3 年前,那选择它之前就要好好考虑一下了。

Issues / Pull Requests(越少,Open/Close 比越小越好)

Issue 数值越低,说明存在问题的可能越小。另外由于不同项目流行度的差异,看 Issues 的 Open/Close 比值更能说明问题。比值越小说明作者维护项目的意愿越强,出了问题更可能被解决。

Pull Requests 同理。

Contributors 贡献者数量(越多越好)

Contributors 越多,说明社区力量越强大,项目可维护性更好。

作者(优先选择知名作者)

相比于 someone/project,我可能更倾向于选择 Microsoft/project。当然这个标准也不是绝对的。

Star / Watch / npm downloads(越多越好)

没错,这些指标还是有一定参考意义的。但是我们应该知道,一个项目比另一个更流行,可能仅仅是因为它是一个知名作者的项目,或者被媒体/知名作者推荐过。所以我们不能说一个 500 Star 的比 5000 Star 的差。

但是 5000 Star 的项目通常来说会比 50 Star 的项目要好。

体积(越小越好)

对于 Web 应用来说,代码体积至关重要,它影响页面渲染时间,进而影响用户体验。所以要尽量选择较小的库。

开源许可协议

如果软件涉及商用的话,那就需要了解 MIT、BSD、Apache、GPL 等开源协议的区别。

Choose an open source license

复杂性

复杂性低的表现:

  • 有全面的文档
  • Get Started 指引,帮助我们快速上手
  • 即插即用,无需额外操作

复杂性高的表现:

  • 没有文档或文档混乱
  • 不知道怎样快速上手
  • 接入十分复杂,需要改我们的很多代码,甚至其他第三方代码

一个例子是关于我们如何舍弃 react-hot-loader 的。该项目是著名的 Dan Abramov 开发的,现在由社区维护版本升级。直到 React 16.8 发布之后,它与 React Hooks 不兼容,以至于要想使其正常工作需要在开发时舍弃 React 官方的 React-Dom 而采用他们维护的 React-Dom 版本。即使如此,经过复杂的配置之后,我还是没能让它顺利正确地工作起来,所以最后只能舍弃了它。

简洁代码的重要性:代码读的频率远高于写

Programs must be written for people to read, and only incidentally for machines to execute.

(程序必须书写得能够让人阅读,只是顺便能够让机器执行。)

———— Abelson & Sussman:《计算机程序的构造与解释》


你或许会问:代码真正“读”的成分有多少呢?难道力量主要不是用在“写”上吗?

读与写花费时间的比例超过10:1。写新代码时,我们一直在读旧代码。

既然比例如此之高,我们就想让读的过程变得轻松,即便那会使得编写过程更难。没可能光写不读,所以使之易读实际也使之易写。

这事概无例外。不读周边代码的话就没法写代码。编写代码的难度,取决于读周边代码的难度。要想干得快,要想早点做完,要想轻松写代码,先让代码易读吧。

———— Robert C. Martin《代码简洁之道》


One of Guido's key insights is that code is read much more often than it is written. A style guide is about consistency. Consistency with this style guide is important. Consistency within a project is more important. Consistency within one module or function is the most important.

(代码的读取频率远高于编写频率。 风格指南是关于一致性的。 与此风格指南的一致很重要。 项目的一致性更为重要。 一个模块或功能内的一致性是最重要的。)

———— PEP 8:Python 官方代码风格指南

什么是简洁的代码

简洁的代码都有共同的特点,简洁的代码最好是能够自解释的,当你读这些代码的时候就像读一篇文章一样。关于一些原则,比较推荐 Python 之禅 的描述:

>>> import this
The Zen of Python, by Tim Peters

Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!

Python之禅 by Tim Peters

优美胜于丑陋(Python 以编写优美的代码为目标)
明了胜于晦涩(优美的代码应当是明了的,命名规范,风格相似)
简洁胜于复杂(优美的代码应当是简洁的,不要有复杂的内部实现)
复杂胜于凌乱(如果复杂不可避免,那代码间也不能有难懂的关系,要保持接口简洁)
扁平胜于嵌套(优美的代码应当是扁平的,不能有太多的嵌套)
间隔胜于紧凑(优美的代码有适当的间隔,不要奢望一行代码解决问题)
可读性很重要(优美的代码是可读的)
即便假借特例的实用性之名,也不可违背这些规则(这些规则至高无上)
不要包容所有错误,除非你确定需要这样做(精准地捕获异常,不写 except:pass 风格的代码)
当存在多种可能,不要尝试去猜测
而是尽量找一种,最好是唯一一种明显的解决方案(如果不确定,就用穷举法)
虽然这并不容易,因为你不是 Python 之父(这里的 Dutch 是指 Guido 
做也许好过不做,但不假思索就动手还不如不做(动手之前要细思量)
如果你无法向人描述你的方案,那肯定不是一个好方案;反之亦然(方案测评标准)
命名空间是一种绝妙的理念,我们应当多加利用(倡导与号召)

如何写简洁的代码

知道什么是简洁的代码不代表能写出简洁的代码。就像可以用数学公式来完全描绘出来骑自行车的过程,但不代表就会骑车了。这一点需要持之以恒的练习。一些通用的方法包括:

  • 好的命名
  • 合适的抽象(数据、过程)
  • 不多不少的注释
  • 统一的格式

一些详细的方法在《代码整洁之道》里都有详细的介绍。

在写代码的时候,不要只是为了能够实现这个需求,而要考虑实现的逻辑合不合理。一种不好的实现是:改了几遍,它最后能工作了,但我明天就不清楚它为啥能工作了

读的话,如果能读各种源码的话最好,另一个很有用的方法就是读自己的代码。每隔一段时间读一下自己之前写的代码,总是能发现可以提高的地方。

一些值得参考的代码规范

需要有强制的代码风格吗?

我们有一个统一推荐的代码风格,但是由于历史原因,我们并没有强制执行。因为这些代码写出来的时候还没有这一规定,我们的首选是兼容它的风格(在修改其他人代码的时候也遵循这一准则);

另外也有意见认为,代码风格是相对虚幻的东西,强制团队内所有人风格一致会产生更多问题。

所以,我们有统一推荐的风格,期望大家能尽量保持统一;也允许有一些个人的风格。但是有一点是确认的:不管是自己新建,还是修改别人的代码,同一个模块、文件、函数内的风格要保持一致。如果自己新建文件,就自己来维护风格一致;如果修改别人的文件,就与原文件保持兼容风格。所以我们也提供了统一的格式化工具,鼓励大家在可能的情况下来使用它来保持整个项目风格统一。

我们采用的一些风格与其原因:

格式化工具 prettier 有其默认风格。除了默认风格外,我们有一些自定义的。

抛开格式化工具,一些值得注意的风格是:

printWidth: 100:单行最大字符数

默认是 80,由于现在显示器越来越大,所以我们扩展到 100,但是我们经常还会看到超过 150,甚至 200 的,这种不换号的话一个显示器都显示不下了。

// VSCode 添加标尺,提示自己的代码已经过长了
"editor.rulers": [
    80,
    100,
],

trailingComma: 'all': 多行模式添加逗号

这个主要是为了减少 git diff。

// before
let items = [
    1,
    2,
    3
]

// after
let items = [
    1,
    2,
    3,
    4
]

上述例子,在向数组添加一个元素后,会引起一行修改(3)和一行添加(4)。

let items = [
    1,
    2,
-   3
+   3,
+   4
]

如果 3 的后边加了逗号,就会只有一行添加。

let items = [
    1,
    2,
    3,
+   4,
]

二元操作符中间加空格

let x=a+b-c+d

vs

let x = a + b - c + d

尽量少用三元操作符

let result = value === '1' ? '一' : value === '2' ? '二' : '三'

// vs

let result
switch (value) {
    case '1':
        result = '一'
        break
    case '2':
        result = '二'
        break
    default:
        result = '三'
        break
}

// 或者
let result = {
    '1': '一',
    '2': '二',
}[value] || '三'

采用 4 个空格缩进

这是一个约定俗成的,但是也可以用 2 个,或者 Tab。唯一要确定的是:不能有混用的情况。

类似的还有很多,可以去 Airbnb 风格推荐那里查看。

一个例子

下边是一个简单的例子,但是暴露的问题在我们的代码中都是真实存在的(第一眼,你能找到这个组件的 render 方法吗?)。

  • import 与 class 之间应该有空行
  • 混乱的缩进,不统一而且混合用了 Tab 和空格
  • <Button> 元素这一行太长
  • 单双引号混用
  • 混乱的命名:同一个函数内 item vs itmind vs idx
  • 类名没有遵循规范(App not app)
  • 各种操作符之间缺少空格,可读性很差
  • 无用的引入:InputModal
  • 错误的使用数组方法(map 返回了一个无用的数组)

我想读这样的代码肯定很折磨,更不用说要在这样的代码基础上做修改,估计很快头就大了。

import React,{Component} from 'react';

import {Button,message,Input,  Modal} from "antd"
class app extends Component{
        items = [{
                items:[1,2,3]},{
                    items:[4,5,6]
                }
        ]
onClick=()=>{
                try{
            message.success('ok')
        }

 catch(error) {
            message.error('not ok')
           }


      finally {
            message.success('finally')
        }
    this.items.map((item, ind)=>{
        item.items.forEach((itm, idx)=>{
            // doSth
        })
    })
    }
      render() {
    return <Button onClick = {this.onClick} type="primary" size="small" shape="circle" htmlType="submit" icon="submit" ghost disabled loading>按钮</Button>
}
}
export default app

但是我们只需要做出一点改变,就能很大的提升代码可读性。上边列出的大部分问题都可以用格式化工具来解决,命名方面的,只需要我们多用点心,就可以提升可辨识度。下边是修改后的代码:

import React, { Component } from 'react'
import { Button, message } from 'antd'

class App extends Component {
    items = [
        {
            items: [1, 2, 3],
        },
        {
            items: [4, 5, 6],
        },
    ]

    onClick = () => {
        try {
            message.success('ok')
        } catch (error) {
            message.error('not ok')
        } finally {
            message.success('finally')
        }
        this.items.forEach((item, index) => {
            item.items.forEach((subItem, subIndex) => {
                // doSth
            })
        })
    }

    render() {
        return (
            <Button
                onClick={this.onClick}
                type="primary"
                size="small"
                shape="circle"
                htmlType="submit"
                icon="submit"
                ghost
                disabled
                loading
            >
                按钮
            </Button>
        )
    }
}
export default App

PS

有无数的指南教我们如何写简洁的代码,但是即便是有经验的程序员也不能写出百分之百简洁优美的代码,Dan Abramov,React 核心开发者,Redux 作者,在前几天发推说

I used to think “clean” means code broken down in small functions, no repetition, no comments. Now I think of it more as code with few possible control flow combinations, direct style (can always trace what connects to what), doesn’t violate grep test, comments explain why.

我曾经认为“整洁”意味着代码被分解为小的函数,没有重复,没有注释。 现在我更倾向于认为是尽可能少地使用控制流组合,风格直接(总是可以追踪什么是什么),不违反grep测试,注释解释原因。

只有不停地练习并思考,才能持续地改进。