news 2026/9/7 9:05:42

Gitea Web Components 深度解析:从 head 阻塞入口到 relative-time 与 overflow-menu 的实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Gitea Web Components 深度解析:从 head 阻塞入口到 relative-time 与 overflow-menu 的实现

Gitea Web Components 深度解析:从 head 阻塞入口到 relative-time 与 overflow-menu 的实现

【免费下载链接】giteaGit with a cup of tea! Painless self-hosted all-in-one software development service, including Git hosting, code review, team collaboration, package registry and CI/CD项目地址: https://gitcode.com/GitHub_Trending/gi/gitea

本文以 Gitea 的web_src/js/webcomponents目录为核心,拆解该目录下 Web Components 的加载时机、编码准则与三个核心元素(relative-timeoverflow-menuWeakRefpolyfill)的完整实现。读完你可以掌握:为什么这些组件被刻意放进<head>的独立 IIFE 入口、如何编写一个"轻量、可被树摇、Vue 兼容"的自定义元素,以及相对时间/溢出菜单背后的 Intl 格式化、批量更新调度与键盘无障碍导航机制。

一、目录定位与"为什么要在 head 加载"

README 对目录的定位非常明确:该目录存放 Gitea Web UI 所使用的 Web Components 源码。它给出的第一条、也是最关键的一条准则是:

These components are loaded in<head>(before DOM body) in a separate entry point, they need to be lightweight to not affect the page loading time too much.

也就是说,这些组件不在常规的 JS 主包里,而是被单独打包成一个"阻塞式 IIFE bundle",在<head>中、DOM body 渲染之前执行。这一设计意图有两个直接后果:

  1. 必须轻量——它们运行在页面渲染的关键路径上,任何体积膨胀都会直接拖慢首屏;
  2. 能在 body 就绪前生效——例如提前设定主题色、注册全局自定义元素,避免闪烁(FOUC)或元素降级为普通文本。

加载链路:从模板到 IIFE 插件

从源码结构看,这条链路是这样串起来的:

  • head_script.tmpl 在输出window.config之后,通过{{ctx.ScriptImport "web_src/js/iife.ts"}}(第 34 行)引入一个独立入口。
  • iife.ts 的注释写明"这是应当在阻塞页面渲染的代码的入口,由我们的 iife vite 插件编译"。它的导入顺序是刻意的:
// This file is the entry point for the code which should block the page rendering, it is compiled by our "iife" vite plugin // bootstrap module must be the first one to be imported, it handles global errors import './bootstrap.ts'; // many users expect to use jQuery in their custom scripts // so load globals (including jQuery) as early as possible import './globals.ts'; import './webcomponents/index.ts'; import './modules/user-settings.ts'; // templates also need to use localUserSettings in inline scripts

第 10 行的import './webcomponents/index.ts'就是把整个 Web Components 目录拉进 head 阻塞包的入口。它之所以排在globals.ts之后,是因为globals.ts负责尽早注入全局对象(含 jQuery),而 base/head_script.tmpl 中内联脚本需要用到其中的window.config.i18n

  • vite.config.ts 中的iifePlugin(约第 96 行起)负责在构建期把iife.ts编译成一个 IIFE 文件,开发模式下则作为虚拟文件直接从内存服务,从而保证生产与开发 server 行为一致。

index.ts:head 里真正跑起来的事

index.ts 是 Web Components 目录的顶层入口,内容很短,但每一行都有目的:

import './polyfills.ts'; import './relative-time.ts'; import './overflow-menu.ts'; import {isDarkTheme} from '../utils.ts'; function initPageThemeDarkLight() { // Set page's theme color preference as early as possible, to avoid flicker of wrong theme color during page load. const sync = () => document.documentElement.setAttribute('data-gitea-theme-dark', String(isDarkTheme())); sync(); // Track system theme changes in case Gitea is using "auto" theme. window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', sync); } initPageThemeDarkLight();

这里做两件事:

  1. 注册/初始化三个组件模块——polyfillsrelative-timeoverflow-menu。注意每个组件文件在导入时即执行window.customElements.define(...),因此"import 即注册",不需要额外的 DOM 就绪等待。
  2. initPageThemeDarkLight()——在 head 阶段就把data-gitea-theme-dark属性打到<html>上。注释解释了动机:尽可能早地设定主题色偏好,避免页面加载期间出现"错误主题色"的闪烁;同时监听prefers-color-scheme: dark,以配合"自动(auto)主题"在系统切到深色时实时同步。这正是"必须放在 head 阻塞包"价值的典型体现——它必须在 CSS 应用主题前完成。

二、README 的三条编码准则逐条解读

README 给出三条准则,每一条都直接对应了实现里的一条硬约束,值得作为扩展新组件时的检查清单。

准则 1:保持轻量

见上一节。落地表现为:不引入重型依赖、import即注册、无 DOM 轮询。

准则 2:不要 importsvg.js

Do not importsvg.jsinto a web component because that file is currently not tree-shakeable, import svg files individually instead.

svg.js目前不可被树摇,若整体引入会把所有图标打进这个 head 阻塞包。因此规范要求"单独导入具体的 svg 文件"。这一约束在 overflow-menu.ts 中得到完美示范——它没有引入整个 svg 模块,而是只引入自己需要的那一个 kebab 图标:

import octiconKebabHorizontal from '../../../public/assets/img/svg/octicon-kebab-horizontal.svg';

后续用this.button.innerHTML = octiconKebabHorizontal(第 174 行)注入。这就是"按需引入单个 svg"的标准写法,可直接作为新组件的模板。

准则 3:Vue 中的自定义元素需登记

Any custom element used inside a.vuefile must be added towebComponentsinvite.config.tsso Vue does not try to resolve it as a component. That list also covers custom elements from dependencies.

由于 Vue 会把模板里的未知标签当作组件去解析,如果<relative-time><overflow-menu>出现在.vue文件里而未被登记,Vue 会尝试将其当作组件实例化而非原样保留自定义元素。文档要求把这些自定义元素登记进 vite.config.ts 的webComponents列表,且该列表同样覆盖"来自依赖库"的自定义元素。扩展组件时若涉及.vue,务必同步补登记,否则会出现解析冲突。

三、relative-time:自实现的相对时间元素

这是目录中最具代表性的组件,完整实现见 relative-time.ts,测试见 relative-time.test.ts。文件头部明确标注:

// Vendored and simplified from @github/relative-time-element@4.4.6

即它由 GitHub 的relative-time-element包"vendor 化并简化"而来(MIT 许可),因此 API 与上游保持兼容,但去除了不必要依赖,更契合 Gitea 对轻量性的要求。

3.1 属性与默认值

RelativeTime类在static observedAttributes(第 238–242 行)中声明了它监听的全部属性,任何一个变化都会触发重渲染:

static observedAttributes = [ 'second', 'minute', 'hour', 'weekday', 'day', 'month', 'year', 'prefix', 'threshold', 'tense', 'format', 'format-style', 'datetime', 'lang', 'hour-cycle', ];

这些属性各自有解析器与默认值,几个关键点:

  • threshold(相对/绝对的分界线)#thresholdMsparseDurationMs解析 ISO-8601 Duration(如P1DP30D),若无法解析则回退到30 * 86400000,即默认 30 天。当时间跨度小于阈值时显示相对时间("3 分钟前"),否则显示日期("on Mar 11")。threshold支持 ISO 写法这一细节由测试switches to datetime with P1D threshold验证。
  • format:取值auto | datetime | relative | duration,默认auto
  • tense:取值auto | past | future,默认auto
  • format-style:取值long | short | narrow,在datetime模式下默认short,否则long
  • year:若未显式设置且当前年份与目标年份不同,则自动补为numeric(跨年才显示年份)。
  • hour-cycle:优先取最近的[hour-cycle]祖先元素,否则按浏览器是否 12 小时制回退到h12/h23isBrowser12hCycleIntl.DateTimeFormat(...).resolvedOptions().hourCycle探测并缓存)。
  • lang:优先取最近[lang]祖先,其次navigator.language,再用new Intl.Locale(...)校验,最终回退en

3.2 时间解析的健壮性

dategetter(第 367–371 行)做了两层解析:

get date(): Date | null { const dt = this.datetime; const parsed = unixSecondsRe.test(dt) ? Number(dt) * 1000 : Date.parse(dt); return Number.isNaN(parsed) ? null : new Date(parsed); }
  • 纯数字字符串按Unix 秒unixSecondsRe = /^\d+$/)乘以 1000;
  • 其余按 ISO 字符串走Date.parse
  • 解析失败(NaN)返回null

测试 relative-time.test.ts 用一组"负样本"精确锁定了边界:accepts unix seconds as integer stringString(Math.floor(Date.now()/1000) - 3*60)→ "3 minutes ago")、ignores fractional unix seconds1700000000.5被忽略,保留原文本 fallback)、ignores negative unix secondsignores invalid datetimebogus)、ignores partial numeric datetime123abc)、handles empty datetime。也就是说,任何"看起来像但不是合法时间"的输入都不会抛错,而是安全地回退到元素原有文本——这对服务端模板拼出的字符串尤为重要。

3.3 三种呈现策略与阈值切换

update()(第 475 行起)是核心。它计算elapsedMs = |date - now|,再由#resolveFormat决定走哪条格式化分支:

  • duration#getDurationFormat):优先用Intl.DurationFormat(若浏览器支持),否则用Intl.NumberFormat(..., {style: 'unit'})逐单位拼接,极端老浏览器(注释点名 PaleMoon)再退化为value unit(s)纯文本。
  • relative#getRelativeFormat):用Intl.RelativeTimeFormatnumeric: 'auto'),并做tense钳制——若tense=past而实际是未来时间,则替换为空 Duration(即显示 "now");tense=future反之。
  • datetime#getDateTimeFormat):用Intl.DateTimeFormatsecond/minute/hour/weekday/day/month/year/hourCycle选项格式化,并前置prefix(默认ondatetime格式下为空串)。

roundToSingleUnit(第 101–162 行)是一段精细的"把多单位时长归一到单个最相关单位"的算法,包含秒进位(≥55 秒进位到分钟)、时进日(≥21 小时且无天时进位)、周进月(≥4 周进月)等一堆启发式,保证"26 小时"显示为"1 day"、"3 天"显示为"3 days"而不是奇怪的混合单位。getRelativeTimeUnit(第 164 行)再从中取出[数值, 单位]交给RelativeTimeFormat

阈值切换在测试里被精确验证:

test('switches to datetime format after default threshold', async () => { const el = createRelativeTime(new Date(Date.now() - 32 * 24 * 60 * 60 * 1000).toISOString(), {lang: 'en-US'}); await Promise.resolve(); expect(getText(el)).toMatch(/on [A-Z][a-z]{2} \d{1,2}/); });

超过默认 30 天阈值(示例用 32 天)后,输出从相对时间变为on Mar 11这类日期。

3.4dateObserver:批量定时更新,而不是每个元素各开一个 timer

相对/持续格式需要随时间"自己变旧"("3 分钟前"会逐渐变 "4 分钟前")。若给每个元素各开一个setInterval,元素多时定时器会泛滥。relative-time.ts用一个模块级单例dateObserver(第 195–235 行)统一调度:

  • observe(element):把元素加入Set,按其"精度因子"getUnitFactor计算下一次更新时间。因子分三档——距今 < 1 分钟用秒(1000ms),< 1 小时用分(60000ms),否则用时(3600000ms)。即"越近的标签刷新越勤"。
  • unobserve(element):移除元素;若集合清空则清掉定时器、time重置为Infinity
  • update():遍历所有元素调用各自的update(),然后按"最近一个到期时间"重新排程下一次setTimeout,并钳制上限Math.min(60 * 60 * 1000, nearestDistance),保证最长 1 小时至少刷一次。

元素侧的联动:connectedCallback首次update()disconnectedCallbackdateObserver.unobserve(this)防泄漏;update()结尾按当前格式决定observe还是unobserve——datetime格式不会自动刷新(因为它不随时间变化),只有relative/duration才需要被观察。这是一个很干净的"按需订阅"设计。

3.5 无障碍与国际化

update()中还会同步一个完整日期到data-tooltip-contentaria-label(第 482–486 行),让屏幕阅读器和自定义 tooltip 都能拿到"2024 年 3 月 11 日 15:04"这样的绝对时间:

const tooltip = this.#getFormattedTitle(date); if (tooltip && this.getAttribute('data-tooltip-content') !== tooltip) { this.setAttribute('data-tooltip-content', tooltip); this.setAttribute('aria-label', tooltip); }

#getFormattedTitleIntl.DateTimeFormat固定输出day numeric + month short + year numeric + hour numeric + minute 2-digit。测试respects lang from parent element验证了把元素放进lang="de"的父容器后,输出会变成德文vor 3 Tagenfalls back when navigator.language is invalid验证了当navigator.language返回非法值('undefined')时仍能用lang属性回退到英文。

3.6 微任务批处理,避免同帧多次重算

attributeChangedCallback(第 381–390 行)用queueMicrotask把同一次微任务内的多次属性变更合并成一次update()

attributeChangedCallback(_attrName, oldValue, newValue) { if (oldValue === newValue) return; if (!this.#updating) { this.#updating = true; queueMicrotask(() => { this.update(); this.#updating = false; }); } }

测试batches multiple attribute changes into single update连续设置second/hour/minute三个属性后断言updateCount为 1。这避免了服务端模板一次性写多个属性时引发的重复 DOM 重算。

3.7 开发自测页与生产使用

  • 开发自测页 relative-time.tmpl 几乎把上面每一种形态都摆了出来:now / 3m ago / 3h ago / 1d / 3d / 3d future / 40d ago(阈值边界)tense="past"(含未来钳制到 now、60 天前)、tense="future"format="duration"(含format-style="short"/"narrow")、format="datetime"(含month=short/longthreshold="P0Y"year="numeric"threshold="P1D")。这是"组件契约"的活文档,扩展属性时可在此补充样例。
  • 生产页面 system_status.tmpl 用它展示系统运行的"已运行时长"与"上次 GC":
<dd><relative-time format="duration" datetime="{{.SysStatus.StartTime}}">{{.SysStatus.StartTime}}</relative-time></dd> ... <dd><relative-time format="duration" datetime="{{.SysStatus.LastGCTime}}">{{.SysStatus.LastGCTime}}</relative-time></dd>

注意format="duration"+ 标签体里放了服务端时间作为 fallback——当 JS 尚未就绪或datetime非法时,用户仍能看到服务端渲染出的绝对时间,这正是第 3.2 节"解析失败回退原文本"设计在实际模板中的落地。

四、overflow-menu:让菜单位于 head 也能自适应的溢出菜单

overflow-menu.ts 定义了<overflow-menu>元素,解决一个非常具体的 UI 问题:水平菜单栏里,当窗口变窄放不下所有项时,把"被部分挤出容器"的项收进一个"更多"按钮的下拉弹层里。它被用在仓库头、组织菜单、探索导航、图片 diff 切换条等多处,例如 repo/header.tmpl(第 96 行<overflow-menu class="ui secondary pointing menu">)、org/menu.tmpl、explore/navbar.tmpl、image_diff.tmpl。

4.1 结构契约与初始化

元素要求内部必有一个.overflow-menu-items容器(里面是一组.item,可含一个.item-flex-space作为"弹性留白"标记)。connectedCallback(第 233 行起)做了两件事:

  1. 给自身打上role="navigation"
  2. querySelector('.overflow-menu-items')检查容器是否已存在——这是因为它要同时适配两种渲染时机:
    • Vue 渲染:首连时容器可能已存在 → 直接init()
    • 浏览器模板渲染:容器稍后才出现 → 用MutationObserver监听childList,一旦.overflow-menu-items被插入就init()disconnect

init()(第 188 行起)里一个细节很值得注意:它手动调用updateItems,而是完全依赖ResizeObserver的"首次渲染即触发"特性:

// ResizeObserver triggers on initial render, so we don't manually call `updateItems` here which // also avoids a full-page FOUC in Firefox that happens when `updateItems` is called too soon. this.resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { const newWidth = entry.contentBoxSize[0].inlineSize; if (newWidth !== this.lastWidth) { requestAnimationFrame(() => { this.updateItems(); this.setAttribute('data-ready', ''); // reveal via CSS [data-ready] }); this.lastWidth = newWidth; } } });

只有在宽度真正变化时才requestAnimationFrame(updateItems),并且通过data-ready属性驱动 CSS 才"揭示"菜单——注释明确说明这是为了规避 Firefox 在过早调用updateItems时的整页 FOUC。

4.2 测量与"收进弹层"的算法

updateItems是一个 100ms 节流的函数(throttle(..., 100))。核心逻辑:

  1. 先还原:把上一轮被收进弹层的项按原位置塞回.overflow-menu-items(用data-after-flex-space标记是否在弹性留白之后),以便重新测量。
  2. 隐藏干扰项:临时把.item-flex-space.overflow-menu-button设为display:none !important,让它们不参与测量。
  3. 逐项测量:对每个.item,若menuRight - itemRight < 38(约等于一个 overflow 按钮的宽度再加点余量)则判定为溢出,加入overflowItems。这里还有个特例——"最后一个项且它其实放得下"时不收。
  4. 清理/生成:没有溢出项 → 隐藏弹层、移除按钮与 popup;否则给溢出项打role="menuitem"append进 popup,若按钮还不存在就创建(用window.config.i18n.more_itemsaria-label,内联那个 kebab 图标 svg)。

4.3 键盘导航与无障碍

showPopup创建/显示 popup 时,给它role="menu"tabIndex=-1(仅程序化聚焦用),并在keydown里实现了完整的菜单键盘契约(第 47–94 行):

  • Tab/Shift+Tab:在首尾项之间循环(首项上 Shift+Tab 跳到末项,末项 Tab 跳到首项),不跳出弹层;
  • Escape:关闭弹层并把焦点还给按钮;
  • Space/Enter:触发当前menuitem的点击;
  • ArrowDown/ArrowUp:在菜单项间上下移动焦点(从 popup 本身按下时分别跳到首/末项)。

按钮侧的 ARIA(第 171–173 行):aria-haspopup="true"aria-expanded随开合切换、aria-controls指向 popup 的 id(由generateElemId('overflow-popup-')生成)。点击项、点击外部(onClickOutside,capture 阶段)都会关闭弹层并同步按钮的 active 态。disconnectedCallback统一disconnect两个 Observer 并移除全局 click 监听,保证无泄漏。

这套实现把"自适应布局 + 完整键盘可达性"封装进了一个自定义元素,模板侧只需写一个带.overflow-menu-items<overflow-menu>容器即可,无需为每处菜单重复写 JS。

五、WeakRefpolyfill:为什么 webcomponents 还需要它

polyfills.ts 很短,但解释了"为什么 webcomponents 目录还要带一个 polyfill":

export function weakRefClass() { const weakMap = new WeakMap(); return class { constructor(target: any) { weakMap.set(this, target); } deref() { return weakMap.get(this); } }; } if (!window.WeakRef) { window.WeakRef = weakRefClass() as any; }

它用WeakMap模拟出WeakRefderef()语义,并在window.WeakRef缺失时挂载。由于index.ts第一行就import './polyfills.ts',这个兼容层保证在支持 web components(customElements)但缺少WeakRef的较老浏览器里,后续依赖WeakRef的库不会直接崩溃。测试 polyfill.test.ts 仅验证了weakRefClass()deref()能正确返回目标值。

说明:WeakRef的真实语义是"不阻止被引用对象被 GC 回收",而这里用WeakMap实现时,WeakRef实例本身由WeakMap的 key 持有,属于"够用"的近似兼容,主要目的是让下游库能安全探测到WeakRef存在并调用deref()。这属于从源码结构看可推断的取舍。

六、如何按现有约定扩展一个新的 Web Component

把 README 的准则与上面三个组件的实现对照,扩展一个新元素(例如假设要写一个<copy-button>)的标准步骤是:

  1. 新建文件web_src/js/webcomponents/copy-button.ts,内部window.customElements.define('copy-button', class extends HTMLElement {...}),遵循connectedCallback/disconnectedCallback生命周期,并在断开时清理所有 Observer/全局监听(参照overflow-menudisconnectedCallback)。
  2. 按需引入 svg:只import具体图标文件,绝不import 'svg.js'(准则 2)。
  3. 在入口注册:在 index.ts 顶部import './copy-button.ts',随 head 阻塞包一起加载。
  4. Vue 场景登记:若该元素会出现在.vue模板里,按准则 3 把它加入 vite.config.ts 的webComponents列表。
  5. 补测试:参照 relative-time.test.ts 的模式——用document.createElement造元素、await Promise.resolve()等微任务、读shadowRoot.textContent断言,并覆盖属性变更、非法输入回退、无障碍属性(aria-label/data-tooltip-content)等分支。
  6. (可选)加 devtest 样例:在 devtest 模板 风格下补一个可视化页面,便于人工核对各形态。

小结

web_src/js/webcomponents目录的价值不在"功能多",而在把一类跨浏览器、跨渲染时机(Vue vs 浏览器模板)、且必须抢在 head 阻塞阶段生效的 UI 能力,封装成轻量、可树摇、Vue 兼容的自定义元素

  • index.ts演示了"head 阶段提前设主题色"如何避免闪烁;
  • relative-time.ts演示了"vendor 化 Intl + 单例批量定时器 + 微任务批处理 + 健壮解析回退"的完整工程范式;
  • overflow-menu.ts演示了"ResizeObserver + MutationObserver + 完整键盘无障碍"封装自适应菜单;
  • polyfills.ts补齐了老浏览器的WeakRef缺口。

对要在 Gitea Web UI 中新增自定义元素的开发者而言,这份目录与 README 的三条准则,就是可直接复用的实现模板与验收清单。

【免费下载链接】giteaGit with a cup of tea! Painless self-hosted all-in-one software development service, including Git hosting, code review, team collaboration, package registry and CI/CD项目地址: https://gitcode.com/GitHub_Trending/gi/gitea

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 9:04:24

拆解RS 2kW UHF电视广播放大器:射频功放设计解析与元件残值评估

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 9:03:43

docx2md实战:Word文档转Markdown的格式转换与自动化处理指南

简介&#xff1a;这是一款使用Go语言开发的Word文档转换工具&#xff0c;可将docx文件快速转为Markdown&#xff0c;适合需要批量整理文档、用Markdown写作或维护知识库的开发者。工具提供简洁的命令行用法&#xff0c;支持标题、超链接、缩进、表格、清单、加粗、斜体、删除线…

作者头像 李华
网站建设 2026/9/7 9:02:47

PowerBuilder数据窗口与HIS系统维护实战:从架构到打印预览

简介&#xff1a;PB&#xff08;PowerBuilder&#xff09;全面教程是一份面向初学者与有经验开发者的系统学习资料&#xff0c;聚焦企业级数据库应用开发&#xff0c;内容覆盖DataWindow数据窗口、GUI拖放式界面设计、PBL脚本语言、多数据库连接以及.NET/Java桥接和Web服务等进…

作者头像 李华
网站建设 2026/9/7 9:02:40

谭浩强C程序设计第五版PPT源码高效自学指南

简介&#xff1a;这份rar压缩包为谭浩强《C程序设计&#xff08;第五版&#xff09;》配套PPT讲义与源码合集&#xff0c;适合C语言初学者、高校在读学生及自学备考者&#xff0c;用于对照教材完成从语法理解到上机实践的全流程学习。资源共171个文件&#xff0c;约5.39MB&…

作者头像 李华
网站建设 2026/9/7 9:00:11

600kW IGBT串联谐振式中频电炉主电路设计解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华