Islands 架构原理和实践

发表于 2年以前  | 总阅读数:425 次

Islands 架构是今年比较火的一个话题,目前社区一些比较知名的新框架如 Fresh、Astro 都是 Islands 架构的典型代表。本文将给大家介绍 Islands 架构诞生的来龙去脉,分析它相比于 Next.js、Gatsby 等传统方案的优势,并且剖析社区相关框架的实现原理,以及分享笔者在这个方向上的一些实践。

MPA 和 SPA 的取舍

MPA 和 SPA 是构建前端页面常见的两种方式,理解 MPA 和 SPA 的区别和不同场景的取舍是理解 Islands 架构的关键。

概念

MPA(Multi-page application) 即多页应用,是从服务器加载多个 HTML 页面的应用程序。每个页面都彼此独立,有自己的 URL。当单击 a 标签链接导航到另一个页面时,浏览器将向服务器发送请求并加载新页面。例如,传统的模板技术如JSP、Python、Django、PHP、Laravel 等都是基于 MPA 的框架,包括目前比较火的 Astro 也是采用的 MPA 方案。

SPA(Single-page application) 即单页应用,它只有一个不包含具体页面内容的 HTML,当浏览器拿到这份 HTML 之后,会请求页面所需的 JavaScript 代码,通过执行 JavaScript 代码完成 DOM 树的构建和 DOM 的事件绑定,从而让页面可以交互。如现在使用的大多数 Vue、React 中后台应用都是 SPA 应用。

对比

1. 性能

在 MPA 中,服务器将响应完整的 HTML 页面给浏览器,但是 SPA 需要先请求客户端的 JS Bundle 然后执行 JS 以渲染页面。因此,MPA 中的页面的首屏加载性能比 SPA 更好。

但 SPA 在后续页面加载方面有更好的性能和体验。因为 SPA 在完成首屏加载之后,在访问其它的页面时只需要动态加载页面的一部分组件,而不是整个页面。而且,当页面发生跳转时,SPA 不会重新加载页面,对用户更友好。

2. SEO

MPA 中服务端会针对每个页面返回完整的 HTML 内容,对 SEO 更加友好;而 SPA 的页面内容则需要执行 JS 才能拉取到,不利于 SEO。

3. 路由

MPA 在浏览器侧其实不需要路由,每个页面都在服务端都有一份 URL 地址,浏览器拿到 URL 直接请求服务端即可。但 SPA 则不同,它需要 JS 掌管后续所有路由跳转的逻辑,因此会引入一些路由方案来管理前端的路由,比如基于 hashchange 事件或者浏览器 history API 来实现。

4. 状态管理

除了路由,SPA 另外一个复杂的点在于状态管理。SPA 当中所有路由的状态都是由 JS 进行管理,在不同的路由进行跳转时通过 JS 代码进行一些状态的流转,在页面的规模越来越大的时候,状态管理就变得越来越复杂了。因此,社区也诞生了不少的状态管理方案,如传统的 Redux、社区新秀 Valtio、Zustand 包括字节自研的 Reduck,都是为了解决 SPA 状态管理的问题,一方面降低操作的复杂度、另一方面引入一些规范和限制(比如 Redux 中的 action 机制)来提高项目可维护性。而 MPA 则会简单很多,因为每个页面之间都是相互独立的,不需要在前端做复杂的状态管理。

取舍

总而言之,MPA 有更好的首屏性能,SPA 在后续页面的访问中有更好的性能和体验,但 SPA 也带来了更高的工程复杂度、略差的首屏性能和 SEO。这样就需要在不同的场景中做一些取舍。

不过,MPA 和 SPA 也并不是完全割裂的,两者也是能够有所结合的,比如 SSR/SSG 同构方案就是一个典型的体现,首先框架侧会在服务端生成完整的 HTML 内容,并且同时注入客户端所需要的 SPA 脚本。这样浏览器会拿到完整的 HTML 内容,然后执行客户端的脚本事件的绑定(这个过程也叫 hydrate),后续路由的跳转由 JS 来掌管。当下很多的框架都是采用这样的方案,比如 Next.js、Gatsby、公司内部的 Eden SSR、Modern.js。

但实际上,把 MPA 和 SPA 结合的方案也并不是完美无缺的,主要的问题在于这类方案仍然会下载全量的客户端 JS及执行全量的组件 Hydrate 过程,造成页面的首屏 TTI 劣化。

我们可以试想对于一个文档类型的站点,其实里面的大多数组件是不需要交互的,主要以静态页面的渲染为主,因此直接采用 MPA 方案是一个比 MPA + SPA 更好的一个选择。进一步讲,对于更多的轻交互、重内容的应用场景,MPA 也依然是一个更好的方案。

由于页面中有时仍然不可避免的需要一些交互的逻辑,那放在 MPA 中如何来完成呢?这就是 Islands 架构所要解决的问题。

什么是 Islands 架构?

Islands 架构模型早在 2019 年就被提出来了,并在 2021 年被 Preact 作者Json Miller 在 Islnads Architecture 一文中得到推广。这个模型主要用于 SSR (也包括 SSG) 应用,我们知道,在传统的 SSR 应用中,服务端会给浏览器响应完整的 HTML 内容,并在 HTML 中注入一段完整的 JS 脚本用于完成事件的绑定,也就是完成 hydration (注水) 的过程。当注水的过程完成之后,页面也才能真正地能够进行交互。当一个页面中只有部分的组件交互,那么对于这些可交互的组件,我们可以执行 hydration 过程,因为组件之间是互相独立的。

而对于静态组件,即不可交互的组件,我们可以让其不参与 hydration 过程,直接复用服务端下发的 HTML 内容。可交互的组件就犹如整个页面中的孤岛(Island),因此这种模式叫做 Islands 架构。

Islands 实现原理

Astro

https://astro.build/

在 Astro 中,默认所有的组件都是静态组件,比如:

// index.astro
import MyReactComponent from '../components/MyReactComponent.jsx';
---
<MyReactComponent />

这种写法不会在浏览器添加任何的 JS 代码。但有时我们需要在组件中绑定一些交互事件,那么这时就需要激活孤岛组件了,在使用组件时加上client:load指令即可:

// index.astro
---
import MyReactComponent from '../components/MyReactComponent.jsx';
---
<MyReactComponent client:load />

Astro 除了支持本身 Astro 语法之外,也支持 Vue、React 等框架,可以通过插件的方式来导入。在构建的时候,Astro 只会打包并注入 Islands 组件的代码,并且在浏览器渲染,分别调用不同框架(Vue、React)的渲染函数完成各个 Islands 组件的 hydrate 过程。

Astro 是典型的 MPA 方案,不支持引入 SPA 的路由和状态管理。

Fresh

Fresh 是一个基于 Preact 和 Deno 的全栈框架,同时也主打 Islands 架构。它约定项目中的 islands 目录专门存放 island 组件:

.
├── README.md
├── components
│   └── Button.tsx
├── deno.json
├── dev.ts
├── fresh.gen.ts
├── import_map.json
├── islands                 // Islands 组件目录
│   └── Counter.tsx
├── main.ts
├── routes
│   ├── [name].tsx
│   ├── api
│   │   └── joke.ts
│   └── index.tsx
├── static
│   ├── favicon.ico
│   └── logo.svg
└── utils
    └── twind.ts

Fresh 在渲染层核心主要做了以下的事情:- 通过扫描 islands 目录记录项目中声明的所有 Islands 组件。

  • 拦截 Preact 中 vnode 的创建逻辑,目的是为了匹配之前记录的 Island 组件,如果能匹配上,则记录 Island 组件的 props 信息,并将组件用 的注释标签来包裹,id 值为 Island 的 id,数字为该 Island 的 props 在全局 props 列表中的位置,方便 hydrate 的时候能够找到对应组件的 props。
  • 调用 Preact 的 renderToString 方法将组件渲染为 HTML 字符串。
  • 向 HTML 中注入客户端 hydrate 的逻辑。
  • 拼接完整的 HTML,返回给前端。

值得注意的是客户端 hydrate 方法的实现,传统的 SSR 一般都是直接对根节点调用 hydrate,而在 Islands 架构中,Fresh 对每个 Island 进行独立渲染。

更多细节可以参考篇文章:[深入解读 Fresh]

实践分享

笔者基于 Islands 架构开发了一个文档站方案 island.js。- 仓库: https://github.com/sanyuan0704/island.js

大体定位是支持 Mdx 的类 VitePress 方案,目前也实现了 Islands + MPA 架构,接下来给大家分享一下这个方案是如何来实现 Islands 架构的。

使用 Island 组件

与 Astro 类似,Island.js 里面默认采用 MPA 且 0 JS 的方案,如果存在存在交互的组件,在使用的时候传入一个__island 标志即可,比如:

import { Aside } from '../components/Aside';

export function Layout() {
  return <Aside __island />;
}

这样在生产环境打包的过程中自动识别出 Islands 组件并打包,在 hydrate 的时候各自执行 hydration。

内部实现

总体流程如下:

1. SSR Runtime

指组件 renderToString 的过程,我们需要在这个运行时过程中搜集到所有的 Islands 组件。主要的实现思路是拦截组件创建的逻辑,在 React 中可以通过拦截 React.createElement 实现或者 jsx-runtime 来完成,Island.js 里面实现了后者,通过自定义 jsx-runtime 来拦截 SSR 运行时:

// island-jsx-runtime.js
import * as jsxRuntime from 'react/jsx-runtime';

export const data = {
  // 存放 islands 组件的 props
  islandProps: [],
  // 存放 islands 组件的文件路径
  islandToPathMap: {}
};

const originJsx = jsxRuntime.jsx;
const originJsxs = jsxRuntime.jsxs;

const internalJsx = (jsx, type, props, ...args) => {
  if (props && props.__island) {
    data.islandProps.push(props || {});
    const id = type.name;
    // __island 的 prop 将在 SSR 构建阶段转换为 `__island: 文件路径`
    data.islandToPathMap[id] = props.__island;
    delete props.__island;

    return jsx('div', {
      __island: `${id}:${data.islandProps.length - 1}`,
      children: jsx(type, props, ...args)
    });
  }
  return jsx(type, props, ...args);
};

export const jsx = (...args) => internalJsx(originJsx, ...args);

export const jsxs = (...args) => internalJsx(originJsxs, ...args);

export const Fragment = jsxRuntime.Fragment;

然后在 JSX 编译阶段,指定 jsxRuntime 参数为我们自定义的路径即可。

2. Build Time

Build Time 分为两个阶段: renderToString 之前、renderToString 之后。

renderToString 之前会打两份 bundle:

  • SSR bundle: 用于 renderToString
  • Client bundle: 客户端 Runtime 代码,用于激活页面

在 SSR bundle 生成过程中,我们会特殊处理 __island prop,它实际上是为了标识该组件是一个 Islands 组件,但我们拿不到组件的路径信息。为了之后能够顺利打包 Islands 组件,我们需要在 SSR 构建过程中将 __isalnd 进行转换,使之带上路径信息。比如下面有两个组件:

// Layout.tsx
import { Aside } from './Aside.tsx';

export function Layout() {
  return (
    <div>
      <Aside __island a={1} />
    </div>
  )
}

// Aside.tsx
export function Aside() {
  return <div>内容省略...</div>
}

可以看到 Layout 组件中通过<Aside __island /> 的方式来使用 Aside 组件,标识其为一个 Islands 组件。那么我们将会在 SSR 编译过程中用 babel 插件改写这个 prop,原理如下:

<Aside __island />
// 被转换为
<Aside __island="./Aside.tsx!!island!!Users/project/src/Layout.tsx" />

这样,在 renderToString 过程中,我们就能记录下 Islands 组件所在的文件路径。当 renderToString 完成之后,我们可以通过自定义的 jsx-runtime 模块拿到如下的数据:

{
  islandProps: [ { a: 1 } ],
  islandToPathMap: {
    Aside: './Aside.tsx!!island!!Users/project/src/Layout.tsx'
  }
}

之后在 Build Time 会做两件事情:

  1. 将 islandProps 的数据作为 id 为island-props 的 script 标签注入到 HTML 中;
  2. 根据 islandToPathMap 的信息构造虚拟模块,打包所有的 Islands 组件。

虚拟模块内容如下:

import { Aside } from './Aside.tsx!!island!!Users/project/src/Layout.tsx';

window.islands = {
  Aside
};

window.ISLAND_PROPS = JSON.parse(
  document.getElementById('island-props').textContent
);

将这个虚拟模块打包后我们得到一份 Islands bundle,将这个 bundle 注入到 HTML 中以完成 Islands 组件的注册。

问题: islands bundle 和 client bundle 有共同的依赖 React,由于在两次不同的打包流程中,所以 React 会打包两份。解决方案是 external 掉 react 和 react-dom 依赖,通过 import map 指向全局唯一的 React 实例。

3. Client Runtime

在客户端渲染阶段,我们仅需要少量的脚本来激活 Islands 组件:

  import { hydrateRoot, createRoot } from 'react-dom/client';

  const islands = document.querySelectorAll('[__island]');
  for (let i = 0; i < islands.length; i++) {
    const island = islands[i];
    const [id, index] = island.getAttribute('__island')!.split(':');
    const Element = window.ISLANDS[id];
    hydrateRoot(
      island,
      <Element {...window.ISLAND_PROPS[index]}></Element>
    );
  }

由此,我们便在 React 实现了 Islands 架构,在实际的页面渲染过程中,浏览器仅需请求 React + 少量组件的代码甚至是 0 js。SSG + SPA 方案和 Islands 架构的页面加载情况对比如下:

SSG + SPA

SSG + Islands architecture(MPA)

HTTP 请求资源(KB) FCP (s) DCL(s)
SPA 模式 451 0.48 0.84
Islands 模式 141 0.40 0.52
优化情况 减少近 60% 变化不大 提前近 40%

Islands 架构的适用性

1. 框架无关

Island 架构的实现其实是可以做到框架无关的。从 SSR Runtime、Build Time 到 Client Runtime,整个环节中关于 React 的部分,我们都可以替换成其它框架的实现,这些部分包括:

  • 创建组件的方法
  • 组件转换为 HTML 字符串的 renderToString 方法
  • 浏览器端的 hydrate 方法

因此,不光是 React,对于 Vue、Preact、Solidjs 这些框架中都可以实现 Islands 架构。因此,在 Island.js 中兼容除 React 的其它框架也是原理上完全可行的。

并且考虑到 React 的包体积问题,后续 Island.js 考虑适配其它的框架,如 Solid,体积相比 React 可以减少 90%:

数据来源: https://dev.to/this-is-learning/javascript-framework-todomvc-size-comparison-504f

2. VitePress 的特殊优化

关于是否需要支持 Vue,这里就不得不提到目前基于 Vue 框架的文档方案 VitePress 了,Vue 官网现已接入 VitePress 方案,那基于 VitePress 是否需要做 Islands 架构的优化呢?答案是不需要。VitePress 内部使用的是 Shell 架构,以 Vue 官网为例: VitePress 会在 hydrate 的过程中把正文的静态部分排除,具体实现原理如下:

  • Vue 模板编译阶段,Vue 会对静态虚拟 DOM 节点进行优化,输出 createStaticVNode 的格式

  • 在 Chunk 生成阶段(实现链接),把内容部分用 __VP_STATIC_START____VP_STATIC_END__ 标志位包裹
  • 在生成打包产物前,针对每个页面打包出两份 JS
  • 一份是包含完整内容的 JS,把标志位去掉即可,比如文件名为recommend.[hash].js
  • 另一份是不包含内容的 JS,把标志位及其里面的内容删掉,文件名为recommend.[hash].lean.js

由于 VitePress 采用的是 SSG + SPA 模式,其会根据是否为首屏来分发不同的 JS:- 首屏使用 .lean.js,不包含正文部分的 JS,实现 Partial Client Bundle + Partial Hydration,跟 Islands 架构一样的效果

  • 二次页面跳转使用完整的 JS,因为走 SPA 路由跳转,需要拿到完整的页面内容,用 JS 渲染出来。

你可能会问了,在 .lean.js 里面,组件的代码都被改了,难道 Vue 在 hydrate 不会发现内容和服务端渲染的 HTML 对应不上进而报错吗?答案是不会,我们可以看看 Vue 里面 createStaticVNode 的实现:

注意第二个传参,里面会记录静态节点的数量,在 hydrate 的过程中对静态节点会特殊处理,直接检查 staticCount即节点数量而不是内容,那么对于如下的 VNode 节点来讲 hydrate 仍然是可以成功的:

// recommend.[hash].lean.js
const html = ` A <span>foo</span> B`
const { vnode, container } = mountWithHydration(html, () =>
// 保证第二个参数正确即可
  createStaticVNode(``, 3)
)

总之, VitePress 利用 Vue 的编译时优化以及内部定制的 Hydrate 方案足以解决传统 SSG 的全量 hydration 问题,采用 Islands 架构意义并不大。

那进一步讲,像 Vue 这种 Shell 优化方案对于包含编译时的前端框架是否通用?这里我们可以先大概总结出 Shell 方案需要满足的条件:

  • 模板编译阶段,将静态节点进行特殊标记
  • 运行时,支持 hydrate 跳过对静态节点的内容检查

基于上面这两点,其他的代表性编译时框架如SolidSvelte 很难实现 Vue 的 Shell 架构(没法标记静态节点),因此 Shell 方案可以理解为在 Vue 框架下的一个特殊优化了。对于 Vue 外的其它框架方案,仍然可以采用 Islands 进行特定场景的优化。

本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/zclXgcz-LfBIc4aVCpcc1A

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:1年以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:1年以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:1年以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:1年以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:1年以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:1年以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:1年以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:1年以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:1年以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:1年以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:1年以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:1年以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:1年以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:1年以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:1年以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:1年以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:1年以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
Android插件化方案 5年以前  |  237226次阅读
vscode超好用的代码书签插件Bookmarks 2年以前  |  8062次阅读
 目录