LocalHub 这个项目从第一天起就没装过打包工具。理由很朴素:它要作为 Tauri 应用的前端资源被整体打进安装包,多加一层构建就多一层出错的地方,而它的页面数量用一只手数得过来。
但「不用打包工具」不等于「随便写」。没有 bundler 帮你做的事,得自己用约定补上。下面是三个月维护下来比较稳定的一套做法。
一、目录先按职责切开
没有模块解析器,路径就得自己写全。所以目录结构必须一眼能看出依赖方向,不能出现 A 目录引 B 目录、B 又引 A 的情况。
site/
├── index.html
├── assets/
│ ├── style.css
│ └── fonts/
└── js/
├── main.js # 入口,只负责装配
├── core/
│ ├── dom.js # 查询与创建元素的小工具
│ ├── storage.js # 本地读写封装
│ └── events.js # 事件总线
├── views/
│ ├── links.js
│ ├── apps.js
│ └── files.js
└── ui/
├── toast.js
└── modal.js
约定是这样的:core 不允许引用 views 和 ui,ui 可以引用 core,views 可以引用两者,main.js 只引用 views 并负责初始化。依赖方向单向,就不会绕成一团。
二、用 module 而不是全局脚本
关键是把 <script type="module"> 用起来。它带来两个直接好处:一是作用域天然隔离,不需要 IIFE 包一层;二是浏览器自动按 defer 处理,不会阻塞解析。
<script type="module" src="js/main.js"></script>
对应的模块里正常写 import / export 就行:
// js/core/storage.js
const KEY = 'localhub.items'
export function load() {
try {
return JSON.parse(localStorage.getItem(KEY)) || []
} catch {
return []
}
}
export function save(items) {
localStorage.setItem(KEY, JSON.stringify(items))
}
// js/main.js
import { load, save } from './core/storage.js'
import { mountLinks } from './views/links.js'
注意路径要带 ./ 前缀和 .js 后缀。浏览器不做扩展名补全,写 import x from 'core/storage' 这种 bare specifier 会直接报错——这是从打包工具迁过来最容易踩的第一脚。
三、别让模块在加载时干活
模块顶层只做声明,不执行副作用。这是我在这个项目里最坚持的一条。
原因很实际:ES module 的加载顺序由依赖图决定,但几个互不依赖的模块之间,执行先后并不总是符合直觉。如果某个模块在顶层就去读 DOM、注册全局事件,很可能执行时元素还没解析出来。
// 不推荐:顶层就操作 DOM
const list = document.querySelector('#list')
list.addEventListener('click', onClick)
// 推荐:导出初始化函数,由 main.js 统一调用
export function mount(container) {
container.addEventListener('click', onClick)
}
这样还有一个副作用上的好处:所有挂载点集中在 main.js 里,想知道应用启动时做了哪些事,看一个文件就够了。
四、状态别散在 DOM 上
没有框架托管状态时,很容易变成「数据存在元素的 dataset 里、需要时再读回来」。项目小的时候能用,条目一多就开始出鬼:同一个数据在两处渲染,改了一处忘了另一处。
我的做法是每个视图模块内部持有一个普通数组,DOM 只作为渲染结果。改数据、重渲染,单向流动。
let items = []
export function mount(el) {
items = load()
render(el)
}
function render(el) {
el.innerHTML = ''
for (const item of items) {
el.appendChild(createRow(item))
}
}
整体重渲染在小列表上完全够用,没必要上 diff。真到了几百条卡顿的时候,说明项目规模已经不该这么写了。
五、什么时候该老老实实引入打包工具
我给自己定的界线是三条,中任意一条就该上 Vite:
- 需要第三方库,而且是 npm 上的东西。手动下载 UMD 文件再挂全局,版本管理和依赖传递都会失控。
- 模块数量超过三十个,或者开始出现循环依赖。这时候靠人肉维护依赖图不现实。
- 需要 TypeScript、JSX、Sass 这类要编译的语言。没有构建步骤,浏览器不认。
LocalHub 一直在界线内,所以没上。toolbox 从第一天就过了第三条线——它用 Vue 3 + TypeScript,直接 Vite 走起,没有犹豫。
三个月后的真实感受
好处是真的省心:改完文件刷新浏览器就能看到效果,没有构建等待,没有 sourcemap 对不上,出问题打开 devtools 看到的就是源码本身。部署也简单,整个目录丢给 Nginx 就完了。
代价是自律成本。没有 lint 阶段的强制约束,全靠自己守约定;新人接手(或者三个月后的自己)得先读一遍目录约定才敢动手。项目能长期维持,靠的是它足够小——一旦它长到需要「规范」来管,就说明该换成有构建工具的那套了。
所以这不是「原生比打包工具好」,而是「在什么规模下,哪一层的复杂度值得付」。