接了个活儿,要把一个跑在 Webflow 上的品牌官网完整搬回自己的静态服务器。一开始我以为这事挺简单:浏览器打开源码,另存为 HTML 文件,再把图片 CSS 链接理顺就行。真正动手才发现,Webflow/Framer 网站一键导出为干净的 HTML 文件这个需求,看着容易,实操里全是细节——页面源码里混着大量框架运行时脚本、按节点自动生成的 class 类名、一堆跨域资源引用,head 区块恨不得占半屏,但页面视觉上其实就五六个区块。
这篇文章把我自己做的“一键导出”工具和踩过的坑整理出来。它解决什么问题呢?就是把你用 Webflow、Framer 这类视觉建站工具做出的页面,从“只能在平台上跑”变成“一份你能读懂、能二次开发、能丢到任何静态服务器里的干净的 HTML 文件”。适合谁看?想把营销页从 Webflow/Framer 迁出来、想拿这些页面当邮件模板或单页落地页、或者单纯不想被平台绑定的人。
1. 为什么 Webflow/Framer 导出的页面又脏又乱
1.1 导出的不是 HTML,是“运行时现场”
先搞清楚一件反直觉的事:在 Webflow 的 Designer 里点的 Export,和在浏览器里看到的“另存为”,两者逻辑完全不同。Webflow 的生成机制基于可视化节点树,每个元素背后都带了一堆用于编辑器交互的数据属性,真正发布到 CDN 前才会做“编译式”输出,但编译结果仍然很“重”。
以 Webflow 页面为例,我见过比较典型的单页源码结构是这样的:head 里堆着一串 meta、预加载字体、link[rel=stylesheet] 引用若干 hash 命名的 CSS;header 和 section 里的 class 像 w-layout-blockcontainer、home-hero-wrapper 这类“结构词 + 业务词”混在一起,如果原来的画板层级用中文或者符号命名,还会被转义成更绕的 class;body 底部挂了一堆 script,有的是站点统计,有的是 Webflow 自己的交互脚本,有些是动效库。
Framer 这边更夸张一点。它默认并不像传统网页那样给你“一份完整 HTML”,更像是画布渲染 + 覆写数据的结构,很多元素是在浏览器里由脚本动态拼出来的。你直接 view-source 抓到的只是外壳。所以“一键导出干净的 HTML”这个问题,Framer 场景下要比 Webflow 更折腾,难点不在清理,而在“怎么拿到完整渲染后的结构”。
1.2 为什么这份代码会严重影响后续使用
如果你只是想在平台上继续改,那无所谓。但如果你跟我一样需要把页面交给客户、丢到 GitHub Pages 或者做二次开发,这些“脏东西”就是麻烦:
- 可读性极差:6 万行的单文件 HTML,你根本没法快速定位某个按钮在哪个区块,更别说拿去给团队协作。
- 依赖平台资源:页面字体、图片、CSS 经常引用
assets.website-files.com或framerusercontent.com这类外部域名,哪天源站域名调整、资源过期,页面就残了。 - 性能负担:运行时脚本、重复的 observers、整套动画内核被打包进一个页面里,带来的结果就是一个静态页要下载几百 KB 甚至几 MB 的 JS。
- 迁移困难:想接进 WordPress、Astro、11ty 这类项目,你需要的是一份可被模板化的结构,而不是一个“只能在原平台跑的黑盒”。
1.3 一键导出工具的目标边界
所以这个“一键导出”项目的边界其实很清晰:它不是一个全自动、所有页面类型通吃的工程化迁移系统,而是针对“营销页、落地页、作品集首页”这类内容形态的清洗器。
它在做的事是:抓取页面资源 → 解构 DOM → 移除框架脚本 → 下载本地化资源 → 规范化文档结构 → 输出可读的 HTML。它不解决的事同样重要:不支持 Webflow CMS 动态集合的完整还原,也不支持把 Framer 的响应式断点完美“翻译”成手写 CSS。做之前必须把范围框住,不然你会在“保留交互动效”和“代码干净”之间反复拉扯,最后哪个都做不好。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体设计思路与方案选型
2.1 为什么用 Node.js + cheerio,而不是 Python 或 Chrome 插件
我最早试过用 Python 的 BeautifulSoup,后来放弃了。BeautifulSoup 做静态 DOM 解析没问题,但这套流程里有大量“需要模拟浏览器策略”的环节,比如资源相对路径换算、CSS 里内嵌引用提取、DOM 操作后的序列化,Node.js 生态更顺手。尤其是 cheerio,它提供类似 jQuery 的选择器和遍历接口,处理批量移除、改属性这类操作效率很高。
之所以不直接用 Chrome 的 “Save as” 或 Puppeteer 无头浏览器完整渲染再存,是因为渲染后的 DOM 会引入更多自动化生成节点,比如 Framer 会给很多元素包一层绝对定位的容器,保存结果是“所见即所得”,但代码比原始产物还乱,二次编辑成本极高。我的工具以静态解析为主,Puppeteer 只作为 Framer 场景下“先执行脚本、拿最终 DOM 字符串”的前置手段。
2.2 整条流水线:抓取、解析、清理、改写、回写
工具在逻辑上分成五个环节,这也是我建议任何做类似需求的人都遵循的顺序:
- 抓取层:输入目标 URL 或已保存的 HTML 文件,拉取页面 HTML,随后扫描文档里所有需要本地化的资源地址。
- 解析层:用 cheerio 把字符串变成可遍历的 DOM 树。
- 清理层:按规则删除与视觉无关的脚本、空标签、无用属性、动态交互内核,同时保留图片懒加载字段和 SEO 关键 meta。
- 改写层:把 CSS、JS、图片、字体引用全部改写为本地相对路径;可选地把关键 CSS 内联到
<head>。 - 回写层:用 prettier 或自写格式化器整理缩进,输出到目标目录,并生成一份资源清单报告。
注意:千万不要试图用正则去清理 HTML。正则不是不能解析,但它对标签嵌套、属性顺序敏感,很容易误伤。尤其是 Webflow 那种一段 script 里只有一行但内容超长的情况,你手写正则匹配边界会疯掉。DOM 树操作才是正解。
2.3 “干净”的三种含义:你要哪种干净
说到“干净”,具体标准其实是分场景的。我在项目里预设了三种输出模式:
- 还原模式(默认):保留语义化结构和必要的少量 JS,比如移动端菜单切换、锚点平滑滚动;资源全部本地化。适合静态托管。
- 单文件模式:把 CSS 内联、图片转 base64(或仅内联首屏图)、移除一切外链 JS,最终输出一个文件。适合做邮件模板或拿去各种平台直接粘贴,但体积会变大,只推荐小页面用。
- 极简模式:只保留正文结构和少量 class 标记,其余全部清理。适合做内容搬运,导出的正文区块甚至可以直接转成 Markdown 用。
三种模式共用同一套清理内核,只是参数不同。这样做的原因是,单纯追求“文件最小”在很多场景下并不是最优解。你总不想为了省几 KB 把图片全部 base64 塞进一个 2MB 的 HTML 里,结果预览卡半天。
3. 核心实现拆解:一键导出是怎么做出来的
3.1 资源嗅探与本地化下载
先解决“脏”的第一大来源:外链资源。页面里常见的引用位置有三类:<link href>、<script src>、<img src>,但最容易漏的是 CSS 文件内部的 url(...),比如背景图、字体文件、图标库。
资源嗅探的逻辑就是递归扫描。先扫描 HTML,发现外部 CSS 链接就先下载 CSS,再在 CSS 内容里用正则找出所有 url(...) 路径,把其中的相对路径换算成绝对路径后下载。换算相对路径有一个坑:CSS 文件本身的 URL 层级不固定,不能用字符串拼接,要用 new URL() 基于当前文件地址解析,否则很容易出现路径多一层少一层的问题。
核心代码大致长这样:
javascript复制const { readFileSync, writeFileSync } = require('fs');
const path = require('path');
function localizeUrl(url, baseUrl) {
// baseUrl 是当前资源所在页面的完整 URL
const absolute = new URL(url, baseUrl).href;
const name = path.basename(absolute.split('?')[0]);
// 这里做一个 hash 截断,避免文件名过长或包含非法字符
const localName = name.replace(/[^a-zA-Z0-9._-]/g, '_');
return { absolute, localName };
}
我在实际使用中还加了两个判定:一是只下载同域或属于白名单域(如 assets.website-files.com、framerusercontent.com)的资源;二是只处理 http/https/data 开头的引用,data:image 已经内联的就不再重复下载。否则工具可能把统计脚本、第三方字体全家桶全部拉回来,清理量反而大。
3.2 HTML 结构清理:删掉哪些节点,留哪些节点
清理阶段我用 cheerio 实现,规则上坚持“先删脚本,再删属性,后处理标签”的顺序。
脚本的删法不是一概而论。<script> 里带 src 的,基本全部移除;内联脚本则要分情况:Webflow 会在 HTML 里注入一段用于数据初始化的 JSON,比如 window.__UNIVERSAL_DATA__,这类可以安全删;但某些导航交互的初始化脚本也写在行内,删了就没了。所以我设置了保留关键词列表,比如 mobile-menu、accordion、smooth-scroll,源码里只要脚本变量名或注释命中关键词,就保留,否则删除。
属性清理方面,Webflow 会给 DOM 元素添加大量 data-w-id、data-wf-ignore、data-wf-page 等自定义属性,这些是平台运行时用的,对静态页面没有任何用处,可以直接删。id 属性要不要删需要小心,它可能是锚点跳转的目标,也可能是保留脚本的依赖,所以我的默认策略是保留 id,但把乱码型 ID 统一改成语义化 ID。
清理空标签也是必要步骤。富文本编辑器粘贴过来的内容、CMS 空字段渲染留下的 <div></div>、只有 的段落,都会让 HTML 看起来又臭又长。但注意不能无脑删:某些布局依赖 min-height 的空 div 撑开空间,删了布局就塌了。我的做法是:空标签且没有 style、没有 id、没有 class 的才删;有 class 的先看 CSS 里有没有对应规则,有则保留。
3.3 规范化文档结构:doctype、meta、语言、编码
清理完节点,还要让文件成为一份“标准得挑不出毛病”的 HTML 文档。很多视觉建站平台导出的页面,lang 可能是 en,但内容是中文;doctype 和大写标签混用;meta charset 缺失或者被放到很后面。这些在浏览器上看不出差别,但把文件交给后端模板引擎解析或做 HTML 转 Markdown 时就会出问题。
我写了一个 normalizeDoc 函数来做这层处理:
javascript复制function normalizeDoc($) {
if (!$('html').attr('lang')) {
$('html').attr('lang', 'zh-CN');
}
if ($('meta[charset]').length === 0) {
$('head').prepend('<meta charset="utf-8">');
}
if ($('meta[name="viewport"]').length === 0) {
$('head').append('<meta name="viewport" content="width=device-width, initial-scale=1">');
}
$('meta[property^="og:"]').each(function() {
// og 标签保留,但在静态化场景需要把动态 URL 改成实际线上地址
});
}
这里有一个容易被忽略的细节:Webflow 页面顶部头几行一定是干净的 <!doctype html><html lang="zh-cn"> 吗?不一定。版本不同、有无嵌入代码不同,实际输出的 head 顺序经常错乱。所以我统一用 cheerio 序列化整个文档,再把 doctype 手动补在前面,这样最终文件始终是标准开头,无论后续你用它做邮件、写文档还是二次开发,第一步都不会踩编码问题。
3.4 导出校验:怎么确认“干净”了
导出完成不代表结束,需要校验。我写了一个 Post-check 逻辑,在文件落盘后重新解析一遍,统计几个指标:
- 还有没有外链的
script、link、img - 有没有残留
data-wf-、data-framer-这类平台私有属性 - HTML 标签是否有未闭合的标签(cheerio 能解析不代表浏览器渲染没歧义)
- 页面体积是否异常
最后在终端打印一张清单,哪个文件、多少资源、有没有警告,一眼就能看出问题。这一步最初我没加,后来发现有的页面经过清理后 CSS 引用数量不足,因为原本有多个样式表,其中一个包含全部响式规则却被误删了。有了校验报告,这类问题可以在输出阶段就被拦下。
4. 实操过程:从 Webflow 页面到干净 HTML 的完整走查
4.1 准备环境与依赖
环境准备很简单,你需要一台安装了 Node.js 18+ 的电脑。项目的依赖只有三个重点包:cheerio 负责 DOM 解析,undici 或 node-fetch 负责下载资源,prettier 负责格式化 HTML。Framer 场景额外加一个 puppeteer。
bash复制mkdir webflow-clean
cd webflow-clean
npm init -y
npm install cheerio undici prettier puppeteer
提示:puppeteer 体积大、首次运行还会下载 Chromium,如果只处理 Webflow 页面可以不装它,等真遇到 Framer 项目再加。Framer 的导出方式跟 Webflow 不一样,我后面单独说。
4.2 抓取原始页面:不要用浏览器的“另存为”
先说 Webflow 的做法。正确姿势是用脚本抓取页面 HTML,而不是手动“另存为”。浏览器另存为会附带一堆浏览器生成的临时资源文件,而且它保存的是渲染后状态,不是源码状态。更稳妥的方式是直接请求目标 URL,拿到 200 响应后把 HTML 保存成 raw.html。
javascript复制const { request } = await import('undici');
async function fetchPage(url) {
const response = await request(url, {
headers: {
'user-agent': 'Mozilla/5.0 (compatible; StaticSiteExport/1.0)'
},
maxRedirections: 5
});
const html = await response.body.text();
return html;
}
const html = await fetchPage('https://your-webflow-site.webflow.io/');
writeFileSync('raw.html', html);
这里一定要带一个正常的 User-Agent,部分 CDN 对没有 UA 的请求会直接拒绝或返回压缩后的乱码。Webflow 的免费子域名有时候加载慢,所以 maxRedirections 也要设置,否则拿到 301 就中断了。
4.3 运行一键清理脚本
原始页面拿到手后,跑清理脚本:
bash复制node clean.js --input raw.html --output dist/ --mode restore
脚本内部流程就是第三节说的那些。等它跑完后,dist/ 目录结构大致如下:
text复制dist/
├── index.html
├── assets/
│ ├── index_style.css
│ ├── logo.svg
│ ├── hero-bg.jpg
│ └── fonts/
│ └── inter.woff2
└── report.json
注意,Webflow 生成页面时 CSS 文件经常会按区块拆成好几个,实际加载顺序影响层叠优先级。脚本在下载回本地时,必须保持它们在 head 里出现的顺序,否则可能错位。我在实现时给每个 CSS 文件名加了数字前缀,比如 0_style.css、1_about.css,就是为了防止某些静态服务器按文件名重新排序导致样式错乱。
4.4 三个一定要人工收尾的细节
自动脚本无法做到 100% 完美,我每次跑完后还会人工做三件事:
第一,检查页面标题和 meta description。 Webflow 的 SEO 设置是在项目设置里的,如果原来没填,导出后 title 常常是“Home”或者站点名,直接改掉才能交付。第二,确认表单动作。Webflow 页面的表单如果接了平台自带的后端,HTML 里会留一个 form 结构,但这个结构提交到哪、需不需要改造,平台外无法自动知道,这块要单独写逻辑。第三,检查所有的 id 是否有重复。Webflow 允许视觉层重复命名,但合法 HTML 中 id 必须唯一,页面里锚点跳转依赖这个,重复会导致点击导航滚不动。
我一般会用 Lighthouse 对导出后的本地页面跑一次可访问性检查,重点看 heading 层级是否合理、图片有没有 alt。Webflow/Framer 的页面视觉上好看,但作者往往忽略 h1-h3 顺序,导出后正好借机修正。
5. Framer 场景的特殊处理:先渲染,再清理
5.1 Framer 为什么不能直接抓 HTML
Framer 生成的页面,技术上可以部署到自定义域名,也可以导出为静态 HTML。但实际导出能力很有限,它更希望你继续在平台内维护。
如果你打开浏览器开发者工具看 Framer 站点的网络请求,会看到页面先吐出一个极简的 HTML 外壳,然后通过一段 bootstrap 脚本加载体量不小的 JS,再由 JS 把可视化画布上的内容“画”到页面里。这就意味着你直接抓那个 URL 拿到的 HTML,只有 <div id="root"></div>,页面正文空空如也。
解决方案是用 Puppeteer 打开页面,等网络空闲后抓取 document.documentElement.outerHTML,拿到 JavaScript 执行后的完整文档。这样做的代价是资源地址往往指向 Framer 的 CDN,清理时要把 CDN 资源下载回本地,并且处理跨域字体加载的 CORS 问题。
5.2 Framer 资源下载的兼容写法
Framer 的资源 URL 有个特点:文件名通常是强 hash,比如 hero-2f8a1c3d.webp,而且 URL 里有时带着 ?q=80 这类压缩参数。下载时需要把 query 去掉再保存,否则存到本地文件名里全是 % 和 ?。
javascript复制function sanitizeUrl(rawUrl) {
const url = new URL(rawUrl);
url.search = ''; // 去掉压缩参数
return url.href;
}
Framer 的图片资源本身已经是 WebP/AVIF 等现代格式,浏览器兼容性没问题,但如果你要交付给使用老旧浏览器的客户,可能要考虑格式转换。我这里只是提一句,具体转换工具不展开,反正国内用户基本不会踩这个坑。
5.3 批量页面一键导出:用 sitemap 做循环
单个页面清理成功后,批量导出就容易了。Framer 和 Webflow 都自动生成 sitemap.xml,把里面的 URL 列表读取出来,逐个跑一遍抓取 + 清理流程即可。
bash复制node clean.js --sitemap https://site.com/sitemap.xml --output dist/
不过程序化不等于机械化,有些详情页、CMS 动态页不需要导出,硬导只会生成大量重复页面。批量操作前我会先过滤掉带 ? 参数、带 /blog/ 前缀、以及 /404 之类的非目标页面,保留的 URL 再进入队列。这个过滤规则要写在配置文件里,不然下次换项目又得改代码。
6. 常见问题与排查技巧实录
6.1 导出的 HTML 文件无法在浏览器预览
这是被问得最多的一个问题:双击 index.html 结果白屏,或者图片全裂。如果你看完文档头发现是正确的 <!doctype html> 和 <meta charset="utf-8">,页面样式还在但图片丢失,那八成是相对路径问题。
用 file:// 协议打开 HTML 时,浏览器对本地文件访问有限制;如果你从代码里直接写 /assets/hero.jpg 这种绝对根路径,本地预览会去找磁盘根目录的 assets,当然找不到。解决办法是清理脚本一律输出相对路径 ./assets/hero.jpg,或者用本地静态服务器预览,比如 npx serve dist。另外,不要把原站点的 HTTPS 资源硬写成 HTTP,混合内容浏览器会直接拦截。
6.2 CSS 顺序错乱导致样式对不上
Webflow 静态页导出后正常显示,但你重排过 <link> 顺序后,按钮颜色就变了?这是因为原页面可能同时有多个 class 定义同一个属性,哪个 class 生效取决于来源顺序,而不是 HTML 里 class 书写顺序。Webflow 本身的样式表组织在设计器里是“按组件”来的,导出后表现为多个 CSS chunk。
处理方式就是第三节说的加数字前缀,保证 head 中 <link> 顺序和原站一致。另外,如果清理脚本把内联 <style> 抽成了独立 CSS,那这个 CSS 必须保持放在原来 <style> 所在位置,不能统一丢到 head 末尾,否则它会覆盖正常样式。
6.3 字体图标变成小方块
Webflow 的很多页面用到了 icon font 或 SVG sprite,清理时单单下载字体文件还不够,因为 CSS 里的 font-face 可能还带着远程 URL。一个容易漏的点是:字体文件有 woff、woff2、ttf 多个格式,浏览器会按顺序选择第一个能用的,如果只下载 woff2 而丢掉 woff,某些旧浏览器就会显示方块。
偏方是:如果页面里只是简单几个图标,干脆把 icon font 的 HTML 替换成内联 SVG,这样既少了字体请求,又避免跨域字体加载失败。代价是 HTML 里的 SVG 代码会拉长,但对“干净”程度和可维护性来说是加分项。
6.4 清理后的 HTML 不能迁回 Webflow/Framer
不少人会问,能不能把清理后的干净代码重新导入平台继续可视化编辑?答案是不能,也不建议。这套工具导出的 HTML 逻辑上已经脱离了平台私有数据结构,class 名也经过了重写,导回去只会丢失原本的布局控制。
那这个“干净 HTML”的价值在哪?我个人的实践是:导出的页面可以直接变成 WordPress 主题的静态模板,可以丢进 Astro/11ty 作为页面骨架,也可以在清理后的正文区域用工具转换成 Markdown 后再写文档。每种用途只需要在通用导出基础上做少量适配,比从零开始手写快得多。比如之前我把一个 Framer 作品集首页清理干净后,把 hero 区块、项目列表、页脚模板化,接进 11ty,整套流程一个下午就完成了。
6.5 运行脚本时如何保证可恢复
动态页面里偶尔有几个区块是通过 JS 从接口拉数据的,清理成纯静态后,这个区块就会永远空白。我在清理时引入了“区块快照”机制:清理前先把所有可能动态渲染的容器原始 HTML 存到一份 snapshot.json 里,万一导出后发现某个区块丢了,可以从快照里手动挑选回收。虽然多数场景用不上,但遇到复杂首页时,它是救命的底牌。
批量跑完后,我通常还会用 diff 对比一下清理前后页面的文本内容,确保所有可见文案没有被误删。这个检查可以用一个简单的正则:把清理前和清理后 HTML 里的标签全部剥掉,比较剩余文本是否包含相同的连续 15 字片段。如果某段文案在清理后彻底消失,大概率是它在数据属性里而不是在正文节点里,需要人工决定保不保留。
写在最后的经验
我前后迭代了三四版才把这套导出流程稳定下来,最大的体会是:所谓的“干净的 HTML”,不是满足某种单一标准,而是要让这份代码在脱离原平台后依然好读、好改、好交付。不要试图一次清到底,更不要迷信某个“银弹”脚本能覆盖所有页面。项目里最常改的不是解析逻辑,而是“保留清单”——哪些脚本需要留、哪些属性需要留、哪些外部资源不能下载,每接一个页面就得重新过一遍。
如果你目前只是在找一个能把自己的官网导出后存个档的方案,我建议别一上来就写工具,先手动处理一个页面,把页面里所有“平台痕迹”列出来,再决定哪些规则值得自动化。Webflow/Framer 这类工具输出越重的页面,清洗的价值越大,但前提是你已经知道去掉那些运行时包袱之后,页面真正想表达的内容是什么。
