import 带来一整支亲友团
奶茶店开门了,第一单却记不进去。记账员抱着账本等,面板员举着刷子等,所有人都在等一箱颜料。浏览器老大掀开名单:“谁点的统计图?”没人举手。名单最底下,倒是有 JavaScript 老大自己的签名。
第一杯奶茶,卡在颜料上
早上,奶茶店刚开门,店员就在柜台电脑上打开了一个记单小网页。
页面上写着“今日订单数:0”,下面有个“记一单”按钮。每卖出一单,点一下,数字就该加一。角落另有一个“查看统计”,闲下来可以打开订单统计图。
第一位客人点好奶茶,店员点了一下“记一单”。
零。
又点一下。
还是零。
店员把鼠标抬起来,看了看底下。
屏幕里面,浏览器老大已经冲进执行大厅:“别查鼠标了!按钮的工作谁没安排?”
JavaScript 老大站在门口,咖啡端了半天,一口没喝。
“我。等人呢。”
记账员抱着账本坐在第一张椅子上,铅笔削得尖尖的。面板员举着写数字的刷子,站在第二张椅子旁。
浏览器老大指了指两位:“记账的到了,写数字的到了。等谁?店长?”
JavaScript 老大往后让了半步。
第三张椅子上坐着统计员。统计员拉着图表员,图表员扒着门框,朝运输通道喊:“颜料!我的颜料呢?”
远处,一只颜料箱正慢吞吞地过来。
浏览器老大看看屏幕角落。“查看统计”从开门到现在,根本没人碰过。
“这边卖出一杯奶茶,那边得先把颜色配齐?”
统计员摊开空白图纸:“我也没画啊。我只是来准备。”
“准备得很好,下次先别准备。”
浏览器老大去拽门口的 import。刚拽一下,后面跟出一串申请单,拖过椅子腿,一直铺到 JavaScript 老大的鞋面。
最上面一张,签名很眼熟。
JavaScript 老大默默把咖啡杯压了上去。
分了房,连名字都不认了
事情得从这张名单说起。
记单网页原来只有一个大文件,记账、改数字、画图,全挤在里面。开发者前几天想换图表颜色,翻了半天,差点先改了记账的代码。
于是,工作拆开了。
counter.js 保管订单数,panel.js 把数字显示到页面,report.js 负责统计图。末尾的 .js,表示这些文件装着 JavaScript 代码。
入口另留一份 main.js:接到哪个按钮的点击,就叫相应的人干活。
管房间的小弟把几块门牌钉上墙,记账员第一个搬进去,贴出工作说明:
// counter.js
export let count = 0;
export function increment() {
count += 1;
}
count 是订单数,初始值为零。increment 是一个函数,也就是一项可以叫来执行的工作:每调用一次,就把 count 加一。
前面的 export,则把这两个名字列进了对外提供的清单。
JavaScript 老大在入口探头:“房都分好了,count,出来一下!”
走廊里很安静。
“就隔一面墙,装什么不认识。”
管房间的小弟把门轻轻关上。
“各管各的名字。这是模块作用域。”
这些文件现在按模块管理,各自拥有自己的作用域。一个模块里声明的名字,不会因为文件挨得近,就自动出现在另一个模块里。
JavaScript 老大伸手去推门,小弟从门缝塞出一张申请单。
// main.js
import { increment } from "./counter.js";
意思是:从旁边的 counter.js,导入名为 increment 的功能,供入口使用。
记账员这才把“加一”的操作柄接到入口。
浏览器老大立刻低头查账。
还是零。
记账员护住铅笔:“刚接好,没人按。别一办手续就给店里多算一单。”
导入函数,让当前模块能使用这个函数;真正写下 increment() 调用它,函数里的加一动作才会发生。
面板员扛着刷子挤过来:“那我读订单数,也填?”
小弟递出第二张。
// panel.js
import { count } from "./counter.js";
一个接动作,一个读数字,各自说清楚要用什么。
JavaScript 老大终于没再拍门。拆文件这事,剪开容易,剪完的接口得接好。
可眼下,接口还没接完,走廊先挤满了。
我只请了三个!
浏览器老大掀开咖啡杯,把入口名单拉平:
// main.js,开工前的名单
import { increment } from "./counter.js";
import { renderCount } from "./panel.js";
import { showReport } from "./report.js";
renderCount 负责更新页面数字,showReport 负责显示统计图。再往下,才是给按钮安排点击后做什么的代码。
“我只请了三个。”JavaScript 老大用手指按住第三行,“多出来的别算我头上。”
穿灰蓝背心的模块加载员蹲下来,从椅子底下抽出 report.js 的名单。
上面又写着:找 counter.js 读数,找 chart.js 画图。
图表员把自己的名单递来:找 palette.js 拿颜色。
浏览器老大拿着最后一张,朝 JavaScript 老大晃了晃。
“没有多出来。都是顺着你的签名来的。”
加载员把文件名钉上白幕,沿申请关系接线:
main.js(入口)
├─ counter.js(记账)
├─ panel.js(显示数字)
│ └─ counter.js(还是刚才那份)
└─ report.js(统计)
├─ counter.js(还是刚才那份)
└─ chart.js(画图)
└─ palette.js(颜色)
这张依赖关系图,叫模块图。
浏览器拿到入口文件,读出里面的导入地址,再去获取那些文件;新文件里还有导入,就继续沿线找。一个 import 写得短,后面牵着的依赖未必短。
URL 抱着 ./chart.js 的地址卡赶来,差点踩上地面的长名单。
“这个‘旁边’,是旁边哪间?”
加载员在 report.js 上敲了两下。
“谁写的导入,就从谁的地址算。report.js 在 https://shop.example/js/report.js,它写的 ./chart.js 就是 https://shop.example/js/chart.js。”
URL 换上完整地址,重新出发。
这里说的是浏览器直接加载模块。若代码先经过打包工具整理,实际送到浏览器的文件和依赖关系,可能已经换了模样。
浏览器老大不想再等,抓住第三行,把它拖到名单最底下。
“排后面。先给记账按钮安排工作。”
加载员的笔跟着挪到底下,重新画了一个圈。
“还是开工前的依赖。”
浏览器老大又折起纸角,把那行藏到背面。
加载员把纸翻过来。
“背面也是。”
这种写法叫静态导入。它提前声明模块依赖,并不等代码一行行执行到这里才临时请人。就算挪到文件末尾,仍要在模块正文执行前处理;塞进函数或条件块更不行,静态 import 声明必须写在模块顶层。
记账员举着削好的铅笔,试图从旁边绕过去。
“要不我先记?”
加载员指向入口。
记账模块已经取到了,但 main.js 这边的静态依赖还没准备好。给按钮注册点击处理的代码,也在这份入口里,尚未开始执行。
记账员坐了回去。
铅笔又削了一圈。
颜料到了,也别乱按铃
运输通道终于传来箱轮声。
图表员跳起来接颜料。JavaScript 老大伸手去拉开工铃,加载员把名册垫在铃下面。
还差一项:接线。
面板申请的 count,接向记账模块导出的 count;入口申请的 increment,接向同名的函数。
浏览器老大盯着那根细线:“都是订单总数,申请单写 total 不也一样?”
记账员把窗口的两个名字转过来。
count。increment。
没有 total。
加载员的笔停在空位上:“写错成 total,链接就会报错。浏览器不会替代码猜同义词,入口也开不了工。”
浏览器老大把笔收了回去。这种地方,文采太好也没用。
文件取回来,叫加载;把导入名字与导出接好,叫链接。接着才是求值,也就是执行模块代码。
这次铃终于响了。
JavaScript 老大先执行记账模块,把订单数设为零;再按依赖关系完成面板、配色、图表和统计模块的准备;最后运行入口,为按钮注册点击处理。
这张模块图没有循环依赖,也没有异步初始化等待,因此依赖先执行,使用依赖的模块随后执行。下载跑第一的,不一定先开工。
统计员把 showReport 的工作牌挂好,摊开的图纸仍然是白的。
浏览器老大停在旁边:“折腾半天,怎么还不画?”
统计员指指屏幕外。
“没人点我。”
准备好一个函数,并不会顺便执行函数体。统计图没画,静态导入该做的加载和初始化,却一点没少。
屏幕外,店员试探着又点了一下“记一单”。
一。
店员终于把鼠标放回桌面。
三张申请,一本账
加载员继续收线,三条线绕过白幕,全部接进同一间记账室。
浏览器老大刚松下来的眉毛又抬了起来。
“入口要一份,面板要一份,统计也要一份。账本是不是少发了两本?”
JavaScript 老大已经把复印机推来了。
记账员一把抱紧账本。
“别印。刚卖出的一单,你们打算分到哪本?”
加载员踩住复印机轮子,翻开登记册。
三张申请,解析到同一个模块 URL。当前页面环境里,浏览器只为这个 JavaScript 模块登记一份;多一个地方导入,不会让模块顶层代码再跑一次。
假如在 counter.js 顶层加一句:
console.log("新账本,订单数从零开始");
这份模块首次执行时打印一次。入口、面板和统计不会各拿一本新账,也不会每导入一次,就把订单数重置为零。刷新成新的页面环境,才是新的一轮。
JavaScript 老大把复印机往回推。走廊终于宽了一点。
浏览器老大却盯上了面板员:“账本没复制,那你导入 count 时拿到的零,总该是旧的吧?”
面板员朝窗口看了一眼。
“一。”
浏览器老大又看了看刚才的申请单。单子没换,数字却变了。
导入这里接到的是活绑定:读 count 时,读的是记账模块里这个变量当前的值,并非导入当时拍下的一张照片。
JavaScript 老大来了兴趣。为免乱改营业账,先另开同样的测试页面,记上一单,再在测试模块中导入 count 和 increment,试了三步:
console.log(count); // 当前是 1
increment(); // 测试:把账本加到 2
console.log(count); // 读到 2
屏幕上,那个一却站得稳稳当当。
浏览器老大敲了敲屏幕,又敲了敲窗口。
“活的?哪边活的?”
面板员举起还没动过的刷子。
“账本变了。叫我了吗?”
活绑定保证重新读取时拿到新值,不会替应用自动改 DOM,也就是页面里的元素和文字。
面板员真正负责的代码是:
// panel.js
import { count } from "./counter.js";
export function renderCount() {
document.querySelector("#count").textContent = count;
}
#count 指定页面上显示订单数的位置。这行代码找到那个位置,把里面的文字换成当前的 count。
JavaScript 老大补叫一次 renderCount()。
二终于出现在屏幕上。
“所以点一次按钮,先 increment(),再 renderCount()。别只叫记账员,让我站着晾刷子。”
面板员说完,伸笔想直接在导入的 count 上改数字,记账窗口却把笔挡了回来。
这个导入名字不能由使用方重新赋值。要改这里的计数,调用记账模块提供的 increment()。若导出的是对象,限制的是给导入名字重新赋值,不等于对象里的属性都被冻结。
记账员收好笔,在测试账封面上写了个大大的“练习”。
“这两单别拿去跟店员对账。根本没做奶茶。”
JavaScript 老大重新载入测试页面,把计数归零。接下来的修改和验收,也都在这里做。
别让记账陪着看热闹
浏览器老大没有撤走慢速网络的测试开关。
“现在账算明白了,明早还等不等颜料?”
HTML 解析器听见动静,戴着安全帽过来,把入口牌拍到桌上:
<script type="module" src="./main.js"></script>
type="module" 告诉浏览器按模块处理这份入口。
这块写在 HTML 里的牌子没加 async:加载模块期间,HTML 解析器可以继续搭页面;文档解析结束、模块图准备好后,模块才安排执行。
HTML 解析器用扳手点了点那两个按钮。
“页面能先搭出来。可点击后干什么,得等你们的代码来注册。我总不能给每个按钮配个算命的。”
开头的事故就出在这里:按钮已经显示,点击处理却还没安装。先前那几次点击,也不会在代码准备好后自动补办。
JavaScript 老大把静态名单上的 report.js 圈了出来。
记账离不开 counter.js 和 panel.js。统计图完全可以等有人要看,再准备。
加载员这次递来的纸上,多了一对括号:
import("./report.js")
动态导入。
这句要等真正运行到这里,才发起导入;可以放进点击后执行的函数里。
浏览器老大拿着新单子就往按钮旁边贴。
加载员伸手,抽走入口顶部原来的这一行:
import { showReport } from "./report.js";
“旧邀请还在,亲友团照样一开门就来。”
JavaScript 老大又沿启动名单查了一遍,确认剩下的模块也没有谁偷偷静态导入统计模块。然后重写入口:
// main.js
import { increment } from "./counter.js";
import { renderCount } from "./panel.js";
renderCount();
document.querySelector("#add").addEventListener("click", () => {
increment();
renderCount();
});
const reportButton = document.querySelector("#report");
reportButton.addEventListener("click", async () => {
reportButton.disabled = true;
reportButton.textContent = "统计图准备中…";
try {
const report = await import("./report.js");
report.showReport();
reportButton.textContent = "查看统计";
} catch {
reportButton.textContent = "统计图没打开,请稍后再试";
} finally {
reportButton.disabled = false;
}
});
#add 是“记一单”按钮,#report 是“查看统计”按钮。addEventListener 做的事,就是登记:收到 click 点击事件后,执行后面这段工作。
上半张工作单维持记账流程:加一,改屏幕数字。
下半张则要等统计按钮被点到,才运行 import()。请人期间,按钮暂时禁用,文字换成“统计图准备中…”。
统计员看见禁用按钮,点了点头:“好。免得我还在赶路,门口已经站了十个催我的。”
import() 返回一个 Promise,表示这次导入的结果还可能在路上。await 暂停当前这次按钮处理的后续步骤,等待结果;等待期间,JavaScript 老大可以去接其他工作,并没有把整个浏览器锁住。
等统计模块及其依赖加载、链接、执行成功,report 就能用来访问模块的导出,再调用 report.showReport() 画图。这里的画图函数是同步执行的。
若导入失败,就走 catch,把按钮上的文字换成失败提示。最后的 finally 恢复按钮,让店员还能再试。
图表员举起颜料箱:“所以我以后来得更快了?”
加载员看了一眼测试里的慢速通道。
“路没变。只是记账的不用陪你站路边了。”
这次,三单先记上
JavaScript 老大重新载入测试页面。
记账员进门,面板员进门。入口很快给两个按钮安排好工作。
统计员刚想跟着挤进来,加载员把新邀请单转向门外。
“还没点。”
统计员只好把图纸卷回去。
点“记一单”。
一。
再点。
二。
浏览器老大这才点下“查看统计”。
角落亮出“统计图准备中…”。统计员带着图表员动身,图表员继续等那只故意慢下来的颜料箱。
浏览器老大没等,转头又点一次“记一单”。
三。
记账员的铅笔这回没空削了。
颜料箱抵达,统计模块准备完成,showReport() 终于被调用。统计员从共享账本里读到三,画出了今天的测试统计图。
“咦,我来晚了,怎么没从零开始?”
加载员把导入线接回原来的记账室。
同一页面环境里,同一 URL 的模块已经执行过,后来通过动态导入走到它,也不会重新初始化。那本账从头到尾都在。
JavaScript 老大端起咖啡,又指了指图表员。
“等颜料时,我能接别的活。真开始画,要是你算上半天,工作台照样被占着。动态导入可没给我变出第二双手。”
图表员把准备搬来的大桌子默默推了回去。
浏览器老大最后核对了一遍:启动时不再等统计依赖;点统计时有等待提示;等待中仍能记账;图打开后读到当前的数。测试账清掉,修改再交回店里的页面。
第二天,奶茶店开门。
第一单记进去了。
第二单也记进去了。
屏幕里面,统计员抱着图纸在门外坐了好一会儿,忍不住探头:“今天真没人要看我?”
记账员没抬头,铅笔又划了一笔。
“先让人家把奶茶卖了。”