- 如何彻底解决小程序滚动穿透问题
背景 俗话说,产品设计有三宝:弹窗、浮层加引导。可见“弹窗”在产品设计中的重要性。对于前端开发者来说,实现一个基础的模态框(Modal)并不难,可一旦模态框内部出现可滚动内容,各种意想不到的问题就会接踵而至——其中最为典型的,就是“滚动穿透”。 什么是滚动穿透? 滚动穿透指的是:当我们在顶层弹窗中执行滑动操作时,实际滚动的是底层页面的内容。这种体验非常影响交互,也常让用户感到困惑。 解决方案分析 解决滚动穿透的思路通常有两种:改变顶层或改变底层。 1. 改变顶层(不推荐) 一种自然的想法是阻止顶层的事件继续传播。例如,给遮罩层(蒙层)绑定 catchtouchmove 事件。但这种方法在部分场景下并不生效,因此并不是一个可靠的通用方案。 2. 改变底层(推荐) 既然问题是底层内容跟随滚动,那么只要在弹窗打开时禁止底层页面滚动,问题就迎刃而解。 具体实现方案 🟡 不成熟的方案 将底层页面的最外层 view 设置为 position: fixed,但这会导致页面滚动位置丢失,回到顶部。 另一种方式是:在打开弹窗前记录当前滚动位置,关闭弹窗后使用 wx.pageScrollTo 滚动回原位置。这种方法实现较为繁琐,且体验不够流畅。 ✅ 成熟的方案 方案一:使用 page-meta 组件 通过微信小程序提供的 page-meta 组件,我们可以直接控制页面根容器的样式,类似于在 H5 中设置 body { overflow: hidden; }。 我们可以动态设置 overflow 属性为 hidden 或 auto,来控制页面是否可滚动。 方案二:使用 wx.setPageStyle 方法 这是一个更灵活的 API,特别适合在组件内部(如封装好的弹窗组件)使用,无需额外编写 page-meta 结构。 [代码]wx.setPageStyle({ style: { overflow: 'hidden' // 或 'auto' } }) [代码] 代码示例 这里提供一个可供测试的代码片段,帮助你快速上手: 👉 点击查看代码片段 拓展:支付宝小程序的情况 支付宝小程序虽然也支持 page-meta 组件,但由于其 WebView 内核限制(版本 69),设置 overflow: hidden 并不能完全阻止底层滚动。 支付宝团队为此专门提供了新的 API:my.setPageScrollable,用于精确控制页面是否可滚动。 [代码]my.setPageScrollable({ scrollable: false, success: (res) => console.log(res), fail: (err) => console.log(err) }) [代码] 更新(2025.06.18) 该 API 已正式开放。不过目前测试发现:在 安卓端 设置禁止滚动后,弹窗内的可滚动区域也会被一并禁止;iOS 端 则表现正常,仅底层页面被锁定。 原因分析: 安卓与 iOS 在滚动禁止的层级控制上存在系统级差异: 安卓 采用 Webview 级别的滚动限制,生效时整个页面(包括所有弹层)均不可滚动; iOS 采用组件级控制,能智能区分层级,仅锁定底层页面而保持弹窗内部可滚动。
11-13 - 2022-03-04
- 如何使用canvas绘制签名板?
场景分析在小程序业务中如需用户进行手写签名的场景如:寄快递,签约合同时需要在小程序中进行手写签名。 处理方法 实现原理运用 canvas 监听用户 touch 事件,然后在 canvas 上画出与 touch 事件相近的线模仿手写签名效果。 实现方法参考如下代码片段:https://developers.weixin.qq.com/s/MYDTQAmR7EIa [图片]
2024-09-09 - 如何使用scroll-view制作左右滚动导航条效果
最新:2020/06/13。修改为scroll-view与swiper联动效果,新增下拉刷新以及上拉加载效果。。具体效果查看代码片段,以下文章内容和就不改了 刚刚在社区里看到 有老哥在问如何做滚动的导航栏。这里简单给他写了个代码片段,需要的大哥拿去随便改改,先看效果图: [图片] 代码如下: wxml [代码]<scroll-view class="scroll-wrapper" scroll-x scroll-with-animation="true" scroll-into-view="item{{currentTab < 4 ? 0 : currentTab - 3}}" > <view class="navigate-item" id="item{{index}}" wx:for="{{taskList}}" wx:key="{{index}}" data-index="{{index}}" bindtap="handleClick"> <view class="names {{currentTab === index ? 'active' : ''}}">{{item.name}}</view> <view class="currtline {{currentTab === index ? 'active' : ''}}"></view> </view> </scroll-view> [代码] wxss [代码].scroll-wrapper { white-space: nowrap; -webkit-overflow-scrolling: touch; background: #FFF; height: 90rpx; padding: 0 32rpx; box-sizing: border-box; } ::-webkit-scrollbar { width: 0; height: 0; color: transparent; } .navigate-item { display: inline-block; text-align: center; height: 90rpx; line-height: 90rpx; margin: 0 16rpx; } .names { font-size: 28rpx; color: #3c3c3c; } .names.active { color: #00cc88; font-weight: bold; font-size: 34rpx; } .currtline { margin: -8rpx auto 0 auto; width: 100rpx; height: 8rpx; border-radius: 4rpx; } .currtline.active { background: #47CD88; transition: all .3s; } [代码] JS [代码]const app = getApp() Page({ data: { currentTab: 0, taskList: [{ name: '有趣好玩' }, { name: '有趣好玩' }, { name: '有趣好玩' }, { name: '有趣好玩' }, { name: '有趣好玩' }, { name: '有趣好玩' }, { name: '有趣好玩' }, { name: '有趣好玩' }, { name: '有趣好玩' }, { name: '有趣好玩' }, { name: '有趣好玩' }, { name: '有趣好玩' }, { name: '有趣好玩' }, { name: '有趣好玩' }, { name: '有趣好玩' }, { name: '有趣好玩' }, ] }, onLoad() { }, handleClick(e) { let currentTab = e.currentTarget.dataset.index this.setData({ currentTab }) }, }) [代码] 最后奉上代码片段: https://developers.weixin.qq.com/s/nkyp64mN7fim
2020-06-13 - 【交互方案】针对不小心触发返回按钮的交互
17年 因为业务上有该类需求,所以提过一个问题。 想实现监听左上角按钮来做其他操作,比如跳转其他页面。 https://developers.weixin.qq.com/community/develop/doc/92f7cdbacdf724cba640955423a8444f 此交互优化只应对表单等内容提交回填的处理 应用场景: 某个表单提交页,页面挺多内容,用户填写完后,不小心触发了左上角返回,或者不小心点了物理按键,那么用户填写的内容就丢失了,那么最开始大家的想法都是,点击左上角返回按钮或者物理按键返回能触发监听我们再弹窗提醒用户是否退出当前页面。这个交互应该是再正常不过的了,在各种app都有见过这种交互。 网页里: h5里都是通过监听popstate,以及设置pushstate实现。 小程序里: 然而小程序的翻遍官方文档,没找到该方法,发帖询问后也是得到官方童鞋回复,不会提供该方法。 官方童鞋给的原因是:会有某些开发者,阻止用户退出某些页面,以达到一些xxx目的,所以官方为了防止开发者滥用,并不打算开放该功能。 翻了社区大家实现方式,有大佬给了一个方案: https://developers.weixin.qq.com/community/develop/article/doc/000844b537c230b04b999a54f56013 该监听方法的缺点: [图片] 最后确实没发现有什么好的监听方案了,那既然代码无法实现,那么我们可以优化用户体验来达到该效果。 实现操作方案如下: [代码]// app.js下跟onLaunch同级新增个globalData字段。 globalData: { formData: {} // 这里需要默认填写该字段,不然其他地方使用了会报错。 } // 首先用户填写任意字段都存储一个对象到globalData下。 <input type="text" placeholder="请输入用户昵称" bindinput="handleUserName" /> handleUserName(e) { getApp().globalData.formData.userName = e.detail.value } [代码] 这样将用户填写的内容都存到globalData下,而我们最初的交互是,存储后用户点击返回下次进来自动回填。 [代码]onShow() { this.setData({ userName: getApp().globalData.formData.userName || '' }) } [代码] 而最终的交互是这样操作: 如果用户填写完一整页内容,而内容我们都存到了globalData下,用户不小心返回了上一页,那么我们在用户重新进入该页面时,判断globalData的formData下是否存在内容,如果存在,弹窗提醒用户是否回填上次填写的内容,如果用户确认回填那么我们给用户自动回填上次填写的信息,如果用户取消回填,那么我们将globalData下的formData设置为空对象即可。 如此 我们即从交互上规避了不小心点击返回导致需要重新输入的问题,并且交互体验得到极大提升。。
2020-06-04 - 小程序分享图生成指南:告别 Canvas 踩坑,轻松实现朋友圈分享
由于微信小程序无法直接分享到朋友圈,业界普遍采用 Canvas 生成带小程序码的图片,引导用户保存后分享,相信不少开发者在绘制分享图时都曾遇到过各种 Canvas 的“彩蛋”(坑)。 今天推荐一个开源组件:Painter,它能够帮助我们轻松实现分享图绘制,并且已在支付宝小程序中验证可用。 快速开始 第一步:创建自定义组件 在组件的 JSON 文件中引入 Painter: [代码]{ "component": true, "usingComponents": { "painter": "/painter/painter" } } [代码] 第二步:编写组件 WXML 将 Painter 组件定位在屏幕外,避免影响用户界面: [代码]// 将该组件定位在屏幕之外,用户查看不到。 <painter style="position: absolute; top: -9999rpx;" palette="{{imgDraw}}" bind:imgOK="onImgOK" /> [代码] 第三步:实现组件逻辑 [代码]Component({ properties: { // 是否开始绘图 isCanDraw: { type: Boolean, value: false, observer(newVal) { newVal && this.handleStartDrawImg() } }, // 用户头像昵称信息 userInfo: { type: Object, value: { avatarUrl: '', nickName: '' } } }, data: { imgDraw: {}, // 绘制图片的大对象 sharePath: '' // 生成的分享图 }, methods: { handleStartDrawImg() { wx.showLoading({ title: '生成中' }) this.setData({ imgDraw: { width: '750rpx', height: '1334rpx', background: 'https://qiniu-image.qtshe.com/20190506share-bg.png', views: [ { type: 'image', url: 'https://qiniu-image.qtshe.com/1560248372315_467.jpg', css: { top: '32rpx', left: '30rpx', right: '32rpx', width: '688rpx', height: '420rpx', borderRadius: '16rpx' }, }, { type: 'image', url: this.data.userInfo.avatarUrl || 'https://qiniu-image.qtshe.com/default-avatar20170707.png', css: { top: '404rpx', left: '328rpx', width: '96rpx', height: '96rpx', borderWidth: '6rpx', borderColor: '#FFF', borderRadius: '96rpx' } }, { type: 'text', text: this.data.userInfo.nickName || '青团子', css: { top: '532rpx', fontSize: '28rpx', left: '375rpx', align: 'center', color: '#3c3c3c' } }, { type: 'text', text: `邀请您参与助力活动`, css: { top: '576rpx', left: '375rpx', align: 'center', fontSize: '28rpx', color: '#3c3c3c' } }, { type: 'text', text: `宇宙最萌蓝牙耳机测评员`, css: { top: '644rpx', left: '375rpx', maxLines: 1, align: 'center', fontWeight: 'bold', fontSize: '44rpx', color: '#3c3c3c' } }, { type: 'image', url: 'https://qiniu-image.qtshe.com/20190605index.jpg', css: { top: '834rpx', left: '470rpx', width: '200rpx', height: '200rpx' } } ] } }) }, onImgErr(e) { wx.hideLoading() wx.showToast({ title: '生成分享图失败,请刷新页面重试' }) //通知外部绘制完成,重置isCanDraw为false this.triggerEvent('initData') }, onImgOK(e) { wx.hideLoading() // 展示分享图 wx.showShareImageMenu({ path: e.detail.path, fail: err => { console.log(err) } }) //通知外部绘制完成,重置isCanDraw为false this.triggerEvent('initData') } } }) [代码] 如何使用 1. 引入组件 在页面的 JSON 文件中引入封装好的分享组件: [代码]{ "usingComponents": { "share-box": "/components/shareBox/index" } } [代码] 2. 页面布局 [代码]<!-- 触发按钮 --> <button class="intro" bindtap="getUserInfo">点我生成分享图</button> <!-- 分享组件 --> <share-box isCanDraw="{{isCanDraw}}" userInfo="{{userInfo}}" bind:initData="handleClose" /> [代码] 3. 页面逻辑 [代码]Page({ data: { isCanDraw: false, userInfo: {} }, // 重置绘制状态 handleClose() { this.setData({ isCanDraw: false }) }, // 获取用户信息并开始绘制 getUserInfo() { wx.getUserProfile({ desc: "获取您的头像昵称信息", success: (res) => { const { userInfo = {} } = res this.setData({ userInfo, isCanDraw: true // 触发绘制 }) }, fail: (err) => { console.log('获取用户信息失败:', err) } }) } }) [代码] 效果展示 完成上述步骤后,即可生成如下的分享图片: [图片] 实用技巧 文字居中:通过设置 [代码]align: 'center'[代码] 和合适的[代码]left[代码]值实现 文字换行:设置[代码]width[代码]和 [代码]maxLines[代码] 属性,当 [代码]maxLines: 1[代码] 时,超出一行会显示为省略号 资源链接 代码片段:点击查看 Painter 可视化工具:点击查看 常见问题 Q:为什么图片无法加载? A:需要在小程序后台配置 downloadFile 合法域名: 进入「开发」→「开发设置」→「服务器域名」 配置图片的域名前缀,如 https://qiniu-image.qtshe.com Q:真机调试注意事项? A:开发者工具中可在「详情」→「本地设置」中开启「不校验合法域名」,真机调试时需开启调试模式。 通过以上步骤,你就可以轻松实现小程序分享图生成功能了。如有任何问题,欢迎在评论区留言讨论!
11-13 - 如何实现一个自定义数据版省市区二级、三级联动
社区可能有其他的方案了,但是再分享下吧,给有需要的童鞋。 效果图: [图片] 额,这个视频转GIF因为社区上传不了大图,所以剪了一部分,具体的效果还是直接工具打开代码片段预览吧~ 第一步:你的页面JSON引入该组件: [代码]{ "usingComponents": { "city-picker": "/components/cityPicker/index" } } [代码] 第二步:你的页面WXML引入该组件 [代码]<city-picker visible="{{visible}}" column="2" bind:close="handleClick" bind:confirm="handleConfirm" /> [代码] 第三步:你的页面JS调用 [代码]// 显示/隐藏picker选择器 handleClick() { this.setData( visible: !this.data.visible }) }, // 用户选择城市后 点击确定的返回值 handleConfirm(e) { const { detail: { provinceName = '', provinceId = '', cityName, cityId='', areaName = '', areaId = '' } = {} } = e this.setData({ cityId, cityName, areaId, areaName, provinceId, provinceName }) } [代码] 组件属性 属性 默认值 描述 visible false 是否显示picker选择器 column 3 显示几列,可选值:1,2,3 values [0, 0, 0] 必填,默认回填的省市区下标,可选择具体省市区后查看AppData的regionValue字段 close function 点击关闭picker弹窗 confirm function 点击选择器的确定返回值 confirm: 属性 默认值 描述 provinceName 北京市 省份名称 provinceId 110000 省份ID cityName 市辖区 城市名称 cityId 110100 城市ID areaName 东城区 区域名称 areaId 110000 区域Id 至于怎么获取你想默认城市的下标,可以滑动操作下选中省市区后,点击确定后查看appData里的regionValue的值。 以上就是一个自定义数据版本的省市区二级、三级联动啦,老规矩,结尾放代码片段。 https://developers.weixin.qq.com/s/F9k9cTmT7LAz
2022-07-20 - 如何写一个自己的脚手架 - 一键初始化项目
如何写一个自己的脚手架 - 一键初始化项目 介绍 脚手架的作用:为减少重复性工作而做的重复性工作 即为了开发中的:编译 es6,js 模块化,压缩代码,热更新等功能,我们使用[代码]webpack[代码]等打包工具,但是又带来了新的问题:初始化工程的麻烦,复杂的[代码]webpack[代码]配置,以及各种配置文件,所以就有了一键生成项目,0 配置开发的脚手架 本文项目代码地址 本文以我司的脚手架工具 简化之后为基础 本系列分 3 篇,详细介绍如何实现一个脚手架: 一键初始化项目 0 配置开发环境与打包 一键上传服务器 首先说一下个人的开发习惯 在写功能前我会先把调用方式写出了,然后一步一步的从使用者的角度写,现将基础功能写好后,慢慢完善 例如一键初始化项目功能 我期望的就是 在命令行执行输入 [代码]my-cli create text-project[代码],回车后直接创建项目并生成模板,还会把依赖都下载好 我们下面就从命令行开始入手 创建项目 [代码]my-cli[代码],执行 [代码]npm init -y[代码]快速初始化 bin [代码]my-cli[代码]: 在 [代码]package.json[代码] 中加入: [代码]{ "bin": { "my-cli": "bin.js" } } [代码] [代码]bin.js[代码]: [代码]#!/usr/bin/env node console.log(process.argv); [代码] [代码]#!/usr/bin/env node[代码],这一行是必须加的,就是让系统动态的去[代码]PATH[代码]目录中查找[代码]node[代码]来执行你的脚本文件。 命令行执行 [代码]npm link[代码] ,创建软链接至全局,这样我们就可以全局使用[代码]my-cli[代码]命令了,在开发 [代码]npm[代码] 包的前期都会使用[代码]link[代码]方式在其他项目中测试来开发,后期再发布到[代码]npm[代码]上 命令行执行 [代码]my-cli 1 2 3[代码] 输出:[代码][ '/usr/local/bin/node', '/usr/local/bin/my-cli', '1', '2', '3' ][代码] 这样我们就可以获取到用户的输入参数 例如[代码]my-cli create test-project[代码] 我们就可以通过数组第 [2] 位判断命令类型[代码]create[代码],通过第 [3] 位拿到项目名称[代码]test-project[代码] commander [代码]node[代码]的命令行解析最常用的就是[代码]commander[代码]库,来简化复杂[代码]cli[代码]参数操作 (我们现在的参数简单可以不使用[代码]commander[代码],直接用[代码]process.argv[3][代码]获取名称,但是为了之后会复杂的命令行,这里也先使用[代码]commander[代码]) [代码]#!/usr/bin/env node const program = require("commander"); const version = require("./package.json").version; program.version(version, "-v, --version"); program .command("create <app-name>") .description("使用 my-cli 创建一个新的项目") .option("-d --dir <dir>", "创建目录") .action((name, command) => { const create = require("./create/index"); create(name, command); }); program.parse(process.argv); [代码] [代码]commander[代码] 解析完成后会触发[代码]action[代码]回调方法 命令行执行:[代码]my-cli -v[代码] 输出:[代码]1.0.0[代码] 命令行执行: [代码]my-cli create test-project[代码] 输出:[代码]test-project[代码] 创建项目 拿到了用户传入的名称,就可以用这么名字创建项目 我们的代码尽量保持[代码]bin.js[代码]整洁,不将接下来的代码写在[代码]bin.js[代码]里,创建[代码]create[代码]文件夹,创建[代码]index.js[代码]文件 [代码]create/index.js[代码]中: [代码]const path = require("path"); const mkdirp = require("mkdirp"); module.exports = function(name) { mkdirp(path.join(process.cwd(), name), function(err) { if (err) console.error("创建失败"); else console.log("创建成功"); }); }; [代码] [代码]process.cwd()[代码]获取工作区目录,和用户传入项目名称拼接起来 (创建文件夹我们使用[代码]mkdirp[代码]包,可以避免我们一级一级的创建目录) 修改[代码]bin.js[代码]的[代码]action[代码]方法: [代码]// bin.js .action(name => { const create = require("./create") create(name) }); [代码] 命令行执行: [代码]my-cli create test-project[代码] 输出:[代码]创建成功[代码] 并在命令行所在目录创建了一个[代码]test-project[代码]文件夹 模板 首先需要先列出我们的模板包含哪些文件 一个最基础版的[代码]vue[代码]项目模板: [代码]|- src |- main.js |- App.vue |- components |- HelloWorld.vue |- index.html |- package.json [代码] 这些文件就不一一介绍了 我们需要的就是生成这些文件,并写入到目录中去 模板的写法后很多种,下面是我的写法: 模板目录: [代码]|- generator |- index-html.js |- package-json.js |- main.js |- App-vue.js |- HelloWorld-vue.js [代码] [代码]generator/index-html.js[代码] 模板示例: [代码]module.exports = function(name) { const template = ` { "name": "${name}", "version": "1.0.0", "description": "", "main": "index.js", "scripts": {}, "devDependencies": { }, "author": "", "license": "ISC", "dependencies": { "vue": "^2.6.10" } } `; return { template, dir: "", name: "package.json" }; }; [代码] [代码]dir[代码]就是目录,例如[代码]main.js[代码]的[代码]dir[代码]就是[代码]src[代码] [代码]create/index.js[代码]在[代码]mkdirp[代码]中新增: [代码]const path = require("path"); const mkdirp = require("mkdirp"); const fs = require("fs"); module.exports = function(name) { const projectDir = path.join(process.cwd(), name); mkdirp(projectDir, function(err) { if (err) console.error("创建失败"); else { console.log(`创建${name}文件夹成功`); const { template, dir, name: fileName } = require("../generator/package")(name); fs.writeFile(path.join(projectDir, dir, fileName), template.trim(), function(err) { if (err) console.error(`创建${fileName}文件失败`); else { console.log(`创建${fileName}文件成功`); } }); } }); }; [代码] 这里只写了一个模板的创建,我们可以用[代码]readdir[代码]来获取目录下所有文件来遍历执行 下载依赖 我们平常下载[代码]npm[代码]包都是使用命令行 [代码]npm install / yarn install[代码] 这时就需要用到 [代码]node[代码] 的 [代码]child_process.spawn[代码] api 来调用系统命令 因为考虑到跨平台兼容处理,所以使用 cross-spawn 库,来帮我们兼容的操作命令 我们创建[代码]utils[代码]文件夹,创建[代码]install.js[代码] [代码]utils/install.js[代码]: [代码]const spawn = require("cross-spawn"); module.exports = function install(options) { const cwd = options.cwd || process.cwd(); return new Promise((resolve, reject) => { const command = options.isYarn ? "yarn" : "npm"; const args = ["install", "--save", "--save-exact", "--loglevel", "error"]; const child = spawn(command, args, { cwd, stdio: ["pipe", process.stdout, process.stderr] }); child.once("close", code => { if (code !== 0) { reject({ command: `${command} ${args.join(" ")}` }); return; } resolve(); }); child.once("error", reject); }); }; [代码] 然后我们就可以在创建完模板后调用[代码]install[代码]方法下载依赖 [代码]install({ cwd: projectDir }); [代码] 要知道工作区为我们项目的目录 至此,解析 cli,创建目录,创建模板,下载依赖一套流程已经完成 基本功能都跑通之后下面就是要填充剩余代码和优化 优化 当代码写的多了之后,我们看上面[代码]create[代码]方法内的回调嵌套回调会非常难受 [代码]node 7[代码]已经支持[代码]async,await[代码],所以我们将上面代码改成[代码]Promise[代码] 在[代码]utils[代码]目录下创建,[代码]promisify.js[代码]: [代码]module.exports = function promisify(fn) { return function(...args) { return new Promise(function(resolve, reject) { fn(...args, function(err, ...res) { if (err) return reject(err); if (res.length === 1) return resolve(res[0]); resolve(res); }); }); }; }; [代码] 这个方法帮我们把回调形式的[代码]Function[代码]改成[代码]Promise[代码] 在[代码]utils[代码]目录下创建,[代码]fs.js[代码]: [代码]const fs = require(fs); const promisify = require("./promisify"); const mkdirp = require("mkdirp"); exports.writeFile = promisify(fs.writeFile); exports.readdir = promisify(fs.readdir); exports.mkdirp = promisify(mkdirp); [代码] 将[代码]fs[代码]和[代码]mkdirp[代码]方法改造成[代码]promise[代码] 改造后的[代码]create.js[代码]: [代码]const path = require("path"); const fs = require("../utils/fs-promise"); const install = require("../utils/install"); module.exports = async function(name) { const projectDir = path.join(process.cwd(), name); await fs.mkdirp(projectDir); console.log(`创建${name}文件夹成功`); const { template, dir, name: fileName } = require("../generator/package")(name); await fs.writeFile(path.join(projectDir, dir, fileName), template.trim()); console.log(`创建${fileName}文件成功`); install({ cwd: projectDir }); }; [代码] 结语 关于进一步优化: 更多功能与健壮 例如指定目录创建项目,目录不存在等情况 [代码]chalk[代码]和[代码]ora[代码]优化[代码]log[代码],给用户更好的反馈 通过[代码]inquirer[代码]问询用户得到更多的选择:模板[代码]vue-router[代码],[代码]vuex[代码]等更多初始化模板功能,[代码]eslint[代码] 更多的功能: 内置 webpack 配置 一键发布服务器 其实要学会善用第三方库,你会发现我们上面的每个模块都有第三方库的身影,我们只是将这些功能组装起来,再结合我们的想法进一步封装 虽然有[代码]vue-cli[代码],[代码]create-react-app[代码]这些已有的脚手架,但是我们还是可能在某些情况下需要自己实现脚手架部分功能,根据公司的业务来封装,减少重复性工作,或者了解一下内部原理
2019-09-26 - 深入 Parcel--架构与流程篇
[图片] 本篇文章是对 [代码]Parce[代码] 的源码解析,代码基本架构与执行流程,带你了解打包工具的内部原理,在这之前你如果对 [代码]parcel[代码] 不熟悉可以先到 Parcel官网 了解 介绍 下面是偷懒从官网抄下来的介绍: 极速零配置Web应用打包工具 极速打包 [代码]Parcel[代码] 使用 [代码]worker[代码] 进程去启用多核编译。同时有文件系统缓存,即使在重启构建后也能快速再编译。 将你所有的资源打包 Parcel 具备开箱即用的对 [代码]JS[代码], [代码]CSS[代码], [代码]HTML[代码], 文件 及更多的支持,而且不需要插件。 自动转换 如若有需要,[代码]Babel[代码], [代码]PostCSS[代码], 和 [代码]PostHTML[代码] 甚至 [代码]node_modules[代码] 包会被用于自动转换代码. 零配置代码分拆 使用动态 [代码]import()[代码] 语法, [代码]Parcel[代码] 将你的输出文件束([代码]bundles[代码])分拆,因此你只需要在初次加载时加载你所需要的代码。 热模块替换 [代码]Parcel[代码] 无需配置,在开发环境的时候会自动在浏览器内随着你的代码更改而去更新模块。 友好的错误日志 当遇到错误时,Parcel 会输出 语法高亮的代码片段,帮助你定位问题。 打包工具 时间 browserify 22.98s webpack 20.71s parcel 9.98s parcel - with cache 2.64s 打包工具 我们常用的打包工具大致功能: 模块化(代码的拆分, 合并, [代码]Tree-Shaking[代码] 等) 编译([代码]es6,7,8 sass typescript[代码] 等) 压缩 ([代码]js, css, html[代码]包括图片的压缩) HMR (热替换) version [代码]parcel-bundler[代码] 版本: “version”: “1.11.0” 文件架构 [代码]|-- assets 资源目录 继承自 Asset.js |-- builtins 用于最终构建 |-- packagers 打包 |-- scope-hoisting 作用域提升 Tree-Shake |-- transforms 转换代码为 AST |-- utils 工具 |-- visitors 遍历 js AST树 收集依赖等 |-- Asset.js 资源 |-- Bundle.js 用于构建 bundle 树 |-- Bundler.js 主目录 |-- FSCache.js 缓存 |-- HMRServer.js HMR服务器提供 WebSocket |-- Parser.js 根据文件扩展名获取对应 Asset |-- Pipeline.js 多线程执行方法 |-- Resolver.js 解析模块路径 |-- Server.js 静态资源服务器 |-- SourceMap.js SourceMap |-- cli.js cli入口 解析命令行参数 |-- worker.js 多线程入口 [代码] 流程 说明 [代码]Parcel[代码]是面向资源的,[代码]JavaScript,CSS,HTML[代码] 这些都是资源,并不是 [代码]webpack[代码] 中 [代码]js[代码] 是一等公民,[代码]Parcel[代码] 会自动的从入口文件开始分析这些文件 和 模块中的依赖,然后构建一个 [代码]bundle[代码] 树,并对其进行打包输出到指定目录 一个简单的例子 我们从一个简单的例子开始了解 [代码]parcel[代码] 内部源码与流程 [代码]index.html |-- index.js |-- module1.js |-- module2.js [代码] 上面是我们例子的结构,入口为 [代码]index.html[代码], 在 [代码]index.html[代码] 中我们用 [代码]script[代码] 标签引用了 [代码]src/index.js[代码],在 [代码]index.js[代码] 中我们引入了2个子模块 执行 [代码]npx parcel index.html[代码] 或者 [代码]./node_modules/.bin/parcel index.html[代码],或者使用 [代码]npm script[代码] cli [代码]"bin": { "parcel": "bin/cli.js" } [代码] 查看 [代码]parcel-bundler[代码]的 [代码]package.json[代码] 找到 [代码]bin/cli.js[代码],在[代码]cli.js[代码]里又指向 [代码]../src/cli[代码] [代码]const program = require('commander'); program .command('serve [input...]') // watch build ... .action(bundle); program.parse(process.argv); async function bundle(main, command) { const Bundler = require('./Bundler'); const bundler = new Bundler(main, command); if (command.name() === 'serve' && command.target === 'browser') { const server = await bundler.serve(); if (server && command.open) {...启动自动打开浏览器} } else { bundler.bundle(); } } [代码] 在 [代码]cli.js[代码] 中利用 [代码]commander[代码] 解析命令行并调用 [代码]bundle[代码] 方法 有 [代码]serve, watch, build[代码] 3个命令来调用 [代码]bundle[代码] 函数,执行 [代码]pracel index.html[代码] 默认为 [代码]serve[代码],所以调用的是 [代码]bundler.serve[代码] 方法 进入 [代码]Bundler.js[代码] bundler.serve [代码]async serve(port = 1234, https = false, host) { this.server = await Server.serve(this, port, host, https); try { await this.bundle(); } catch (e) {} return this.server; } [代码] [代码]bundler.serve[代码] 方法 调用 [代码]serveStatic[代码] 起了一个静态服务指向 最终打包的文件夹 下面就是重要的 [代码]bundle[代码] 方法 bundler.bundle [代码]async bundle() { // 加载插件 设置env 启动多线程 watcher hmr await this.start(); if (isInitialBundle) { // 创建 输出目录 await fs.mkdirp(this.options.outDir); this.entryAssets = new Set(); for (let entry of this.entryFiles) { let asset = await this.resolveAsset(entry); this.buildQueue.add(asset); this.entryAssets.add(asset); } } // 打包队列中的资源 let loadedAssets = await this.buildQueue.run(); // findOrphanAssets 获取所有资源中独立的没有父Bundle的资源 let changedAssets = [...this.findOrphanAssets(), ...loadedAssets]; // 因为接下来要构建 Bundle 树,先对上一次的 Bundle树 进行 clear 操作 for (let asset of this.loadedAssets.values()) { asset.invalidateBundle(); } // 构建 Bundle 树 this.mainBundle = new Bundle(); for (let asset of this.entryAssets) { this.createBundleTree(asset, this.mainBundle); } // 获取新的最终打包文件的url this.bundleNameMap = this.mainBundle.getBundleNameMap( this.options.contentHash ); // 将代码中的旧文件url替换为新的 for (let asset of changedAssets) { asset.replaceBundleNames(this.bundleNameMap); } // 将改变的资源通过websocket发送到浏览器 if (this.hmr && !isInitialBundle) { this.hmr.emitUpdate(changedAssets); } // 对资源打包 this.bundleHashes = await this.mainBundle.package( this, this.bundleHashes ); // 将独立的资源删除 this.unloadOrphanedAssets(); return this.mainBundle; } [代码] 我们一步步先从 [代码]this.start[代码] 看 start [代码]if (this.farm) { return; } await this.loadPlugins(); if (!this.options.env) { await loadEnv(Path.join(this.options.rootDir, 'index')); this.options.env = process.env; } if (this.options.watch) { this.watcher = new Watcher(); this.watcher.on('change', this.onChange.bind(this)); } if (this.options.hmr) { this.hmr = new HMRServer(); this.options.hmrPort = await this.hmr.start(this.options); } this.farm = await WorkerFarm.getShared(this.options, { workerPath: require.resolve('./worker.js') }); [代码] [代码]start[代码]: [代码]开头的判断[代码] 防止多次执行,也就是说 [代码]this.start[代码] 只会执行一次 [代码]loadPlugins[代码] 加载插件,找到 [代码]package.json[代码] 文件 [代码]dependencies, devDependencies[代码] 中 [代码]parcel-plugin-[代码]开头的插件进行调用 [代码]loadEnv[代码] 加载环境变量,利用 [代码]dotenv, dotenv-expand[代码] 包将 [代码]env.development.local, .env.development, .env.local, .env[代码] 扩展至 [代码]process.env[代码] [代码]watch[代码] 初始化监听文件并绑定 [代码]change[代码] 回调函数,内部 [代码]child_process.fork[代码] 起一个子进程,使用 [代码]chokidar[代码] 包来监听文件改变 [代码]hmr[代码] 起一个服务,[代码]WebSocket[代码] 向浏览器发送更改的资源 [代码]farm[代码] 初始化多进程并指定 [代码]werker[代码] 工作文件,开启多个 [代码]child_process[代码] 去解析编译资源 接下来回到 [代码]bundle[代码],[代码]isInitialBundle[代码] 是一个判断是否是第一次构建 [代码]fs.mkdirp[代码] 创建输出文件夹 遍历入口文件,通过 [代码]resolveAsset[代码],内部调用 [代码]resolver[代码] 解析路径,并 [代码]getAsset[代码] 获取到对应的 [代码]asset[代码](这里我们入口是 [代码]index.html[代码],根据扩展名获取到的是 [代码]HTMLAsset[代码]) 将 [代码]asset[代码] 添加进队列 然后启动 [代码]this.buildQueue.run()[代码] 对资源从入口递归开始打包 PromiseQueue 这里 [代码]buildQueue[代码] 是一个 [代码]PromiseQueue[代码] 异步队列 [代码]PromiseQueue[代码] 在初始化的时候传入一个回调函数 [代码]callback[代码],内部维护一个参数队列 [代码]queue[代码],[代码]add[代码] 往队列里 [代码]push[代码] 一个参数,[代码]run[代码] 的时候[代码]while[代码]遍历队列 [代码]callback(...queue.shift())[代码],队列全部执行完毕 [代码]Promise[代码] 置为完成([代码]resolved[代码])(可以将其理解为 [代码]Promise.all[代码]) 这里定义的回调函数是 [代码]processAsset[代码],参数就是入口文件 [代码]index.html[代码] 的 [代码]HTMLAsset[代码] [代码]async processAsset(asset, isRebuild) { if (isRebuild) { asset.invalidate(); if (this.cache) { this.cache.invalidate(asset.name); } } await this.loadAsset(asset); } [代码] [代码]processAsset[代码] 函数内先判断是否是 [代码]Rebuild[代码] ,是第一次构建,还是 [代码]watch[代码] 监听文件改变进行的重建,如果是重建则对资源的属性[代码]重置[代码],并使其缓存失效 之后调用 [代码]loadAsset[代码] 加载资源编译资源 loadAsset [代码]async loadAsset(asset) { if (asset.processed) { return; } // Mark the asset processed so we don't load it twice asset.processed = true; // 先尝试读缓存,缓存没有在后台加载和编译 asset.startTime = Date.now(); let processed = this.cache && (await this.cache.read(asset.name)); let cacheMiss = false; if (!processed || asset.shouldInvalidate(processed.cacheData)) { processed = await this.farm.run(asset.name); cacheMiss = true; } asset.endTime = Date.now(); asset.buildTime = asset.endTime - asset.startTime; asset.id = processed.id; asset.generated = processed.generated; asset.hash = processed.hash; asset.cacheData = processed.cacheData; // 解析和加载当前资源的依赖项 let assetDeps = await Promise.all( dependencies.map(async dep => { dep.parent = asset.name; let assetDep = await this.resolveDep(asset, dep); if (assetDep) { await this.loadAsset(assetDep); } return assetDep; }) ); if (this.cache && cacheMiss) { this.cache.write(asset.name, processed); } } [代码] [代码]loadAsset[代码] 在开始有个判断防止重复编译 之后去读缓存,读取失败就调用 [代码]this.farm.run[代码] 在多进程里编译资源 编译完就去加载并编译依赖的文件 最后如果是新的资源没有用到缓存,就重新设置一下缓存 下面说一下这里吗涉及的两个东西:缓存 [代码]FSCache[代码] 和 多进程 [代码]WorkerFarm[代码] FSCache [代码]read[代码] 读取缓存,并判断最后修改时间和缓存的修改时间 [代码]write[代码] 写入缓存 [图片] 缓存目录为了加速读取,避免将所有的缓存文件放在一个文件夹里,[代码]parcel[代码] 将 [代码]16进制[代码] 两位数的 [代码]256[代码] 种可能创建为文件夹,这样存取缓存文件的时候,将目标文件路径 [代码]md5[代码] 加密转换为 [代码]16进制[代码],然后截取前两位是目录,后面几位是文件名 WorkerFarm 在上面 [代码]start[代码] 里初始化 [代码]farm[代码] 的时候,[代码]workerPath[代码] 指向了 [代码]worker.js[代码] 文件,[代码]worker.js[代码] 里有两个函数,[代码]init[代码] 和 [代码]run[代码] [代码]WorkerFarm.getShared[代码] 初始化的时候会创建一个 [代码]new WorkerFarm[代码] ,调用 [代码]worker.js[代码] 的 [代码]init[代码] 方法,根据 [代码]cpu[代码] 获取最大的 [代码]Worker[代码] 数,并启动一半的子进程 [代码]farm.run[代码] 会通知子进程执行 [代码]worker.js[代码] 的 [代码]run[代码] 方法,如果进程数没有达到最大会再次开启一个新的子进程,子进程执行完毕后将 [代码]Promise[代码]状态更改为完成 [代码]worker.run -> pipeline.process -> pipeline.processAsset -> asset.process[代码] [代码]Asset.process[代码] 处理资源: [代码]async process() { if (!this.generated) { await this.loadIfNeeded(); await this.pretransform(); await this.getDependencies(); await this.transform(); this.generated = await this.generate(); } return this.generated; } [代码] 将上面的代码内部扩展一下: [代码]async process() { // 已经有就不需要编译 if (!this.generated) { // 加载代码 if (this.contents == null) { this.contents = await this.load(); } // 可选。在收集依赖之前转换。 await this.pretransform(); // 将代码解析为 AST 树 if (!this.ast) { this.ast = await this.parse(this.contents); } // 收集依赖 await this.collectDependencies(); // 可选。在收集依赖之后转换。 await this.transform(); // 生成代码 this.generated = await this.generate(); } return this.generated; } // 最后处理代码 async postProcess(generated) { return generated } [代码] [代码]processAsset[代码] 中调用 [代码]asset.process[代码] 生成 [代码]generated[代码] 这个[代码]generated[代码] 不一定是最终代码 ,像 [代码]html[代码]里内联的 [代码]script[代码] ,[代码]vue[代码] 的 [代码]html, js, css[代码],都会进行二次或多次递归处理,最终调用 [代码]asset.postProcess[代码] 生成代码 Asset 下面说几个实现 [代码]HTMLAsset[代码]: pretransform 调用 [代码]posthtml[代码] 将 [代码]html[代码] 解析为 [代码]PostHTMLTree[代码](如果没有设置[代码]posthtmlrc[代码]之类的不会走) parse 调用 [代码]posthtml-parser[代码] 将 [代码]html[代码] 解析为 [代码]PostHTMLTree[代码] collectDependencies 用 [代码]walk[代码] 遍历 [代码]ast[代码],找到 [代码]script, img[代码] 的 [代码]src[代码],[代码]link[代码] 的 [代码]href[代码] 等的地址,将其加入到依赖 transform [代码]htmlnano[代码] 压缩代码 generate 处理内联的 [代码]script[代码] 和 [代码]css[代码] postProcess [代码]posthtml-render[代码] 生成 [代码]html[代码] 代码 [代码]JSAsset[代码]: pretransform 调用 [代码]@babel/core[代码] 将 [代码]js[代码] 解析为 [代码]AST[代码],处理 [代码]process.env[代码] parse 调用 [代码]@babel/parser[代码] 将 [代码]js[代码] 解析为 [代码]AST[代码] collectDependencies 用 [代码]babylon-walk[代码] 遍历 [代码]ast[代码], 如 [代码]ImportDeclaration[代码],[代码]import xx from 'xx'[代码] 语法,[代码]CallExpression[代码] 找到 [代码]require[代码]调用,[代码]import[代码] 被标记为 [代码]dynamic[代码] 动态导入,将这些模块加入到依赖 transform 处理 [代码]readFileSync[代码],[代码]__dirname, __filename, global[代码]等,如果没有设置[代码]scopeHoist[代码] 并存在 [代码]es6 module[代码] 就将代码转换为 [代码]commonjs[代码],[代码]terser[代码] 压缩代码 generate [代码]@babel/generator[代码] 获取 [代码]js[代码] 与 [代码]sourceMap[代码] 代码 [代码]VueAsset[代码]: parse [代码]@vue/component-compiler-utils[代码] 与 [代码]vue-template-compiler[代码] 对 [代码].vue[代码] 文件进行解析 generate 对 [代码]html, js, css[代码] 处理,就像上面说到会对其分别调用 [代码]processAsset[代码] 进行二次解析 postProcess [代码]component-compiler-utils[代码] 的 [代码]compileTemplate, compileStyle[代码]处理 [代码]html,css[代码],[代码]vue-hot-reload-api[代码] HMR处理,压缩代码 回到 [代码]bundle[代码] 方法: [代码]let loadedAssets = await this.buildQueue.run()[代码] 就是上面说到的[代码]PromiseQueue[代码] 和 [代码]WorkerFarm[代码] 结合起来:[代码]buildQueue.run —> processAsset -> loadAsset -> farm.run -> worker.run -> pipeline.process -> pipeline.processAsset -> asset.process[代码],执行之后所有资源编译完毕,并返回入口资源[代码]loadedAssets[代码]就是 [代码]index.html[代码] 对应的 [代码]HTMLAsset[代码] 资源 之后是 [代码]let changedAssets = [...this.findOrphanAssets(), ...loadedAssets][代码] 获取到改变的资源 [代码]findOrphanAssets[代码] 是从所有资源中查找没有 [代码]parentBundle[代码] 的资源,也就是独立的资源,这个 [代码]parentBundle[代码] 会在等会的构建 [代码]Bundle[代码] 树中被赋值,第一次构建都没有 [代码]parentBundle[代码],所以这里会重复入口文件,这里的 [代码]findOrphanAssets[代码] 的作用是在第一次构建之后,文件[代码]change[代码]的时候,在这个文件 [代码]import[代码]了新的一个文件,因为新文件没有被构建过 [代码]Bundle[代码] 树,所以没有 [代码]parentBundle[代码],这个新文件也被标记物 [代码]change[代码] [代码]invalidateBundle[代码] 因为接下来要构建新的树所以调用重置所有资源上一次树的属性 [代码]createBundleTree[代码] 构建 [代码]Bundle[代码] 树: 首先一个入口资源会被创建成一个 bundle,然后动态的 import() 会被创建成子 bundle ,这引发了代码的拆分。 当不同类型的文件资源被引入,兄弟 bundle 就会被创建。例如你在 JavaScript 中引入了 CSS 文件,那它会被放置在一个与 JavaScript 文件对应的兄弟 bundle 中。 如果资源被多于一个 bundle 引用,它会被提升到 bundle 树中最近的公共祖先中,这样该资源就不会被多次打包。 [代码]Bundle[代码]: [代码]type[代码]:它包含的资源类型 (例如:js, css, map, …) [代码]name[代码]:bundle 的名称 (使用 entryAsset 的 Asset.generateBundleName() 生成) [代码]parentBundle[代码]:父 bundle ,入口 bundle 的父 bundle 是 null [代码]entryAsset[代码]:bundle 的入口,用于生成名称(name)和聚拢资源(assets) [代码]assets[代码]:bundle 中所有资源的集合(Set) [代码]childBundles[代码]:所有子 bundle 的集合(Set) [代码]siblingBundles[代码]:所有兄弟 bundle 的集合(Set) [代码]siblingBundlesMap[代码]:所有兄弟 bundle 的映射 Map<String(Type: js, css, map, …), Bundle> [代码]offsets[代码]:所有 bundle 中资源位置的映射 Map<Asset, number(line number inside the bundle)> ,用于生成准确的 sourcemap 。 我们的例子会被构建成: [代码]html ( index.html ) |-- js ( index.js, module1.js, module2.js ) |-- map ( index.js, module1.js, module2.js ) [代码] [代码]module1.js[代码] 和 [代码]module2.js[代码] 被提到了与 [代码]index.js[代码] 同级,[代码]map[代码] 因为类型不同被放到了 子[代码]bundle[代码] 一个复杂点的树: [代码]// 资源树 index.html |-- index.css |-- bg.png |-- index.js |-- module.js [代码] [代码]// mainBundle html ( index.html ) |-- js ( index.js, module.js ) |-- map ( index.map, module.map ) |-- css ( index.css ) |-- js ( index.css, css-loader.js bundle-url.js ) |-- map ( css-loader.js, bundle-url.js ) |-- png ( bg.png ) [代码] 因为要对 css 热更新,所以新增了 [代码]css-loader.js, bundle-url.js[代码] 两个 js [代码]replaceBundleNames[代码]替换引用:生成树之后将代码中的文件引用替换为最终打包的文件名,如果是生产环境会替换为 [代码]contentHash[代码] 根据内容生成 [代码]hash[代码] [代码]hmr[代码]更新: 判断启用 [代码]hmr[代码] 并且不是第一次构建的情况,调用 [代码]hmr.emitUpdate[代码] 将改变的资源发送给浏览器 [代码]Bundle.package[代码] 打包 [代码]unloadOrphanedAssets[代码] 将独立的资源删除 package [代码]package[代码] 将[代码]generated[代码] 写入到文件 有6种打包: [代码]CSSPackager[代码],[代码]HTMLPackager[代码],[代码]SourceMapPackager[代码],[代码]JSPackager[代码],[代码]JSConcatPackager[代码],[代码]RawPackager[代码] 当开启 [代码]scopeHoist[代码] 时用 [代码]JSConcatPackager[代码] 否则 [代码]JSPackager[代码] 图片等资源用 [代码]RawPackager[代码] 最终我们的例子被打包成 [代码]index.html, src.[hash].js, src.[hash].map[代码] 3个文件 [代码]index.html[代码] 里的 [代码]js[代码] 路径被替换成立最终打包的地址 我们看一下打包的 js: [代码]parcelRequire = (function (modules, cache, entry, globalName) { // Save the require from previous bundle to this closure if any var previousRequire = typeof parcelRequire === 'function' && parcelRequire; var nodeRequire = typeof require === 'function' && require; function newRequire(name, jumped) { if (!cache[name]) { localRequire.resolve = resolve; localRequire.cache = {}; var module = cache[name] = new newRequire.Module(name); modules[name][0].call(module.exports, localRequire, module, module.exports, this); } return cache[name].exports; function localRequire(x){ return newRequire(localRequire.resolve(x)); } function resolve(x){ return modules[name][4][x] || x; } } for (var i = 0; i < entry.length; i++) { newRequire(entry[i]); } // Override the current require with this new one return newRequire; })({"src/module1.js":[function(require,module,exports) { "use strict"; },{}],"src/module2.js":[function(require,module,exports) { "use strict"; },{}],"src/index.js":[function(require,module,exports) { "use strict"; var _module = require("./module"); var _module2 = require("./module1"); var _module3 = require("./module2"); console.log(_module.m); },{"./module":"src/module.js","./module1":"src/module1.js","./module2":"src/module2.js","fs":"node_modules/parcel-bundler/src/builtins/_empty.js"}] ,{}]},{},["node_modules/parcel-bundler/src/builtins/hmr-runtime.js","src/index.js"], null) //# sourceMappingURL=/src.a2b27638.map [代码] 可以看到代码被拼接成了对象的形式,接收参数 [代码]module, require[代码] 用来模块导入导出,实现了 [代码]commonjs[代码] 的模块加载机制,一个更加简化版: [代码]parcelRequire = (function (modules, cache, entry, globalName) { function newRequire(id){ if(!cache[id]){ let module = cache[id] = { exports: {} } modules[id][0].call(module.exports, newRequire, module, module.exports, this); } return cache[id] } for (var i = 0; i < entry.length; i++) { newRequire(entry[i]); } return newRequire; })() [代码] 代码被拼接起来: [代码]`(function(modules){ //...newRequire })({` + asset.id + ':[function(require,module,exports) {\n' + asset.generated.js + '\n},' + '})' [代码] [代码](function(modules){ //...newRequire })({ "src/index.js":[function(require,module,exports){ // code }] }) [代码] hmr-runtime 上面打包的 [代码]js[代码] 中还有个 [代码]hmr-runtime.js[代码] 太长被我省略了 [代码]hmr-runtime.js[代码] 创建一个 [代码]WebSocket[代码] 监听服务端消息 修改文件触发 [代码]onChange[代码] 方法,[代码]onChange[代码] 将改变的资源 [代码]buildQueue.add[代码] 加入构建队列,重新调用 [代码]bundle[代码] 方法,打包资源,并调用 [代码]emitUpdate[代码] 通知浏览器更新 当浏览器接收到服务端有新资源更新消息时 新的资源就会设置或覆盖之前的模块 [代码]modules[asset.id] = new Function('require', 'module', 'exports', asset.generated.js)[代码] 对模块进行更新: [代码]function hmrAccept(id){ // dispose 回调 cached.hot._disposeCallbacks.forEach(function (cb) { cb(bundle.hotData); }); delete bundle.cache[id]; // 删除之前缓存 newRequire(id); // 重新此加载 // accept 回调 cached.hot._acceptCallbacks.forEach(function (cb) { cb(); }); // 递归父模块 进行更新 getParents(global.parcelRequire, id).some(function (id) { return hmrAccept(global.parcelRequire, id); }); } [代码] 至此整个打包流程结束 总结 [代码]parcle index.html[代码] 进入 [代码]cli[代码],启动[代码]Server[代码]调用 [代码]bundle[代码],初始化配置([代码]Plugins[代码], [代码]env[代码], [代码]HMRServer, Watcher, WorkerFarm[代码]),从入口资源开始,递归编译([代码]babel, posthtml, postcss, vue-template-compiler[代码]等),编译完设置缓存,构建 [代码]Bundle[代码] 树,进行打包 如果没有 [代码]watch[代码] 监听,结束关闭 [代码]Watcher, Worker, HMR[代码] 有 [代码]watch[代码] 监听: 文件修改,触发 [代码]onChange[代码],将修改的资源加入构建队列,递归编译,查找缓存(这一步缓存的作用就提醒出来了),编译完设置新缓存,构建 [代码]Bundle[代码] 树,进行打包,将 [代码]change[代码] 的资源发送给浏览器,浏览器接收 [代码]hmr[代码] 更新资源 最后 通过此文章希望你对 [代码]parcel[代码] 的大致流程,打包工具原理有更深的了解 了解更多请关注专栏,后续 深入Parcel 同系列文章,对 [代码]Asset[代码],[代码]Packager[代码],[代码]Worker[代码],[代码]HMR[代码],[代码]scopeHoist[代码],[代码]FSCache[代码],[代码]SourceMap[代码],[代码]import[代码] 更加 详细讲解与代码实现
2019-07-24