作者 | 昭朗
TC39 1月会议在 1 月 29 日如期结束,以下是这次会议中成功争取到了阶段性进展的提案的介绍、近期改动回顾等提案的进展总结。
Stage 3 -> Stage 4
从 Stage 3 进入到 Stage 4 有以下几个门槛:
必须编写与所有提案内容对应的 tc39/test262 测试,用于给各大 JavaScript 引擎和 transpiler 等实现检查与标准的兼容程度,并且 test262 已经合入了提案所需要的测试用例;
至少要有两个实现能够兼容上述 Test 262 测试,并发布到正式版本中;
发起了将提案内容合入正式标准文本 tc39/ecma262() 的 Pull Request,并被 ECMAScript 编辑签署同意意见。
https://github.com/tc39/test262
https://github.com/tc39/ecma262
Intl.DateTimeFormat.prototype.formatRange
提案链接:https://github.com/tc39/proposal-intl-DateTimeFormat-formatRange
这个提案为 JavaScript 引入了在国际化场景格式化日期区间的能力。长期以来,不同文化背景下的日期书写标记都有或多或少的细小区别,而为了给多样化的用户提供合适的日期文本说明,对于开发者来说是一个非常大的维护负担。目前 ECMA402 作为 ECMA262 (ECMAScript)国际化专项,已经给 ECMAScript 带来了许多非常实用的 Intl API。
这个提案引入的 API 可以通过 Intl.DateTimeFormat.prototype.formatRange
与 Intl.DateTimeFormat.prototype.formatRangeToParts
来使用。前者将日期区间格式化成文本,后者则将日期区间格式化成分割好的区间描述,开发者可以选择性的使用其中的片段。
let date1 = new Date(Date.UTC(2007, 0, 10, 10, 0, 0));
let date2 = new Date(Date.UTC(2007, 0, 10, 11, 0, 0));
// > 'Wed, 10 Jan 2007 10:00:00 GMT'
// > 'Wed, 10 Jan 2007 11:00:00 GMT'
let fmt = new Intl.DateTimeFormat("en", {
hour: 'numeric',
minute: 'numeric'
});
fmt.formatRange(date1, date2);
// return value:
// '10:00 – 11:00 AM'
fmt.formatRangeToParts(date1, date2);
// return value:
// [
// { type: 'hour', value: '10', source: "startRange" },
// { type: 'literal', value: ':', source: "startRange" },
// { type: 'minute', value: '00', source: "startRange" },
// { type: 'literal', value: ' – ', source: "shared" },
// { type: 'hour', value: '11', source: "endRange" },
// { type: 'literal', value: ':', source: "endRange" },
// { type: 'minute', value: '00', source: "endRange" },
// { type: 'literal', value: ' ', source: "shared" },
// { type: 'dayPeriod', value: 'AM', source: "shared" }
// ]
目前提案所包含的内容已经可以在 Chrome 79 与 Node.js 12.9.0 中开始在生产环境中使用了。
Stage 2 -> Stage 3
提案从 Stage 2 进入到 Stage 3 有以下几个门槛:
- 撰写了包含提案所有内容的标准文本,并有指定的 TC39 成员审阅并签署了同意意见;
- ECMAScript 编辑签署了同意意见。
JSON Modules
提案链接:https://github.com/tc39/proposal-json-modules
这个提案原本是 Import Assertion 提案的一部分。不过在 2020 年 7 月的 TC39 会议中经过讨论决定将定义 JSON Modules 的部分从 Import Assertion 提案中分离出来,单独讨论、演进。
这个提案为 ECMAScript Module 语法带来了断言模块类型为 JSON 的能力。这样引擎就可以以 JSON 模式解析目标模块。对于开发者来说,明确地断言模块类型可以获得更快的解析速度。
import json from "./foo.json" assert { type: "json" };
import("foo.json", { assert: { type: "json" } });
值得注意的是,因为采用的断言的语义,如果目标模块是一个 JavaScript 文件或者其他不符合 JSON 语法定义的文件,则会导致模块解析失败,无法导入。同时,在浏览器中,这也意味着目标模块的类型(是否是 JSON,还是一个 JavaScript 模块)不是取决于服务端返回的 Content-Type HTTP Header 来判断,而是需要开发者明确在代码中指定类型,来解决提案在最初时所被挑战的安全性问题。
Private Fields In Operator
提案链接:https://github.com/tc39/proposal-private-fields-in-in
这个提案提出了使用 in 操作符来判断当前 Stage 3 Class Private Fields 提案引入的 #字段 是否在一个对象中存在。相比于直接通过访问私有字段 try { obj.#foo } catch { /* #foo not exist in obj */ }
来判断一个对象是否有安装对应的 #字段
来说,Private-In 可以区分是访问错误,还是真正没有 #字段
,如以下场景通过 try-catch 就无法区分是否是访问异常还是 #字段
确实不存在:
class C {
get #getter() { throw new Error('gotcha'); }
static isC(obj) {
try {
obj.#getter;
return true;
} catch {
return false;
}
}
}
而通过 Private-In 可以简单、直白地、与普通字段类似的 in 操作符语义来判断一个 #field 是否存在在一个对象上:
class C {
#brand;
#method() {}
get #getter() {}
static isC(obj) {
return #brand in obj && #method in obj && #getter in obj;
}
}
值得注意的是,这个提案并没有改变 #field
的语义。也就是说,在对象词法作用域的外部还是无法访问这些 #字段 ,同样也无法判断这些 #字段
是否存在。另外,因为in
通常用在 duck-type 的场景,而对于 #字段
来说,下面这个例子虽然看起来字段名是一样的,但是对于每一个类来说,他们的 #字段
都是不同的(即使名字一样),所以与普通字段的 in
还是有些许因 #字段
带来的不同。
class C {
#foo;
staitc isC(obj) {
return #foo in obj;
}
}
class D {
#foo;
staitc isD(obj) {
return #foo in obj;
}
}
const d = new D();
C.isC(d);
// => false;
另一个值得注意的事情是,对于库作者来说,用户在某些场景下是可以在同一个 JavaScript 程序中使用多个版本的库,比如 lodash 3.1.1 和 lodash 3.1.2,这取决于依赖管理的定义。而如果这些库中使用了私有字段,则他们的对象是不具有互操作性的。这与上面这个同名字段例子的原因相同,这些不同版本的库虽然库名相同,但是实际上他们在程序中是具有独立的运行上下文的,class C
会被定义两次,并且这两次是毫无关系的,所以对于 3.1.1 版本的 C.isC
判断会对 3.1.2 版本的实例 c
会判断为 false
。这对于库作者维护来说,是非常需要注意的一个事项。
最后需要注意的是,在下面这个场景中,Private-in 检查可以造成正确但是不符合直觉的结果:
class Foo extends function(o) { return o; } {
#first = 1;
#second = (() => { throw null })();
static hasFirst = o => checkHas(() => o.#first);
static hasSecond = o => checkHas(() => o.#second);
}
let checkHas = fn => { try { return fn(), true; } catch { return false; } };
let obj = {};
try { new Foo(obj); } catch {}
console.log('obj.#first exists', Foo.hasFirst(obj)); // true
console.log('obj.#second exists', Foo.hasSecond(obj)); // false
这其中的重点是 JavaScript 的 class constructor 和多个 #字段
是可以部分初始化的(虽然 extends
一个函数的写法也是让人无法挪开视线,直拍大腿:你咋这么能呢?但这不是问题重点 :D)。而如果使用 Private-in 来做 class brand check,即严格类型检查,则有可能因为部分初始化而只检查了成功初始化的 #first
但是没检查 #seond
是否存在,导致访问 #second
的时候可能抛出异常。特别是在实际场景中我们可能有非常多的 #字段
,为了解决这个问题,这次会议也提出了一个新的提案 Class Brand Checks,我们也会在后文详细解析。
Class Staic Initializer Block
提案链接:https://github.com/tc39/proposal-class-static-block
自从有了 Class Private Fields,对于类的语法是不断地有新的实践与需求。这个提案提议的 Class Static 初始化块会在类被执行、初始化时被执行。Java 等语言中也有类似的静态初始化代码块的能力,Static Initialization Blocks。
提案中定义的初始化代码块可以获得 class 内的作用域,如同 class 的方法一样,也意味着可以访问类的 #字段
。通过这个定义,我们就可以实现 JavaScript 中的 Friend 类了。
let getX;
export class C {
#x
constructor(x) {
this.#x = { data: x };
}
static {
// getX has privileged access to #x
getX = (obj) => obj.#x;
}
}
export function readXData(obj) {
return getX(obj).data;
}
Stage 1 -> Stage 2
从 Stage 1 进入到 Stage 2 需要完成撰写包含提案所有内容的标准文本的初稿。
JavaScript Module Blocks
提案链接:https://github.com/tc39/proposal-js-module-blocks
目前很多设备都有非常多的处理器核心,JavaScript 代码在多个执行单元(线程、进程,如 Web Worker,Node.js 进程,Node.js worker_threads)中同时执行也是越来越常见。但是现在的 JavaScript 对于多个执行单元间共享 JavaScript 代码并没有非常理想的方案,通常我们有以下几个方案:
1、将不同执行单元间的代码写在不同的文件里:
const Worker = new Worker('./my-worker.js')
worker.postMessage({ action: 'add', params: [40, 2] })
worker.addEventListener('message', data => alert(`Result: ${data}`))
2、通过一个通用的 Worker 执行器,然后将期望执行的 JavaScript 直接以字符串形式发送过去并执行(即每一次执行 JavaScript 引擎都需要重新解析这段 JavaScript 代码):
const result = await runInWorker('return "foobar"')
3、通过一个通用的 Worker 执行器,接受一个函数并将这个函数 toString 后直接以字符串发送执行,:
function add(lhs, rhs) {
// 这里捕获了外部变量的话,难以检测
return lhs + rhs;
}
const result = await runInWorker(add, 40, 2)
// 不支持 async function
async function generateGraph() {
/** complex code */
}
这些方式要么不够工效,要么效率较差、更有安全风险。而这个提案则提出了一个隔离了变量作用域的代码块,并且这个代码块中的代码可以实现解析一次,到处使用,也意味着其中有任何语法问题,都可以快速被引擎第一次解析即可检查出来:
let workerCode = module {
onmessage = function({ data }) {
let mod = await import(data);
postMessage(mod.fn());
};
};
let worker = new Worker(workerCode, { type: 'module' });
worker.onmessage = ({ data }) => alert(data);
worker.postMessage(module { export function fn() { return 'hello!' } });
ResizableArrayBuffer and GrowableSharedArrayBuffer
提案链接:https://github.com/tc39/proposal-resizablearraybuffer
这个提案可以给 Streaming、WebAssembly 等场景提供一个更加方便、高效的内存扩展方式。目前调整一个 ArrayBuffer 的大小需要复制内容,但是因为复制非常慢,而且可能导致内存空间碎片化,实际实践中限制非常多。
提案提出了两种新的 ArrayBuffer 类型:ResizableArrayBuffer 和 GrowableSharedArrayBuffer。
ResizableArrayBuffer 是一个内部存储区域可以拆卸的 ArrayBuffer。设计上希望 ResizableArrayBuffer 可以原地调整大小,但是提案没有对调整大小是否能够被观测做要求(改变内部存储区域实际内存地址等)。同样地,提案不对调整大小的方案有做定义。
let rab = new ResizableArrayBuffer(1024, 1024 ** 2);
assert(rab.byteLength === 1024);
assert(rab.maximumByteLength === 1024 ** 2);
rab.resize(rab.byteLength * 2);
assert(rab.byteLength === 1024 * 2);
GrowableSharedArrayBuffer 是可以在多个执行环境中共享的 ArrayBuffer,但是考虑到多个执行环境的同步,所以 GrowableSharedArrayBuffer 是一个只能增长而不能缩减大小的设计。
Intl.LocaleInfo
提案链接:https://github.com/tc39/proposal-intl-locale-info
这个 ECMA402 国际化提案为 Intl 带来了获取用户本地化偏好的信息的 API。比如获取用户习惯的周开始日(常见的有周一和周日),周末定义,用户的书写方向等。
let zhHans = new Intl.Locale("zh-Hans")
zhHans.weekInfo
// {firstDay: 1, weekendStart: 6, weekendEnd: 7, minimalDays: 4}
zhHans.textInfo
// { direction: "ltr"}
在这个例子中,1 代表周一,7 代表周日,沿用了 ISO-8861 定义的方案,并且与 Temporal 提案 通用。
Intl.DisplayNames
提案链接:https://github.com/tc39/intl-displaynames-v2
很多 Intl API 都提供了各种值的国际化能力,如:
let dtf = new Intl.DateTimeFormat("en", {month: "long"})
dtf.format(new Date("2020-01-01")) // January
但是目前还没有 Intl API 可以直接获取“一月”这个名词的国际化格式。
目前已有的 Intl.DisplayNames
API 只包含基本的地名、语言、书写系统、货币的支持。而这个提案则对这个国际化数据进行了扩充,如星期(星期一等)、月份(一月等)、单位(米等)、时区(北京时间等)、日历(公历等)、计数系统(“第一”等)。
let dn = new Intl.DisplayNames("zh-Hans", {type: "month", style: "long"})
dn.of(1) // "1月"
// 科普特历
let dn = new Intl.DisplayNames("en", {type: "month", style: "long",
calendar: "coptic"})
dn.of(1) // "Tout"
Stage 0 -> Stage 1
从 Stage 0 进入到 Stage 1 有以下门槛:
- 找到一个 TC39 成员作为 champion 负责这个提案的演进;
- 明确提案需要解决的问题与需求和大致的解决方案;
- 有问题、解决方案的例子;
- 对 API 形式、关键算法、语义、实现风险等有讨论、分析。
Stage 1 的提案会有可预见的比较大的改动,以下列出的例子并不代表提案最终会是例子中的语法、语义。
Async do expressions
提案链接:https://github.com/tc39/proposal-async-do-expressions
Async do 表达式是 do 表达式提案 的 async 语义扩充。遗憾的是,do expression 提案在这次会议上因为以下几个问题寻求进入 Stage 2 的共识失败了:
let x = do {
if (random() < .5) {
0
} // else undefined
};
do exression 以 do body 中的最后一个语句值作为 do expression 的 completion value,在这种情况下,do expression 的最后一个语句不能是 for 语句等循环语句、try-catch 语句、变量声明语句等等,因为这些语句作为 do expression 的尾语句的话,do expression 的 completion value 是容易造成歧义、或者引入了隐式 undefined
作为 completion value。但是提案又允许 if 语句作为尾语句,在这种情况下如果 if 没有命中条件则 completion value 默认为 undefined
,而这样的不一致从语法上难以让人信服。那么什么样的语法是更加具有一致性的呢?从设计理念来说,至少应该是 ban 掉所有可能造成隐式 undefined
completion value 的语句作为尾语句,或者让这些语句能有一致的语法设计避免隐式 undefined
作为 completion value。
另外几个争议点则是 do expression 的 completion value 不需要通过 return 语句来显式返回。同时在 do expression 中不能使用 break、continue、return 等语句。这给 do expression 的语法上增加了更多的不一致设计。
话题回到 async do expression。这个提案为 do 表达式引入了 async 修饰符,这样我们可以在非 async 函数上下文中的 do 表达式中使用 await 关键字了,同时,async do 表达式的语义是 IIAFE(Immediately Invoked Async Function Expression,原地立刻调用的 async 函数)类似,其中的代码是立刻执行的,直到第一个 await 为止。
Promise.all([
async do {
let result = await fetch('thing A');
await result.json();
},
async do {
let result = await fetch('thing B');
await result.json();
},
]).then(([a, b]) => console.log([a, b]));
Class Brand Checks
提案链接:https://github.com/tc39/proposal-class-brand-check
上文我们说到,Private-In 提案对于部分初始化的多 #字段
类的场景解决的问题非常有限,所以这次会议中,这个提案就是为了解决更直接的类类型检查的场景:是不是这个类的真实实例?JavaScript 中,因为原型链是可以非常方便地修改的,我们也经常可以通过修改原型链来获得一些便捷的能力。但是自从有了 Private Fields #字段
,原型链上的 #方法
就无法在这些通过修改原型链的对象上使用了(这些对象上不存在应该存在的 #字段
),问题多多。为了让开发者、库作者便捷的探知这个问题,通过早期错误来避免难以排查的运行时问题,这个提案为 ECMAScript 带来了检查一个对象是一个类的真实实例的能力。
问题来了,那什么是一个类的真实实例?在提案的定义中,一个对象如果被一个 class C 的 constructor 初始化过,那么这个对象就是这个 class C 的真实实例。
class C {
static isC = o => class.hasInstance(o);
}
C.isC({}) // => false
C.isC(new Foo) // => true
class Foo extends function(o) { return o; } {
static isFoo = o => class.hasInstance(o);
}
const obj = {};
new Foo(obj);
Foo.isFoo(obj) // => true
上面这个案例中,前两个例子非常好理解,普通对象不是 C 的真实实例,通过 new C 构造的对象是 C 的真实实例。但是第三个例子是什么情况?这是因为一个派生类的 constructor 会调用 super(),而 super call 则会将其基类的 constuctor 返回值绑定为当前的 this
,然后会执行该 constructor 的初始化流程,比如(如果有的话)将他的#字段
安装到这个 this
上。而我们这里真实实例的定义是如果一个对象被 class constructor 初始化过,所以对于这个场景,这个对象确实被这个 constructor 初始化过,所以是这个类的真实实例。那么问题来了,这个对象可能是基类的真实实例吗?这些具体的语义与可能会碰到的问题,将会在这个提案的 Stage 1 之后,预计在 Stage 2 中定义。
RegExp set notation
提案链接:https://github.com/tc39/proposal-regexp-set-notation
许多正则表达式引擎都支持预设的字符集(通常都是 Unicode 的各种字符集),避免开发者需要在正则表达式中硬编码字符集。同时提案也包含了字符集的交集、差集操作,便于自由组合多个字符集。
// 差集
[A--B]
// 交集
[A&&B]
// 嵌套字符集
[A--[0-9]]
比如下面这个正则表达式可以匹配所有非 ASCII 数字,然后我们就可以将这些非 ASCII 数字转换成 ASCII 数字:
[\p{Decimal_Number}--[0-9]]
或者匹配所有非 ASCII 的 Emoji:
[\p{Emoji}--\p{ASCII}]
Revisiting RegExp escape
提案链接:https://github.com/tc39/proposal-regex-escaping
长期以来,如果我们希望将用户输入作为正则表达式来使用的话,都会因为安全问题而需要将用户输入的内容转义后再输入 RegExp 来使用。但是转义实现都不一定能正确地将当前环境所支持的 RegExp 有特殊含义的字符完全枚举、并转义,或者是无法正确处理多 code unit 的 UTF8 字符(如 '😂'.length === 2
),这给开发者带来了较大的维护负担。
const str = prompt("Please enter a string");
const escaped = RegExp.escape(str);
const re = new RegExp(escaped, 'g'); // handles reg exp special tokens with the replacement.
console.log(ourLongText.replace(re));
这个提案就是希望解决这个问题,提议新增一个 RegExp.escape 函数,可以将所有正则表达式中具有特殊含义的字符转义。
RegExp.escape("The Quick Brown Fox"); // "The Quick Brown Fox"
RegExp.escape("Buy it. use it. break it. fix it.") // "Buy it\. use it\. break it\. fix it\."
RegExp.escape("(*.*)"); // "\(\*\.\*\)"
RegExp.escape("。^・ェ・^。") // "。\^・ェ・\^。"
RegExp.escape("😊 *_* +_+ ... 👍"); // "😊 \*_\* \+_\+ \.\.\. 👍"
RegExp.escape("\d \D (?:)"); // "\\d \\D \(\?\:\)"
Array find from last
提案链接:https://github.com/tc39/proposal-array-find-from-last
这个提案引入了 Array.prototype.findLast
与 Array.prototype.findLastIndex
。从 Array.prototype.lastIndexOf
和 Array.prototype.find
可以衍生得出这两个新的 API 语义是与 Array.prototype.find
类似的,不过是从 Array 的尾部开始遍历寻找符合其第一个 callback 参数所期望的元素。
const array = [{ value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }];
// find
array.findLast(n => n.value % 2 === 1); // => { value: 3 }
// findIndex
array.findLastIndex(n => n.value % 2 === 1); // => 2
array.findLastIndex(n => n.value === 42); // => -1
Defer module import eval
提案链接:https://github.com/tc39/proposal-defer-import-eval
模块的懒加载一直是一个常见的启动速度优化手段。但是目前的 ECMAScript Module import 语句都是立刻加载的,而 import()
调用则会将所有依赖这些懒加载模块的调用都传播成 async/await 模式的,给开发维护造成了一定负担。
async function lazySomeMethod() {
const { someMethod } = await import("./my-module.js");
return someMethod();
}
// 我们必须将这个较少使用的同步函数改成 async 函数才能使用上懒加载的特性;
function actuallySynchronousButRarelyUsed() {
someMethod();
}
而提案就是为了期望解决这个问题,基于类似于 Import Assertion 提案 后续的改进部分 模块属性 的语法,对模块的导入标记懒加载来避免 async/await 的传播:
import {x} from "y" with { lazyInit: true }
import defaultName from "y" with { lazyInit: true }
import * as ns from "y" with { lazyInit: true }
当然这个方案给原生 async 的 JavaScript 执行环境带来了更多的挑战。并且在 Top Level Await 已经 Stage 3 的前提下,懒加载导入的语义怎么将这些包含 TLA 的模块给兼并成同步的语义呢?目前提案针对 TLA 还没有一个让人信服的方案。不过 Mozilla 的代表认为经过评估,JavaScript 模块的加载时间不管是在 Web 还是其他平台都是受硬件(HDD、SSD 等)影响非常大,当然语义挑战也非常大。目前提案期望解决的问题非常美好,期待会有更好的解决方案:)
Extend TimeZoneName Option
提案链接:https://github.com/tc39/proposal-intl-extend-timezonename
这个 ECMA402 国际化提案扩展了 Intl.DateTimeFormat 中的 timeZoneName 选项,支持更多的格式化选项。
let timeZoneNames = ["short", "long", "shortGMT", "longGMT", "shortWall", "longWall"];
timeZoneNames.forEach(function(timeZoneName) {
console.log((new Date()).toLocaleTimeString("zh-Hans", {timeZoneName}));
});
上午9:27:27 [PST]
上午9:27:27 [太平洋标准时间]
上午9:27:27 [GMT-8]
上午9:27:27 [GMT-08:00]
上午9:27:27 [PT]
上午9:27:27 [太平洋时间]
EraDisplay
提案链接:https://github.com/tc39/proposal-intl-eradisplay
这个 ECMA402 国际化提案扩展了 Intl.DateTimeFormat 的选项,增加了 eraDiaplay 选项,用来格式化纪元信息。
提案提出的 eraDiaplay 支持三种选项:"never" 总是不格式化纪元;"always" 总是格式化纪元;"auto" 如果格式化年份并且纪元与当前时间历法的纪元不同,则格式化纪元。
new Intl.DateTimeFormat("zh-Hans").format(new Date(-752,3,13))
// 之前 => "753/4/13"
// 提案 => "公元前 753/4/13 BC"
Intl LocaleMatcher
提案链接:https://github.com/tc39/proposal-intl-localematcher
通常支持国际化显示的网站都会有较好地支持一组预设的本地化配置,而我们通常可以通过 Accept-Language
请求头或者 navigator.language
来匹配期望的本地化配置。但是用户所期望的本地化配置不一定被我们所支持,这时候就需要给用户匹配最佳的本地化配置。
目前这个匹配操作已经在 ECMA402 中定义,只不过还没有将这个操作通过 JavaScript API 暴露出来。这个提案就是将这个操作定义成了 JavaScript API。
Intl.LocaleMatcher.match(
/* requestedLocales */ ["zh-Hans", "en"],
/* availableLocales */ ["zh", "en"],
/* defaultLocale */ "en");
// => 'zh'
结语
日前由贺师俊 Hax 牵头组建、阿里巴巴前端标准化小组、中国 ECMAScript 社区活跃的同学等等一起管理的 JSCIG(JavaScript Chinese Interest Group),旨在代表中国国内的广大 JavaScript 开发者和初学者的利益,在语言使用效能方面向 TC39 技术委员会提出建议。
目前 JSCIG 中的参与者都较为活跃,而且大多数都是国内 JS 社区的活跃布道者。不过目前只有少数成员在 TC39 中。而且由于国内缺乏上游浏览器代表,因此还无法对很多议题产生比较强烈的影响,但是目前发展的趋势还不错。当然也跟目前 TC39 的议题大多数都是和工效议题相关。
JSCIG 在 GitHub 上开放讨论各种 ECMAScript 的问题,非常欢迎有兴趣的同学可以参与讨论:https://github.com/JSCIG/es-discuss/discussions。
关注「Alibaba F2E」
把握阿里巴巴前端新动态