AICon议程上新60%,阿里国际、360智脑、科大讯飞、蔚来汽车分享大模型探索与实践 了解详情
写点什么

前端所有主流框架,其实都是在自欺欺人

作者 | Moonthoughts

  • 2023-10-30
    北京
  • 本文字数:9491 字

    阅读完需:约 31 分钟

前端所有主流框架,其实都是在自欺欺人

怎么样,这个开头够不够标题党?但大家千万别误会,我并不是要侮辱接下来列出的这些技术,而是想跟各位讨论一个困扰了我很久的问题。


另外,本文并非软文,不会向大家推销什么“完美的替代方案”。


一切要从 Svelte 说起


本月初,Svlte 5 预告片正式发布,其中介绍了新的 runes。


大家似乎都对此感到兴奋,我自己也一样。


真正让我恼火的,其实是 Svelte 在解决 UI 问题时同样选择了东拼西凑的方案。


为什么组件中的反应方法仍然需要转译 / 编译?为什么还在使用带有假指令的劫持 HTML 语法?


为什么 UI 表达还是命令式的?为什么开发技术还在试图模仿 HTML?


让我们先从最后一点说起。


谁说 HTML 就是正确的抽象?


有些朋友可能觉得我在无理取闹。可能吧,但 HTML 本身也只是 DOM 树的映照,是一种表示树结构的方式,而且还不是最优的那种。没错,绝对算不上最优。


可别误会,我不是说 HTML 本身有啥问题——它确实是项很好的技术,有它自己的功能定位。但浏览器不会直接处理 HTML,而是处理 DOM 节点。另外,所有主流客户端库 / 框架现在都会直接通过 JS-API 生成 DOM,借此回避 HTML 表示。也就是说,我们不仅可以使用 HTML,也可以使用其他 DOM 序列化格式作为目标 DOM 描述语言。典型的例子包括 HAML 还有 JSON。所以从这个角度来看,使用 HTML 模板更多是种对固有传统的致敬,而不是真有什么必要性。


再有,为了完整描述每个 DOM 节点,就需要用到以下七种 props:


  • 属性

  • 处理程序

  • 样式

  • data- 属性

  • 可见性

  • 文本内容

  • 子元素


可遗憾的是,很多开发者压根没意识到或者没考虑过,我们其实并不需要隐藏这种复杂性。这是我们自己的平台,当然有责任把一切都搞清楚。


此外,现代开发会假定组件可以拆分。而有拆分,自然就有组合。所以说我们需要一款工具来创建组件实例,对其进行自定义,再通过不同方向的反应链接把它们相互对接起来。而这一切,在 HTML 中都没办法实现。


遗憾的是,几乎所有 UI 解决方案使用的都是最原始的技术——用简单性建议一次又一次自欺欺人。


它们都试图把 DOM 节点属性的多样性压缩成一份简单的属性列表。这样根本没用。而且它完全可见。把 DOM 节点 props 的七种类别压缩成一份扁平的属性列表并不会让开发更轻松,毕竟这七种类别仍然存在,只是换了种形式。


复杂性,它严重吗?


解释一下,这里的复杂性分为两种类别:引入的,还有天然的。引入复杂性来自库、框架、语言和范式等。天然复杂性则是平台本身所固有的,旨在解决领域内的基本问题。出色的工程师会减少引入复杂性,并尝试接受并处理天然复杂性。所以,请别再刻意隐藏天然复杂性,尊重平台的客观现实才是正道。


这里我要再对 Svelte 说几句:Rich Harris 发布的这段视频相当精彩,介绍了 getter 和 setter 的情况,还回应了一些人对于 Svelte 新的反应方法的担忧。但唯一没能充分解决的,就是“必须编写更多代码”这个问题。我们的最终目标不是编写更少的代码,而是在明确表达应用程序意图的前提下编写更少的代码。如果这项技术单纯强调“简单性”,那就是在试图掩盖种种重要的细微差别。它们最终还是会暴露在开发者面前,只是角度有所不同。


但现在只给你 HTML


现代前端中之所以还是沿用类 HTML 解决方案,主要理由就是“开发人员更熟悉”。只要大家之前用过 HTML,那么 Vue 或者 Angular 等模板也就是在对已经精通的内容做扩展。但如果再深挖下去,我们就会发现事情没这么简单——不管宣传怎么说,它们本质上并不是 HTML。


换言之,这些格式都是模拟出来的幻象。


虽然看似是扩展,但它们实际上却属于完全不同的格式。现在它们呈现为类 HTML 的形式,可未来随时有可能转换成其他某种完全独立的新形式。而且在这类格式当中,每个属性都有不同的语义,但在语法上又相互等效,这当然容易产生误导效果。


下面咱们来看看这个 Angular“模板”(语法高亮完全对不上,请大家直接忽略):


<bi-panel class="example">    <check-box        class="editable"        side="left"        [(checked)]="editable"        i18n        >        Editable    </check-box>    <text-area        #input        class="input"        side="left"        [(value)]="text"        [enabled]="editable"        placeholer="Markdown content.."        i18n-placeholder="Showed when input is empty"    />    <div        *ngIf="text"        class="output-label"        side="right"        i18n        >        Result    </div>    <mark-down        *ngIf="text"        class="output"        side="right"        text="{{text}}"    /></bi-panel>
复制代码


  • #input 属于本地标识符,用于通过 TS 访问。

  • class=“editable” 是通过 CSS 绑定样式的类的名称。

  • side=“left” 是放置此元素的 slot 的名称。

  • [(checked)]=“editable” 是嵌套组件与外部组件的属性的双向绑定。

  • [enabled]=“editable” 则是单向绑定。

  • text="{{text}}" 也是一样。

  • placeholer=“Markdown content…” 是某种 Markdown 文本。

  • i18n-placeholder=“Showed when input is empty.” 这里突然又说占位符属性是可翻译的,并对翻译器做了解释。

  • *ngIf=“text” 这部分跟组件完全无关,负责控制组件是否能在父级中呈现。


它们用起来又是一样的


所以在我看来,非得从 onClick={…}、on:click={…} 和 @click="…"当中做出选择,其实就是缺乏选择的表现。我真的受够了。


从某些方面来说,十年之前的解决方案是这个样子倒是可以理解:


  • 因为这样的栈易于使用、但难于设计。

  • 因为早期的应用程序更简单,而且原始的模板方法足以支持 DOM API。

  • 因为直到最近 4、5 年,这种形式的代码才具有合理的运行性能。


总结成一句话,就是:


  • 因为我们需要大量时间进行试验,并且能够接受新实现和当前方法的失败。


不幸的是,大家很少关注后面一半。但我也理解,毕竟这就是习惯的力量。但每一年过去,僵化的现实都令人心生不满。难道大家不会为自己在 HOC、render-props 和其他毫无意义的东西上浪费的时间感到心痛吗?


于是我开始认为这已经形成了一种畸形的逻辑链:因为我们没有学会如何正确地开发一套平台,所以才因为各种妥协而浪费精力;这就导致财务成本很高且难于维护,致使如今的应用开发仍然很困难。


被劫持的语法


大家可能会抱怨 React 中的 JSX 语法、Vue 中的模板方法,或者 Svelte 中的组件。没错,它们都有各自的毛病。但原因并不是它们不够好,而是它们从根本上就选错了方向、而且错得离谱。


下面咱们一起看点代码示例,我会借此论证自己的判断:


React


function Component() {  return (    <div>      <h1>Hey there</h1>    </div>  )}
复制代码


Vue


<template>  <div>    <h1>Hey there</h1>  </div></template>
复制代码


Svelte


<div>  <h1>Hey there</h1></div>
复制代码


说实在的,它们看起来都还不错。


但在尝试添加一些条件渲染之后,情况就不同了:


React


function ConditionalComponent({ showMessage }) {  return (    <div>      {showMessage ? (        <h1>Hey there</h1>      ) : null}    </div>  );}...<ConditionalComponent showMessage={true} />
复制代码


Vue


<template>  <div>    <h1 v-if="showMessage">Hey there</h1>  </div></template><script>  export default {    name: 'ConditionalComponent',    props: {      showMessage: Boolean    }  }</script>...<ConditionalComponent :showMessage="true"
复制代码


Svelte


<div>  {#if showMessage}    <h1>Hey there</h1>  {/if}</div>...<ConditionalComponent showMessage={true} />
复制代码


呃……


视图树内的 If 语句回退为 null(或者某些插件组件,但这并不重要)?v-if 指令是什么?{#if …}模板块又是什么?带 *ngIf 的结构指令?我得说 DOM API 里压根没有这些东西,它们单纯就是些廉价的把戏。请注意,我针对的不是它们的命名,而是其概念本身。


其中最引人注目的,还得数 Vue。我们要么使用 v-if 并每次都销毁组件,要么愚蠢地把组件隐藏掉。都 2023 年了,还在用 display: none?这完全就是对开发平台的亵渎好吗?


而且有问题可不只是 Vue。比如在 React 当中,函数组件的内容也充满了副作用。因此,只能大量使用重新渲染来计算这些副作用并更新数据,白白增加不必要的工作量。


“虽然 React 导致了不必要的重绘,但其底层机制还是有道理的,就是为了优化性能并让 UI 跟应用程序的数据保持同步。”真的吗?拜托面对现实吧,重新渲染绝对是每个人都想绕着走的麻烦事。而像 useMemo 和 useCallback 这类“解决方案”也仍不足以彻底消除额外渲染。


我再说一次,这就是自暴自弃加盲目妥协的产物。能解决问题的不是加快重新渲染速度,而是消除不必要的重新渲染步骤。


具体方式,可以对整个接口树做静态初始化。每个元素(更确切地讲,是栈内元素的回调)只会被计算并调用一次,从而将反应值跟节点关联起来。如此一来,主任务就只须执行一次所描述的代码,接下来沿着 DOM 图的数据 / 事件流推进即可。


咱们继续讨论。比如说要对一个列表中的内容进行渲染,它们分别是这么干的:


React


function UserList() {  return (    <div>      <ul>        {users.map(user => (          <li key={user.id}>{user.name}</li>        ))}      </ul>    </div>  );}
复制代码


Vue


<template>  <div>    <ul>      <li v-for="user in users" :key="user.id">{{ user.name }}</li>    </ul>  </div></template>
复制代码


Svelte


<div>  <ul>    {#each users as user (user.id)}      <li>{user.name}</li>    {/each}  </ul></div>
复制代码


好吧,又来了。大量虚构的语法、模板、还有指令。这种情况过去有、现在有,将来恐怕还是有。毕竟看那个意思,React 和 Vue 两位大哥毫无做出改变的念头。而这样的技术一旦脱离了主流,大概率会沦为难以维护的遗留债,不信就想想当年的 Ember 吧。


拥抱 DOM API


下面,咱们继续聊聊有可能解决这个问题的潜在答案——DOM API!这里有不少有趣的点,而且奇怪的是,多年来人们其实一直在做潜心研究。DOM API 体量庞大、功能繁多,而且其中很多特性根本没法用 props 掩盖掉。


例如,我们要怎么解决条件渲染的问题?DOM API 提供 node.append() 或 node.appendChild() 方法、node.remove() 方法和 node.isConnected 属性。我们可以用它随时添加或删除节点,并确定其是否接入 DOM 树。


接入 DOM 树的节点(甚至是其子节点)的状态就应该由组件本身来报告,而非借助那些外部块。所以我们完全可以这样:


export function Component({ showMessage }) {  h('div', () => {    h('h1', {      text: 'Hey there',      visible: showMessage,    })  })}
复制代码


用不着虚构语法和扩展,也不必非得把这些基本特性隐藏在 props 之下。这就是个常规的 JS 函数,有着用于跟 DOM 交互的便捷 API。那要怎么在应用程序中使用这个组件?当然就是把它当普通函数处理喽:


using(body, () => {  Component({ showMessage: true })})
复制代码


注意,这里只是一段伪代码示例。


这种方法借鉴了 SwiftUI 和 Flutter 的思路。其中的第二个回调参数就相当于 SwiftUI 中的嵌套组件块,visible 属性就类似于 Flutter 中的 visible 属性。没错,这里的 visible 不再是 Vue 中的“花招”,而会从子树中实际插入 / 提取 DOM 节点。


这样,我们就不用发明一大堆抽象语法来模拟自己需要的行为。是的,我知道很多朋友可能并不喜欢 JavaScript,也能理解个中缘由。但前端开发的“原生”语言仍然是 JavaScript,尝试用虚假的解决方案绕过它只会让事情变得更糟。类似的情况之前出现过很多次了,无一例外。


好的,处理 visible 的方法已经基本清楚了。那渲染组件列表又该如何?下面来看:


export const function User({ key, name, isRestricted }) {  h('li', {    attr: { id: key },    text: name,    visible: isRestricted,    classList: ["border-gray-200"]  })}using(document.body, () => {  h('ul', () => {    list(users, ({ store: user, key: idx }), () => {      User({ key: idx, name: user.name, isRestricted: user.isRestricted })    })  })})
复制代码


这种方法参考的是 SwiftUI 的解决思路,即:


List(users) { user in  // usage of user}
复制代码


此外,所呈现代码中使用的每个变量或属性都可以是反应式的。这样,每当我们更改用户列表或其属性时,结果都会反映在最终布局当中。


为什么不用 for/map 循环?因为 for/map 循环是个黑箱,会与内部调用的上下文相脱离,我们根本没办法提前采取行动。例如,React 要求开发人员为此类列表中的各个条目指定唯一键,借此使其保持稳定。看见没有,又是个明明没有困难、非要制造困难的典型。


再有,这种 list 方法也让列表本身更加精巧。它不再计算列表中各个条目的所有内容,而是创建模板(请注意,是 JS 模板,不要跟 Vue 等其他模板弄混了)以供应用程序使用。这些模板会提前生成,每次 list 调用对应一个模板。这样,每当 users 的反应值发生变化,我们就只需要为已配置模板创建新实例,而不必在运行时内计算所有内容。


但遗憾的是,不少现代解决方案仍在使用虚拟 DOM 和协调(reconciliation),引入阶段的概念来检查从组件返回的结构变更。正因为如此,重绘和性能问题才反反复复得不到解决。此外还有其他一些人为限制。


有一说一,Svelte 在这方面表现得不好。它并不依赖虚拟 DOM,而是使用编译器将组件转换为 JS。转换出的 JS 代码非常高效,但又会引发新的问题:不必要的 build 步骤,而且 Svelte 的这些特定代码会一直存在于最终包当中。所以说,重新渲染和假语法问题依旧在那里。


咱们再次回归主题。那事件处理程序和属性规范呢?我们用以下代码为例:


using(document.body, () => {  h('section', () => {    spec({ style: {width: '15em'} })    h('form', () => {      spec({        handler: {          config: { prevent: true },          on: { submit },        },        style: {          display: 'flex',          flexDirection: 'column',        },      })      h('input', {        attr: { placeholder: 'Username' },        handler: { input: changeUsername },      })      h('input', {        attr: { type: 'password', placeholder: 'Password' },        classList: ['w-full', 'py-2', 'px-4'],        handler: { input: changePassword },      })      h('button', {        text: 'Submit',        attr: {          disabled: fields.map(            fields => !(fields.username && fields.password),          ),        },      })    })  })})
复制代码


第一眼看去,很多读者朋友可能会觉得:


  • 这跟常规习惯不太一样;

  • 太过冗长;

  • 必须亲自处理 DOM API 的那些琐事。


但事实真是如此吗?


其实这里没什么不一样的,它就是个 JS 函数,其余的部分分别为:


  • attr:带有节点属性的对象。

  • style:带有节点样式的对象。

  • classList:节点类的数组。顺带一提,在 DOM API 里它也叫这个名字。

  • handler:带有节点事件处理程序的对象,其中包含配置对象 (注意 config: { prevent: true })。

  • spec:一个打包函数,用于描述节点的属性类别(如果组件在其回调内具有子元素的话)。通过这种方式,我们可以在组件之上描述属性集(其实在回调内的任意位置都可以,但这不重要)。


有点冗长?确实,这种方法看起来确实比 React、Vue、Svelte 和 Solid 之类的要繁复。但这些框架只是让人误以为回避掉了前端复杂性,却并不能真正让事情变得简单。所以我觉得大家不妨直面现实,跟难题交朋友,而不是一味躲藏。通过这种方式,我们能够清楚了解自己的应用程序是如何构建而成。没错,确实冗长,但却并不复杂。相信大家都能看明白这是在干什么,甚至理解每一行的具体作用。


另外,这里我们也不用直接使用 DOM API。真正需要的,就是一个能用来与之交互的便捷 JS API。我也坚持认为视图树应该由原生工具管理,而对树进行添加、删除和更新的手动操作,倒是可以交给技术工具来接管。


再次强调,我不是想跟大家推销什么看似酷炫的技术工具。相反,我是想指出现有解决方案中存在的问题,还有如何通过原生工具将其解决,避免重新造轮子。


总之,我的核心观点就是尊重平台的天然属性。大家都应该学会怎么使用自己的平台,而不再像过去那样不断用新的虚假解决方案来自欺欺人。


不出问题就别管?但真的没出问题吗?


坦白地讲,本文展示的简单案例很难表现真实的情况,因为有些问题不会在简单的例子中暴露出来。


比方说,我们要怎么描述实际应用程序中的表单部分:


export const Auth = () => {  h("div", () => {    spec({      classList: ["mt-10", "max-w-sm", "w-full"],    });    h("form", () => {      Input({        type: "email",        label: "Email",        inputChanged: authForm.fields.email.changed,        errorText: authForm.fields.email.$errorText,        errorVisible: authForm.fields.email.$errors.map(Boolean),      });      Input({        type: "password",        label: "Password",        inputChanged: authForm.fields.password.changed,        errorText: authForm.fields.password.$errorText,        errorVisible: authForm.fields.password.$errors.map(Boolean),      });      Button({        text: "Create",        event: authForm.submit,        size: "base",        prevent: true,        variant: "default",      });      ErrorHint($authError, $authError.map(Boolean));    });  });};...export const Input = ({  value,  type,  label,  required,  inputChanged,  errorVisible,  errorText,}: {  value?: Store<string>;  type: string;  label: string;  required?: boolean;  inputChanged: Event<any>;  errorVisible?: Store<boolean>;  errorText?: Store<string>;}) => {  h("div", () => {    spec({      classList: ["mb-6"],    });    h("label", () => {      spec({        classList: ["block", "mb-2", "text-sm", "font-medium", "text-gray-900", "dark:text-white"],        text: label,      });    });    h("input", () => {      const localInputChanged = createEvent<any>();      sample({        source: localInputChanged,        fn: (event) => event.target.value,        target: inputChanged,      });      spec({        classList: [          "bg-gray-50",          "border",          "border-gray-300",          "text-gray-900",          "text-sm",          "rounded-lg",          "focus:ring-blue-500",          "focus:border-blue-500",          "block",          "w-full",          "p-2.5",          "dark:bg-gray-700",          "dark:border-gray-600",          "dark:placeholder-gray-400",          "dark:text-white",          "dark:focus:ring-blue-500",          "dark:focus:border-blue-500",        ],        attr: { type: type, required: Boolean(required), value: value || createStore("") },        handler: { on: { input: localInputChanged } },      });    });    ErrorHint(errorText, errorVisible);  });};...export const ErrorHint = (text: Store<string> | string | undefined, visible: Store<boolean> | undefined) => {  h("p", {    classList: ["mt-2", "text-sm", "text-red-600", "dark:text-red-400"],    visible: visible || createStore(false),    text: text || createStore(""),  });};
复制代码


又该怎么用带有标签、属性和动态内容的预定义卡来描述日志列表?


export const LogsList = () => {  h("div", () => {    spec({      classList: ["flex", "flex-col", "space-y-6", "mt-2"],    });    list(logModel.$logsGroups, ({ store: group }) => {      CardHeaded({        tags: group.map((g) => g.tags),        href: group.map((g) => `${g.schema_name}/${g.group_hash}`),        content: () => {          LogsTable(group.map((g) => g.logs));        },        withMore: true,      });    });  });};
复制代码


我们根本不需要用到这些 createStore、createEvent。Store 就是个反应值,事件则是用来改变或调用某些效果的执行信号。它们可以来自任何库。


这里最重要的就是描述视图这个基本事实,也就是视图逻辑。在我看来,哪怕视图描述本身比较简单,也不该随意引入不必要的解决方案。那目前的主流框架能否以最佳方式发挥作用?如果不能,问题出在哪里?


HTMX 能不能解救我们?


HTMX 可太棒了!它正在市场上积聚人气,这里请允许我向 ThePrimeagen 表达谢意。


但这项技术只是另外一种反模式,甚至夸张点说是种反平台方案。


请别误会,HTMX 确实给问题提供了答案。而且据我所知,它在功能和方法所及范围内的确很好地解决了问题。但这项技术还是老毛病——对前端的客观现实视而不见,用开倒车的方式打补丁。具体讲,它其实是把前端的问题移交给了后端,指望着“能在那边解决”。


是的,没人喜欢前端,就连前端自己也不喜欢。但咱们能不能现实一点,用户交互难道不该由客户端负责处理吗?谁见过哪款移动应用会在用户交互时把请求发给服务器,再从那边获取新布局的?桌面端有吗?


另外,使用 HTMX 还给网络连接速度带来了新的挑战,任何一点小事都去劳烦服务器真的很讨厌。并不是每个人在所有场景下都有足够好的网络连接,这么搞肯定会被印度和非洲的移动用户骂个狗血淋头。而且那里可是目前增长速度最快的新兴市场哦。


另外,这个例子可能有点极端,但大家可以尝试在 HTMX 上执行以下操作:


创建一份预订表单,预订剧院第 16 排的 4 到 8 个座位,场次为下午 1:00 至 3:30。其中 6 号座已经售出。如果一次性购买 3 个以上座位,可享受 5% 的折扣。由于是老顾客预订,所以你这一单可以得到免费的爆米花。浏览器时区为 CT,剧院时区为 ET。服务器偶尔会响应 502。


这就是我们需要在前端解决的实际问题,不用指望什么 Todo MVC 加超媒体。


HTMX 有它的作用,但更适合那些以后端为中心的任务。至于前端,咱们还是尽量用自己的功能和平台。


本文是不是太过关注语法了?


谈到语法这个问题,大家的观点往往各不相同。有人觉得语法不重要、没必要争来争去,但也有很多人被固有语法折磨得头痛欲裂。我想说的是,语法在“定义”技术方面确实发挥着极其重要的作用。但受篇幅所限,这里就不过多展开了。


为什么要讨论这个问题


大家可能觉得我对当前主流框架方案的评价过于激进,但事实并非如此。


事实上,我承认这些技术都有一定程度的必要性,也在常规前端开发当中解决了开发者的部分问题。但让我难以接受的是,我们过去十年来一直在同样的困境里打转,至今没人给开发者们提个醒。所以,我们的应用程序仍然难以复现,而且即使是在最简单的开发需求下也得承受大量不必要的工作内容。


我的观点绝不是劝大家直接放弃所有现成的解决方案。不,那也太蠢了。我也不建议大家每次都手动执行 DOM 操作,这确实该由库 / 框架 / 技术 /API 之类来代劳。我想说的是,也许是时候给那些具有严重设计缺陷的“雪花”型方案提个醒了。至于就个人来讲,我觉得这个问题很有意义。


而且行业似乎还没有意识到当前实践的缺陷,反而在错误的道路上越走越远。


总之,请尊重我们的平台、尊重它的固有特性。


原文链接:

https://moonthought.github.io/posts/all-your-mainstream-ui-frameworks-are-lying-to-you/

相关阅读:

前端精准测试实践

前后端分离技术体系

大前端测试的思考和在语雀的实践分享

前端文档站点搭建方案

2023-10-30 13:515509

评论 2 条评论

发布
用户头像
对,作者最好说明一下自己的方案设想。
2023-11-06 12:01 · 河北
回复
用户头像
emmm, 所以呢?
2023-11-04 13:22 · 中国香港
回复
没有更多了
发现更多内容

Week 9 作业01

Croesus

Snowpack - 更快的前端构建工具

曲迪

效率工具 大前端

第九周作业

极客大学架构师训练营

训练营第五周作业

大脸猫

极客大学架构师训练营

第五周学习总结

晴空万里

极客大学架构师训练营

极客时间架构 1 期:第 9 周 性能优化(三) - 命题作业

Null

第9周作业1

Yangjing

极客大学架构师训练营

Netty源码解析 -- 对象池Recycler实现原理

binecy

Netty 对象存储 高性能

week5 作业二

Sean Chen

数据库工程师整理最常见mysql面试题,每一道都是工作面试经典

小Q

MySQL 数据库 学习 架构 面试

第九周学习总结

Meow

一致性 hash 算法的实现

幸福小子

一致性Hash算法

真零基础Python开发web

MySQL从删库到跑路

Python django Web bottle

助推城市智慧化!正舵者携手中科院演绎区块链魅力

CECBC

区块链 人工智能

架构师训练营第九周课后练习

薛凯

第九周作业

Meow

文件上传踩坑记及文件清理原理探究

比伯

Java 大数据 编程 架构 计算机

常见的负载均衡实现方案

幸福小子

负载均衡架构

第9周作业2

Yangjing

极客大学架构师训练营

架构师 01 期,第九周课后作业

子文

架构师训练营

第五周作业

晴空万里

极客大学架构师训练营

请简述 JVM 垃圾回收原理

orchid9

第九周作业

Geek_ce484f

极客大学架构师训练营

架构师训练营 1 期 - 第九周作业(vaik)

行之

极客大学架构师训练营

极客时间架构 1 期:第 9 周 性能优化(三) - 学习总结

Null

大数据和Hadoop平台介绍

MySQL从删库到跑路

大数据 hadoop

第九周学习总结

orchid9

训练营第九周作业 2

仲夏

极客大学架构师训练营

架构师训练营 1 期 - 第九周总结(vaik)

行之

极客大学架构师训练营

第九周作业总结

Geek_ce484f

极客大学架构师训练营

前端所有主流框架,其实都是在自欺欺人_架构/框架_InfoQ精选文章