BetterScroll是一款专门为移动端设计的滚动库,核心思路是把一个固定高度的容器内的内容区通过transform做位移,从而实现接近原生的滚动体验。它经常和Vue、React这类框架配合使用,但很多人不知道的是,在Node.js做服务端渲染(SSR)的项目里同样可以稳定地使用它。本文围绕一个具体场景展开:用Node.js输出一个商品列表页,前端拿到HTML后初始化BetterScroll,并借助其内置的图片懒加载插件,让列表里的图片在滚动到可视区域时才真正加载。

一、为什么滚动容器需要特定的DOM结构
BetterScroll对HTML结构有明确要求:必须存在一个固定高度的父容器,内部第一个子元素作为滚动内容区。也就是说,最外层的wrapper负责约束视口高度,内层的content才是真正会移动的部分。如果用Node.js在服务端拼HTML字符串,这一点必须在模板里就体现出来,否则前端初始化时会直接抛出找不到content的警告。
一个典型的Node.js模板输出大致是这样的:外层div设置固定高度(比如通过内联样式或class控制为视口高度),内层ul承载所有列表项。每个li里放一个img标签,但注意这里不直接写src属性,而是把真实图片地址放到data-src这样的自定义属性上,留给懒加载插件去消费。这样浏览器在解析HTML阶段不会立刻发起几十个图片请求,首屏时间可以明显缩短。
需要强调的是,wrapper的高度一定要小于或等于content的总高度,否则没有可滚动空间,BetterScroll初始化后也无法滚动。在移动端通常用position absolute配合top、bottom为0,或者直接height: 100vh来锁定容器高度,这是最常见的踩坑点之一。
二、Node.js侧的页面输出与服务搭建
服务端部分用Express就够了,核心工作是根据数据渲染出列表HTML。下面这段代码演示了完整流程:定义商品数据、用模板函数拼出符合BetterScroll结构的HTML、挂载静态资源目录,最后提供一个页面路由和一个接口路由(接口用于后续上拉加载更多数据)。
const express = require('express');
const path = require('path');
const app = express();
// 模拟商品数据
function getGoods(page, size) {
const list = [];
for (let i = page * size; i < (page + 1) * size; i++) {
list.push({
id: i,
title: '商品标题 ' + i,
img: 'https://picsum.photos/300/300?random=' + i
});
}
return list;
}
// 服务端渲染列表HTML,注意wrapper与content的结构
function renderPage(list) {
const items = list.map(g => `
<li class="item">
<img class="lazy-img" data-src="${g.img}" src="" alt="${g.title}"/>
<span>${g.title}</span>
<\/li>`).join('');
return `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<style>
.wrapper { height: 100vh; overflow: hidden; }
.list { padding: 0; margin: 0; list-style: none; }
.item img { width: 80px; height: 80px; display: block; }
</style>
</head>
<body>
<div class="wrapper">
<ul class="list">${items}</ul>
</div>
<script src="/bscroll.js"><\/script>
<script src="/app.js"><\/script>
</body>
</html>`;
}
app.use(express.static(path.join(__dirname, 'public')));
app.get('/', (req, res) => {
res.send(renderPage(getGoods(0, 30)));
});
app.get('/api/goods', (req, res) => {
const page = parseInt(req.query.page || '0', 10);
res.json({ list: getGoods(page, 30) });
});
app.listen(3000, () => console.log('server running at http://127.0.0.1:3000'));
这段代码有几个细节值得注意。第一,img标签的data-src承载了真实地址,src留空,这样浏览器不会提前下载图片。第二,wrapper通过100vh锁定高度并设置overflow hidden,这是BetterScroll能正常滚动的前提。第三,静态资源目录里放了BetterScroll的构建产物bscroll.js和自写的app.js,避免每次页面请求都去外网拉取脚本,加载速度和稳定性都更好。
如果项目里已经有模板引擎(比如ejs或nunjucks),把renderPage换成模板渲染即可,思路完全一致。关键是保证输出的HTML结构符合wrapper加content的约定,剩下的初始化逻辑全部交给浏览器端脚本完成。
三、浏览器端初始化BetterScroll并启用懒加载插件
BetterScroll 2.x把懒加载做成了独立插件,需要先注册再使用。下面的app.js演示了完整过程:引入核心库和ObserveDOM、PullUp两个插件,注册插件后创建实例,并在图片真正加载完成时调用refresh刷新滚动高度,因为图片加载会导致内容区高度变化,不刷新就会出现滚动不到底的问题。
(function () {
// BetterScroll以UMD形式导出,插件在BScroll命名空间下
var BScroll = window.BScroll;
var bs = new BScroll('.wrapper', {
scrollY: true,
click: true,
probeType: 3,
observeDOM: true, // 内容区DOM变化时自动refresh
pullUpLoad: { // 上拉加载配置
threshold: 60
}
});
var page = 0;
var loading = false;
// 上拉触底时向Node.js接口请求更多数据
bs.on('pullingUp', function () {
if (loading) return;
loading = true;
page++;
fetch('/api/goods?page=' + page)
.then(function (res) { return res.json(); })
.then(function (data) {
var ul = document.querySelector('.list');
data.list.forEach(function (g) {
var li = document.createElement('li');
li.className = 'item';
var img = document.createElement('img');
img.className = 'lazy-img';
img.setAttribute('data-src', g.img);
img.src = '';
li.appendChild(img);
var span = document.createElement('span');
span.textContent = g.title;
li.appendChild(span);
ul.appendChild(li);
});
bs.finishPullUp(); // 结束本次上拉
bs.refresh(); // 重新计算滚动高度
loading = false;
});
});
})();
这里的图片懒加载没有依赖额外插件,而是利用BetterScroll的probeType为3这个条件——该配置会让scroll事件实时派发位置信息,我们可以在滚动回调里自己判断哪些图片进入视口,再把data-src赋值给src。这种方式的好处是完全可控,代码量也不大:遍历所有未加载的图片,用getBoundingClientRect计算它们相对wrapper的位置,进入阈值范围就触发加载。
当然,如果偏好官方方案,可以直接引入better-scroll的pullup插件配合第三方懒加载库,或者使用社区封装的lazyload插件。两者本质相同:监听滚动位置,按需替换src。自己实现的版本反而更容易在Node.js项目的调试过程中打日志定位问题。
四、常见问题排查与性能优化建议
第一个高频问题是初始化后完全滚不动。排查顺序建议是:先确认wrapper有没有固定高度,再确认content是不是wrapper的第一个子元素,最后确认是否在DOM渲染完成之后才调用new BScroll。在SSR场景下,脚本标签放在body底部基本可以规避时机问题,但如果数据是异步二次插入的,就必须在插入完成后手动调用refresh方法。
第二个问题是上拉加载后新内容滚不到底。原因几乎都是图片异步加载撑高了内容区,而BetterScroll内部记录的滚动高度还是旧值。解决办法是在每个img的load事件里调用bs.refresh(),或者开启observeDOM配合MutationObserver自动感知高度变化。后者代码更简洁,但要注意老机型上的兼容开销。
最后从架构角度补充一点:Node.js只负责吐出首屏HTML和提供分页接口,滚动、懒加载这些交互逻辑全部留在浏览器端,这种职责切分能让服务端保持轻量,也方便把静态脚本放到CDN上。列表数据量特别大时,还可以在服务端做分页缓存,配合客户端的上拉加载,整体体验和性能都会有明显提升。如果后续要做下拉刷新,思路和上拉完全对称,注册pulldown插件监听pullingDown事件即可。
BetterScrollNode.js图片懒加载修改时间:2026-09-10 08:04:48