2022-09-21前沿技术00
请注意,本文编写于 582 天前,最后修改于 582 天前,其中某些信息可能已经过时。

本期精读文章是:TC39, ECMAScript, and the Future of JavaScript

1 引言

logo

觉得 es6 es7 动不动就加新特性很烦?提案的讨论已经放开了,每个人都可以做 js 的主人,赶快与我一起了解下有哪些特性在日程中!

2 内容概要

TC39 是什么?包括哪些人?

一个推动 JavaScript 发展的委员会,由各个主流浏览器厂商的代表构成。

为什么会出现这样一个组织?

从标准到落地是一个漫长的过程,相信大家上次阅读 web components 就能体会到标准到浏览器支持是一个漫长的过程。

TC39 这群人主要的工作是什么?

制定 ECMAScript 标准,标准生成的流程,并实现。

标准的流程是什么样的?

包括五个步骤:

  • stage0 strawman

任何讨论、想法、改变或者还没加到提案的特性都在这个阶段。只有 TC39 成员可以提交。

  • stage1 proposal (1)产出一个正式的提案。 (2)发现潜在的问题,例如与其他特性的关系,实现难题。 (3)提案包括详细的 API 描述,使用例子,以及关于相关的语义和算法。

  • stage2 draft (1)提供一个初始的草案规范,与最终标准中包含的特性不会有太大差别。草案之后,原则上只接受增量修改。 (2)开始实验如何实现,实现形式包括 polyfill, 实现引擎(提供草案执行本地支持),或者编译转换(例如 babel)

  • stage3 candidate (1)候选阶段,获得具体实现和用户的反馈。此后,只有在实现和使用过程中出现了重大问题才会修改。 (1)规范文档必须是完整的,评审人和 ECMAScript 的编辑要在规范上签字。 (2)至少要在一个浏览器中实现,提供 polyfill 或者 babel 插件。

  • stage4 finished (1)已经准备就绪,该特性会出现在下个版本的 ECMAScript 规范之中。。 (2)需要通过有 2 个独立的实现并通过验收测试,以获取使用过程中的重要实践经验。

一般可以去哪里查看 TC39 标准的进程呢?

stage0 的提案 https://github.com/tc39/proposals/blob/master/stage-0-proposals.md stage1 - 4 的提案 https://github.com/tc39/proposals

我们怎么在程序中应用这些新特性呢?

babel 的插件:babel-presets-stage-0 babel-presets-stage-1 babel-presets-stage-2 babel-presets-stage-3 babel-presets-stage-4

3 精读

3.1 Stage 4 大家庭

Array.prototype.includes

assert([1, 2, 3].includes(2) === true);
assert([1, 2, 3].includes(4) === false);

assert([1, 2, NaN].includes(NaN) === true);

assert([1, 2, -0].includes(+0) === true);
assert([1, 2, +0].includes(-0) === true);

assert(["a", "b", "c"].includes("a") === true);
assert(["a", "b", "c"].includes("a", 1) === false);

这个 api 很方便,没有悬念的进入了草案中。

曾争议过是否使用 Array.prototype.contains,但由于 不兼容因素 而换成了 includes。

Exponentiation operator

// x ** y

let squared = 2 ** 2;
// same as: 2 * 2

let cubed = 2 ** 3;
// same as: 2 * 2 * 2

列表中进入了 stage4,但其 git 仓库 readme 还停留在 stage3。。

虽然已经有 Math.pow 了,但由于其他语言都支持此方式,js 也就支持了。

Object.values/Object.entries

Object.values({
	a: 1,
	b: 2,
	c: Symbol(),
}) // [1, 2, Symbol()]

Object.entries({
	a: 1,
	b: 2,
	c: Symbol(),
}) // [["a", 1], ["b", 2], ["c", Symbol()]]

也没有什么争议,Object.keys 都有了,获取 values、entries 也是合理的。

TC39 会议中有争辩过为何不返回迭代器,原因挺有意思,因为 Object.keys 返回的是数组,所以这两个 api 还是与老大哥统一吧。

String.prototype.padStart / String.prototype.padEnd

"foo".padStart(5, "bar") // bafoo
"foo".padEnd(5, "bar") // fooba

解决了字符串补齐需求,很棒!

Object.getOwnPropertyDescriptors

Object.getOwnPropertyDescriptors({ a: 1})
// { a: {
// 	  configurable: true,
// 	  enumberable: true,
// 	  value: 1,
//	  writable: true
// } }

特别是 babel 与 typescript 处理 class property decorator 方式不同的时候(typescript 处理得更成熟一些),会导致 babel 处理装饰器时,成员变量不设置默认值时,configurable 默认为 false,通过这个函数检查变量的配置很方便。

Trailing commas in function parameter lists and calls

function clownPuppiesEverywhere(
   param1,
   param2, // Next parameter that's added only has to add a new line, not modify this line
 ) { /* ... */ }

js 终于原生支持了,以前不支持的时候多加逗号还会报错,需要预编译工具删除最后一个逗号,现在终于名正言顺了。

Async functions

这个不用多说了,都说好用。

Shared memory and atomics

这是 ECMAScript 共享内存与 Atomics 的规范,涉及内容非常多,主要涉及到 asm.js。

asm.js 是一种性能解决方案,比如可以定义一个精确的 64k 堆:

var heap = new ArrayBuffer( 0x10000 )

Lifting template literal restriction

styled.div`
  background-color: red;
`

styled.div = text => {} 就可以处理了,目前使用最多在 styled-components 库里,这种场景还是蛮方便的。

3.2 Stage 3 大家庭

Function.prototype.toString revision

对函数的 toString 规则进行了修改:http://tc39.github.io/Function-prototype-toString-revision/#sec-function.prototype.tostring

当调用内置函数或 .bind 后函数,toString 方法会返回 NativeFunction

global

为 ECMAScript 规范添加 global 变量,同构代码再也不用这么写了:

var getGlobal = function () {
	// the only reliable means to get the global object is
	// `Function('return this')()`
	// However, this causes CSP violations in Chrome apps.
	if (typeof self !== 'undefined') { return self; }
	if (typeof window !== 'undefined') { return window; }
	if (typeof global !== 'undefined') { return global; }
	throw new Error('unable to locate global object');
};

虽然前端环境与 nodejs 区别很大,但既然提案进入了 stage3,说明大家非常关注 js 整体的生态,只要整体方向良性发展,相信不久将会进入 stage4。

Rest/Spread Properties

let { x, y, ...z } = { x: 1, y: 2, a: 3, b: 4 };
x; // 1
y; // 2
z; // { a: 3, b: 4 }

不得不说,非常常用,而且 babeljsTransformtypescript 均支持,感觉很快会进入 stage4.

Asynchronous Iteration

const { value, done } = syncIterator.next();

asyncIterator.next().then(({ value, done }) => /* ... */);
for await (const line of readLines(filePath)) {
  console.log(line);
}
async function* readLines(path) {
  let file = await fileOpen(path);

  try {
    while (!file.EOF) {
      yield await file.readLine();
    }
  } finally {
    await file.close();
  }
}

异步迭代器实现了 async await 与 generator 的结合。 然而 async await 是使用 generator 的语法糖,generator 也可以通过 switch 等流程控制函数模拟。更重要的是异步在 generator 中本身就可以实现,我在《Callback Promise Generator Async-Await 和异常处理的演进》 文章中提过。

语法的修改一定不能为了方便(在 ECMAScript 中可能出现),但这种混杂的方式容易让人混淆 await 与 generator 之间的关系,是否进入 stage4 还需仔细斟酌。

import()

import(`./section-modules/${link.dataset.entryModule}.js`)
    .then(module => {
      module.loadPageInto(main);
    })
    .catch(err => {
      main.textContent = err.message;
    });

这个提案主要增加了函数调用版的 import,而 webpack 等构建工具也在积极实现此规范,并作为动态加载的最佳范例。希望这种“官方 Amd”可以早日加入草案。

RegExp Lookbehind Assertions

javascript 正则表达式一直不支持后行断言,不过现在已经进入 stage3,相信不久会进入 stage4.

前向断言:

/\d+(?=%)/.exec("100% of US presidents have been male") // ["100"]
/\d+(?!%)/.exec("that’s all 44 of them") // ["44"]

后向断言:

/(?<=\$)\d+/.exec("Benjamin Franklin is on the $100 bill")  // ["100"]
/(?<!\$)\d+/.exec("it’s is worth about €90")                // ["90"]

后向断言会获取某个字符后面跟的内容,在获取美刀等货币单位上有很大用途。chrome 可以使用 chrome.exe --js-flags="--harmony-regexp-lookbehind" 命令开启。

RegExp Unicode Property Escapes

const regexGreekSymbol = /\p{Script=Greek}/u;
regexGreekSymbol.test('π');
// → true

以上 π 字符是一个希腊字符,通过指定 \p{Script=Greek} 就可以匹配这个字符了!

虽然可以通过引用希腊字符(或者其他编码)表做正则处理,当每当更新表时,更新起来会非常麻烦,不如让浏览器原生支持 \p{UnicodePropertyName=UnicodePropertyValue} 的正则语法,帮助开发人员解决这个烦恼。

RegExp named capture groups

let re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/u;
let result = re.exec('2015-01-02');
// result.groups.year === '2015';
// result.groups.month === '01';
// result.groups.day === '02';

// result[0] === '2015-01-02';
// result[1] === '2015';
// result[2] === '01';
// result[3] === '02';
let {groups: {one, two}} = /^(?<one>.*):(?<two>.*)$/u.exec('foo:bar');
console.log(`one: ${one}, two: ${two}`);  // prints one: foo, two: bar

同时,还支持 反向引用能力,可以通过 \k<name> 的语法,在正则中表示同一种匹配类型,这个和 ts 范型很像:

let duplicate = /^(?<half>.*).\k<half>$/u;
duplicate.test('a*b'); // false
duplicate.test('a*a'); // true

总体来看非常给力,毫无意义的下标也是正则反人类的原因之一,这个提案通过的话,正则会变得更加可读。

s (dotAll) flag for regular expressions

/foo.bar/s.test('foo\nbar');
// → true

通过添加了新的标识符 /s,表示 . 这个标志可以匹配任何值。原因是觉得现在正则的做法比较反人类:

/foo[^]bar/.test('foo\nbar');
// → true
/foo[\s\S]bar/.test('foo\nbar');
// → true

从保守派角度来看,可能因为掌握了 [^] [\s\S] 这种奇技淫巧而沾沾自喜,借此提高正则的门槛,让初学者“看不懂”,而高级语言的第一要义是可读性,RegExp Unicode Property EscapesRegExp named capture groups 进入草案就是表明了对正则语义化改进的决心,相信这个提案也会被采纳。

Legacy RegExp features in JavaScript

该提案主要针对 RegExp 遗留的静态属性进行梳理。平时很少接触,希望了解的人解读一下。

3.3 Stage2 大家庭

function.sent metaproperty

generator 的第一个 .next 参数会被抛弃,因为第一次 next 没有对应上任何 yield,如下代码就会产生疑惑:

function *adder(total=0) {
   let increment=1;
   while (true) {
       switch (request = yield total += increment) {
          case undefined: break;
          case "done": return total;
          default: increment = Number(request);
       }
   }
}

let tally = adder();
tally.next(0.1); // argument will be ignored
tally.next(0.1);
tally.next(0.1);
let last=tally.next("done");
console.log(last.value);  //1.2 instead of 0.3

当引入 function.sent 后,可以接收来自 next 的传值,包括初始传值

function *adder(total=0) {
   let increment=1;
   do {
       switch (request = function.sent){
          case undefined: break;
          case "done": return total;
          default: increment = Number(request);
       }
       yield total += increment;
   } while (true)
}

let tally = adder();
tally.next(0.1); // argument no longer ignored
tally.next(0.1);
tally.next(0.1);
let last=tally.next("done");
console.log(last.value);  //0.3

这是个很棒的特性,也不存在语意兼容问题,但 api 还是比较怪,而且自此 yield 接收参数也变得没有意义,况且如今 async await 逐渐成为主流,这种修正没有强烈刚需。而且 yield 的语意本身没有错误,这个提案比较危险。

String.prototype.{trimStart,trimEnd}

既然 padStartpadEnd 都进入了 stage4,trimStart trimEnd 这两个 api 也非常常用,而且从 ES5 将 String.prototype.trim 引入了标准来看,这两个非常有望晋升到 stage3。

Class Fields

class Counter extends HTMLElement {
  x = 0;
  #y = 1;
}

类成员变量,有了它 js 就完整了。虽然觉得似有变量符号很难看,但成员变量绝对是非常有用的语法,在 react 中已经很常用了:

class Todo extends React.Component {
  state = { //.. }
}

Promise.prototype.finally

就像 try/catch/finally 一样,try return 了都能执行 finally,是非常方便的,对 promise 来说也是如此,bluebird Q 等库已经实现了此功能。

但是库实现不足以使其纳入标准,只有当这些需求足够常用和通用时才会考虑。第三方库可能从竞争力角度考虑,多支持一种功能、少些一行代码就是多一份筹码,但语言规范是不能在乎这些的。

Class and Property Decorators

类级别的装饰器已经进入 stage2 了,但现代前端开发中已经非常常用,很可能会进一步进入 stage3.

如果这个提案被废弃,那么大部分现代 js 代码将面临大量使用不存在语法的窘境。不过乐观的是,目前还找不到更好的装饰器替代方案,而在 python 中也存在装饰器模式可以参考。

Intl.Segmenter

// Create a segmenter in your locale
let segmenter = new Intl.Segmenter("fr", {granularity: "word"});

// Get an iterator over a string
let iterator = segmenter.segment("Ceci n'est pas une pipe");

// Iterate over it!
for (let {segment, breakType} of iterator) {
  console.log(`segment: ${segment} breakType: ${breakType}`);
  break;
}

// logs the following to the console:
// segment: Ceci breakType: letter

Intl.Segmenter 可以帮助分析单词断句分析,可能在 nlp 领域比较有用,在文本编辑器自动选中功能中也很有用。

虽然不是刚需,但 js 作为网页交互的语言,确实需要解决分析用户输入的问题。

Arbitrary-precision Integers

新增了基本类型:整数类型,以及 Integer api 与字面语法 1234n。

目前 js 使用 64 位浮点数处理所有计算,直接导致了运算效率低下,这个提案弥补了 js 的计算缺点,希望可以早日进入草案。

提案名称由 Integer 改为 BigInt。

import.meta

提出了使用 import.meta 获取当前模块的域信息。类比 nodejs 存在 __dirname 等信息标志当前脚本信息,通过浏览器加载的模块也应当拥有这种能力。

目前 js 可以通过如下方式获取脚本信息:

const theOption = document.currentScript.dataset.option;

这样污染了全局变量,脚本信息应当存储在脚本作用域中,因此提案希望将脚本信息存储在脚本的 import.meta 变量中,因此可以这么使用:

(async () => {
  const response = await fetch(new URL("../hamsters.jpg", import.meta.url));
  const blob = await response.blob();

  const size = import.meta.scriptElement.dataset.size || 300;

  const image = new Image();
  image.src = URL.createObjectURL(blob);
  image.width = image.height = size;

  document.body.appendChild(image);
})();

3.4 Stage1 大家庭

Date.parse fallback semantics

通过字符串格式化日期一直是跨浏览器的痛点,本提案希望通过新增 Date.parse 标准完成这个功能。

"The function first attempts to parse the format of the String according to the rules (including extended years) called out in Date Time String Format (20.3.1.16). If the String does not conform to that format the function may fall back to any implementation-specific heuristics or implementation-specific date formats."

正如提案所说,“如果字符串不满足 ISO 8601 格式,可以返回你想返回的任何值” 这样迷惑开发者是没有任何意义的,这样只会让开发者越来越不相信 js 是跨平台的语言。

这么重要的规范居然才 stage1,必须要顶上去。

export * as ns from "mod"; statements

export * as someIdentifier from "someModule";

很方便的 api,很多时候希望导出某个模块的全部接口,又不希望命名冲突,可以少写一行 import。

export v from "mod"; statements

这个提案与 export * as ns from "mod"; statements 冲突了,感觉 export * as ns from "mod"; statements 提案更清晰一些。

Observable

可观察类型可以从 dom 事件、轮询等触发事件中创建监听并订阅:

function listen(element, eventName) {
    return new Observable(observer => {
        // Create an event handler which sends data to the sink
        let handler = event => observer.next(event);

        // Attach the event handler
        element.addEventListener(eventName, handler, true);

        // Return a cleanup function which will cancel the event stream
        return () => {
            // Detach the event handler from the element
            element.removeEventListener(eventName, handler, true);
        };
    });
}

// Return an observable of special key down commands
function commandKeys(element) {
    let keyCommands = { "38": "up", "40": "down" };

    return listen(element, "keydown")
        .filter(event => event.keyCode in keyCommands)
        .map(event => keyCommands[event.keyCode])
}

let subscription = commandKeys(inputElement).subscribe({
    next(val) { console.log("Received key command: " + val) },
    error(err) { console.log("Received an error: " + err) },
    complete() { console.log("Stream complete") },
});

这个名字和 Object.observe 很像,不过没什么关系。该功能已经被 RxJSXStream 等库实现。

String#matchAll

目前正则表达式想要匹配全部的语法不够语义化,提案希望通过 matchAll 返回迭代器来遍历匹配结果,很赞!

现在匹配全部只能使用 while ((result = patt.exec(str)) != null) 这种方式遍历,不优雅。

WeakRefs

弱引用,提案地址文档:https://github.com/tc39/proposal-weakrefs/blob/master/specs/Weak%20References%20for%20EcmaScript.pdf

有点像 OC 的弱引用,当对象被释放时,当前持有弱引用的对象也会被 GC 回收,但似乎还没有开始讨论,js 越来越底层了?

Frozen Realms

增强了 Realms 提案,利用不可变结构,实现结构共享。

Math Extensions

Math 函数的拓展包含的函数:https://rwaldron.github.io/proposal-math-extensions/

这个函数拓展很给力,特别是设计游戏,计算角度的时候:

Math.DEG_PER_RAD // Math.PI / 180

Math.DEG_PER_RAD 是一种单位,让角度可以用 0~360 为周期的数字表示,比如射击子弹时的角度、或者做可视化时都非常有用,类比 css 中的:transform: rotate(180deg);

of and from on collection constructors

该提案设计了 Set、Map 类型的 of from 方法,具体见此:https://tc39.github.io/proposal-setmap-offrom/

问题由于:

Reflect.construct(Array, [1,2,3]) // [1,2,3]
Reflect.construct(Set, [1,2,3]) // Uncaught TypeError: undefined is not a function

因为 Set 接收的参数是数组,而 construct 会调用 CreateListFromArrayLike 将参数打平,变成了 new Set(1, 2, 3) 传入,实际上是语法错误的,因此作者提议新增下 Set、Map 的 of from 方法。

Set、Map 在国内环境用的比较少,也很少有人计较这个问题,不过从技术角度来看,确实需要修复。。

Generator arrow functions (=>*)

还是挺有必要的,毕竟都出箭头函数了,也要支持一下箭头函数的 generator 语法。

Promise.try

同理,各大库都有实现,好处是所有错误都可以通过 .catch 捕获,而不用担心同步、异步错误的抛出。

Null Propagation

超级有用,看代码就知道了:

const firstName = message.body?.user?.firstName || 'default'

该功能完全等同:

const firstName = 
	(message && 
	message.body && 
	message.body.user &&
	message.body.user.firstName) || 'default'

希望立刻进入 stage4.

Math.signbit: IEEE-754 sign bit

当值为 负数 或 -0 时返回 true。由于 Math.sign 不区分 +0 与 -0,因此提案建议增加此函数,而且此函数在 c、c++、go 语言都有实现。

Error stacks

提案建议将 Error.prototype.stack 作为标准,这对错误上报与分析特别有用,强烈支持。

do expressions

return (
  <nav>
    <Home />
    {
      do {
        if (loggedIn) {
          <LogoutButton />
        } else {
          <LoginButton />
        }
      }
    }
  </nav>
)

jsx 再也不用写得超长了,styled-components 中被诟病的分支判断难以阅读的问题也会烟消云散,因为我们有 do!

Realms

let realm = new Realm();

let outerGlobal = window;
let innerGlobal = realm.global;

let f = realm.evalScript("(function() { return 17 })");

f() === 17 // true

Reflect.getPrototypeOf(f) === outerGlobal.Function.prototype // false
Reflect.getPrototypeOf(f) === innerGlobal.Function.prototype // true

Realms 提供了 global 环境的隔离,eval 执行代码时不再会污染全局,简直是测试的福利,脑洞很大。

Temporal

Date 类似,但功能更强:

var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59);
var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, options);
var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, 59);
var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, 59, options);
var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, 59, 123);
var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, 59, 123, options);
var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, 59, 123, 456789);
var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, 59, 123, 456789, options);

// add/subtract time  (Dec 31 2017 23:00 + 2h = Jan 1 2018 01:00)
var addHours = new temporal.LocalDateTime(2017, 12, 31, 23, 00).add(2, 'hours');

// add/subtract months  (Mar 31 - 1M = Feb 28)
var addMonths = new temporal.LocalDateTime(2017,03,31).subtract(1, 'months'); 

// add/subtract years  (Feb 29 2020 - 1Y = Feb 28 2019)
var subtractYears = new temporal.LocalDateTime(2020, 02, 29).subtract(1, 'years');

还自带时区转换 api 等等,如果进入草案,可以放弃 moment 这个重量级库了。

Float16 on TypedArrays, DataView, Math.hfround

由于大多数 WebGL 纹理需要半精度以上的浮点数计算,推荐了 4 个 api:

  • Float16Array
  • DataView.prototype.getFloat16
  • DataView.prototype.setFloat16
  • Math.hfround(x)

Atomics.waitNonblocking

var sab = new SharedArrayBuffer(4096);
var ia = new Int32Array(sab);
ia[37] = 0x1337;
test1();

function test1() {
  Atomics.waitNonblocking(ia, 37, 0x1337, 1000).then(function (r) { console.log("Resolved: " + r); test2(); });
}
var code = `
var ia = null;
onmessage = function (ev) {
  if (!ia) {
    console.log("Aux worker is running");
    ia = new Int32Array(ev.data);
  }
  console.log("Aux worker is sleeping for a little bit");
  setTimeout(function () { console.log("Aux worker is waking"); Atomics.wake(ia, 37); }, 1000);
}`;
function test2() {
  var w = new Worker("data:application/javascript," + encodeURIComponent(code));
  w.postMessage(sab);
  Atomics.waitNonblocking(ia, 37, 0x1337).then(function (r) { console.log("Resolved: " + r); test3(w); });
}
function test3(w) {
  w.postMessage(false);
  Atomics.waitNonblocking(ia, 37, 0x1337).then(function (r) { console.log("Resolved 1: " + r); });
  Atomics.waitNonblocking(ia, 37, 0x1337).then(function (r) { console.log("Resolved 2: " + r); });
  Atomics.waitNonblocking(ia, 37, 0x1337).then(function (r) { console.log("Resolved 3: " + r); });
  
}

该 api 可以在多线程操作中,有顺序的操作同一个内存地址,如上代码变量 ia 虽然在多线程中执行,但每个线程都会等资源释放后再继续执行。

Numeric separators

1_000_000_000           // Ah, so a billion
101_475_938.38          // And this is hundreds of millions

let fee = 123_00;       // $123 (12300 cents, apparently)
let fee = 12_300;       // $12,300 (woah, that fee!)
let amount = 12345_00;  // 12,345 (1234500 cents, apparently)
let amount = 123_4500;  // 123.45 (4-fixed financial)
let amount = 1_234_500; // 1,234,500

提案希望 js 支持分隔符使大数字阅读性更好(不影响计算),很多语言都有实现,很人性化。

4 总结

每个草案都觉得很靠谱,涉及语义化、无障碍、性能、拓展语法、连接 nodejs 等方面,虽然部分提案从语言设计角度是错误的,但 js 运行在网页端,涉及到人机交互、网络加载等问题,遇到的问题自然比任何语言都要复杂,每个提案都是从实践中出发,相信这种道路是正确的。

由于篇幅与时间限制,stage0 的提案等下次再讨论。特别提一点,stage0 的 Cancellation API 很值得大家关注,取消异步操作是人心所向,大势所趋啊。

本文作者:前端小毛

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!