R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。 MUGer, hacker, developer, amateur UI designer, punster, Japanese learner.

从 Svelte 3 的编译器中得到的启示

前端的编译器,除了可以做转换,是否还有其它用途?

由于现在国内“前端主流框架”的称号基本被 React、Vue、Angular 占领,前二者在渲染层的技术,本质上都是极为相似的:在内存中维护 Virtual DOM,通过某些方式获取到变量被修改,然后利用 DOM Diff 算法(以及一些优化)计算出需要对 DOM 做的操作,再 Patch 到真实的 DOM 上。相信大家(尤其是准备过面试的小伙伴)一定对这些内容比较了解。

Angular 的最新版使用了一个叫 Ivy 的引擎。对于 Angular 来说,脏值检测跟之前的方法类似(监听用户输入、劫持 setTimeout 等),渲染方面则是用了 incremental-dom 的思想做增量渲染。

我第一次听说 Svelte(读作 [svɛlt]),也只是在两个月前。我刚看到 Svelte 3 文章时,就被“编译型框架”的字样吸引住了。据说它的编译器帮忙做了很多工作,可以不用脏值检测、setState、Proxy 等方式来触发视图更新,也没有了 DOM Diff 的代价。我决定好好研究一番。

几种前端框架的数据响应原理

Svelte 3 的特点

在翻阅 Svelte 的博客时,很容易翻到这篇文章:Virtual DOM is pure overhead。通读下来,作者的观点是,Virtual DOM 可以做到状态驱动 UI,性能也不差,并且代码的 Bug 更少;但 Svelte 不用 Virtual DOM 也可以做到类似的效果,并且性能更高。

模板语法、模板数据分离

本文的重点不是详细讲述 Svelte 3 的语法(照着文档来就可以了),这里只举一些最常用也最简单的例子,一个组件基本长这样,跟 Vue 的组件长得差不多,但有点区别。我列举了变量、双向绑定、事件处理、Watch、常见的控制语句、生命周期这几个特性:

<!-- App.svelte -->

<script lang="ts">
import { onMount } from 'svelte';

// 1:变量、双向绑定、事件处理
let name = 'world';
const onClick = () => name = 'Rex';

// 2:`if` 块、Watch
let x = 0;
const incX = () => x++;
// 这就像 Vue 中的 `watch` 功能
// 语法很奇怪,但是是合法的
$: doubleX = x * 2;

// 3:`each` 块
let characters = [
    { name: 'Aroma White' },
    { name: 'Neko Asakura' },
    { name: 'ROBO Head' }
];
const append = (name: string) => {
    if (!characters.find(t => t.name === name)) {
        characters = [...characters, { name }];
    }
};

// 生命周期
onMount(async () => {
    const res = await fetch(`/photos`);
    const photos = await res.json();
    console.log(photos);
});
</script>

<main>
    <section class="my-section-1">
        <h1>Hello {name}!</h1>
        <input bind:value={name}>
        <button on:click={onClick}>I'm Rex</button>
    </section>
    <section class="my-section-2">
        {#if x > 10}
            <p>{x} is greater than 10</p>
        {:else if x < 5}
            <p>{x} is less than 5</p>
        {:else}
            <p>{x} is between 5 and 10</p>
        {/if}
        <p>Double x is {doubleX}</p>
        <button on:click={incX}>x++</button>
    </section>
    <section class="my-section-3">
        <button on:click={() => append('Simon Jackson')}>
            Add Simon
        </button>
        <ul>
            {#each characters as c, i}
                <li>{i + 1}: {c.name}</li>
            {/each}
        </ul>
    </section>
</main>

<style>
main {
    font-size: 18px;
}
</style>

单看 <main> 的部分,是不是感觉找回了上古时期写 PHP 和 Django 模板的感觉?虽然语法比较复古,但其实现代前端支持的东西,它基本都能做:

  • 组件化:每个 .svelte 文件就是一个组件,并且也完整支持 Props、Context、Slot;
  • 完整的生命周期:onMountonDestroybeforeUpdateafterUpdate

利用编译器实现响应式

那么它是怎么做的呢?记得我之前年少轻狂写 rsjs 的时候(那时候的主流还是 Angular 1),由于水平太菜,迟迟不知道如何做到 Angular 的脏值检测,于是只好退而求其次,搞了个 setData 函数,并且每次数据更新都是完整渲染,可以说是效率很低了。这其实跟 React 更贴近一些……

其实 Svelte 以前也是这样做的,但 Svelte 3: Rethinking reactivity 这篇文章的意思是,由于 Svelte 3 在编译期间就可以知道每个变量在模板的什么地方被用到,因此只要变量有被模板用到,编译器就会在该变量的所有“直接赋值”的语句外面包一个 $$invalidate 函数,例如这样:

// 原始代码:
count += 1;
// 会被编译成这样的形式:
$$invalidate('count', count += 1);

至于 Array.prototype.push 之类的函数,以及修改引用值,Svelte 没有像 Vue 那样劫持原生函数,也没有在编译器中做引用判断,因此需要开发者变通一下,转换成对变量直接赋值的语句。鉴于 React 也是这样的要求(每次 setState 都需要设置一个新对象),因此这一点我觉得是可以接受的。

局部更新和直接操作 DOM

由于 Svelte 3 使用的是类似上古时期的模板语法,没有 TSX Render Function 这么灵活的功能,因此可以更好地被用作静态分析。

我我把刚刚的代码编译了一下(详见彩蛋),在未压缩的模式下,很容易找出每个函数和变量在源代码中的位置。大概看了一眼,能得出这样的结论:

  • 数据会被统一打包到一个数组中方便管理,类似 React Hooks 的线性存储方式;
  • 模板被转换成渲染函数,每个 if 和 each 块也被转换成渲染函数,方便局部更新;
  • 对于每个渲染函数,有 Create、Make Tree、Patch、Detach 四个操作;
  • 没有 Virtual DOM,而是为每个块定制 Create 和 Make Tree 操作,直接修改 DOM;
  • Vue 通过 Proxy 来实现的监听,在 Svelte 3 中成了为每个块定制的 Patch 操作;
  • 在卸载的时候,会先卸载组件,再执行清理函数(取消监听器之类的)。

其它

对 Svelte 3 的更具体的分析,由于网上已经有人写了(详见文末的参考资料),这里就不多提了。

我从中得到的启示

1. Virtual DOM 并不是唯一出路

由于我和我的前端朋友们几乎全都是写 React 和 Vue 的,因此我们很自然地认为 Virtual DOM、TSX 等方式已经足够了。但是 Svelte 3 跟快被我们忽略的 Angular,坚持采用模板的方式,没有拥抱 Virtual DOM,并且也取得了不错的成果。这说明 Virtual DOM 并不是唯一出路。

个人觉得,Angular 现在之所以没有成为主流,是因为它是一整套服务,上手难度高,而非因为没有拥抱 Virtual DOM。

2. 无论如何编码,总会有 Tradeoff

我们应该听到过很多“最佳实践”,或者是“错误的写法”,例如:

  • 在 React 中,不管是 setState 还是 Reducer,每次必须返回一个新的对象;
  • 在 React Hooks 中,一个 Hooks 必须只能写在函数式组件里或另一个 Hooks 里;
  • 在 Vue 的 data 中必须要声明所有用到的字段,否则必须要用 $set 来更新;
  • 在 Vue 的模板中,不能直接使用模块内变量或全局变量,需要通过 data 传过去;
  • 在 immerjs 中,不能认为 a=b 之后修改 a 对象就是修改 b,它俩不是同一个东西。

这些都是为了实现库或框架的功能而必须的 Tradeoff,因为过度自由的编码,会导致你的代码无法通过统一的算法来检测和优化,也无法让你的代码更加工程化。举个例子,我在写面单编辑器的时候针对业务数据做了一些限制,否则不可能一个人在一周的时间内快速完成;TypeScript 的代码可达性检测也不能检测停机问题。

Svelte 3 针对编译器的功能,限制了用户使用 Render Function 以及 Virtual DOM,以及不能对需要响应变化的变量用 push 这些可以原地操作数据的函数(对象的深层次赋值是可以的),虽然会失去一定的灵活性,但可以让编译器帮你做更多的事情。

时间、空间、编程复杂度往往互相牵制

3. 编译器能做的事情,比我认为的要多

其实这一点,搞二进制开发、逆向的大佬们感触应该会更深。例如:

  • C 语言程序开始执行后并不会先进入 main 函数,而是一个叫做 _start 的函数;
  • 上古时期 x * 9 会被开发者写成 x << 3 + x,但现在大部分优化都被编译器做了(而且绝对比你手工优化做的好);
  • 事实上,C 编译的过程就是把支持了很多特性的高级语言,转换成只支持基本指令的机器语言,源代码和编译产物往往很不相似(这也是为什么 C 逆向比 JS 逆向难得多)。

前端编译器最开始的作用,无非是转换一些语法、添加一些 Runtime;后来逐渐出现了各种 Webpack Loader 和 Rollup 插件,支持了更多的文件格式(.vue 文件转换前后整个结构就变了),这也在某种程度上反映出前端在复杂度和工程化程度上的提升。这些东西也成了一些公司面试的必考点,用来区分普通程序员和高级工程师。

这次 Svelte 3 的编译器,在保留了“响应式前端”概念的基础之上,进一步让代码得到了简化,并且也极大减少了运行时代码的执行,为世界的碳排放做出了一定的贡献。(咦?)

我有一个小预测:随着前端复杂度和工程化程度的进一步提升,未来的编译器一定可以帮我们做更多的事情,现有的框架可能也会将一部分在运行时的功能使用编译器辅助实现。由于编译器的功能越来越多,面试的要点会逐渐从框架的原理,变成编译器的原理。

4. 优化除了提高算法效率,还有预处理

在以前当赛棍的时候,经常会提到两个概念:时间复杂度、空间复杂度。利用空间换时间的技术一般被叫做“预处理”,即提前计算好一些东西,用的时候直接调用。

谷歌出的一个代码压缩器 GCC(Google Closure Compiler),可以帮忙做一些 Inline 的操作,以及提前计算,避免在运行的过程中重复取值。不过一些优化过于激进,可能会导致代码无法运行,加上后来出现的 Terser 等工具做的确实不错,因此 GCC 没有被广泛使用。

而 Svelte 3 的做法,则是利用编译器直接计算出每个值改变时需要做什么,相当于直接把脏值检测跟 DOM Diff 计算后的执行流程写死到了组件的代码里,免去了在运行时计算的过程。虽然代码会比较长,但由于重复操作多,信息量小,因此压缩后并不会占用太多的空间。这是个既省空间又省时间的做法。

P.S. 三年前我面试 Shopee 的时候,老板问了我一个主观问题(之前的文章可以看 这里):

如果单从修改 DOM 结构来看,React + DOM Diff 算法与纯 jQuery 直接操作(假设已经知道该如何最快的 Patch)相比,哪个效率比较高?

我的答案是:当结构不复杂的时候,DOM Diff 的耗时占比重是不能被忽略的,此时当然是 jQuery 快;但是当结构复杂了之后,DOM Diff 的耗时占比重就比较低了,更多的时间被消耗在 DOM 的操作上——React 是直接 Patch 到 DOM 上,但 jQuery 是在原生 DOM 上面封装了一层,所以会再慢一些,这个的时间是要多于 DOM Diff 的。

当时我并不知道标准答案是什么,现在看前端的趋势也没必要再考虑 jQuery 了。但如果再加上 Svelte 3,问题就有意思起来了。Svelte 由于把预处理的时间放到了编译期间,在运行时也只对 DOM 操作做了最简单的封装,因此既没有 React 的 DOM Diff 时间,又比 jQuery 要快。

究竟封装有多简单呢?可以看一下 Svelte 3 的工具函数,基本相当于没有封装啊……估计是为了以后添加更多的钩子吧:

function append(target, node) {
    target.appendChild(node);
}
function insert(target, node, anchor) {
    target.insertBefore(node, anchor || null);
}
function detach(node) {
    node.parentNode.removeChild(node);
}
function element(name) {
    return document.createElement(name);
}
function text(data) {
    return document.createTextNode(data);
}
function space() {
    return text(' ');
}
function listen(node, event, handler, options) {
    node.addEventListener(event, handler, options);
    return () => node.removeEventListener(event, handler, options);
}
function attr(node, attribute, value) {
    if (value == null)
        node.removeAttribute(attribute);
    else if (node.getAttribute(attribute) !== value)
        node.setAttribute(attribute, value);
}
function children(element) {
    return Array.from(element.childNodes);
}
function set_input_value(input, value) {
    input.value = value == null ? '' : value;
}

彩蛋

到底该信哪一份数据

我在统计框架使用量的时候,一开始认为开发者提的问题越多,代表这个框架越火。根据 StackOverflow 在 2020 年对接近 65000 个开发者的调查(传送门),可以发现最主流的几个框架依次是 Angular/Angular.js、React、Vue。这跟我的认知不符,因为在国内我几乎没有遇到多少 Angular 的开发者,反而因为 Vue 的上手难度低,导致中小型公司更喜欢用 Vue。

来自 StackOverflow 的统计数据

我在想,可能是因为幸存者偏差,可以再看看 NPM 上面的下载量。于是我用 npm-stat 统计了一下三大框架在 2020 年的下载量,结果是这样的:

来自 npm-stat 的统计数据

可以看到 React 基本占了主流,跟 Angular 简直是数量级的差距。

但是 NPM 的下载量就一定能说明结果吗?是否有可能因为 Angular 的使用者由于构建优化的更好,避免了每次流水线都从 NPM 下载?我不了解,也不知道从何了解。

编译结果究竟长啥样

答案:长这样,长相并不好看。(摊手)

我已经尽可能添加注释帮助理解了,如果不愿意看代码,把注释当文章来读也是可以的。

// 一堆通用的工具函数,用来创建 DOM、设置数据等
import { ... } from "svelte/internal";
import { onMount } from "svelte";

// 一个取决于组件数据的工具函数
// 用于找到 `each` 块中的 Context
function get_each_context(ctx, list, i) {
    const child_ctx = ctx.slice();
    child_ctx[9] = list[i];
    child_ctx[11] = i;
    return child_ctx;
}

// 第 48 行的 else 块,主要包含了四个函数
function create_else_block(ctx) {
    // 这些是每一个 DOM 元素
    let p;
    let t0;
    let t1;

    return {
        // c:创建(Create)
        // 调用工具函数,依次创建每一个 DOM 元素
        c() {
            p = element("p");
            // 这里 ctx[0] 就对应了 x,编译器的注释标的恰到好处
            t0 = text(/*x*/ ctx[0]);
            t1 = text(" is between 5 and 10");
        },
        // m:建树(Make Tree)
        // 通过创建好的 DOM 元素,将 DOM 树构建好
        m(target, anchor) {
            insert(target, p, anchor);
            append_1(p, t0);
            append_1(p, t1);
        },
        // p:更新(Patch)
        // 在这个 else 块中,只依赖了一个 x,因此只有一个 if 语句
        // 若组件被标记为 dirty,且 x 被改
        // 则调用工具函数 set_data 直接更新 DOM 元素中的 innerText
        // Svelte 将“某变量被修改”的标记用位运算做了压缩,详见参考资料
        p(ctx, dirty) {
            if (dirty & /*x*/ 1) set_data(t0, /*x*/ ctx[0]);
        },
        // d:卸载(Detach)
        // 当组件被卸载时需要做的事情,一般是卸载元素
        // 如果有事件监听的话也会取消监听
        d(detaching) {
            if (detaching) detach(p);
        }
    };
}

// 接下来的两个函数分别是:
// 第 46 行的 else 块
// 第 44 行的 if 块
// 里面长得跟刚才基本一样,不多提了
function create_if_block_1(ctx) { ... }
function create_if_block(ctx) { ... }

// 第 59 行的 each 块
// 稍微有一点区别,因为有 key
// 并且模板渲染用到的数据不是 JS 的基本类型
function create_each_block(ctx) {
    let li;
    // 因为在模板中我们写了 i + 1
    let t0_value = /*i*/ ctx[11] + 1 + "";
    let t0;
    let t1;
    // 因为在模板中我们写了 c.name
    let t2_value = /*c*/ ctx[9].name + "";
    let t2;

    return {
        c() {
            li = element("li");
            // 这里直接调用 i + 1 的值
            t0 = text(t0_value);
            t1 = text(": ");
            // 这里直接调用了 c.name 的值
            t2 = text(t2_value);
        },
        m(target, anchor) { ... },
        p(ctx, dirty) {
            // 这里除了判断组件是 dirty 和 characters 被修改以外
            // 还多判断了一步 characters[i].name 是否被修改
            if (
                dirty & /*characters*/ 4 &&
                t2_value !== (t2_value = /*c*/ ctx[9].name + "")
            ) {
                set_data(t2, t2_value);
            }
        },
        d(detaching) { ... }
    };
}

// 整个组件的创建函数
function create_fragment(ctx) {
    // 依旧是各种 DOM 元素
    // ...为了简洁,我只保留了 main,省略其它所有变量
    let main;
    // 这个用来判断组件是否已经挂载结束
    let mounted;
    // 这个用来收集在卸载时需要做的事情,是一个数组
    // Svelte 的监听器跟 Angular、React.useEffect 类似
    // 都是返回一个卸载函数
    let dispose;

    // 第 44 行的 if 块,在这里被转换成了真正的 JS
    // 这里判断该使用哪个渲染函数来渲染 if 块
    function select_block_type(ctx, dirty) {
        if (/*x*/ ctx[0] > 10) return create_if_block;
        if (/*x*/ ctx[0] < 5) return create_if_block_1;
        return create_else_block;
    }

    // 第 44 行的 if 块,需要用这个渲染函数来渲染
    let current_block_type = select_block_type(ctx, -1);
    let if_block = current_block_type(ctx);

    // 第 59 行的 each 块,需要转换为若干个渲染函数
    // 每个函数都只负责渲染其中的一次迭代
    let each_value = /*characters*/ ctx[2];
    let each_blocks = [];
    for (let i = 0; i < each_value.length; i += 1) {
        each_blocks[i] = create_each_block(
            get_each_context(ctx, each_value, i)
        );
    }

    // 跟刚才类似的几个函数,只不过复杂了很多
    return {
        // 这里的 c 里面除了创建基本的元素以外
        // 还要调用刚刚 if、each 相关的 c 函数
        c() {
            main = element("main"); // ...省略其它
            // 这里调用了 if 块的 c 函数
            if_block.c();
            t7 = space(); // ...省略其它

            // 这里调用了 each 数组中每个元素的 c 函数
            for (let i = 0; i < each_blocks.length; i += 1) {
                each_blocks[i].c();
            }

            // 为刚创建好的 DOM 元素设置属性
            attr(section0, "class", "my-section-1");
            attr(section1, "class", "my-section-2");
            attr(section2, "class", "my-section-3");
            // 组件自身的 Scoped CSS 在编译阶段即完成
            attr(main, "class", "svelte-5if2as");
        },
        // 跟之前类似,利用创建好的 DOM 元素,构建 DOM 树结构
        // 这里也会调用每个 if、each 块中的 m 函数
        m(target, anchor) {
            insert(target, main, anchor); // ...省略其它
            // 模板中的 input 有个双向绑定
            set_input_value(input, /*name*/ ctx[1]);
            append_1(section0, t4); // ...省略其它

            for (let i = 0; i < each_blocks.length; i += 1) {
                each_blocks[i].m(ul, null);
            }

            // 执行到这里,其实已经挂载完成了
            // 收集一下卸载时需要执行的函数,然后标记为 mounted
            if (!mounted) {
                dispose = [
                    listen(input, "input", /*input_input_handler*/ ctx[7]),
                    listen(button0, "click", /*onClick*/ ctx[4]),
                    listen(button1, "click", /*incX*/ ctx[5]),
                    listen(button2, "click", /*click_handler*/ ctx[8])
                ];
                mounted = true;
            }
        },
        // 需要 Patch 的变量变得多了起来
        // 可以看出来,这是依据组件内容生成的代码
        // 随着组件中用到的变量增多,这个函数会变得越来越大
        p(ctx, [dirty]) {
            if (dirty & /*name*/ 2) set_data(t1, /*name*/ ctx[1]);

            // 这里是双向绑定
            if (dirty & /*name*/ 2 && input.value !== /*name*/ ctx[1]) {
                set_input_value(input, /*name*/ ctx[1]);
            }

            // ...省略其它
        },
        i: noop,
        o: noop,
        // 在卸载的时候:
        // 先卸载组件
        // 再卸载所有块
        // 最后执行之前收集的清理函数
        d(detaching) {
            if (detaching) detach(main);
            if_block.d();
            destroy_each(each_blocks, detaching);
            mounted = false;
            run_all(dispose);
        }
    };
}

// 这一段是源代码中的 <script> 部分
// 编译器很贴心,把我在源代码中写的注释都给保留了
function instance($$self, $$props, $$invalidate) {
    let doubleX;
    let name = "world";
    const onClick = () => $$invalidate(1, name = "Rex");

    // 2:`if` 块、Watch
    let x = 0;

    const incX = () => $$invalidate(0, x++, x);

    // 3:`each` 块
    let characters = [
        { name: "Aroma White" },
        { name: "Neko Asakura" },
        { name: "ROBO Head" }
    ];

    const append = name => {
        if (!characters.find(t => t.name === name)) {
            $$invalidate(2, characters = [...characters, { name }]);
        }
    };

    // 生命周期
    onMount(async () => {
        const res = await fetch(`/photos`);
        const photos = await res.json();
        console.log(photos);
    });

    function input_input_handler() {
        name = this.value;
        $$invalidate(1, name);
    }

    const click_handler = () => append("Simon Jackson");

    // 补充:
    // 在这里 doubleX 被直接套在了一个叫 update 的函数中
    // 并且也用跟 p 函数类似的方式来判断是否需要更新(如果 x 被改变)
    // 如果有其它 Watch 的变量,也会被放在这里
    $$self.$$.update = () => {
        if ($$self.$$.dirty & /*x*/ 1) {
            // 这就像 Vue 中的 `watch` 功能
            // 语法很奇怪,但是是合法的
            $: $$invalidate(3, doubleX = x * 2);
        }
    };

    return [x, name, characters, doubleX, onClick, incX, append, input_input_handler, click_handler];
}

// 很简单地对外暴露一个实例
class App extends SvelteComponent {
    constructor(options) {
        super();
        init(this, options, instance, create_fragment, safe_not_equal, {});
    }
}

export default App;

参考资料


Update 2021-01-18

经大佬提示,有个叫 State of JS 的网站会对开发者做调查,并发布一份报告。在链接中,可以看到目前用途最广泛的是 React,然后 Angular 比 Vue 略高一筹。此外,Svelte 无论是满意度、使用量还是价值,都在稳步提升,公众对它也始终保持很高的兴趣。

Update 2021-01-21

从另一个大佬那里得知了 Vue 有两个新提案:New script setup and ref sugar,第一个是为了直接用最新的 setup 函数替代之前的组件创建流程,第二个是跟 Svelte 类似,利用 JS Label 的语法来做响应式的语法糖。

它俩分别是这样用的:

<script setup>
// imported components are also directly usable in template
import Foo from './Foo.vue'
import { ref } from 'vue'

// write Composition API code just like in a normal setup()
// but no need to manually return everything
const count = ref(0)
const inc = () => { count.value++ }
</script>

<template>
    <Foo :count="count" @click="inc" />
</template>
<script setup>
// declaring a variable that compiles to a ref
ref: count = 1

function inc() {
    // the variable can be used like a plain value
    count++
}

// access the raw ref object by prefixing with $
console.log($count.value)
</script>

<template>
    <button @click="inc">{{ count }}</button>
</template>

跟大佬聊了一下,我的观点主要是这样的:

  • 我对这提案的看法,和对 Svelte 的看法类似,甚至我都觉得 Evan You 也发现了编译器可能是个未来的优化点。
  • 至于它破坏了 JS 语义的问题,我觉得与其让 Label 这个语法被忘记,不如赋予它新的意义,因为目前写 JS 已经基本离不开编译器了(例如 TypeScript、JSX、Vue Loader)。
  • 此外,语言本身就是在变化中的,JS 正是因为以前的语法不好,才有了那么多提案和 Linter。
  • 在其它行业,Lisp 有 Clojure、Common Lisp、Scheme 这样一堆方言,Java“语系”中的 Groovy、Scala、Kotlin 也差不多能算是方言,我觉得 JS 演变出 React、Vue、Angular、Svelte 几种方言是完全合理的。

之后,大佬拿出了另外一份 JS 的提案:Reference (ref) declarations and expressions,这样确实不会 Break the Web 了,不过看起来有点像 C 中的 int const a = 1 或者 Rust 中的 let mut x = 5,这在我看来也不是坏事。

当然,语言不可能无限添加 Feature,否则容易长成这样:cRAzY eSnEXt (*all* proposals mixed in)……

版权声明:除文章开头有特殊声明的情况外,所有文章均可在遵从 CC BY 4.0 协议的情况下转载。
上一篇: 盲调试解决 Zebra 打印机吞纸问题的经历
下一篇: TypeScript 小技巧:校验 i18n 参数

这是我们共同度过的

第 2083 天