import 带来一整支亲友团

奶茶店开门了,第一单却记不进去。记账员抱着账本等,面板员举着刷子等,所有人都在等一箱颜料。浏览器老大掀开名单:“谁点的统计图?”没人举手。名单最底下,倒是有 JavaScript 老大自己的签名。

分享
import 带来一整支亲友团

第一杯奶茶,卡在颜料上

早上,奶茶店刚开门,店员就在柜台电脑上打开了一个记单小网页。

页面上写着“今日订单数: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 老大端起咖啡,又指了指图表员。

“等颜料时,我能接别的活。真开始画,要是你算上半天,工作台照样被占着。动态导入可没给我变出第二双手。”

图表员把准备搬来的大桌子默默推了回去。

浏览器老大最后核对了一遍:启动时不再等统计依赖;点统计时有等待提示;等待中仍能记账;图打开后读到当前的数。测试账清掉,修改再交回店里的页面。

第二天,奶茶店开门。

第一单记进去了。

第二单也记进去了。

屏幕里面,统计员抱着图纸在门外坐了好一会儿,忍不住探头:“今天真没人要看我?”

记账员没抬头,铅笔又划了一笔。

“先让人家把奶茶卖了。”

资料对照