手写友情链接代码实战:轻量可控的数据渲染与安全优化
玩博客这些年我在友情链接这个功能上折腾过不少次。最开始用的是某款插件功能挺全但一次主题升级后插件和新版前端框架冲突整个页面样式垮掉排查了半天才发现是插件往页面里塞了一段没兼容性的脚本。后来换了主题插件作者不维护了我干脆把那坨东西全删了自己动手写了一套友情链接代码一直用到现在稳定得很。这篇文章就是想把“友情链接的代码到底怎么自己写”这件事讲明白。不管你是用静态博客、还是带后端的站点只要有点前端基础都能照着做。核心目标就三个轻量、可控、好维护。文中的方案我都实测过代码可以直接拿去改。1. 需求分析与设计思路别急着动手先把需求拆清楚1.1 为什么我抛弃现成插件决定手写代码市面上的友情链接插件通常没问题但它们有个共性问题功能过剩。我就想展示一堆网址和标题插件却给我塞了申请表单、自动审核、权重统计、广告位甚至还有更新推送。这些功能用不上还一直跑着白白消耗资源。有一次我用开发者工具数了数插件在首页居然加载了4个JavaScript文件和2个CSS文件就为显示十几条链接这性能成本也太亏了。更重要的是定制自由度。插件的HTML结构是固定的我想把链接做成卡片式、想按分类分组、想给失效链接加灰色样式都要去改插件源码。改完主题一更新改动全被覆盖。自写代码就不存在这个问题——数据结构自己定渲染逻辑自己写想在哪个页面展示就往哪插入一段代码一切都是透明的。但我得提醒一句自写的前提是你愿意维护。如果完全不懂代码出问题连排查都无从下手那还是老实选个靠谱插件。自写适合的是那种“不求功能多但求页面干净、逻辑自己能掌控”的人。1.2 功能清单核心功能和加分项要分清动手写代码前我先把需求列了个清单把功能分成了“必须有”和“可以有”。必须有链接展示网站名称、URL、简介、头像Logo分类分组能按“技术伙伴”“生活随笔”等分开展示排序规则手动控制顺序比如新加的、关系好的排前面外链安全属性自动加relnoopener nofollow防止权重流失和钓鱼攻击失效标识方便肉眼识别哪些链接打不开或已被对方撤掉可以有SEO友好页面链接能被搜索引擎正常抓取而不是等JavaScript执行完才渲染链接有效性检测定时脚本批量检查无效链接自动标记申请审核后台有表单、有审核而不是直接通过暗链检测友情链接页不输出任何隐藏的东西这个“功能边界”非常重要。我见过不少人的友情链接页面做得跟后台管理一样复杂其实绝大部分都是自我感动。链接就是一个入口访客点一下就走真正需要绞尽脑汁的是别让这个页面成为整站的负担。1.3 技术选型静态HTML、JavaScript数组还是后端模板这里我先做个分类看你的站点属于哪种类型因为友情链接代码在不同架构下的实现方式差很多。纯静态HTML网站最简单的方法是直接把友情链接写成静态HTML样式靠CSS控制。优点是速度最快、零依赖缺点是数据分散在HTML里如果要接头像、改排序得手动编辑一大段代码维护成本高链接一多就乱。带前端构建的静态博客如Hexo、VuePress可以用数据文件YAML/JSON 组件渲染。以Hexo为例可以把友情链接数据放在一个独立YAML文件里模板里for循环输出轻松实现数据和视图分离而且构建时数据就是静态的SEO不受影响。这个方案是Hexo/VuePress站点的最佳实践。带后端的网站PHP、Node.js、Python等最灵活可以从数据库读取、可以做接口、可以写申请审核。不需要数据库的轻量方案也能用PHP直接读JSON文件。数据更新后刷新页面就生效不需要重新构建。我自己的主站是个PHP站所以我用方案C后来搭了个Hexo子站用了方案B。两套方案各有特点后面章节我会把各自的完整代码都贴出来。注意如果你用了纯前端JS渲染友情链接列表请务必考虑搜索引擎的爬取问题。百度、搜狗对JS渲染的页面抓取效果不好Google稍好但也做不到100%。在意SEO的话尽量用服务端渲染或静态化方案。这点后面我会专门展开讲。2. 数据结构与核心函数封装地基打牢后面才不返工2.1 数据格式怎么定决定了你能不能撑到上百个链接友情链接的数据格式最常规的是对象数组。每个链接一个对象字段我做成了这样{ name: 翠翠的博客, url: https://cuicui.dev, desc: 专注前端与算法, logo: https://cuicui.dev/avatar.png, group: 技术伙伴, sort: 1, status: active }逐字段说明name站点名称必填。url站点链接必须以https://或http://开头不然保存时可能被浏览器和搜索引擎当成站内链接处理隐患很大。desc一句话简介很多人的友情链接页没有这个字段只露出名字和Logo。实际上简介能让访客在没点链接之前就大概知道对方站点的内容点击率反而更高。logo头像地址。如果没有头像可以用站点域名的favicon很多第三方接口可以生成或者稳妥起见就用一个默认占位图。group分组名。我习惯建“技术伙伴”“同行交流”“偶尔拜访”这几个分组中间那个是调侃用的但分组维度确实能让页面丰满很多。sort排序数字越小越靠前。status状态标记。active表示正常pending表示等对方回链invalid表示失效。在前端渲染时可以给invalid加半透明样式。这套结构在PHP里就是关联数组在JSON文件里也就是对应的对象结构。换语言不影响思路。数据文件我建议独立出来别把数据写死在渲染逻辑里——哪怕只有10个链接你也不敢保证以后不会迁站、换语言数据独立出来迁移成本会低很多。2.2 核心渲染函数分组、排序、过滤一次搞定不管用哪种后端语言友情链接渲染都躲不开四个步骤读取数据、过滤、排序、分组。我用JavaScript写了一个通用示例思路是通用的function renderFriendLinks(links, options {}) { const defaultOptions { containerId: friend-links, showInvalid: false, grouped: true }; const opts Object.assign({}, defaultOptions, options); // 1. 过滤掉不想显示的 const visibleLinks links.filter(item opts.showInvalid ? item.status ! deleted : item.status active ); // 2. 排序sort值小的在前 visibleLinks.sort((a, b) (a.sort || 999) - (b.sort || 999)); // 3. 分组 const groups {}; visibleLinks.forEach(item { const groupName item.group || 默认分组; if (!groups[groupName]) { groups[groupName] []; } groups[groupName].push(item); }); // 4. 渲染DOM const container document.getElementById(opts.containerId); if (!container) return; let html ; if (opts.grouped) { Object.keys(groups).forEach(group { html h3 classfl-group-title${escapeHtml(group)}/h3; html ul classfl-list; groups[group].forEach(link { html buildLinkHtml(link); }); html /ul; }); } else { html ul classfl-list; visibleLinks.forEach(link { html buildLinkHtml(link); }); html /ul; } container.innerHTML html; } function buildLinkHtml(link) { const rel link.nofollow false ? noopener : noopener nofollow; return li classfl-item a classfl-link href${escapeAttr(link.url)} target_blank rel${rel} title${escapeAttr(link.desc || link.name)} img classfl-logo src${escapeAttr(link.logo || default.png)} alt${escapeAttr(link.name)} loadinglazy / span classfl-info span classfl-name${escapeHtml(link.name)}/span span classfl-desc${escapeHtml(link.desc || )}/span /span /a /li; } function escapeHtml(str) { return String(str) .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;) .replace(//g, #039;); } function escapeAttr(str) { return escapeHtml(str).replace(//g, #096;); }这段代码里最容易被忽略的是escapeHtml和escapeAttr。友情链接的数据如果是自己手写一般没什么问题但如果你做了申请表单、允许访客提交链接那name和desc就变成了用户输入。如果不转义别人提交一个scriptalert(1)/script当网站名你的页面加载时就弹出全站告警这就是典型的存储型XSS漏洞。友情链接的代码虽然小安全习惯不能省。buildLinkHtml里的rel属性也值得多说一句。target_blank会打开新标签但如果不配noopener新页面可以通过window.opener来操纵你的原始页面这叫“反向Tabnabbing”攻击。加上nofollow则是告诉搜索引擎我不为这个链接背书常见做法是默认都加除非对方是深度合作、值得推荐的站点才单独设nofollow: false。2.3 头像处理与图片懒加载别让友情链接拖慢整站速度友情链接的头像很多人都直接用对方网站的favicon也就是https://域名/favicon.ico。问题在于很多站点出于安全策略会拒绝外部直接调用favicon或者favicon根本不在根目录。我的处理方式是建一个images/avatars/目录把头像图片下载到本地统一管理。好处有两个一是速度由自己控制不受对方服务器影响二是避免对方服务器挂掉后你的页面加载头像时一直在转圈。本地化之后还要加loadinglazy表示图片在滚动到视口附近时才加载。友情链接页面如果链接很多、头像几十个不加懒加载的话页面一打开就要发出大量图片请求白白拖慢首屏。这个属性现代浏览器都支持加上几乎零成本。另外一个细节是头像尺寸。很多人直接把favicon放上去16x16的图标被拉伸成64x64糊得没法看。我的做法是统一用前端CSS控制显示尺寸图片本身则尽量用64x64或128x128的正方形图常用接口比如https://api.iowen.cn/favicon/{domain}.png可以取到目标站点的favicon并重设尺寸。如果你想做得再讲究一点还可以在onerror事件里给头像加一个灰底默认图标兜底防止图片加载失败显示一个大裂图。3. 实操过程三套可直接用的完整方案3.1 方案AHexo/VuePress静态博客的数据文件拆分配置场景Hexo博客的友情链接我强烈建议用数据文件。根目录下新建source/_data/friends.json[ { name: 博主A, url: https://a.example.com, desc: 热爱开源与写作, logo: /images/friends/a.png, group: 技术伙伴, sort: 1 }, { name: 博主B, url: https://b.example.com, desc: 产品经理的日常思考, logo: /images/friends/b.png, group: 同行交流, sort: 2 } ]然后在友情链接页面模板里写% const friends site.data.friends; % % const grouped {}; % % friends.forEach(f { const g f.group || 默认; if (!grouped[g]) grouped[g] []; grouped[g].push(f); }); % % Object.keys(grouped).forEach((group, idx) { % h3 classfl-group-title% group %/h3 ul classfl-list % grouped[group].sort((a, b) a.sort - b.sort).forEach(link { % li classfl-item a href% link.url % target_blank relnoopener nofollow img src% link.logo % alt% link.name % loadinglazy width48 height48 / div strong% link.name %/strong p% link.desc %/p /div /a /li % }) % /ul % }) %这套写法的核心思路是数据与展示彻底分离。想加一个链接只需要编辑JSON文件不用碰HTML想改布局也只改模板。Hexo会在生成静态页面时把数据直接渲染成纯HTML搜索引擎抓到的就是真实链接SEO上完全没问题。3.2 方案BPHP轻量动态方案连数据库都不需要如果网站有PHP环境友情链接数据量不大完全没必要上MySQL。我用了一个独立PHP文件存数据另一个文件做渲染部署起来极其方便。数据文件friends-data.php内容?php // 存友情链接数据的数组结构一目了然 return [ [ name 示例博客, url https://example.com, desc 专注于PHP和前端开发, logo /images/example.png, group 技术伙伴, sort 1, status active ], [ name 懒人客栈, url https://blog.example.org, desc 记录生活与旅行, logo /images/lazy.png, group 生活随笔, sort 2, status active ] ];渲染函数render_friends.php?php function render_friends($friends []) { if (empty($friends)) { return p暂无友情链接欢迎交换。/p; } // 过滤出有效链接 $active array_filter($friends, function ($item) { return $item[status] active; }); // 按sort排序 usort($active, function ($a, $b) { return ($a[sort] ?? 999) ($b[sort] ?? 999); }); // 按group分组 $grouped []; foreach ($active as $item) { $grouped[$item[group] ?? 默认分组][] $item; } $html ; foreach ($grouped as $group $links) { $html . h3 classfl-group-title . htmlspecialchars($group, ENT_QUOTES, UTF-8) . /h3; $html . ul classfl-list; foreach ($links as $link) { $html . li classfl-item a classfl-link href . htmlspecialchars($link[url], ENT_QUOTES, UTF-8) . target_blank relnoopener nofollow img classfl-logo src . htmlspecialchars($link[logo], ENT_QUOTES, UTF-8) . alt . htmlspecialchars($link[name], ENT_QUOTES, UTF-8) . loadinglazy / span classfl-info span classfl-name . htmlspecialchars($link[name], ENT_QUOTES, UTF-8) . /span span classfl-desc . htmlspecialchars($link[desc] ?? , ENT_QUOTES, UTF-8) . /span /span /a /li; } $html . /ul; } return $html; }在页面模板中直接调用?php $friends include friends-data.php; echo render_friends($friends); ?这里所有的数据输出都包了一层htmlspecialchars作用跟前面JS里的escapeHtml一样——避免用户可控的内容破坏页面结构或触发XSS。这个方案的另一个优势是如果你后面想加申请表单只需要在friends-data.php里追加一个写入方法把访客提交的数据存成JSON或者直接追加到另一个pending数组审核环节就是把这个数组移到active。数据量超过50条我建议还是老老实实上数据库但一般个人博客的友情链接撑死几十个这种文件存储模式非常爽备份就是复制一个PHP文件迁移也方便。3.3 链接有效性检测用脚本代替手动点链接友情链接页面维护成本最高的是定期检查对方是否还挂着你的链接以及对方网站是否已经打不开。手动逐个点开累死人我写了个PHP脚本用curl批量检测返回非200状态的自动标记?php // check_links.php $friends include friends-data.php; $ch curl_multi_init(); $handles []; foreach ($friends as $index $f) { $handles[$index] curl_init($f[url]); curl_setopt_array($handles[$index], [ CURLOPT_RETURNTRANSFER true, CURLOPT_CONNECTTIMEOUT 5, CURLOPT_TIMEOUT 10, CURLOPT_NOBODY true, CURLOPT_USERAGENT Mozilla/5.0 (compatible; LinkChecker/1.0) ]); curl_multi_add_handle($ch, $handles[$index]); } $running null; do { curl_multi_exec($ch, $running); curl_multi_select($ch); } while ($running 0); foreach ($handles as $index $handle) { $code curl_getinfo($handle, CURLINFO_HTTP_CODE); if ($code 400 || $code 0) { echo $friends[$index][name] . . $friends[$index][url] . . ($code ?: 连接失败) . PHP_EOL; } curl_multi_remove_handle($ch, $handle); } curl_multi_close($ch);命令行执行php check_links.php几秒钟就能把几十个链接全测一遍。加个cron定时任务每周跑一次就能及时发现死链。这套脚本我用了很多年比手动检查省心太多。4. 样式与响应式布局功能没问题了再追求颜值4.1 卡片式布局的样式核心友情链接常见的两种展示形态是“列表式”和“卡片式”。列表式简单一行一个链接信息密度高卡片式视觉效果更强每个链接占一个小方块鼠标悬停有交互反馈。我自己的页面用的是卡片式CSS样式大概长这样.fl-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; } .fl-item { border: 1px solid #e5e7eb; border-radius: 10px; transition: box-shadow 0.2s ease, transform 0.2s ease; background: #fff; } .fl-item:hover { box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); transform: translateY(-2px); } .fl-link { display: flex; align-items: center; text-decoration: none; color: #1f2937; padding: 14px 16px; height: 100%; box-sizing: border-box; } .fl-logo { width: 48px; height: 48px; border-radius: 8px; margin-right: 12px; object-fit: cover; background: #f3f4f6; } .fl-info { display: flex; flex-direction: column; min-width: 0; } .fl-name { font-size: 15px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .fl-desc { font-size: 12px; color: #6b7280; margin-top: 4px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }grid布局是这里的主角。repeat(auto-fill, minmax(240px, 1fr))的含义是页面宽度允许的情况下每列至少240px能排几列就排几列剩余空间均分。这样从手机到桌面都不用写媒体查询自动适应。这个特性实际用起来比手写media方便多了我后面维护的多个页面都是靠它搞定响应式的。一句话简介fl-desc我控制最多显示两行超出部分用省略号处理。原因是有些站点的简介写得特别长如果不用-webkit-line-clamp限制卡片高度就会参差不齐一眼望去页面会显得很乱。4.2 分组标题和链接数量让访客一眼看懂页面结构分组展示时我每个组前面加一个标题和计数.fl-group-title { font-size: 16px; font-weight: 700; margin: 24px 0 10px; padding-left: 10px; border-left: 4px solid #4f46e5; } .fl-group-title .count { font-size: 12px; font-weight: 400; color: #9ca3af; margin-left: 6px; }HTML里输出时对应为h3 classfl-group-title技术伙伴 span classcount12/span/h3这个小细节别小看。访客看到“技术伙伴 12”不用滚完整页就知道大致规模配合页面顶部的一个总览连“这个博主到底有多少友情链接”都一目了然。4.3 移动端适配的3个细节移动端最容易出问题的不是布局而是点击区域。友情链接一行卡片如果做得很窄在手机上两个相邻链接的点击区域可能互相重叠。解决办法是给.fl-link设置足够的padding卡片间距也至少保持12px。我的网格布局因为每列有gap: 14px天然避免了这个问题。第二个细节是头像在窄屏下的表现。48px的头像在手机上偏大我最开始没处理导致一行只能显示一个半卡片。后来在min-width: 640px时把头像调整到40px字体也相应缩小一点实际体验好了很多。第三个细节是图片的loadinglazy要写上。移动端网络环境不稳定一次性加载几十张头像很伤流量。懒加载配合适当的本地压缩图片移动端打开友情链接页加载速度会比较理想。我常用的做法是头像图片全部压缩到64x64以内体积控制在5KB左右整页几十个头像总图片体积比一张普通照片还小。5. 常见问题与排查技巧实录5.1 中文乱码和样式不生效多半是编码或路径问题自写友情链接代码的人遇到的第一类问题通常是乱码。特别是用PHP写数据文件时文件编码不是UTF-8或者页面没有声明字符集友情链接里含中文的网站名就会显示成一堆问号。排查思路是这样的先看HTML源码里中文是否正常。打开浏览器右键查看源代码如果源码里就是乱码那是文件编码问题用编辑器把文件另存为UTF-8无BOM格式如果源码正常但页面显示乱码那是响应头或meta charset没设置。PHP文件建议在顶部加一行header(Content-Type: text/html; charsetUTF-8);另外友情链接的头像路径如果写的是相对路径而页面是伪静态或者子目录图片很容易404样式也会因为相对路径失效突然掉了一部分。我的做法是头像路径全部写绝对路径也就是以/开头的站内路径跟当前页面地址无关怎么跳转都不会丢。5.2 搜索引擎不收录或不更新友情链接页“为什么不收录”这个问题我得说句实话如果一个小型个人博客的友情链接页收录慢最大的原因往往不是代码而是整站权重本来就低。但代码层面确实存在一个可优化的点如果你的友情链接页面是通过JS动态渲染出来的百度爬虫大概率拿不到内容那必然不收录。如果测试时发现百度站长后台抓取是空白页面基本上就是这个原因。所以我的建议是在意收录的话友情链接列表尽量服务端渲染或者静态化。PHP版和Hexo版都会在HTML源码里直接输出链接完全不需要担心。纯静态HTML的就更不用说了。至于“不更新”常见原因是页面被加了一层缓存。如果你用了缓存插件或CDN修改数据后发现页面没变化先去清缓存。我遇到过几次这种“假问题”折腾了半天最后都是缓存惹的祸。5.3 对方撤链了我该怎么快速发现友情链接的维护本质上是双向的。你挂了对方的链接对方可能某天悄无声息地撤掉导致你单方面“单向链接”这对站点的链接权重其实不太有利。检测对方是否还挂我的链接可以写个简单的爬虫逻辑抓取对方首页HTML搜索我的域名是否出现。用PHP为例?php function check_backlink($targetUrl, $myDomain) { $ch curl_init($targetUrl); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER true, CURLOPT_FOLLOWLOCATION true, CURLOPT_CONNECTTIMEOUT 10, CURLOPT_TIMEOUT 15, CURLOPT_USERAGENT Mozilla/5.0 (compatible; LinkChecker/1.0), CURLOPT_COOKIEFILE /tmp/cookies.txt, CURLOPT_COOKIEJAR /tmp/cookies.txt ]); $html curl_exec($ch); curl_close($ch); if ($html false) return 抓取失败; return strpos($html, $myDomain) ! false ? 已挂 : 未挂; } // 用法示例 echo check_backlink(https://example.com, cuicui.dev);脚本思路很直白抓页面搜字符串。这里有个坑——有些网站对爬虫做了JS渲染检查或强制跳转验证页直接抓取看不到完整内容。为了减少被拦截的概率USERAGENT模拟成正常浏览器cookie保持会话能解决一部分问题再配合FOLLOWLOCATION跟踪跳转应对做了www跳转的站点。如果对方用了防爬机制这个检测就不能做到100%准确只能作为辅助参考。5.4 防刷、防垃圾链接申请入口要设门槛如果你做了友情链接申请表单很快会发现互联网上不缺垃圾流量。有些脚本会批量提交低质量站点甚至塞一些加了跳转参数的链接来引流。我的处理规则很简单表单加一个简单算术验证码比如“35等于几”挡住大部分脚本。URL校验严格必须是合法的HTTP/HTTPS地址解析出来的域名必须是独立域名不能是一长串二级目录。必须填写网站简介纯垃圾站通常不会认真写。提交后进pending状态人工审核通过才展示。绝对不要做成提交后立即生效不然你的友情链接页会变成垃圾外联集合地。我在PHP方案里就是把这些逻辑写在了表单处理函数里配合fgets之类的简单保存方式不到一百行代码就能实现一套完整的申请审核流程。对个人站来说够用且不会被刷。6. 最后的几点体会自写友情链接代码这件事听起来小做起来其实能学到不少东西。最开始我上手的第一个版本特别糙数据和HTML写在一起想调个顺序得开编辑器一行行改。后来接触了数据驱动渲染、分组、缓存、安全转义这些概念一次一次迭代代码才慢慢变得能“撑得起场面”。如果你也想自己动手写我的建议是先跑通最简版本再加功能。比如第一天就写一个循环输出所有链接第二天做分组第三天做头像懒加载第四天考虑SEO。别一上来就追求完美先把能用的东西上线再逐步优化。毕竟友情链接的本质是“连接”而不是“炫技”页面能正常展示、维护不费力就已经达到目的了。最后分享一个我踩过的坑在页面里输出友情链接时千万别忘了给target_blank的链接加上relnoopener nofollow少了这两个属性页面轻则容易被外站反向操作重则被搜索引擎视为垃圾外链。这种细节上的严谨才是自写代码比插件更值得信赖的地方。祝你那几行“友情链接的代码”写得顺手用得更顺手。