首先,作为一个常识,在ES6之后的JavaScript中,不能声明同名的let
与var
,也同样不能声明同名的const
。但是,在极客时间的课程中,Elmer同学问出了下面这样的问题:
// 示例1
function test(x = 2) {
var x = 3;
console.log(x);
}
test(); // 3
在这段代码中,既然参数中的x
是一个let
风格的声明,那么为什么还能声明var x
呢?
其中,
参数
x
是let
风格声明的变量,
是一个少有人知的事实。简单来看,它可以通过如下方式来验证:
// 示例2
function test(x=x) {}
test(
]]>首先,作为一个常识,在ES6之后的JavaScript中,不能声明同名的let
与var
,也同样不能声明同名的const
。但是,在极客时间的课程中,Elmer同学问出了下面这样的问题:
// 示例1
function test(x = 2) {
var x = 3;
console.log(x);
}
test(); // 3
在这段代码中,既然参数中的x
是一个let
风格的声明,那么为什么还能声明var x
呢?
其中,
参数
x
是let
风格声明的变量,
是一个少有人知的事实。简单来看,它可以通过如下方式来验证:
// 示例2
function test(x=x) {}
test(); // ReferenceError: x is not defined
由于这个x
是let
风格的声明,因此在当它作为右边的x
被访问时,会出现x未声明
这样的异常(这种情况下,x
是在当前作用域中是一个未绑定初值的声明)。
那么,问题是:“示例1”中的var x
究竟又怎么会成功声明了呢?
传统上,我们认为所谓“语法错误(SyntaxError)”就是在语法分析期出现的错误,也就是说,这些错误通过对代码文本的在静态语法检查就能识别出来,因此它可以在正式执行之前就被抛出(throw)。
通常情况下,这是正确的。并且,这也是JavaScript可以“阻止错误代码的执行”的原因。例如:
x = 100;
function x() {
/5
}
如果其中的函数x()
是语法错,那么整个代码块都不能执行,所以x将是undefined;如果x()
不导致语法错,那么x就将是100。——因为第一行代码是可以正常执行的。当然,现实是:JavaScript选择了前一种方案,因此整个代码块都失效了(如果这是一个模块,那么该模块就不能被载入)。
但并不是所有的SyntaxError都是这样处理的,因为有些语法错误是不能通过静态语法检测来发现的。对于JavaScript来说尤其如此,因为它存在“严格/非严格”这样的模式,而这个模式是运行期决定的,不是简单的静态代码声明。
也许有人会提出异议:"use strict"是必须写在代码块的头部的,因此是可以静态语法检查的。答案未见得如此,因为至少存在两种特例,一种是引擎初始化,例如Node.js以node --use-strict
参数来启动,另一种则是eval()代码块。
所以,JavaScript中的语法错误,不见得总是静态语法分析的结果。——它也有可能是在执行过程中抛出的。
那么就具体的情况来说,比如Elmer提出的“示例1”,其中的“变量名重复”究竟是哪一种语法错呢?静态语法检查的,还是动态执行的?
答案是:不一定。
这是JavaScript中的另一个混乱之地:引擎并不总是“完全地”按ECMAScript规范来实现。更确切地说,事实上在ECMAScript中,“变量名重复”并没有被规范成一种静态语法检查的错误,而是在作用域(环境)初始化时,由初始化过程动态发现的。——也就是说,当引擎初始化一个环境块时,如果
然而这其中就存在更多复杂的细节了。怎么说呢?要知道,“引擎初始化一个环境块”这件事情,其实也是有两个阶段的,第一个是该环境块在词法作用域(Scope)中被找到(也就是静态的语法分析结果),并在生成一个作用域对应的环境块时初始化该函数实例(FunctionInitialize(F, kind, ParameterList, Body, Scope)
);第二个是函数在执行前对函数内的所有声明做实例化(FunctionDeclarationInstantiation(F, argumentsList)
)。
在前一个阶段中,参数是形式参数,所以基本上就是将形式参数直接保存到函数实例的[[FormalParameters]]
域中,而在后一阶段中,参数是实际参数,所以会参考之前的形参声明(F.[[FormalParameters]]
),将值一一绑定到闭包(函数实例的环境/作用域)中。
同样,上述的两个阶段中对“重复的变量名”理解也是不一致的。按之前所说,在前一阶段中的名字冲突是由语法解析器来决定的,其结果影响[[FormalParameters]]
是否创建;后一个阶段则由EvaluateBody
这个内部过程来决定,影响的是函数体(Body)是否执行。
无论如何,这两个过程都是在执行期的,不过如果在其中发现错误,抛出的仍然是SyntaxError异常。
var
重复声明的理解在更具体地解释“示例1”的代码之前,还需要再解释一个语法现象,就是:
在JavaScript中如何理解“var变量的重复声明”?
例如:
// 示例3
function f(x, x) {
...
}
// 示例4
function f() {
var x = 1;
var x = 2;
}
// 示例5
function f(x) {
var x = 3;
}
在JavaScript中,var变量
声明会被解析成一个名字(name),这个名字将在函数实例化时被填写到环境中。上述**“示例3”说明了JavaScript接受参数名重复,“示例4”则说明也同时接受多次var
声明重复,所以事实上JavaScript在语法上是允许*varNames***或***parameterNames***之任一为重复的。
在具体处理这两种情况时,JavaScript将首先按参数名(parameterNames)来创建,因此它只需要在扫描变量名(varNames)时“忽略已创建的名字”即可。而这,就是在ECMAScript规范中,有关FunctionDeclarationInstantiation()的实现代码里有一段叙述逻辑:
亦即是说,对于上述**“示例5”**的情况,事实上var x = 3
根本就没有起到“声明var”的作用,因为名字n
在instantiatedVarNames中已经出现了,所以varNames
中的声明就被忽略了。——同样,像示例4中那样的情况,也在处理第二个var声明
时因为instantiatedVarNames中存在既有项而被忽略。
这样一来,下面的**“示例6”**——这是一种传统的为x指定缺省值的写法——才能得到合理的解释:
// 示例6
function f(x) {
var x = x || 3; // 这里的`var`也是被忽略的
}
var
声明的不同处理因此也就出现了下面这个问题,即:
function f(x=1)
中的x是let声明,那么也仍然需要代码中的var x = x || 3
是合法的逻辑。亦即是说,需要实现下面的代码与“示例6”在语法上的一致性:
// 示例7
function f(x=false) {
var x = x || 3;
}
我们之前说过,示例7使用了缺省参数,因此它是“非简单参数”,并且因此参数名x
将会是let声明。——而这,又与本文最开始所讲语法约定“不能声明同名的var
和let
”互相冲突。
为了解决这个问题,ECMAScript在这里对引擎约定了另外一个处理:
所以ECMAScript中在这里也有一段特殊处理:
这样一来,“示例1”和“示例7”中的变量名声明就不会与参数名冲突了。但是需要注意的是,这里只是处理varNames的规则,而let/const
声明是在lexicalNames中,所以不受这一处理逻辑的影响。例如:
// 示例8
function f(x=1) {
let x = 100; // SyntaxError
}
而与此相关的,在全局代码中,由于varName和lexicalNames所对应的环境是同一个,所以它总是冲突的:
// 示例9(参考示例7的语义,但在全局代码中总是抛出异常)
let x = false;
var x = x || 3; // SyntaxError
var
的不同处理“示例8”会带来另一个层面的思考:声明该函数f()时并不会导致异常,直到执行f()时,代码才会抛出SyntaxError。
// 示例8(执行期)
function f(x=1) {
let x = 100; // SyntaxError
}
f()
然而这并不是绝对的。
因为在ECMAScript的规范中,“变量名重复”从来就不是一个静态语法分析期的错误。——所以严格按照该规范来实现的话,所有的这类SyntaxError都应当是在运行期抛出的。然而,不同的引擎在实现时,采用的语法解析引擎并不相同,这其中的差异就非常巨大。
更不幸的是:ECMAScript并没有规定“Parser怎么写”。——当然也没有规定它解析出来的AST(抽象语法树)是什么样子。
所以,这些错误究竟是在哪个阶段抛出,就变得不可知了。例如上面说Node.js在语法解析时并不会处理示例8的语法错,而TypeScript在代码转换(也就是处理语法解析而并不执行)时就会抛出错误:
> cat t.ts
// 示例8
function f(x=1) {
let x = 100;
}
# Nothing
> node -c t.ts
# Error
> tsc --allowJs t.ts
t.ts:1:12 - error TS2300: Duplicate identifier 'x'.
...
究竟哪些解析器是在语法阶段处理这种错误的(以及对应的引擎是否延迟到运行期才抛出Syntax),可以参考:
例如经典引擎esprima和flow,事实上对“示例9”的全局代码都不会解析出语法错,而@babel/parser或者acorn,连“示例8”都提前到了语法解析期出错。
所以,因名字重复导致的语法错误,一方面可能是语法分析期的(这取决于Parser的实现),另一方面也可以是执行期的,并且后者总是由ECMAScript规范约定的。
仅对于ECMAScript规范来说,“(var/let/const和参数声明所致的)名字重复”总是在执行期才抛出的语法错误。并且,为了在执行期兼容旧式的函数声明与使用惯例,ECMAScript约定在“非简单参数类型”的函数内为varNames多创建了一层对应的环境,从而使var名字与参数名(即使它采用的是let风格的声明)不再冲突——这是对“示例1”的最确切的解释。
NOTE:
]]>
极少数名字重复是ECMAScript约定过的静态语法错误,例如catch(x,x)以及与它的代码块中的var/let/const重名,又例如在严格模式或非简单参数模式下的函数参数名重名。
示例8是一个很好的例子,它强调了某些语法错误“是只有在执行时才会抛出的”,这也说明了lint类工具的重要性:在执行前进行更严格的语法检查,从而避免引擎差异。
本文是系列文章,包括:
- "Field提案"是什么东东(本文)
- 关于废止proposal-class-fields提案的建议 - 在这里
- 私有属性的实现 - 在这里
- No prefix! operator is Ok! - 在这里
- (未完待续)
本文是对一份tc39提案的讨论。原有提案:https://github.com/tc39/proposal-class-fields
上次掺和JavaScript的事,是说JavaScript语言有值的问题;再往前一年,就是在“红绿灯大战”中讨论Promise了。这细数起来,都是两三年前的旧事了。
但我还是跟进JavaScript的。最新近的事,是持续地讨论这个所谓“TC39提案”的事情。本以为真关心的人并不多,但前两天连老宋都转了篇贴子过来,“这帮人要把js折腾成啥样子啊”,老宋说。
“啥样子?”
本文是系列文章,包括:
- "Field提案"是什么东东(本文)
- 关于废止proposal-class-fields提案的建议 - 在这里
- 私有属性的实现 - 在这里
- No prefix! operator is Ok! - 在这里
- (未完待续)
本文是对一份tc39提案的讨论。原有提案:https://github.com/tc39/proposal-class-fields
上次掺和JavaScript的事,是说JavaScript语言有值的问题;再往前一年,就是在“红绿灯大战”中讨论Promise了。这细数起来,都是两三年前的旧事了。
但我还是跟进JavaScript的。最新近的事,是持续地讨论这个所谓“TC39提案”的事情。本以为真关心的人并不多,但前两天连老宋都转了篇贴子过来,“这帮人要把js折腾成啥样子啊”,老宋说。
“啥样子?”
“Field提案”全称是“proposal-class-fields”(在这里),是试图在类声明中添加“Fields”的一项语言设计。提案基本的想法是这样:
// 在类中声明字段(Field)
class Counter {
x = 0;
foo() {
console.log(this.x); // 0
}
}
这看起来并没有什么大不了,毕竟之前在类中只能声明方法,新方案可以直接为this.x
赋个初值,大家都很happy。
但“字段(Field)”是什么?没人鸟这个问题。
后来这个方案扩展了,提供了公开字段(Public fields)、私有字段(Private fields)等特性,并整合了种种有关于“类”的提案的思想,还提出了向前兼容类装饰器(decorators)等愿景,摇身一变,成了一个巨无霸方案。同时,也不可避免地在社区掀起了轩然大波:多达120余条issues,其中#100高达300余条讨论,Git issues折了好几回才能加载完。
话说,我就是被这个issues入坑的。
那么,现在这个巨无霸的方案又在讲什么呢?它说起来就是提供了两个语法:
// 在类中声明私有字段(private field)
class Counter {
#x = 100;
foo() {
console.log(this.#x); // 100
}
}
然而这样一来,即便连最初的提案者都跳出来反对了,包括BE大神也反对。即便如此,都挡不住这辆战车轰隆隆开进到了Stage 3(阶段3是提案即将正式写入规范前的最后一个阶段)。而且一并拖上的还有另外两个方案,称为“静态成员与方法(Static class fields and private static methods)”,以及“私有方法与存取器(Private instance methods and accessors)”。这两个提案推动的语法是这样:
// 对象字段
class Counter {
// 声明私有字段(private fields)
#xValue = 100;
// 声明和使用存取器(accessors for private name '#x')
get #x() { return #xValue; }
set #x(value) {
this.#xValue = value;
}
// 私有方法
#foo() {
this.#x++;
}
}
// 类(静态)字段
class Counter2 {
// 声明类私有字段(class private fields)
static #x = 100;
// 访问类私有字段
static foo2() {
console.log(Counter2.#x);
}
// 类私有方法
static #foo2() {
Counter2.foo2();
}
}
等等,这还不够,又还有一份与此相关的提案进入了Stage 2。是这种所谓“未来的装饰器”:
class Counter {
@observed #x = 0;
@bound
#clicked() {
this.#x++;
}
}
还有……
如果你想深入了解一点细节,在这里:
难道没有人注意到么,到现在为止,谁也没有(或许也是不敢)说清楚:
Fields是什么?
如果一个字段不是属性(property),那么所谓“公开字段”在语义上跟属性又有什么区别?如果它是属性,那么它为什么不在属性表中,而是一个“私有名(private names in scope over the same body of code)”呢?
所以,打蛇就得打七寸。没人说得清这个问题,还没有人敢说,所以你看,我跑去git里面提issues了(在这里)。往深底里,人家能跟你分析规范的每一步,但往这概念上一问就没人说话了。
所以呢,以后跟人吵架,就不要动刀动枪,开口就问:你从哪里来?要往哪里去?人活着是为了什么?等等这些,就很好了。
回到正题。为什么没人讨论Fields是什么
呢?不是的,并不是没有。而是只要一讨论,这个问题的答案就明显会是“No Fields”,这个答案有人专门写过,在这里:
这是本“Fields提案”提及到的几个相关/类似提案之一。而它的观点之一就是“没有字段”,对“Fields”提出了概念层面的质问。关于这个问题的进一步分析,我在上面的提及的issues里面也讲了,或者也可以看看中文版(在这里)。
终于有人无法忍受巨大冗长的#100
了,于是@mbrowne在@littledan的倡议下开了#150这个新主题。对大家的观点做了提炼,从@hax的主要观点开始:
- TC39极大地低估了该标准导致社区分裂(community break)的风险,这对所有人来说都是有害的。
- TC39现在的议事流程完全无法阻止灾难。
- 一些TC39的成员和支持者基于循环论证和双重标准来驱逐别人的意见和竞争性提案。
随后总结的观点包括(#100
里的讨论者太多了,作者一时半会儿大概也数不过来):
private
关键字 @hax @rdking @mbrowne @aimingoo喜大普奔的是,几乎所有批评者都表达了对@hax前三条主要观点的赞同,TC39被严重打脸。
]]>本文是系列文章,包括:
- "Field提案"是什么东东 - 在这里
- 关于废止proposal-class-fields提案的建议(本文)
- 私有属性的实现 - 在这里
- No prefix! operator is Ok! - 在这里
- (未完待续)
本文是对一份tc39提案的讨论。原有提案:https://github.com/tc39/proposal-class-fields
在本文中,我会仔细分析新提案中field
的概念矛盾,并揭示它实质上是作用域设计上的倒退。并且,该提案的错误实现,将不可避免地导致灾难。
Reject it! No more choices!
对象在定义上是“属性集(object
]]>本文是系列文章,包括:
- "Field提案"是什么东东 - 在这里
- 关于废止proposal-class-fields提案的建议(本文)
- 私有属性的实现 - 在这里
- No prefix! operator is Ok! - 在这里
- (未完待续)
本文是对一份tc39提案的讨论。原有提案:https://github.com/tc39/proposal-class-fields
在本文中,我会仔细分析新提案中field
的概念矛盾,并揭示它实质上是作用域设计上的倒退。并且,该提案的错误实现,将不可避免地导致灾难。
Reject it! No more choices!
对象在定义上是“属性集(object is collection of properties)[1]”。因此如果Field
不是属性,则它必然不属于“对象成员(collection elements of object)”这一概念集;如果Filed
是属性,则public field
必然与property
这一现有概念冲突。
这就是现有一切矛盾的根源。
"proposal-class-fields"提案在根本上与ECMAScript对象核心概念是矛盾的,它试图说明[2]:对象是通过类成员来定义的一个结构,而类成员包括属性名与私有名;属性名定义的就是属性。
在这个定义中,对象是一个“类成员(字段)的映像实例”,每个字段是一个“名字*(field define by his name)*”。亦即是说,对象是名字集(object is collection of names),是字段定义的实例(instance of field definitions by class)。
该提案对概念的偷换并非只停留在叙述层面,它在实现上确实就是这么做的。如下[3]:
2.7 DefineField(receiver, fieldRecord)
...
8. If fieldName is a Private Name,
... // add as private filed
9. Else, // public field as property
Assert: IsPropertyKey(fieldName) is true.
Perform ? CreateDataPropertyOrThrow(receiver, fieldName, initValue).
我们已经明显地看到,该提案的提出是对现有对象核心概念的破坏。而 “Not Fields”,是阻止它的唯一手段。
对于如下定义:
class f {
#data = 100;
foo() {
this.#data = 200;
}
}
该提案试图实现的效果与如下类似:
clas f {
constructor() {
... // call super, etc
var data = 100;
this.foo = function() {
data = 200;
}
}
}
注意,该提案本质上并没有“打算”为对象实例this
提供私有的字段,而是通过词法环境为该对象提供一个在特定上下文中可访问的名字集。
但是,如上例所示,如果用这种方法来为this.foo()
提供私有名字data
,那么foo()
将会是有非常多个函数实例(Function Instances)。为了避免单个对象重复绑定多个函数实例(来作为方法),该提案将上述“名字集(collection of names)”作为一个内部槽放在类中,并在创建对象时为this
提供一个[[PrivateFieldValues]]
的槽来作为该名字集的一个“映像(fork)”。
于是事情又绕回来了:该提案“似乎”为每个对象实例提供了这样的一个字段集?
不。真相不是这样的。对于该提案来说,这只是权宜之计,用以避免为每个对象、每个方法来创建各自的实例罢了。真实的情况是:它创建了每个类的、每个函数的、以及每个对象的“私有名”作用域(PrivateNameScope),然后在词法环境中维护这个作用域。由于每个对象都“可能有”私有域,因此在这个方案中,每个函数调用都需要负担起检查这个私有域的代价:每一次、每一处,以及每一个函数相关的特性。
于是整个提案暴涨了!大多数内部调用的方法都需要在界面上加上PrivateNameScope
这个参数,且每一个运行上下文的环境变量中都需要加上PrivateNameEnvironment
。[4]——有点熟悉的感觉了吗?上一个如此大范围影响了ECMAScript规范的东西是什么?
没错,就是VariableEnvironment!就是那个制造了与“块级作用域”冲突的var
声明,导致了新语法关键字let
被启用的历史设计。Private Fields
的蹩脚设计如此明显地与VariableEnvironment放在一起,却似乎没有任何一个规范审阅者看到:
3.9.9 PrepareForOrdinaryCall ( F, newTarget )
...
8. Let localEnv be NewFunctionEnvironment(F, newTarget).
9. Set the LexicalEnvironment of calleeContext to localEnv.
10. Set the VariableEnvironment of calleeContext to localEnv.
11. Set the PrivateNameEnvironment of calleeContext to F.[[PrivateNameEnvironment]].
他们重新发明轮子,又一次。并且,是方的轮子,又一次。
两个方轮子!
注:该规范将创建每个私有名称的映射,其中包含唯一键和设置名称作为描述。这似乎可用,但效率很低。谢谢@bakkot。
(我有一点遗漏,但我不会出于这个原因而撤回本建议。)
接下来每个函数都有了privateNameScope
,如此等等。那么既然有PrivateNameEnvironment或privateNameScope,又为什么要每个对象都有[[PrivateFieldValues]]
内部槽呢?
答案是:对象的内部槽用来存放名字对应的字段值[5],而环境与作用域组件用来检测是否能访问该名字[6]!
然而这是无效的。例如:
但是有一个问题。例如:
class f {
#data = 100;
}
class faked {
#data;
foo() {
console.log(this.#data);
}
}
// access private with a faked class
x = new f;
x.foo = faked.prototype.foo;
x.foo(); // 100
~~我仔细读过完整的提案,是的,上述的BUG无可避免。~~因为这个过程无异于:
function privateScope(name) {
var privated_data = 100;
return eval(name);
}
console.log(privateScope('privated_data'));
只要你允许对私有域做“读变量名”操作,那么所有的privated data都不可能安全。
注: 所以这个方案需要一个map来使用一个唯一key来登记每一个字段名字。
在抽象层面,(就抽象概念对使用者的影响来说)这个提案破坏或偷换了ECMAScript中“对象”的概念;在实现上,它耗用极大却又不能提供private
这个概念应当提供的安全性。我不得不说,这是一个失败的、不可用的、设计粗糙、思想落后的提案。
建议:废止proposal-class-fields提案。
"an object is a collection of zero or more properties." ECMAScript Overview part. ↩︎
The FieldDefinition in proposal-class-fields, New Productions part. ↩︎
The DefineField() algorithms in proposal-class-fields, Modified algorithms part. ↩︎
Every call in proposal-class-fields, 3.9.9 PrepareForOrdinaryCall. ↩︎
Access field value in proposal-class-fields, 3.5 PrivateFieldGet. ↩︎
Check name binding status in proposal-class-fields, 3.8.1 GetValue. ↩︎
本文是系列文章,包括:
- "Field提案"是什么东东 - 在这里
- 关于废止proposal-class-fields提案的建议 - 在这里
- 私有属性的实现(本文)
- No prefix! operator is Ok! - 在这里
- (未完待续)
本文是对一份tc39提案的讨论。原有提案:https://github.com/tc39/proposal-class-fields
在tc39的提案中,这一特性被称为"private field",据说是为了避免与property这个传统的名字出现概念冲突。这简直是扎了裤脚放屁——还要称比脱了裤子文雅!为什么这么讲呢?因为一旦引入了所谓“private filed”,就预示着还会有“public field”等等之类,而这与传统的property又有什么不同吗?
还是叫“私有属性(
]]>本文是系列文章,包括:
- "Field提案"是什么东东 - 在这里
- 关于废止proposal-class-fields提案的建议 - 在这里
- 私有属性的实现(本文)
- No prefix! operator is Ok! - 在这里
- (未完待续)
本文是对一份tc39提案的讨论。原有提案:https://github.com/tc39/proposal-class-fields
在tc39的提案中,这一特性被称为"private field",据说是为了避免与property这个传统的名字出现概念冲突。这简直是扎了裤脚放屁——还要称比脱了裤子文雅!为什么这么讲呢?因为一旦引入了所谓“private filed”,就预示着还会有“public field”等等之类,而这与传统的property又有什么不同吗?
还是叫“私有属性(private property)”吧,别再出妖了。
目前,类与对象都存在两种性质的成员,一是属性,二是方法。——尽管在ES6以前“函数类型的属性”也被称为方法,但在ES6及其以后,它们不再是严格意义上的方法了。后者,亦即是ES6+的方法也是一种“(特殊的)函数类型的属性”,这种特殊性在于它必须通过声明语法来添加到类或对象。——也就是说,ES6中的方法是静态声明的,而不是动态添加的。
类是特定语法声明的函数。由于函数是对象,所以类也是普遍含义上的对象。所以——重要的是——类的成员与对象的成员在性质上并没有任何的不同。确切来讲,它们都是属性,是对象的自有属性表中的成员,或可以通过原型访问的父代类属性。
总之,所有现在能静态声明或动态添加的属性都是公开的(public)。在讨论“私有属性”的时候,有三点是必须要先确定的:
注:本文中所谓“成员”,是在需要区分讨论属性与方法时使用的。此外,在提及“词法私有成员”时,因其与“属性”存在本质不同,所以暂用了“成员”这个概念。
之前我们已经讨论过这个提案的语法。很大程度上我是在尽量保证与现有语法设计上的一致性,而并没有讨论这个提案的实现方法。
简单地说,该提案认为:私有属性是一种特殊前缀表达的、自有的特殊属性。
由于私有属性是“一种...属性”,因此该属性总是在“自有属性表”中,并且也受自有属性表的机制来约束,例如属性描述符。比较简单的处理方法是:在属性描述符中增加新的属性(类似configurable
与enumerable
等等)。因此:
class f() {
private data: 100
}
x = f();
// 在语义上等义于:
Object.defineProperty(x, 'data', {
value: 100,
kind: 'private' // default is 'public'
})
所有与“属性”相关的特性都可以用在这些新式的kind: 'private'
的私有属性上。当然,如此一来,我们之前讨论的三个问题就有答案了:
私有属性是静态声明的,不可以动态添加。
私有属性不支持用Object.defineProperty()等来动态声明,这可以通过kind
性质来限制之。
私有属性使用可以使用一个新的属性表,也可以直接使用现有的自有属性表。
两者的机制是完全一样的,只是kind
性质的不同。但是,无论哪种方法,都需要考虑公开与私有属性同名问题:是可以同时存在,还是禁止重复。(注1、注2)
私有属性可以支持继承。
在这个方案中可以很方便的实现类似protected
的语法,即在子类中可访问的父类私有属性(如果父类将之声明为protected)。
注1:建议允许重名。因为JavaScript允许动态添加公开属性名的,如果不允许重名,则发生与私有属性冲突的机率大增。——更严重的是,用户代码无从得知一个对象有哪些私有属性名。
注2:如果允许重名,则两张属性表更方便;如果禁止重名,则建议使用同一张表。
如上,我的确是建议“私有的与公开的属性名是可以重复的”。这带来了如下的问题(下例假设直接使用this.xxx
来存取私有属性):
// 示例:一个不太可行的方案
class f{
data = 100; // 假设这里是私有成员
foo() {
console.log(this.data); // 假设这里的this.data指向私有成员
}
}
x = new f;
x.data = '200';
f(); // 应该显示200还是100?
这一示例说明:我们无法在语法上“限定在class f()
类声明中使用this.data
将访问私有属性。这个设计是行不通的。
如果我们的假设是“私有属性是属性表中的特殊项”,那么“使用this
来访问属性表”是目前最合理的方式。又如果使用“this
作为绑定给foo()
的对象实例,并试图访问该实例的私有属性”,那么“必然的”,我们需要一个新的语法:用于存取私有属性。
——注意这与目前tc39提案的不同,亦即是“No prefix! operator is Ok!”。
如下例:
class f() {
private data: 100,
private static data: 200,
foo() {
this#data = this#data + f#data;
}
}
在这个设计中,#
是限用于“类和对象的方法声明语法中”的,语义是:
很简单,很明确。
我们使用pravite
作为限定词,核心目的是“访问在class f()
声明之外访问私有属性。然而,如果我们使用属性表,那么没有任何”经济的“方法能做实现这个目标。为了解决这个问题,我们先讨论一个ECMAScript规范上的漏洞:能不能动态添加方法声明?
3.1 动态添加方法
JavaScript不能安全地抄写对象方法,因为方法会绑定源对象的super
。例如:。
// 类f与对象x
ObjectX = function() {}
ObjectX.prototype.data = "ObjectX"
class f extends ObjectX { }
f.prototype.data = 100;
x = new f;
// 类f2与对象x2
MyObject = function() {}
MyObject.prototype.data = 'MyObject';
class f2 extends MyObject {
foo() {
console.log(super.data);
console.log(this.data);
}
}
f2.prototype.data = 200;
x2 = new f2;
x2.foo(); // "MyObject" 200
// 抄写x2的方法声明
// (super没变化,绑定在了f2()类上)
x.foo = x2.foo;
x.foo(); // "MyObject" 100
方法x.foo()
显示了foo()
在词法上的super
,亦即是MyObject
。这表明foo()
方法是与class f2
的继承关系是词法绑定的,并不会“抄写到”对象x
。——当然,如我们一直在讨论的,this
引用是动态绑定的,不受上述影响。
而所谓super
本质上只是在访问一个方法的“内部槽[[HomeObject]]
”的原型而已。考虑到这一点,一种简单的重置super
的方法如下:
// 安全地动态添加方法x.foo()
Object.assign(x, Object.setPrototypeOf({
foo() {
console.log(super.data);
console.log(this.data);
}
}, Object.getPrototypeOf(f.prototype)));
x.foo(); // "ObjectX", "100"
根据这一过程,一个安全地动态添加方法的操作如下(注3):
// 工具函数
// - add method to f.prototype
Object.addMethod = function(proto, methods) {
return Object.assign(proto, Object.setPrototypeOf(methods,
Object.getPrototypeOf(proto)));
}
// 使用示例(在原型上添加方法)
Object.addMethod(f.prototype, {
foo2() {
console.log('Value:', super.data);
}
})
x.foo2(); // "Value: ObjectX"
注3:
Object.addMethod()
在这里实现为“向f.prototype属性添加方法”,是因为通过class
关键字声明的对象方法其实都是原型方法。因此所谓“动态添加方法”是应当通过污染类的.prototype属性来实现的。但是也可以用类似技术来实现不污染该原型属性的方法,例如Object.addOwnMethod()
,因与本文主题无关,在这里就不给出具体代码了。
3.2 动态存取私有属性
Object.addMethod()
方法的实现意味着“在类声明之外”为对象或类动态添加方法是安全的。因此,尽管我们认为下面的私有属性this#data只能通过
foo()`来访问:
class f {
private data: 100,
foo() {
console.log(this#data);
}
}
x = new f;
x.foo();
然而事实上下面的示例可以随时访问之:
Object.addMethod(f.prototype, {
setData(v) {
this#data = v;
}
})
// setData()是动态添加的可以存取私有属性的方法
x.setData(300);
x.foo(); // 300
然而这样一来,在ECMAScript中没有办法“安全地实现私有属性”了。
首先我们得知道为什么。
ECMAScript的方法本质上是“访问this
绑定的函数”——无论是传统的方法,还是ES6之后的方法,皆是如此。在所谓方法中,如果访问的是this.xxx
这样的公共属性自然是不成问题,因为本质上对象就是属性表;类似的,如果属性包支持“private
这样的访问域特性”,那么自然也是可以在支持私有属性访问的。——所以,最新的提案是建议用this.#xxx
这样的语法来访问它,亦即是在本质上仍然是在访问属性表。
所以,这就是根本的问题:如果一个方法就是在访问属性表,且它总是能动态地绑定this
,那么它必然也能够跨类(和跨对象)地访问私有的属性表——以及那些私有属性。
有两个途径来解决这个问题。
其一,是采用“词法私有成员”这样一种潜在实现机制。由于这种实现机制是利用作用域来实现的,与对象的自有属性表无关,因此也不会受addMethod()
这样的方法影响 。
其二,是在方法声明(例如f.prototype.foo()
)中对this#xxx
中的运算符#
做进一步限制。即foo()方法
f.prototype
与this对象的原型
相同时(即private
限定词),以及f.prototype
在this对象的原型
的原型链上时(即protected
限定词),才能使用#
运算。而这一点恰恰是能做到的,因为——正好——每一个ES6之后的方法,都会有一个[[HomeObject]]
内部槽用于存放**“声明时的”**类原型(f.prototype
)或对象本身(obj
)。如下例:
class f {
foo() {}
}
obj = {
foo() {}
}
x = new f;
// 如果能访问[[HomeObject]]内部槽,则:
console.log(x.foo[[HomeObject]] === f.prototype); // true
console.log(obj.foo[[HomeObject]] === obj); // true
那么,上述对#
运算符的限制无非是在说(注4、注5):
// 在foo()方法内实现`#`运算符的伪代码逻辑
foo() {
// source: `this#data`
homeObject = activeFunction[[HomeObject]];
descriptor = Object.getPrivatePropertyDescriptor(this, 'data');
permission = (
(descriptor.kind == 'private' &&
homeObject == Object.getPrototypeOf(this)) ||
(descriptor.kind == 'protected' &&
homeObject.isPrototypeOf(this)));
if (permission) {
result = descriptor.value; // return result of `#` op
}
}
注意这里有一个伪代码说明的变量名activeFunction
,这是指“当前活动的、正在调用的函数,也就是foo()
函数本身。类似在ECMAScript中“需要判断当前函数”的情况并不是没有——也就是说,以前就有存在这样这种方案实现的语言特性了。
是哪种语言特性呢?这就是闻名已久的super()
——注意这里仅指在constructor()
调用父类构造方法的操作,在ECMAScript中称为SuperCall
。这个操作很特殊,因为它需要从当前上下文的环境记录中取activeFunction
并进一步“查找所谓父类(即super)”。
注4: 在ECMAScript中有两个操作是会取activeFunction的,另一个是eval()。
SuperCall()
需要取activeFunction的原因是它无法直接使用constructor()的[[HomeObject]]内部槽。注5:
Object.getPrivatePropertyDescriptor()
与Object.getOwnPropertyDescriptor()
并没有功能的区别,只是这里假设前者需要直接访问私有属性表(关于建议使用两个属性表的问题,参见本文第2节)。
亦即是说,所有实现这一特性所需的技术组件,在现有的ECMAScript中都是充备的。
^^.
看起来还不错哟。看起来很有希望哟。要不,下手来搞搞?
然而,历史中,我们程序员犯得最多的错误就是:
一件事看起来能做,于是就做了。
“私有属性”这个特性真的是必须的么?——常读我写文章的读者,应该知道我总是在最后一步来考虑最原始的问题。——然而关于这个问题,我想要等到下一篇再讨论了。
现在这篇,已经很长了,不能再长了。:)
]]>本文是系列文章,包括:
- "Field提案"是什么东东 - 在这里
- 关于废止proposal-class-fields提案的建议 - 在这里
- 私有属性的实现 - 在这里
- No prefix! operator is Ok!(本文)
- (未完待续)
本文是对一份tc39提案的讨论。原有提案:https://github.com/tc39/proposal-class-fields
所有在我对原有提案的修改中,核心不是“不用#
字符”,而是将它从一个前缀字符,变成了一个操作符。这一方面是使“声明语法”与“表达式运算”分开,另一方面也让这些修改与ECMAScript规范保持在语法上的一致性。
本文是系列文章,包括:
- "Field提案"是什么东东 - 在这里
- 关于废止proposal-class-fields提案的建议 - 在这里
- 私有属性的实现 - 在这里
- No prefix! operator is Ok!(本文)
- (未完待续)
本文是对一份tc39提案的讨论。原有提案:https://github.com/tc39/proposal-class-fields
所有在我对原有提案的修改中,核心不是“不用#
字符”,而是将它从一个前缀字符,变成了一个操作符。这一方面是使“声明语法”与“表达式运算”分开,另一方面也让这些修改与ECMAScript规范保持在语法上的一致性。
在所有类、对象等声明性质的语法中,":"是表明"key/value pair"的,既然这里的私有字段仍然是“key/value pair”,那么仍然建议使用该符号。而原提案建议使用=
并且与TypeScript保持一致,却忽略了TypeScript中完整的语法x: number = 100
中,:
是指示类型的,而不是用于指示值。——这与ECMAScript的一般规则并不一致。
ECMAScript规范事实上是沿用了“旧式的对象字面量”的属性声明与初值的语法,亦即是:
obj = {
x: 100,
y: 100
}
注意在这个语法中”有或没有,
号都是接受的,但如果有,
号则称为一个List,且整个声明是以一个“没有,
号的单个属性声明”结束的。——这与传统的对象字面量声明语法一致。
在ECMAScript中,类声明一定程度上沿用和扩展了这一语法。一方面,把“方法声明”给到了对象字面量声明;另一方面,从对象字面量那里把“get/setter声明”拿了过来。而与TypeScript类似的= xxxx
语法,尽管也是一个称为Initializer的语法组件,也的确出现在了对象初始化语法中,但是作为错误语法来识别的(CoverInitializedName):
PropertyDefinition: CoverInitializedName
Always throw a Syntax Error if code matches this production.
所以,回到最前面的说明,推荐的语法设计是:
// Because
obj = {
data: 100,
foo() {
....
}
}
// YES
class f {
data: 100,
foo() {
...
}
}
// NO!!!
class f {
data = 100;
...
}
这样使用f()
类构造出来的对象实例,与用相似语法声明的对象字面量是类似的。不存在语法设计上的“例外(unexpected)”。
在现有类、对象等声明语法中是使用限定词来指示成员性质的,例如static
、get
等。除了生成器函数之外,并不使用限定符号或“前缀(prefix)”,因此我建议用限定词private
来扩展类声明语法,而反对原提案中使用#
作为限定符来声明类私有字段(private field of class)。例如:
// YES
class f {
private data: 100
}
// NO!
class f {
#data = 100;
}
在所有的列表(List)中,ECMAScript采用的分隔符都是,
号,例如参数列表、对象/数组成员列表,以及导入导出的名字列表,还有变量var
声明的名字列表等等。而;
在语法中惯例是用作语句结束符或分隔符,包括你所见的各种(任意的)语句,以及for(;;)
中的子句等等。我们既然是在声明类或对象的“成员列表”,那么显然应该是按“列表(List)”的规则处理成,
为好,怎么会用到;
号了呢?
TypeScript中在这个位置是这样声明的:
class f {
data: string = 'in typescript';
...
}
留意这里的语法特点:符号:
是用于指示类型的,因此初值声明使用了=
。这个声明与var
语句声明类似,TypeScript将这里处理成了;
是可以理解的。但ECMAScript何必要绕开现成的,
号不用,去使用在这里毫无意义的;
号呢?
ECMAScript既然已经接受了对象字面量声明的末尾逗号(object literal trailing commas),那么下面两种声明都可以是合法的了:
// 推荐
class f {
data: 100,
foo() {
...
}
}
// (可接受)
class f {
..., // more, a list with ','
data: 100
foo() {
...
}
}
包括:
var data = 'outer';
class f {
// reference
data, // outer reference, no computed
// public
data: 100, // for object, equ: f.prototype.data = 100
static data: 100, // for class, equ: f.data = 100
["da"+"ta"]: 100, // computed
static ["da"+"ta"]: 100, // computed
// private
private data: 100, // normal private object properties
private ["da"+"ta"]: 100, // computed private object properties, and symbol keys
private static data: 100, // for class static field
private static ["da" + "ta"]: 100, // computed
// get&setter, etc
private get data() { ... }
...
}
私有属性的存取是一个大问题,也是#
语法争议的焦点。首先必须确定的是:私有属性存取的语义是什么?
在原提案中,私有属性存取是将#
作为一个前缀(prefix),而存取运算仍然是.
或[]
运算符。因此,本质上来说,存取运算的操作没变,但是需要在存取中判断属性名的前缀是否是#
字符。如下例:
// 原提案
class f {
#data = 100;
foo() {
console.log(this.#data); // "#"是前缀,而"."是存取运算符
}
}
根本原因出在.
运算检测data
是否是私有成员的成本过高。例如:
// 示例:一个不太可行的方案
class f{
data = 100; // 假设这里是私有成员
foo() {
console.log(this.data); // 假设这里的this.data指向私有成员
}
}
x = new f;
x.data = '200';
f(); // 应该显示200还是100?
那么在上面这个例子中,x.data
添加了一个公开的属性时,foo()方法是无法识别用户代码的意图的。
所以在旧的提案中需要使用#
前缀。但是,仔细思考这个问题:
当然不需要。那么为什么不为私有字段列表安排一个专门的运算符呢?只要“像使用super一样”限定它使用的上下文就好。因此,新的语法设计如下:
class f{
data: 100,
foo() {
console.log(this#data);
}
}
也就是说#
现在是当作一个运算符(operator )在用,而不是一个前缀(prefix)。
但为什么是#
?
答案是:老实说,我也想不到更好的了。如果你能找一个大家都满意的,我接受。
NOTE:
一个备选的运算符可以是
->
,但老实说我认为比#
更差劲。
在本方案中,是默认用“对象或类”的自有成员列表来实现的,这意味着总是需要用类似this#xxx
的语法来存取它。不过这并非唯一的方案。
关于类似于”采用词法上下文“来实现私有成员的问题,我另写文章来讨论吧。
本文是一个系列,包括:
JavaScript的类型系统一贯是反人类的,以至于JavaScript之父Eich都会跳出来说“我做错了”。但是这并不是说它的整个类型系统就是不可理解的,相反,它提供了观察这门语言的多个不同角度。
在远古时期(我是指JavaScript 1.0)中,JavaScript的世界里还并没有“原型”这个东西,而且undefined
也是一个奇葩的存在。
这时的undefined
是一个概念定义而非值声明,它表明函数或某个运算(例如属性存取)没有返回值。由于还没有===
和!==
运算符,所以undefined
被约定为与null
是等值的。
考察这个阶段,可以发现整个类型系统其实只有如下几种值类型。每种类型都包括一个它们各自表示(逻辑上的)“无值”的成员:
本文是一个系列,包括:
JavaScript的类型系统一贯是反人类的,以至于JavaScript之父Eich都会跳出来说“我做错了”。但是这并不是说它的整个类型系统就是不可理解的,相反,它提供了观察这门语言的多个不同角度。
在远古时期(我是指JavaScript 1.0)中,JavaScript的世界里还并没有“原型”这个东西,而且undefined
也是一个奇葩的存在。
这时的undefined
是一个概念定义而非值声明,它表明函数或某个运算(例如属性存取)没有返回值。由于还没有===
和!==
运算符,所以undefined
被约定为与null
是等值的。
考察这个阶段,可以发现整个类型系统其实只有如下几种值类型。每种类型都包括一个它们各自表示(逻辑上的)“无值”的成员:
接下来就是对象和函数。它们都是引用类型(从最早的JavaScript语言就这样定义了),而且也各有一个表达“无值”的方式:
这基本上就是JavaScript 1.0时代对类型系统的全部理解,以及假设。——之所以称之为假设,是因为这时连typeof
运算符都没有,所我们无法在语言的层面上验证它。
不过有趣的是,这个时代是支持面向对象编程的。JavaScript 1.0中的面向对象是“基于类”的,它有“类构造对象(的实例)”的概念:
obj = new Object;
// OR
function MyObject() {
this.x = 100;
}
obj = new MyObject;
在这时,Object
/MyObject
已经被称为构造器(constructor)了。它采用的是被称为“类抄写”的方式,通过向实例“this
”上添加成员来初始化对象。
这个时代并没有原型继承,也不支持instanceof
运算,因此尽管Object
/MyObject
执行“构造一个对象”的职责,却没有人认得它是“类
”。由于JavaScript默认函数总是可以作new
运算,并且this
可以缺省指向global
,因此它是不是“类”就不要紧,用new运算时都不会出错。
NOTE1:往前追溯20多年,一本名为《结构程序设计( Structured programming)》的书定义了面向对象编程。其中说:如果一个过程产生了比它生存时间更久的实例,那么这个过程就称为类,而这个实例就称为对象。因此,JavaScript 1.0时代的面向对象是古典的、传统的设计,不可思议却又历久弥新。
NOTE2:有关JavaScript 1.0,可以参见这里:https://docs.oracle.com/cd/E19957-01/816-6408-10/object.htm#1193255
从JavaScript 1.1开始,这门语言总算提供了“识别自己的类型”的运算,也就是著名的typeof
。在语义上,它提出的想法是:用undefined
表达语言层面的“没有(无值)”。
由于在JavaScript 1.0中已经为string
、boolean
等各自定义了一个它们在语言层面上的“没有”,因此这一设计也就保留了下来。在1.1中,typeof
返回如下六个值之一:
针对值类型:'undefined'、'string'、'boolean'和'number'
针对引用类型:'object'和'function'
有了undefined
,JavaScript可以自如地表达类型间的运算,例如A + B
。任何函数/过程总应该是有一个静态结果的,因此要么它是值,要么它就是无值(undefined)。——基本想法是:如果算不出结果,那么这个过程应当返回undefined
。
而null
表示的是对象层面的没有,因此它被理解为一个对象,也就是说它的typeof
值仍然是'object'。——就好象说NaN表达数值上的‘无值’,但仍然是数字值。例如:
> typeof null
'object'
但是null
又并不是由对象系统创建出来的,因此它不是Object()
或其子类的实例。因此:
> null instanceof Object
false
对象也会参与跨类型的值计算,因此它也需要一个“对象的值的含义”。于是Object.prototype.valueOf()
就出现了。当JavaScript在值运算中发现操作数(x)是对象时,就会调用x.valueof()
来得到它的值类型数据,并以此为操作数来进行值运算。这个过程很简洁,也很完美。
稍稍差一点的是null
,它是对象,但又不继承自Object()
,所以没有Object.prototype.valueOf()
,而它就必须“被理解”有自己的value
。——但是,如果一个运算数“有自己的value
”,那么它不就是值么?
是的。概念开始混乱了。
无论如何,JavaScript在1.x版本中确立了自己对类型系统的理解。
这个类型系统可以完美地概括JavaScript中可能的各种对象和概念理解。其中"值类型"用于进行值运算或表达值运算的结果,而引用类型用于“索引到”一个值。
在这个系统中,null
并不在基本类型系统中,它只是在“对象类型系统”中的、一个特殊对象的字面量表示。
ECMA开始制定ECMA-262(也就是ECMAScript)标准时,JavaScript已经发展到了v1.2版本,一直到后来发布ECMAScript ed3时(1999.12),它才基本与JavaScript v1.3对齐。随后(2000.07~11月),JavaScript发布了v1.5以及JScript发布了v5.5,才将现实中可用的JavaScript版本与ECMA规范标准对应起来,基本上三者同一了。
然而从ECMAScript ed1开始,它就采用了一种“不同寻常”的类型描述方法。在ECMAScript中,一直将Null
独立作为一个类型来描述,并称之为“六种基本类型(six standard types)”之一。而这,显然是与JavaScript的typeof
的返回值有异的。并且ECMAScript还描述了几种用于实现JavaScript的“内部类型(internal types),其中最重要的就是从ECMAScript ed1就开始包括的完成(Completion)、引用(Reference)和列表(List)。
从ECMAScript ed5开始,“基本类型”与“内部类型”就分别被称为语言类型与规范类型了。
根底上的原因,还在于ECMAScript并不是要描述“JavaScript的语言性质”,而是要描述“JavaScript如何实现”。在比目标语言级别更高的维度上,ECMAScript通过所谓“规范类型”来描述和解释“实现JavaScript”时可能操作的数据以及操作这些数据的方式。——它们被严格的区分为两种:对象,或非对象。其中,function
类型从一开始就是对象,因而不存在语义矛盾。出于在前面讲到的种种理由,处理null
时就比较尴尬了:它在JavaScript中是对象,却用来表示“没有对象(对象的‘无’值)”。
ECMAScript再一次在概念上向typeof
的结果说了不,它约定:null
是值,是非对象类型的。
所以一直以来,JavaScript不得不按“两种类型系统”来进行语言描述,一种是JavaScript应用环境中的,称为“语言类型(Language types)”;另一种则是ECMAScript中的,称为“规范类型(Specification type)”。而ECMAScript在规范中描述的“语言类型”,还与它在实现中使用typeof()
得到的结果不一致。
在这两种类型系统中,JavaScript语言认为null
是个是引用类型(Object)的值,而ECMAScript认为null是个原始类型(Primitive types)的值。
NOTE3:很不幸的是,在讨论JavaScript的时候,这两种描述都是对的。
在ECMAScript中考察一个值(V)的类型时,是根据其内部操作Type()
的结果值,来确定该值V
是或不是“ECMAScript language value”,或者是更具体的某个类型。
当JavaScript引擎在分析脚本代码时,会将代码解析成为数据或执行逻辑以便后续处理,这(通常)也就对应于ECMAScript规范类型中的记录(Record)和词法环境(Lexical Environment)。而在运行期,任何的JavaScript代码最终都会被理解成“可执行的”语句或表达式,并且当一个操作(op)是表达式时,它总有结果值;而当操作是语句时,它的最终状态是由一个称为“完成记录(Completion Record)”的规范类型来存放的——并用这个记录的[[value]]
字段来存放语句的结果值。
下图完整地展示了四种主要的规范类型的使用。
上述由ECMAScript构建的类型系统运转良好——比如我们确实可以按照这样的规范来编写一个JavaScript引擎。然而它仍然无法有效地解释null
值的语言特性。
尤其是在ES5之后。因为ES5开始提供了新的创建对象的语法:
obj = Object.create(null)
ECMAScript无法对“对象create
自null”这样的语义给出合理解释,其根本原因在于它隐藏(至少是有意不讨论)这样的一个事实:Object.prototype
就是一个“创建自null”的对象:
> Object.getPrototypeOf(Object.prototype)
null
ECMAScript禁止用户代码向Object.prototype
置值或改变属性——这很容易理解,它将prototype
创建为一个只读且不可配置的属性就可以了。但是,ECMAScript还同时禁止了用户代码改变Object.prototype
的原型,亦即是:
// 当置为非null值时将触发异常
> Object.setPrototypeOf(Object.oprototype, {})
TypeError: Immutable prototype object '#<Object>' ...
所以Object.prototype
被称为“不可变原型对象(Immutable prototype object)”。——在ECMAScript中目前只有Object.prototype
和模块名字空间的原型会这样(尽管后者并没有明确指出)。
NOTE4:
Object.prototype
是唯一被明确约定为“不可变原型对象(Immutable prototype object)”的,而<aNamespaceOfModule>.prototype
却只是置它的[[SetPrototypeOf]]
内部槽为一个特殊的写方法。这二者的处理方法并不一致,一定程度上是为了强调在“Object.prototype
是什么对象”这个问题上的特殊性。
ECMAScript只说明了“该对象不可变原型”这样的性质,却没有解释“原型为null的对象是什么”这一问题。因为ECMAScript在语义上并没有对应于“创建自null的对象”这样的概念。也正是因此,下面的类声明才显得牵强:
class MyClass extends null {
// ...
}
并且当它没有自有的构造方法(constructor)时,才会出现下面的问题:
extends xxx
决定了缺省情况下由父类来创建实例;但是extends null
表明父类是null;所以,> new MyClass
TypeError: Super constructor null of MyClass ...
在Metameta(@aimingoo/metameta)中对这一现象给出了自己的解释:
在这样的解释下,可以看到Object.prototype
本质上来说也是一个原子对象。亦即是说,我们找到了ECMAScript/JavaScript创建自己的对象系统的原始方式。加上JavaScript开放了get/setPrototypeOf()
和有关操作属性描述符的方法,于是我们既得了创建“原子/原子对象”的能力,也得到了在“属性表”这一级别维护这些原子自有成员的能力。
进一步的,由于JavaScript中的'function'
类型事实上也是对象,因此我们既得到了表达静态数据的原子,也得到了支持运算过程的原子。再加上JavaScript还通过eval()
提供了原生的解析代码和操作执行上下文的能力,那么——事实上——我们也就得到了整个的原子计算环境/运行框架。
这也是Metameta提供了一个.from()
工具方法的原因:Metameta致力于扩展JavaScript的内建概念,并试图用为ECMAScript建立一个更为完整的概念集。在这其中,.from()
所体现出来的事实是:JavaScript的对象系统,是以原子为基础的类型系统的一个实现。
如图:
而下图则是更完整的、以元类型为基础来实现的、对象/类型系统的景象:
本文是一个系列,包括:
JavaScript中可以通过Object.create(null)
来创建原子,这是非常自然而又易于理解的方式。不过也有一些其它的方法来实现相同的效果,虽然在概念上有所不同,但是它们创建的一样是“原子对象”。
// 方法1
atom = Object.create(null)
// 方法2
atom = Object.setPrototypeOf(new Object, null)
// OR
atom = Object.setPrototypeOf({}, null)
// 方法3
function MyObject() {
// ...
}
Object.setPrototypeOf(MyObject.prototype,
]]>本文是一个系列,包括:
JavaScript中可以通过Object.create(null)
来创建原子,这是非常自然而又易于理解的方式。不过也有一些其它的方法来实现相同的效果,虽然在概念上有所不同,但是它们创建的一样是“原子对象”。
// 方法1
atom = Object.create(null)
// 方法2
atom = Object.setPrototypeOf(new Object, null)
// OR
atom = Object.setPrototypeOf({}, null)
// 方法3
function MyObject() {
// ...
}
Object.setPrototypeOf(MyObject.prototype, null);
atom = new MyObject;
注:“非派生类(没有extends声明的类)”,与将一个普通函数用作构造器时的特性基本一致。
class MyClass {
// ...
}
Object.setPrototypeOf(MyClass.prototype, null);
atom = new MyClass;
JavaScript在处理extends null
时会将MyClass.prototype
的原型置为null
,因此这个类构建的实例自然就是atom。但是,派生自null值的类无法直接构建,因此需要声明自己的构造方法(以该方法创建和返回的对象作为this)。
// 方法4
class MyClass extends null {
constructor() {
return Object.create(new.target.prototype);
}
}
atom = new MyClass;
上例在实现构造方法constructor()
时是直接引用new.target.prototype
来作为原型的,这样也就可以在new
运算时引用到MyClass
子类的原型。例如:
// 方法5
class MyClassEx extends MyClass {
get description() {
return 'class MyClassEx';
}
}
atom = new MyClassEx;
console.log(atom.description); // class MyClassEx
下面的代码是兼容构造器、原型继承和函数调用等方式的。
// 方法6
// (当作为函数调用时,new.target为undefined值)
function MyAtom() {
return Object.create(new.target && new.target.prototype || null);
}
// 示例1
atom = new MyAtom;
// 示例2
atom = MyAtom();
在上述方法4中,由于声明了extends null
,因此类MyClass
必须拥有一个自己的构造方法。但事实上在JavaScript中,extends null
所表达的含义是:
extends
声明,所以默认的constructor()
将总是调用父类super()
来创建实例(亦即是所谓“this
引用总是由祖先类创建的”);但是,extends null
意味着父类为null,因此“调用父类super()
”失败。这是类MyClass
不能使用默认的constructor()
——而“通常”必须由用户代码来实现构造方法的原因。然而JavaScript只是在静态语法分析时才通过extends null
来识别父类,真正在运行期时,它是通过方法的内部槽([[HomeObject]]
)来动态查找super的。——由于该内部槽指向类MyClass
(或类的原型属性MyClass.prototype
),因此所谓的super
其实就是如下的运算值:
// for static class methods
_super = Object.getPrototypeOf(MyClass)
// for instance methods
_super = Object.getPrototypeOf(MyClass.prototype)
既然如此,我们就可以通过如下的代码来声明一个“可以创建原子”的类。例如:
// 方法7
Atom = Object.setPrototypeOf(class extends null {}, Object)
atom = new Atom;
在这个方法中,实际上Atom指向类表达式,并且重置了它的原型:
// (如下等价于方法7的第一行代码)
Atom = class extends null {};
Object.setPrototypeOf(Atom.prototype, null);
Object.setPrototypeOf(Atom, Object);
其中extends null
决定了Atom.prototype
的原型指向一个null值,而setPrototypeOf(...)
决定了当new Atom()
时,对象实际上是由Object()
——这个super来创建的。因此,当new Atom
时,实际发生的操作是:
// (如下等价于方法7的第二行代码)
_this = new Object(); // call super()
Object.setPrototypeOf(_this, Atom.prototype); // prototype is null
atom = _this;
所以方法7是一种能够“利用JavaScript原生构造器”来创建原子的技巧。比如最简单的获得一个Arguments()
构造器的方法其实是这样:
let Arguments = Object.setPrototypeOf(class extends Object {}, Array);
这样得到的对象将与JavaScript内置在函数调用中创建的argument
完全一致:
let arguments = new Arguments(1,2,3); // more paraments
不过在ES6中arguments
也实现了迭代器界面,因此需要一行额外的代码来处理之:
// for ES6
Arguments.prototype[Symbol.iterator] = [][Symbol.iterator];
NOTE(2018.08.28):由于arguments并不是原子对象,因此对类Arguments()的写法略作修改,突出了设置Array作为原型(来替换父类构造器)的用法。thanks for hebaby @github
在Metameta(@aimingoo/metameta)中,可以使用Meta.from()
来得到一个映射类,这与上面的方法7是相同的方式:
// 方法8(in metameta)
MyAtomObject = Meta.from(Object);
atom = new MyAtomObject;
有趣的是,这样得到的“MyAtomObject
类”(在Metameta中称为Objext
类)将继承所有来自Object.xxx
的类方法,例如Object.keys()
等。这些方法在元系统中也是可以直接使用的。例如:
// (in metameta)
Objext = Meta.from(Object);
Objext.keys(new Objext);
extends new.target
上面在方法4中提到extends null
相当于将MyClass.prototype
的原型设为null,——在方法7中也使用了这一技巧——因此事实上在Metameta(@aimingoo/metameta)中实现MetaMeta()时,采用的extends new.target
也就相当于:
// class MyClass extends new.target ...
Object.setPrototypeOf(MyClass.prototype, new.target)
这一技巧用在类的constructor()
方法中,返回一个新的类(类声明的表达式)。
本文是对在第四届FEDay中分享的《无类继承:JavaScript面向对象的根基》的进一步讨论。也是对开源项目@aimingoo/metameta的解析。
本文是一个系列,包括:
以及相关下载:
- 资源分享:第四届FEDay讲演主题 - 在这里
ECMAScript中只有两处提及到“Meta”这个概念,一处是说明ECMAScript的规范类型(a specification type)是用于描述和实现语言类型(language types)的元值(meta-values),另一处则是唯一被称为“元属性(Meta Property)”的new.target
。
所以ECMAScript中是没有所谓“元系统(Meta system)”或“元类型系统(Meta
]]>本文是对在第四届FEDay中分享的《无类继承:JavaScript面向对象的根基》的进一步讨论。也是对开源项目@aimingoo/metameta的解析。
本文是一个系列,包括:
以及相关下载:
- 资源分享:第四届FEDay讲演主题 - 在这里
ECMAScript中只有两处提及到“Meta”这个概念,一处是说明ECMAScript的规范类型(a specification type)是用于描述和实现语言类型(language types)的元值(meta-values),另一处则是唯一被称为“元属性(Meta Property)”的new.target
。
所以ECMAScript中是没有所谓“元系统(Meta system)”或“元类型系统(Meta type system)”。我们在这里先定义一个称为“原子(Atom)”的东西,并基于此来构建起一个完整的JavaScript元系统。
定义:原子是JavaScript中的对象的最小单元,它是对象但不继承自Object();以原子为原型的对象也会被称为原子对象。
JavaScript中的对象就是一个属性包(properties bag, or a collection of properties),一个属性包为空集时,它必然是对象的最小形态。因此一个没有原型,且自有属性集为空的对象,必然是一个原子。
原子可以用ES5兼容的语法创建出来:
var atom = Object.create(null);
也可以通过将一般对象的原型置为null来得到一个原子:
var atom = Object.setPrototypeOf(new Object, null);
并且,在ECMAScript中有三个内建/原生对象是原子的:
function isAtom(x) {
switch (typeof x) {
case 'object':
case 'function': return !(x instanceof Object);
}
return false;
}
// modules in es6
import * as namespace from './your-module.js';
console.log(isAtom(null));
console.log(isAtom(Object.prototype));
console.log(isAtom(namespace));
在同一个运行环境中,可以并存多个原子,以及由原型指向原子的、原型继承的对象系统。所有这些原子以及衍生的对象系统都是互不相等、没有交集的。
> Object.create(null) === Object.create(null)
false
因此,JavaScript原生的、由Object()派生或创建的对象、类,在本质上也是上述“对象系统”之一。但是,
并且,
NOTE(2018.08.28): 修正了一处关于arguments的错误,确认arguments对象不是原子。thanks for hebaby @github
定义:能产生原子(atom)的一个过程称为元(meta)。
推论:原子的构造器(Atom)与元(meta)是等义的。
由于atom对象的构造器通常记为Atom(),所以从概念上它与“元(meta)”是等义的,在实际使用中我们也并不明确地区分二者。
meta可以是一个函数,也可以是一个类,甚至也可以是一个代理对象(proxy)、箭头函数(arrow functions)或方法(methods)。——在概念定义中,我们只约定了“meta是一个过程”,并没有强调atom是它构建出来的,亦或只是它的调用结果。
在开源项目中Metameta(@aimingoo/metameta)中,meta是以ES6的语法声明的一个Atom类:
class Atom extends null {
constructor() {
return Object.create(new.target.prototype);
}
}
任何情况下,我们用该meta都可以产生新的原子对象:
> isAtom(new Atom)
true
> new Atom === new Atom
false
定义:所有元(meta)的类型称为元类型(Meta types)
在JavaScript中,一个数据所对应的类型可以用它的构造器来标示,亦即是Meta();并且这也意味着Meta()作为构造器产生的实例是元(meta)。亦即是说,Meta()应当是一个“返回meta过程”的过程。
在ES6的语法中,可以简单地在函数中返回一个“类声明(class definitions)”来得到一个字面量风格的类。因此在Metameta中声明了MetaMeta()类来作为元类型的祖先类:
// Meta's super
class MetaMeta extends null {
constructor(base=Atom) { // Atom() by default, NOTE: 声明在上例
return Object.setPrototypeOf(class extends new.target {}, base);
}
...
NOTE(2018.09.09): 修正了一处关于MetaMeta()类的错误,这是因为在本文中为了简化Meta()类的描述而直接暴露了MetaMeta的实现,但又没有处理相关的逻辑所导致的。需要注意的是,在Metameta项目中该类的实现与本文是略有区别的。thanks for nextdoorUncleLiu @github
所以现在,我们就可以通过如下的方法来得到一个原子了:
// Atom与meta是同义的
> Atom2 = meta = new MetaMeta
// 创建一个原子
> atom = new Atom2
// 检测
> isAtom(atom)
true
我们之所以要用class来声明Atom和MetaMeta,是为了简单地得到面向对象的继承性。亦即是说,当我们想要派生一个新的原子对象类型的时候,可以简单地通过扩展上述的系统来得到它的构造器。例如:
class MyAtomObject extends new MetaMeta {
get description() {
return 'i am an atom.';
}
}
var x = new MyAtomObject;
console.log(x.description);
在这个例子中,new MetaMeta
直接创建了一个Atom,而MyAtomObject
则派生自该Atom,因此它的实例自然是atom。并且,基于ES6的类声明语法,MyAtomObject
也可以具有自己的存取器成员、对象方法,或者类方法。
从MetaMeta也可以基于元类型进行派生,由此我们可以实现“元类(Meta class)类型”。
定义:元类(Meta class)是一个产生类(class)的过程。
从定义上来说,简单的元类可以写成:
function SimpleMetaClass() {
return class {};
}
当然,由于在MetaMeta中“元类型”本身就是基于类实现的——亦即是它本来就是一个“返回类”的过程,因此它只需要简单的一层概念抽象就可以实现“元类”类型了。如下:
// “元(Meta)”类型
class Meta extends MetaMeta { ... }
// “元类(MetaClass)”类型
class MetaClass extends Meta { ... }
之所以让Meta派生自MetaMeta(),主要目的是为了得到一层super声明,以确保Meta()以及它的类方法(static methods)之于它的super是词法上下文绑定的。而“元类(MetaClass)”则用于派生一层类型声明,以便让MetaClass()能拥有自己的类方法,例如MetaClass.isClassOf()
。
现在,我们已经在Meta上实现了一层派生,我们也可以实现更多层的派生,以通过“类类型”的方法来得到更多的构造器——换言之,我们可以产生更多的类,它们都可以作为更多的“不同的对象系统的”祖先类。我们可以让JavaScript中出现多个完全不同的、与Object()所代表的“原生对象系统”并列的对象系统。
如前所述的——它们相互独立,没有交集。例如:
// “元类(MetaClass)”产生类
var ObjectEx = new MetaClass;
// 基于ObjectEx可以派生一个“独立的、不同的”对象系统
class MyObjectEx extends ObjectEx {};
// 可以用类似的方法来派生更多这样的对象系统
class MyObjectPlus extends new MetaClass {
...
};
接下来,你可以检测它们的类属关系:
> ObjectEx.isClassOf(MyObjectEx)
true
> MetaClass.isClassOf(ObjectEx)
true
或使用ECMAScript内置方法检测原子:
> (new MyObjectEx) instanceof ObjectEx
true
> (new MyObjectEx) instanceof MyObjectPlus
false
]]>我在本届FEDay的讲演内容在这里了:
FEDay就是前端日,在这里:
我的主题是《无类继承:JavaScript面向对象的根基》,主要是从JS中面向对象的历史讲起,一直到元类继承的实现。
相关的资源在这里:
]]>我在本届FEDay的讲演内容在这里了:
FEDay就是前端日,在这里:
我的主题是《无类继承:JavaScript面向对象的根基》,主要是从JS中面向对象的历史讲起,一直到元类继承的实现。
相关的资源在这里:
]]>在搞IT之前,我也搞点文学。
读书的时候总会写点东西,但不见得算文学作品。大概在99年的时候,我的《石像的忆述》被评成了网易首届网络文学大赛的金奖,看起来算是真正在做文学了,但此后我却也不怎么写东西了。后来有一阵爬坛论的风气,我跟着老茂的“顶点诗歌”混过一阵,也在精品论坛(ET8)和海浩网的文学版里出没,但几乎没有拿得出手的。再之后就是博客、微博和微信了,文学方面的作品更是寥寥。
但总归还是有些的。例如在其它博文中提到过的:
除了这些之外,还有一些散碎文字。例如也曾经在博客中提到过的两首诗:
]]>之一·2008年入京
昨夜方泸闵,此时已京中。
将相王侯府,笑谈作一梦。
回见来时路,山峦几多重。
俯仰清声处,执手是英雄。
在搞IT之前,我也搞点文学。
读书的时候总会写点东西,但不见得算文学作品。大概在99年的时候,我的《石像的忆述》被评成了网易首届网络文学大赛的金奖,看起来算是真正在做文学了,但此后我却也不怎么写东西了。后来有一阵爬坛论的风气,我跟着老茂的“顶点诗歌”混过一阵,也在精品论坛(ET8)和海浩网的文学版里出没,但几乎没有拿得出手的。再之后就是博客、微博和微信了,文学方面的作品更是寥寥。
但总归还是有些的。例如在其它博文中提到过的:
除了这些之外,还有一些散碎文字。例如也曾经在博客中提到过的两首诗:
之一·2008年入京
昨夜方泸闵,此时已京中。
将相王侯府,笑谈作一梦。
回见来时路,山峦几多重。
俯仰清声处,执手是英雄。之二·2009年赴杭
五月远京城,往此寻高人。
冰杯小作难,唤我掩柴门。
晨作于西湖,暮归在古墩。
筷头还肉香,竹园正新笋。
又例如曾经在北京红叶拾楠偶得的一首:
补闲·2014年秋于红叶拾楠
一杯薄水酬清客,碎雨寥落蕴书香。
侧帽依稀知容若,老气将来喟秋长。
说起这篇补闲,倒也是挺有趣的。在多年之后,我偶然打开闲置很久的Skype,突然在签名档上看到这句“侧帽依稀知容若,老气将来喟秋长”,觉得极好、极趁心情,于是就拿来做了微博的新签名。但一直又想知道这句诗的出处,查了许多次之后,终于还是在微博上搜到这首诗:原来就是自己写的。只是时间隔了三两年,便忘掉了。这件趣事记在这里:
像这样在微博上发的东西真是既容易丢,也不连贯。例如我曾经写过一段配图的心情文字:
我常常回顾自己。例如写字,文字好,但骨子里没东西。如果是书,文字感不强了,但知识总归是有些的。外在的东西花花绿绿的好看了,本质是内地里没东西需要拿些色彩抹在外面。修行的看不见菩萨,拜佛的不过对着一尊彩俑,很多事,就这么一眼眼地看着自己,渐渐地也就看明白了。
事实上这图并不是我的,也原本不是用来配这段文字的。它的出处更早是在“国学论坛”上面,当时有网友发出来,我觉得喜欢,便写了一首诗:
疏竹三竿瘦,青莲半粒苦。
彩鲤影若在,小僧法相无。
没想到网友读不明白,因了“小僧法相无”这一句而觉得这首诗是在讲“画得不好”。于是,我又再写了一篇文字来讲:
这四句诗,有四件物事是诗中有,而画中实不存有的:
- 三竿堪可数,青莲几曾苦;影子须弥在,法相有亦无。
所以这是禅诗的写法:诗与悟在。而且这首诗还进一步地讲了四个意思,是“诗”中说有,而“觉”中实无的:
有形的(瘦)其实无形;
有味的(苦)其实无味;
有色的(彩)其实无色;
有相的(法)其实无相。
这便是在讲佛家的觉识了。是所谓相中有的,正是没有;相中没有的,正是有。有与没有,其实在这相中,皆是不要紧的。因为此相的有无,尽在观相的看客,而不在持相的诗画。
除了写些这种不打紧的,我偶尔写些与麦子(Joy)相关的诗文。最早的一首词,是刚刚认识的时候写的:
《如梦令·一剪梅》
是夜月色无华,雾霭轻扬如画。
悠然笛声下,几度欲归还罢。
无它,无它,深冬一剪梅花。
当时还写了一首“绝黛”,想来也是为麦子写的,只是时间太久,记不真切了😜:
《如梦令·绝黛》
素面无需铅华,蛾眉谁人能画,
踟躇窗篱下,笺满难把笔罢。
愁煞,愁煞,伤春何必梨花。
早些年我的诗词风格是比较苦情的,婉约而清薄。后来阅历丰富了,文字上就沉稳了许多,即便仍是写伤情哀怨的,也不会像上面这样。例如曾经麦子发了一篇朋友圈,是一张照片,画面很简单的:只是高挑的檐角上,站着一只孤伶伶的麻雀。于是我拟了一首古风来配:
天湛湛兮,沧沧如幕;羽习习兮,沾尘带露。
是趁闲时,悄停梁户;或当花前,暗窥妆妇?
稍息止兮,复往何处?君已远兮,惟我凄伫。
看来这点写诗的底子用在现在,便也只能够为日常中的一些小事写写了。呵呵~
之前与麦子同学都很爱户外。有旅行计划不成的,写一首:
无题·于2000年
短桥还正往,行步却已迟。春色稍纵逝,游者当择期。
玩得高兴的,也来一首:
无题·2005.01.18, 因妻游云台禅寺作
雪夜听禅声,驴步入云台。一念别尘嚣,千钟见太白。
有些候玩得馋了也可能写一首哦(呵呵,这是《深夜食味记》系列中的):
无题·2016.02.13 于悉尼
吞津饥渴甚,离乡肚腑哀。思味至夜深,奈何无良材。
多数短诗是用来配麦子同学发的照片的(当然也有长篇成组的,例如这里的诗写江南之行):
蜂蝶轻舞处,青绿破土时。沉香知梅绛,新妆映哪枝?
而最新近的,大概是一首词(写的是张衡路上的张衡公园):
虞美人影
草熏风暖花秀蕊,忽忽一夜春归。云色青青若醉,还与东湖寐。
闲庭信步语声脆,依稀当时初会。贪看莺莺燕燕,又劳佳人催。
其中这一句“云色青青若醉,还与东湖寐”,就是我在博客中的签名档的由来了。
看起来好象全是旧体诗词呢!
我确实是喜欢古文多一些,连麦子都说我看起来像个夫子。不过也并不是说我就不写新体诗,比如早些年有写过一首《关于永恒》。之所以会提到它,是因为我在《石像的忆述》之后便基本不写这类文字了,这是少有的例外(1999年):
《关于永恒》
夜的黑是永恒的吧
我想
我回头看看冰冷的砖墙
漠然 地面对着这黑夜的深处
砖墙显得很完美
被抹得很平也很光滑
砖墙也应该很坚实吧
我想
因为我的脊梁靠着它冷冰的胸膛
砖墙也应该是有生命的吧
我想
因为我的脊梁能感觉到一颗还在搏动的心脏
我倚着这样一道冷漠的墙
在暗夜的街道上
我听见远方的禅钟悠悠敲响
我看见有过往的情侣如同燧石将黑夜擦亮
我知道街道的尽头不是家也不是超级市场
一只很小的蜘蛛很久很久前就在这街灯的顶上织着一张很小的网
我伸手拂了拂裤脚上干裂的泥浆
我回转身去将身体贴紧砖墙
我轻轻地将唇痕留在这堵墙上
砖墙也应该是会流泪的吧
蜷在墙根下
我这样想着
等着天亮
之后应该再没写过了罢?
想想,好象也不全对,应该是还有一首的(2009年):
《要有光》
我一直在寻求尽头,
或展望于将来,
或求源于过往。
我在一道大河的中间,
前后观望,
时而俯首所得的,
不过是一掬破碎的倒影。
倒影中,还是我的迷惘。
不过它并不是一首独立的诗。它事实上是我的那本《JavaScript语言精髓与编程实践》在发布电子版的时候写的序(中的一部分)。这篇序是“世界需要一种什么样的语言”,它也被节选到“语言精髓”这本书的第二版的序中。——不过节选的时候,正好删除了这段文字,所以大多数读者便不知道了。
原文其实只是一段文字(在这里),但排成长短句之后,竟然也如诗一般可读。😛
嗯,说起来,麦子也偶尔会写新体诗哦!
哈哈哈~~
在2012年离开支付宝时,曾作一首:
无题·2012年离杭
四海为家中原客,行来方知所得空。
莫向前程问三载,半作书生半作工。
这首诗写的时候并没有多想,未料后来三数年,真是“半作书生半作工”。它后来也做了我在oschina以及知乎(等等一些我不太常去的网站)的签名。
诗中的“中原客”并非指我是河南人,而是说我这些年四处飘泊,家虽然安在郑州,却也如做客一般。我原是四川人一枚来着。
在2016年11月时,曾有一首谐趣的古风写在朋友圈里:
无题·2016年11月于京城
麦子约帅哥于浦西兮,吾独酌于都朝。
叹门前风卷薄雪兮,吾瑟瑟而悯悼。
素酒三温复凉兮,吾归心如飞矢。
疾兮,迅兮,期期而不至。
这段其实是打趣的,当时麦大老板约老友王大发财于浦西饭局,而我正好在北京吃着火锅看着雪。:)
雨中听泉,茶道寻芳,诗书作伴,美人相陪,美
本也不过是他的赞叹而已。而我是一时兴起,四句各加了一字,伪作古人,回给了gurudk:
雨中听泉落,茶道寻芳归。诗书勤作伴,美人时相陪。
《绝句.归田乐》——宋.时轶名
没想到那时真骗到了gurudk~ 哈哈。
]]>原作于2017.04.16发于微信朋友圈
跟麦子同学策划了一次无计划的江南之行,从4.6~4.15,自上海经苏州、湖洲、宜兴、南京,最后从镇江回到上海。去年游澳大利亚时,便趁着得了空闲,写了一组『深夜食味记』,那是散文。这次亦然,不过换成了古体诗。
这次写法有些特别。在习惯上每日只写两句,写一时之所想,在用韵、平仄这些方也并不特别讲究。十日后掇句而成诗,不求同题同义,只求上下合仄押韵、诗趣暗合,如此一来还真算是写了几首。不过取诗题的时候有些麻烦,因此简简单单地用『·』号隔了,表示这诗原是两段而已。当然也有几首是一次写成的,合在一起,算作了这次的游记。
烟雨入苏杭,春色只平江①
斑斓三两点,珠玉正琳琅①平江:指平江路,沿苏州平江河的一条小路,历史老街。
前两句既写我们到苏杭闲游之实,也写当时的阴雨天气;平江之美,
]]>原作于2017.04.16发于微信朋友圈
跟麦子同学策划了一次无计划的江南之行,从4.6~4.15,自上海经苏州、湖洲、宜兴、南京,最后从镇江回到上海。去年游澳大利亚时,便趁着得了空闲,写了一组『深夜食味记』,那是散文。这次亦然,不过换成了古体诗。
这次写法有些特别。在习惯上每日只写两句,写一时之所想,在用韵、平仄这些方也并不特别讲究。十日后掇句而成诗,不求同题同义,只求上下合仄押韵、诗趣暗合,如此一来还真算是写了几首。不过取诗题的时候有些麻烦,因此简简单单地用『·』号隔了,表示这诗原是两段而已。当然也有几首是一次写成的,合在一起,算作了这次的游记。
烟雨入苏杭,春色只平江①
斑斓三两点,珠玉正琳琅①平江:指平江路,沿苏州平江河的一条小路,历史老街。
前两句既写我们到苏杭闲游之实,也写当时的阴雨天气;平江之美,可以一赞。
后两句中,上句是写一天以后游苏州工艺美术博物馆,却没写藏品和观感。当时博物馆中只有廖廖数人,我与麦子在林荫处倚栏无语,斑斓地红叶天,是安静所在。下句的『珠玉正琳琅』,却是写我们随后去了不远处的苏州博物馆,参观『玉叶金枝——明代江西藩王墓出土文物精品展』。用一『正』字,是错开时空,上一句的寻幽取静,与下一句的琳琅满目对照,是『蝉噪林愈静』的写法。
黄花濯雨娇,玉足春寒薄①②
眉黛藏秀色,青丝起妖娆①黄花:油菜花。时小雨初歇,雨气未尽,春寒绵绵。
②玉足:指美人腿。美人腿是地名,且名如其形,确是玉足纤纤。
前两句说的是雨后春寒的风景。后两句写的是麦子。
麦子同学最喜欢油菜花,所以那天的朋友圈我也就发了一整首诗。
雨过泉声至,风起竹涛还
文峰轻晚客,烟柳落纸鸢①②①文峰:指文峰塔,位于宜兴龙背山森林公园,有『文峰夕照』之景。
②轻:轻慢。
前两句是写我们十日于『趣舍·一棵树』夜宿。到达时正在下雨,而这家民宿是借山成壁,因此屋外下雨,则室内生泉。坐在大厅品茶,便可听耳旁叮咚轻响,雨声渺而泉声近。整个建筑倚于竹林之下,风起如涛,风止无声。
后两句是十一日写于宜兴,游龙背山森林公园。写我们到得晚了,既未看得『文峰夕照』之景,连风筝都已经放过了,依稀有一些落在柳树上,破落寂廖。所以当日在微信发圈时有写:
时至春暮,倒寒愈盛,坐想被时光轻慢的我们,又何曾辜负过风景。
溪桃夜听碧,林竹入目鲜①②
时有小去处,不见真湖山①②碧、鲜:指碧鲜庵。善卷洞风景区内有碧鲜庵,曾为英台读书处。
前两句是十二日游善卷洞,写于碧鲜庵前。庵前小溪边桃花纷纷,至远处,竹林层层。然则碧色岂可耳听,竹鲜哪能目视?斯若听者,斯若视者,惟心下念念耳。
后两句写的是七日我游拙政园和定园后,对苏州园林的观感。说是造景借景、假山假水,实则格局太狭,难得张狂而已。一步画廊半坡桥,小之甚也。
湖畔驻千舟,庭中挂紫藤①②
轻风胡不住,斜阳照半亭①湖畔:指玄武湖,南京市东郊有玄武湖,其北有明城墙、钟山。
②庭中:玄武湖台菱堤有花架七曲,若庭若廊,『台菱花架』一景是也。
前两行写玄武湖景观。千舟不发,紫藤轻挂,静若动也。
后两行是次日游中山植物博览园所写。风轻不停,夕照影移,动亦静尔。
寻香入花海,佳人巧笑频
湖水漫半堤,林荫没短亭
麦子说中山植物园(植物博览园)是此行她最喜欢的去处之一,这时节郁金香开得正盛,满园花香。园中有湖,名曰前湖,时湖水漫堤,林深掩亭,各成景致。
肴肉留客驻,待渡亭下空①②
日落千帆尽,谈笑大江东①肴肉:一种本地美食,本地人将『肴』字读作xiao(音『肖』)。
②待渡亭:西津渡渡口有亭一座,供路人小憩,等待摆渡的场所。
前面说『不舍去』,后面写『君去也』。15日于镇江,上午游镇江西津渡,下午则返回上海浦东。一日三两地,游者终有期。
后两句既是虚写长江上当年日落挥别、谈笑作揖的情景,也是暗指我跟麦子回上海了也。^^.
]]>上一篇手记是讲“改造Gitment”的(在这里)。其实这个“改造”花了不少功夫,以至于后来还出了一个给
hexo-theme-next
的版本。这是推迟了“做多人博客”的一个原因,另一个原因则是我以前的博文需要更新的太多,花了很多时间。当然,那个叫
Monster
的项目(在这里)也是花销时间的大户。所以,结果就是:这篇“手记7”被推迟了很久...很久...
多人博客的功能其实我一早就做出来了——至少,大概的框架是完成了的。但是我一直没有发布出来,“麦秸的垛”也就一直没上线。
下面讲讲这整个的过程。
在Ghost 0.9x中其实有一个Bug,就是你总是只能将博客导入到'id=1'的帐户(或以该帐户身份导入)。我说不清这个Bug是什么时候存在,又或者(可能)什么时候会修复/已修复。我当时找到的解决方法,就是直接改sqlite数据库。
]]>上一篇手记是讲“改造Gitment”的(在这里)。其实这个“改造”花了不少功夫,以至于后来还出了一个给
hexo-theme-next
的版本。这是推迟了“做多人博客”的一个原因,另一个原因则是我以前的博文需要更新的太多,花了很多时间。当然,那个叫
Monster
的项目(在这里)也是花销时间的大户。所以,结果就是:这篇“手记7”被推迟了很久...很久...
多人博客的功能其实我一早就做出来了——至少,大概的框架是完成了的。但是我一直没有发布出来,“麦秸的垛”也就一直没上线。
下面讲讲这整个的过程。
在Ghost 0.9x中其实有一个Bug,就是你总是只能将博客导入到'id=1'的帐户(或以该帐户身份导入)。我说不清这个Bug是什么时候存在,又或者(可能)什么时候会修复/已修复。我当时找到的解决方法,就是直接改sqlite数据库。
不过自从我开始启用Ghost 1.x之后,这个担忧就没有了。在多人博客这个问题上,Ghost 1.x做得其实更好,所以我后面也是在用这个版本,关于0.9x的更多细节就不谈了。
假如你已经有了一个基于Ghost的本地博客,已经有一个帐户了,那么当你需要把它变成“多人博客”时,你自然需要再创建一个帐户——这个不用多说了。一旦你搞定了,那么你可以用monster来查询到多个用户的信息:
> monster list user
id name slug email
------------------------ ---------- ---------- --------------
1 aimingoo aimingoo [email protected]
5951f5fca366002ebd5dbef7 second new-user ...
...
记下新用记的id
值(5951f5fca366002ebd5dbef7),后面我们会用到。
注意:
在Monster v1.0.7以下的版本中
list user
只会显示被截断的user id。因此要正确地查询这个id
值,请将你的Monster升级到v1.0.7
或更高版本——或者你也可以自己打开sqlite数据库查看。
接下来,假设你是用BlogToWordpress来迁移的博客,那么你会有一个wordpress格式的.xml文件,存放用户second的全部博客——如果你是用别的来导出,那么请转换到wordpress格式先。参考:
我们将这个.xml用工具wp2ghost转换到ghost-tmp.json,然后再使用putrefy.js来处理一次,得到最终的ghost.json就可以了。这个过程参考:
但还是要注意以下几点:
你可以考虑使用我的修改版wp2ghost。主要有三处变化:
wp:wp_author
和wp:author
来识别作者名下载在这里:
如果你只是简单导入,那么你可以直接使用putrefy.js命令行传入Ghost用户的id值(上面找到的那个)。例如:
# 获取 putrefy.js
> curl -L https://github.com/aimingoo/ghost-utils/raw/master/putrefy.js -o putrefy.js
# 处理ghost-tmp.json
> node putrefy.js ghost-tmp.json '5951f5fca366002ebd5dbef7' > ghost.json
不过注意,很多情况下putrefy.js不是直接使用的,它几乎总是
需要修改配置才能使用(除了上面的这个new_author_id参数外,就只能直接修改源代码了)。
最后,参考上面的博客说明,你应该可以顺利地用putrefy.js了。
在使用putrefy.js时,它缺省为你的post生成的slug将是authorId-postId
格式的,例如上面的5951f5fca366002ebd5dbef7
,就会生成5951f5fca366002ebd5dbef7-1
.....-n
这样的格式。
你可以改这个缺省值改成特定风格。例如2-
,或者myblog:
这样的前缀。这需要直接修改putrefy.js源代码中的SLUG_FROMID
变量值。如下:
## 参数值:
## - "字符串": 以postId作为slug,并将该字符串值直接作为前缀
## - true: 以postId作为slug,并用当前的"authorId-"作为前缀,authorId值缺省为1
## - false: 不修改slug
SLUG_FROMID="2-"
另外,某些情况下,从wordpress格式的.xml文件中导出的authorId值不总是缺省值1(或者可能因为意外的缘故就没有authorId值),这种情况下,你需要修改putrefy.js代码中的
var author_map = {
"1": ...
}
它可以处理多个authorId,或强制指定转换表。
有两种方法来识别多个作者。
第一种是直接在模板中使用{{author}}
,这个标签对于author、page和post等页都是有效的,因此可以方便地使用这个标志来为不同作者切换内容:
<!-- 可以直接用`{{#has ...}`来识别 -->
{{#has author="aimingoo, qomo"}}
....
{{/has}}
但是这不能作用于{{> ..}}
标签来装载的子模板——你当然可以写多个has
来识别,但这并不方便。因此,我在博客中选择了第二种方案:通过加载<script>
标签来处理。
这个名为author-switch.js
的脚本在这里:
可以通过如下的方式来加载:
<script type="text/javascript" author="{{author.slug}}" src="/assets/js/author-switcher.js"></script>
或
<script type="text/javascript" author="default" src="/assets/js/author-switcher.js"></script>
在author-switcher.js
中会读取所有scripts并查找到这个标签,然后从标签中取出author
属性,并针对不同的author来处理当前页面的显示——基本上就是调整导航栏、标题页等等。
author-switcher.js
中有部分代码显得比较复杂,主要是根据deviceWidth来处理导航栏显示的(用于适应手机等窄屏设备)。
你不能直接使用
author-switcher.js
,它需要根据你的网站定制
一些静态页是多个作者之间不同的,例如A作者可以显示某些标签,而B作者就不需要(你想想如果在麦秸的垛的文章里显示出JavaScript这样的标签会是什么效果)。因此我除了将tag-cloud等页面静态化(处理方法在这里)之外,也在其中加入了相应的代码来处理这种静态页内的信息。例如:
<!-- in page-tag-cloud.hbs -->
var enabledCloudTags = {
'default': 'all',
'aimingoo': 'all',
'more users': [ "some tags for other', ...]
}
function filterByAuthor(context, enabled) {
if (enabled == 'all') return context;
...
}
相应的代码在这里:
注意其中的cloudTagHtmlContext()内的代码是由标签
{{> "tag_cloud"}}
自动生成的,参考:'7.2.1 为标签云添加一个静态页' @这里
Ghost缺省的多作者模式是“多作者单线索”的,也就是所有作者的prev/next
是在同一个list中,这样A作者的上/下一篇日志,就可能是B作者的。对于同一主题的博客(例如相同兴趣的网站)来说这没问题,但是我跟Joy的博客风格差异很大,你想想在一篇讨论户外驴友美食或化妆的文章中出现“上一篇”是某个开源程序项目的……效果,那真的是……
好吧。所以我需要跟Joy的博客使用“多作者多线索”的风格。这个是早先的Ghost版本中是不支持的,因为它默认的prev/next helper是只支持单线索的。这个问题已经被提了两年了……没解决过。
很幸运的事情是,正好在“麦秸的垛”上线前一两天,Ghost v1.14.0发布了。这个版本中带了一个primary tag
的特性,它的实现方案令人眼前一亮啊(呵呵)。“多作者多线索”的实现立即变得简单了,于是我加了几行代码,现在你可以在post.hbs
里这样写:
{{! -- MUST include "author" field -- }}
{{#get "posts" filter="id:{{id}}" include="author" as |current|}}
{{#current}}
{{#prev_post in="author"}} ...
...
{{#next_post in="author"}} ...
...
{{/current}}
{{/get}}
就可以了。——注意其中的include="author"
和in="author"
。
这个简单的feature已经提交到Ghost team了(在这里),如果你等不及的话也可以直接用我fork的版本:https://github.com/aimingoo/Ghost/tree/prev-next-author
]]>经过几个版本的更新,Monster已经开始有了丰富的功能,这其中就包括对Ghost多人博客的支持。
Monster主要是在updatesite.sh
模式中支持多人博客,也就是说这个功能在update
模式下可用。当在.monster
配置文件的EMAIL
参数中使用一个列表,例如:
EMAIL=("[email protected]" "[email protected]")
那么多人博客的模式就自动开启,并在--sync-removed
命令中有效。
注:如果你只是删除post,或更新theme中post的风格,那么由于
update
模式无法从数据库感知到你的更新,所以就需要使用--sync-removed
来强制更新指定用户/多个用户的博客内容。一旦使用该命令,那么也将导致整个的索引页会强制更新(因为上面的操作事实上也意味着全站的posts index pages页发生了变化)
当使用update
经过几个版本的更新,Monster已经开始有了丰富的功能,这其中就包括对Ghost多人博客的支持。
Monster主要是在updatesite.sh
模式中支持多人博客,也就是说这个功能在update
模式下可用。当在.monster
配置文件的EMAIL
参数中使用一个列表,例如:
EMAIL=("[email protected]" "[email protected]")
那么多人博客的模式就自动开启,并在--sync-removed
命令中有效。
注:如果你只是删除post,或更新theme中post的风格,那么由于
update
模式无法从数据库感知到你的更新,所以就需要使用--sync-removed
来强制更新指定用户/多个用户的博客内容。一旦使用该命令,那么也将导致整个的索引页会强制更新(因为上面的操作事实上也意味着全站的posts index pages页发生了变化)
当使用update
模式时,如果一个置为static page
的页面是使用动态生成方式来生成的——例如archive页,那么仍然是由于数据库不可感知的缘故,就需要使用--force
参数来强制生成。你可以通过一个名为FORCEPAGE_LIST
的参数来指定这些页面的列表,该参数也在.monster
配置文件中。例如:
FORCEPAGE_LIST=("archives-post-second-user/")
在这个例子中,由于/archives-post/
页总是动态生成的网站归档页,所以它缺省是被Monster内部处理的。但如果是使用多人博客(或者其它原因),那么可能就需要生成多个这样的归档。这“动态生成”就意味着从sqlite数据库中感知不到,所以就需要象上面这样强制定义列表。
与此类似的,由于update
模式总是尝试使用short_path,因此缺省情况下会将类似/about/index.html
这样的页面处理成/about.html
。这在大多数情况下是适用的,但Ghost却默认会将导航栏上的链接转换成/.../
这样的格式,也就是说它必须使用/about/index.html
。这一类的问题在update
模式时必须手工指定,你可以使用类似下面的配置:
FORCEINDEX_LIST=("about-second-user")
在使用Monster的generate
模式时也会有这个问题,这是通过IGNORE_LIST
来处理的。事实上makesite.sh
模块会将这个列表与FORCEINDEX_LIST
列表合在一起,来跳过所有不需要处理——亦即是将会保留index.html文件——的目录。
Monster提供一种快速搜索的功能,这可以通过monster search ...
来实现。
在Ghost 0.9x版本与1.x版本中的数据库结构并不相同,而Monster会自动识别并处理不同的查询条件。因此,现在开始,你可以简单的了解一下你的Posts中都有些什么了(Monster只搜索Posts的内文,而在Ghost Admin后台上搜索只处理标题):
> monster search "关键字"
id slug created_at title
-------- ---------- ------------------- --------------------------------------
59dafc1a 1-44 2006-04-16 21:03:00 Qomo OpenProject beta1 精彩图集~
59dafc1a 1-50 2006-10-09 14:23:00 【原创】搞了个NetGear的路由?
...
在使用Ghost 0.9x时,数据库中的Post ID是从1开始的自增序的,因此Monster采用的short-path规则是authorId-postId
。但从Ghost 1.x开始,数据库中的authrId和postId都不再是自增序的整数了,而是一个长的字符串(从旧数据库中导入的一些数据存在例外)。因此,即使使用Monster的--short-path
参数,也无法按旧规则来生成这个名字。
因此从Monster 1.06开始,update
模式下的--sync-slug
会根据数据库版本来选择如下两种格式之一:
postId
作为post slug;authorId-postId
作为post slug。考虑到从0.9x迁移到1.x的情况,--sync-slug
不对已经采用了short-path格式的slug再做sync操作——了就是说0.9x已经使用了short-path的post会被忽略。但在识别这种情况时,只处理authorId<9的情况,这在一般情况下是够用的。
Monster为一些常用命令建立了快速命令风格,例如上面的monster search ...
。在这种风格中,search
并不是一个模式——Monster目前只支持generate/update/preview
三种模式。
事实上,monster search ...
是等效于monster update --search ...
的,从Monster v1.0.5开始就提供了这样的一种快速命令风格,用于简单地执行一些命令开关。虽然目前只有search/list
两个,但将来会扩充它。
在早前的Monster脚本中只能处理有限数据的Posts,因为过多的Posts生成特别大的正则表达式,从而导致sed调用失效。在Monster v1.0.6中修正了这个Bug,使得Post会根据标题长度自动计算批次,分批处理,从而实现了对“无限数量”的Posts的支持。
Monster从v1.0.5开始就不再需要预先安装Buster了,所以它也不再依赖Python环境——如果你不使用preview
功能的话。
你可以使用brew来快速安装Monster:brew install aimingoo/repo/monster
。
也可以从Github下载Monster:https://github.com/aimingoo/monster。
]]>麦子的文字写得比我好看,她的博客就叫“麦秸的垛”。
很早就说要把这个博客做成多人博客,其实也一早就做了,只是一直没上线。原因是从旧博客迁移过来的时候,几乎所有的文章都得再按markdown格式重排一遍。这个确实很花时间,比如我的那部分大概做了三个月呢。
所以“麦秸的垛”就一直拖着没上线。
麦子之前在新浪、搜狐和微软MSN上发的博文其实不少,我一次全迁移过来了,也是200多篇呢。所以接下来又得花阵子功夫来做排版了,不过应该会比我的快:毕竟她的博文没代码……
我的文字太硬气,读起来难以让人愉悦,麦子的不会。这个,相信我。
点右上方的链接就好了。
]]>麦子的文字写得比我好看,她的博客就叫“麦秸的垛”。
很早就说要把这个博客做成多人博客,其实也一早就做了,只是一直没上线。原因是从旧博客迁移过来的时候,几乎所有的文章都得再按markdown格式重排一遍。这个确实很花时间,比如我的那部分大概做了三个月呢。
所以“麦秸的垛”就一直拖着没上线。
麦子之前在新浪、搜狐和微软MSN上发的博文其实不少,我一次全迁移过来了,也是200多篇呢。所以接下来又得花阵子功夫来做排版了,不过应该会比我的快:毕竟她的博文没代码……
我的文字太硬气,读起来难以让人愉悦,麦子的不会。这个,相信我。
点右上方的链接就好了。
]]>