尧图精选

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

🕒 发布时间:2026/9/7 9:05:47 📁 来源:尧图网络
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-time、overflow-menu、WeakRefpolyfill的完整实现。读完你可以掌握为什么这些组件被刻意放进head的独立 IIFE 入口、如何编写一个轻量、可被树摇、Vue 兼容的自定义元素以及相对时间/溢出菜单背后的 Intl 格式化、批量更新调度与键盘无障碍导航机制。一、目录定位与为什么要在 head 加载README 对目录的定位非常明确该目录存放 Gitea Web UI 所使用的 Web Components 源码。它给出的第一条、也是最关键的一条准则是These components are loaded inhead(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 渲染之前执行。这一设计意图有两个直接后果必须轻量——它们运行在页面渲染的关键路径上任何体积膨胀都会直接拖慢首屏能在 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.tshead 里真正跑起来的事index.ts 是 Web Components 目录的顶层入口内容很短但每一行都有目的import ./polyfills.ts; import ./relative-time.ts; import ./overflow-menu.ts; import {isDarkTheme} from ../utils.ts; function initPageThemeDarkLight() { // Set pages 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();这里做两件事注册/初始化三个组件模块——polyfills、relative-time、overflow-menu。注意每个组件文件在导入时即执行window.customElements.define(...)因此import 即注册不需要额外的 DOM 就绪等待。initPageThemeDarkLight()——在 head 阶段就把data-gitea-theme-dark属性打到html上。注释解释了动机尽可能早地设定主题色偏好避免页面加载期间出现错误主题色的闪烁同时监听prefers-color-scheme: dark以配合自动auto主题在系统切到深色时实时同步。这正是必须放在 head 阻塞包价值的典型体现——它必须在 CSS 应用主题前完成。二、README 的三条编码准则逐条解读README 给出三条准则每一条都直接对应了实现里的一条硬约束值得作为扩展新组件时的检查清单。准则 1保持轻量见上一节。落地表现为不引入重型依赖、import即注册、无 DOM 轮询。准则 2不要 importsvg.jsDo 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的标准写法可直接作为新组件的模板。准则 3Vue 中的自定义元素需登记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-element4.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相对/绝对的分界线#thresholdMs用parseDurationMs解析 ISO-8601 Duration如P1D、P30D若无法解析则回退到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/h23isBrowser12hCycle用Intl.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 seconds、ignores 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钳制——若tensepast而实际是未来时间则替换为空 Duration即显示 nowtensefuture反之。datetime#getDateTimeFormat用Intl.DateTimeFormat按second/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()disconnectedCallback调dateObserver.unobserve(this)防泄漏update()结尾按当前格式决定observe还是unobserve——datetime格式不会自动刷新因为它不随时间变化只有relative/duration才需要被观察。这是一个很干净的按需订阅设计。3.5 无障碍与国际化update()中还会同步一个完整日期到data-tooltip-content与aria-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); }#getFormattedTitle用Intl.DateTimeFormat固定输出day numeric month short year numeric hour numeric minute 2-digit。测试respects lang from parent element验证了把元素放进langde的父容器后输出会变成德文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阈值边界、tensepast含未来钳制到 now、60 天前、tensefuture、formatduration含format-styleshort/narrow、formatdatetime含monthshort/long、thresholdP0Y、yearnumeric、thresholdP1D。这是组件契约的活文档扩展属性时可在此补充样例。生产页面 system_status.tmpl 用它展示系统运行的已运行时长与上次 GCddrelative-time formatduration datetime{{.SysStatus.StartTime}}{{.SysStatus.StartTime}}/relative-time/dd ... ddrelative-time formatduration datetime{{.SysStatus.LastGCTime}}{{.SysStatus.LastGCTime}}/relative-time/dd注意formatduration 标签体里放了服务端时间作为 fallback——当 JS 尚未就绪或datetime非法时用户仍能看到服务端渲染出的绝对时间这正是第 3.2 节解析失败回退原文本设计在实际模板中的落地。四、overflow-menu让菜单位于 head 也能自适应的溢出菜单overflow-menu.ts 定义了overflow-menu元素解决一个非常具体的 UI 问题水平菜单栏里当窗口变窄放不下所有项时把被部分挤出容器的项收进一个更多按钮的下拉弹层里。它被用在仓库头、组织菜单、探索导航、图片 diff 切换条等多处例如 repo/header.tmpl第 96 行overflow-menu classui secondary pointing menu、org/menu.tmpl、explore/navbar.tmpl、image_diff.tmpl。4.1 结构契约与初始化元素要求内部必有一个.overflow-menu-items容器里面是一组.item可含一个.item-flex-space作为弹性留白标记。connectedCallback第 233 行起做了两件事给自身打上rolenavigation用querySelector(.overflow-menu-items)检查容器是否已存在——这是因为它要同时适配两种渲染时机Vue 渲染首连时容器可能已存在 → 直接init()浏览器模板渲染容器稍后才出现 → 用MutationObserver监听childList一旦.overflow-menu-items被插入就init()并disconnect。init()第 188 行起里一个细节很值得注意它不手动调用updateItems而是完全依赖ResizeObserver的首次渲染即触发特性// ResizeObserver triggers on initial render, so we dont 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)。核心逻辑先还原把上一轮被收进弹层的项按原位置塞回.overflow-menu-items用data-after-flex-space标记是否在弹性留白之后以便重新测量。隐藏干扰项临时把.item-flex-space和.overflow-menu-button设为display:none !important让它们不参与测量。逐项测量对每个.item若menuRight - itemRight 38约等于一个 overflow 按钮的宽度再加点余量则判定为溢出加入overflowItems。这里还有个特例——最后一个项且它其实放得下时不收。清理/生成没有溢出项 → 隐藏弹层、移除按钮与 popup否则给溢出项打rolemenuitem、append进 popup若按钮还不存在就创建用window.config.i18n.more_items作aria-label内联那个 kebab 图标 svg。4.3 键盘导航与无障碍showPopup创建/显示 popup 时给它rolemenu、tabIndex-1仅程序化聚焦用并在keydown里实现了完整的菜单键盘契约第 47–94 行Tab/ShiftTab在首尾项之间循环首项上 ShiftTab 跳到末项末项 Tab 跳到首项不跳出弹层Escape关闭弹层并把焦点还给按钮Space/Enter触发当前menuitem的点击ArrowDown/ArrowUp在菜单项间上下移动焦点从 popup 本身按下时分别跳到首/末项。按钮侧的 ARIA第 171–173 行aria-haspopuptrue、aria-expanded随开合切换、aria-controls指向 popup 的 id由generateElemId(overflow-popup-)生成。点击项、点击外部onClickOutsidecapture 阶段都会关闭弹层并同步按钮的 active 态。disconnectedCallback统一disconnect两个 Observer 并移除全局 click 监听保证无泄漏。这套实现把自适应布局 完整键盘可达性封装进了一个自定义元素模板侧只需写一个带.overflow-menu-items的overflow-menu容器即可无需为每处菜单重复写 JS。五、WeakRefpolyfill为什么 webcomponents 还需要它polyfills.ts 很短但解释了为什么 webcomponents 目录还要带一个 polyfillexport 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模拟出WeakRef的deref()语义并在window.WeakRef缺失时挂载。由于index.ts第一行就import ./polyfills.ts这个兼容层保证在支持 web componentscustomElements但缺少WeakRef的较老浏览器里后续依赖WeakRef的库不会直接崩溃。测试 polyfill.test.ts 仅验证了weakRefClass()的deref()能正确返回目标值。说明WeakRef的真实语义是不阻止被引用对象被 GC 回收而这里用WeakMap实现时WeakRef实例本身由WeakMap的 key 持有属于够用的近似兼容主要目的是让下游库能安全探测到WeakRef存在并调用deref()。这属于从源码结构看可推断的取舍。六、如何按现有约定扩展一个新的 Web Component把 README 的准则与上面三个组件的实现对照扩展一个新元素例如假设要写一个copy-button的标准步骤是新建文件web_src/js/webcomponents/copy-button.ts内部window.customElements.define(copy-button, class extends HTMLElement {...})遵循connectedCallback/disconnectedCallback生命周期并在断开时清理所有 Observer/全局监听参照overflow-menu的disconnectedCallback。按需引入 svg只import具体图标文件绝不import svg.js准则 2。在入口注册在 index.ts 顶部import ./copy-button.ts随 head 阻塞包一起加载。Vue 场景登记若该元素会出现在.vue模板里按准则 3 把它加入 vite.config.ts 的webComponents列表。补测试参照 relative-time.test.ts 的模式——用document.createElement造元素、await Promise.resolve()等微任务、读shadowRoot.textContent断言并覆盖属性变更、非法输入回退、无障碍属性aria-label/data-tooltip-content等分支。可选加 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),仅供参考
上一篇/下一篇内容由系统自动关联 返回资讯列表 →