11

Snowpack全攻略:从入门体验到源码解析

 3 years ago
source link: https://zhuanlan.zhihu.com/p/256482986
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Snowpack全攻略:从入门体验到源码解析

广发证券 技术工程师
文章目录
 ## 为何拥抱snowpack
 ## snowpack并不拒绝webpack
 ## snowpack面临的问题 && 解决的办法
 ## snowpack上手体验
 ## snowpack源码解析
   # snowpack dev的流程逻辑
      - snowpack底层模块梳理
      - HTTP请求响应过程
      - WebSocket事件交互过程
 ## 代码结构梳理
 ## 代码细节讲解
   - createServer
   - buildFile
   - finalizeResponse
   - wrapResponse
   - resolveResponseImports
   - sendFile

## 为何拥抱snowpack

前端的新轮子又又又又又又又又又又又出现了,这一次是出现在打包工具的领域 —— snowpack,与之相伴的是一种新的前端开发风格 —— bundleless

通常我们可能会用webpack一类的工具完成开发和生产的功能,而webpack会把打包过程引入到我们的开发当中。而bundleless则可理解为"去打包"的开发风格。

因此,是否在开发中进行集中打包就构成了snowpack和webpack的核心区别。

先来简单看下snowpack和webpack的性能对比吧:

现在回过头来看,webpack也已经出来好些年了

webpack之所以在诞生之初采用集中打包方式进行开发,有几个方面的原因

  • 一是浏览器的兼容性还不够良好,还没提供对ES6的足够支持(import|export),需要把每个JS文件打包成单一bundle中的闭包的方式实现模块化
  • 二是为了合并请求,减少HTTP/1.1下过多并发请求带来的性能问题

而发展到今天,过去的这些问题已经得到了很大的缓解,因为

  • 主流现代浏览器已经能充分支持ES6了,import和export随心使用
  • HTTP2.0普及后并发请求的性能问题没有那么突出了

webpack做的是加法,而snowpack做的却是减法。

snowpack把开发中拖慢速度的一些工作给去掉,从而获得更快的开发速度。

于是我们思考,在不久的未来, bundleless可能会成为一个前端开发工具的趋势和方向。

目前bundleless的开发工具主要有两个: vite snowpack

  • vite: 尤雨溪开发的bundleless工具,能很好的配合Vue框架的开发,github上star为11k
  • snowpack: 另一个bundleless工具,目前框架生态更广泛一些,支持React/Vue/Svelte,github上star为11.9k

而snowpack就是我们今天主要介绍的bundleless工具。

## snowpack并不拒绝webpack

乍一看,大家可能会觉得snowpack和webpack是两种没有交集的自动化工具,实则不然。

实际上: snowpack并不拒绝webpack,bundleless工具也不拒绝bundle工具

  • snowpack可以通过plugin的方式接入webpack并用于生产,如 @snowpack/plugin-webpack
  • vite则使用rollup这一bundle工具作为内置的生产打包工具

因此,bundleless只是一种开发层面的构建风格,真正生产的时候,我们还是暂时恢复到"打包"的优良传统。即:

  • snowpack等bundleless工具用于开发
  • webpack等bundle工具用于生产

甚至在未来,webpack可能也会吸收采纳snowpack的开发方式,所以这两个工具并不是对立的,而是互利的

## snowpack的开发依据: 浏览器对ESM的支持

ESM(JavaScript module)在现代浏览器中已经得到成熟支持,而这给我们去掉影响开发性能的打包提供了理论依据。

我们日常习惯使用的import/export语法,其实就算不打包编译为ES5也是可以在chrome浏览器上运行的。而且对于chrome/edge浏览器来说,对于import的各种用法都已实现了全面支持,包括:

  • import关键字:通过import | export关键字实现模块的导入导出
  • 动态import: 即import(module)方法调用
  • workers中的import:Chrome80版本及以上已经支持 (非importScripts方法)

如果我们想要在现代浏览器中使用import方法,还需要对定义脚本的地方做些修改

在html中,需要给script标签添加 type="module"这一属性

<script type="module" src="./a.js"></script>               

而在webworker中,需要在可选属性中设置type: module

const worker = new Worker('worker.js', {   type: 'module' });               

这样的话,就相当于告诉浏览器这不是一个普通的脚本,而是一个“模块”脚本,这样你就可以放心的使用import和export了。

下面我们简单地示范一下:

编写三个文件: index.html, a.js 和 b.js,如下所示

 // index.html
...
<body>
 <script type="module" src="./a.js"></script>
 <script type="module" src="./b.js"></script>
</body> 

// a.js
export const a = 1;

// b.js
import { a } from "./a.js";
console.log("a:", a);           

使用静态服务器工具live server启动项目并在浏览器访问index.html

必须启动http服务访问否则module脚本会遇到跨域问题哦!

可看到控制台输出如下,说明import和export是得到当前浏览器支持的。

## snowpack面临的问题 && 解决的办法

既然现在浏览器已经拥抱ESM了,那么是否就完全符合我们的开发需求了呢?

当然不是的,其中很重要的原因是—— 现在import JS浏览器是支持的,但是import CSS浏览器却并不支持。

例如下面有以下代码

// a.js
import "./a.css";
// a.css
body { background: red; }        

将a.js作为module script引入html之后,会报以下错误: 告诉我们并不支持在JS脚本中import一个css文件

这就难免让人感到沮丧,在JS中导入css资源是一个非常常见的开发需求,但浏览器并不支持。

那么,难道snowpack要因此放弃css的bundless并回退到集中打包吗?

snowpack并没有这样做。相反,它会将 app.css 文件转换为app.css.proxy.js文件,并且通过动态创建style元素插入html的方式替代css的直接导入

当然了,这个过程是开发无感知的,我们仍然还是像平时那样新建css文件, 并通过import/export的方式导入css

## snowpack上手体验

1.安装snowpack

npm install --save-dev snowpack               

2.通过snowpack指定模版创建app

 npx create-snowpack-app new-dir --template [SELECT FROM BELOW] [--use-yarn]               

有许多模板可供挑选,并且未来还会进一步丰富,如

  • @snowpack/app-template-react
  • @snowpack/app-template-react-typescript
  • @snowpack/app-template-preact
  • @snowpack/app-template-svelte

例如我们创建一个react-typescript项目

npx create-snowpack-app new-dir --@snowpack/app-template-react-typescript               

3.进入开发模式

snowpack dev

可看到打包时间在10几毫秒内完成

因为考虑到官方文档说的很详细了,这里就算铺陈以冗余叙述也无益于读者,所以实战内容到此为止,大家具体可参考官方文档,地址如下

https://www.snowpack.dev/        

关于入门介绍和实践的部分说完了,读累了的观众可以到此为止了。

不过如果你对snowpack的源码感兴趣的话,那么请看 — —

## snowpack源码解析

本文只讲解和snowpack dev有关的源代码

# snowpack底层模块梳理

snowpack使用了许多丰富的模块帮助实现它的功能,经整理主要分为以下三类,提前了解它们的功能有助于对源码的了解。

1.前端解析/打包框架

  • rollUp: 模块打包器,但snowpack只用这个打包器处理config.webdependencies下定义的依赖,而不用来处理项目代码
  • esbuild: 前端打包工具,在性能优化插件@snowpack/plugin-optimize中使用,用来加快速度
  • es-module-lexer: 用来解析资源 AST 拿到 import 的内容

2.Node原生模块

  • http
  • http2
  • https

3.NPM社区模块(node.js)

  • ws: 提供websocket连接服务
  • chokidar 提供文件监控系统服务的模块
  • http-proxy 提供代理服务的模块
  • cacache 持久化缓存模块
  • eTag 实体标识验证模块
  • events: 自定义事件监听响应模块

## snowpack dev的流程逻辑

运行dev命令启动snowpack之后,背后同时存在两个交互过程,并且这两个过程是相互联系的。

  • 一个是浏览器和http服务器的请求-响应过程,这个过程完成了文件资源的加载
  • 二个是WebSocket交互过程:这个交互过程是用来实现HMR的,也即是Hot Module Reload(模块热重载)

#HTTP请求响应过程

前端请求  -- > 后端响应

逻辑流程如下

  1. snowpack根据config创建http服务器并监听给定端口
  2. 开发者打开浏览器页面,于是浏览器向snowpack服务器发起http请求
  3. snowpack server根据请求url获取请求对应的 js|css|html|asset文件地址
  4. snowpack通过fs.readFile读取文件内容,得到代码字符串
  5. 接下来,如果用户配置了config.plugins,则遍历pugins对象,通过plugin对象中定义的load方法和transform方法对代码进行加载和转换
  6. 然后根据文件后缀的不同(js|css|html),在当前的代码的外层包裹一些固定的代码字符串(如给html添加HMR的script标签)
  7. 再通过es-module-lexer解析器解析import,替换import地址。并将当前文件的import依赖保存起来,以便在后面的HMR中使用
  8. 最后把得到的代码字符串作为响应body返回给浏览器

#WebSocket事件交互过程

后端触发   — > 前端监听
前端触发 < -   后端监听
  1. 前端: snowpack会在index.html中插入HMR脚本,HMR脚本会在前端创建WebSocket对象,并实时监听服务端触发的update事件和reload事件
  2. 服务端: snowpack通过监听httpServer的upgrade事件,从http协议升级到WebSocket协议
  3. 前后端交互:snowpack通过chokidar模块监听项目文件的修改,根据依赖关系遍历受影响的文件,分别触发update事件到前端,让前端调用import(module)方法更新对应的文件。当所有update操作都执行结束后,服务端触发reload事件到前端,让前端通过调用location.reload()方法刷新页面,于是我们就体验到了热重载的功能。

## WebSocket事件交互过程

  • websocket协议本身是从http协议升级过来的,所以需要先创建http.Server对象才能创建ws.WebSocket对象
  • websocket的热重载要结合http请求响应才能完成,即通过websocket触发update事件到前端后,要通过import(module)重新发送请求并获取响应,才能实现HMR

## 代码结构梳理

希望上面的介绍能让大家对snowpack的流程逻辑有基本的认识,但如果只是抽象描述可能会让人感觉有些云里雾里

所以下面我整理出了dev的核心部分的代码框架, 先把握下代码的整体结构,然后再从每一部分阐述细节。

在snowpack项目源代码中,dev命令的主要逻辑包含在 src/commands/dev.ts这个文件中的command方法里。

正如我们上面所说,这里的command方法主要包含的逻辑大体可分为两个层面:

  • HTTP请求和响应处理(基于http等Node原生模块实现)
  • Hot Module Reload(基于chokidar监控系统和websocket实现)

下面我们就来展示一下经过简化后的代码结构

备注:简化了错误处理和非空判断等代码,保留基本逻辑,同时方法名和参数名都没有更改

command方法的第一部分:http请求和响应处理

// src/commands/dev
export async function command(commandOptions: CommandOptions) {
    /* 定义请求处理函数 */ 
    async function requestHandler(req: http.IncomingMessage, res: http.ServerResponse) { 
         // 发送响应给浏览器
         function sendFile  (req: http.IncomingMessage,res: http.ServerResponse,body: string | Buffer, fileLoc: string) => { 
            // case1:处理缓存验证 ... 
            // case2:处理gzip压缩 ... 
            // case3:处理范围请求  ...  
            // default:
            res.writeHead(200, headers); 
            res.write(body);
            res.end();
         }
         async function getFileFromUrl(reqPath: string): Promise<string | null> { 
           // 根据请求url获取文件地址信息
         }
         async function buildFile(fileLoc: string): Promise<SnowpackBuildMap> { 
           // 获取config.plugins中定义的插件对象
           // 并调用其中的load方法和transform方法
           // 对fileLoc对应的文件执行加载和转换。
         }
         async function wrapResponse(code: string | Buffer, ...) {  
           // 在当前code的外层包裹一些固定的代码字符串,然后返回包裹后的代码字符串
           // 根据文件后缀的不同(js|css|html),包裹的代码字符串也是不同的
           // (如给html添加HMR的script标签)
         }
         async function resolveResponseImports(fileLoc: string, responseExt: string,wrappedResponse: string): Promise<string> {   
            // 对每个文件的import语句做解析处理并返回解析后的代码字符串
            // 同时针对每个文件构建依赖关系的Map,这个Map会在下面的HMR中用到
         } 
         async function finalizeResponse(fileLoc: string,requestedFileExt: string, output: SnowpackBuildMap,)
         : Promise<string | Buffer | null> 
         { 
           // 依次调用上面定义的wrapResponse和resolveResponseImports处理代码字符串
           let finalResponse = code;
           finalResponse = await wrapResponse(finalResponse, ...);
           finalResponse = await resolveResponseImports(fileLoc, requestedFileExt, finalResponse);
           return finalResponse;
         }
         
         /* 实际上这里涉及几个不同的if-else的判断 */ 
         /* 会判断memory cache或cacache中是否存有编译数据,如果仍有效就省略buildFile过程 */ 
         /* 不过总体处理方式都是相似的,所以简化为以下几条处理语句 */ 
         let reqPath = decodeURI(Url.parse(req.url).pathname);
         // 从请求获取对应文件地址信息
         const fileLoc = await getFileFromUrl(reqPath);
         // 使用配置指定的snowpack插件对文件做load和transform
         const responseOutput = await buildFile(fileLoc);
         // 生成响应内容(代码字符串)
         const responseContent = await finalizeResponse(fileLoc, requestedFileExt, responseOutput);
         // 发送给浏览器
         sendFile(req, res, responseContent, fileLoc, responseFileExt);
    }
   
   
    /* 创建server */   
   // 原代码会根据配置不同分别调用http2/https模块中创建server的方法,逻辑类似,此处省略
   const {port: defaultPort } = config.devOptions;
   const server = http.createServer(async (req, res) => {
      return await requestHandler(req, res);
   })
   // 监听端口
   server.listen(port);
}                     

流程结构图如下

command方法的第二部分:Hot Module Reload

import {EsmHmrEngine} from '../hmr-server-engine'; 

export async function command(commandOptions: CommandOptions) {
   // 承接上文代码  ... 
    
   // EsmHmrEngine本质是一个WebSocket服务,通过监听httpServer的upgrade事件升级而来
   // server参数即上文创建的http.Server对象
   const hmrEngine = new EsmHmrEngine({server});
      
   function updateOrBubble(url: string, visited: Set<string>) { 
     // 通过深度优先遍历的方式,遍历文件和文件import的依赖文件
     // 对这些文件通过执行import(module)方法调用的方式重新引入
     // 深度优先遍历结束后,触发浏览器刷新页面
   }  
   function handleHmrUpdate(fileLoc: string) {  
     let updateUrl = getUrlFromFile(mountedDirectories, fileLoc, config);
     // 如果变化的文件存在于页面内,则触发一次updateOrBubble
     if (hmrEngine.getEntry(updateUrl)) {
       updateOrBubble(updateUrl, new Set());
       return;
     } 
   }
   
   async function onWatchEvent(fileLoc) { 
     handleHmrUpdate(fileLoc);
   }
    
  // 导入文件监测的Node模块chokidar  
  const chokidar = await import('chokidar');  
  // 创建监听器  
  const watcher = chokidar.watch( ... );
  
  // 监听文件新增并调用函数
  watcher.on('add', (fileLoc) => onWatchEvent(fileLoc)); 
  // 监听文件内容改变并调用函数
  watcher.on('change', (fileLoc) => onWatchEvent(fileLoc));
  // 监听文件删除并调用函数
  watcher.on('unlink', (fileLoc) => onWatchEvent(fileLoc));
}           

大家可能好奇EsmHmrEngine类内代码是怎样,下面先展示一部分,可以看到它通过ws这一NPM模块建立websocket连接,同时经由http协议升级而来

// 来自 src/hmr-server-engine.ts
import WebSocket from 'ws';

export class EsmHmrEngine {
  constructor(options: {server?: http.Server | http2.Http2Server} = {}) {
    const wss = new WebSocket.Server({port: 12321});
    // 和浏览器建立websocket连接
    if (options.server) {
      options.server.on('upgrade', (req, socket, head) => {
        wss.handleUpgrade(req, socket, head, (client) => {
          wss.emit('connection', client, req);
        });
      });
    }
  }
  // 向浏览器发送消息,例如update或reload消息
  broadcastMessage(data: object) {
    this.clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify(data));
      } 
    });
  }
}       

流程结构图如下

#进行HMR时http请求的细节考究

在前面的介绍中,我们对http请求响应处理和HMR已经有了大体的了解。

但从修改某个文件到页面重新刷新中间,这两个部分又各自隐藏了哪些细节呢? 请看

先让我们思考以下两个问题

  • 第一个问题: HMR时会snowpack重新import文件,如果存在index.js -> app.js -> a.js这样的依赖关系,如果我们修改了a.js文件,导致a.js重新被import, 但是这就和原来的旧的a.js产生了冲突: 那么,app.js如何知道要引用的是哪个版本的a.js?
  • 第二个问题: 如果app.js要修改自己import的a.js的具体路径,这意味着要同时导入新的a.js和app.js,那么,snowpack要如何判断依赖关系并一一导入?

## snowpack如何区分前端引用文件的新旧版本?

我们先回答第一个问题: 正因为新import的文件会和旧的文件冲突,所以snowpack通过一个时间相关的后缀进行了标识区分,请看下图:当我们修改a.js文件时,snowpack会请求a.js和依赖它的app.js文件,并通过mtime后缀的方式区分开了先后请求的同一个文件

同时我们还可以明显看出来的是: 同一批HMR请求的文件,它们的mtime后缀是相同的。如上图中a.js和App.js的mtime都是一样的。

同时需要知道的是: 新请求的a.js和APP.js内容的修改是不一样的

  • a.js?mtime=.... 修改是我们手动编辑IDE修改的内容
  • App.js?mtime=....修改的仅仅是import(a.js)这一条语句

这是初次刷新时的app.js

这是HMR时重新请求的app.js?mtime= ...

可以看到APP.js只是修改了下引进a.js文件的地址

下面的源码非常简洁明地告诉我们snowoack如何做到这一点的,请看下文

(Promise.all让多个文件加载并行进行)

/** Called when a new module is loaded, to pass the updated module to the "active" module */
async function runModuleAccept(id) {
  const state = REGISTERED_MODULES[id];
  // 获取mtime时间戳
  const updateID = Date.now();
  for (const {deps,...} of state.acceptCallbacks) {
    const [module, ...depModules] = await Promise.all([
      import(id + `?mtime=${updateID}`),                    // 请求修改的当前文件,并添加mtime后缀
      ...deps.map((d) => import(d + `?mtime=${updateID}`)), // 依次请求引用当前文件的文件列表,并添加mtime后缀
    ]);
  }
}

## snowpack如何判断请求文件的依赖关系并一一导入?

这是我们前面提出的第二个问题, 它的答案是:snowpack在每一次加载文件(包括第一次加载)时,都会进行依赖分析并建立针对某个文件的依赖图谱。

而当浏览器请求某个文件时,snowpack就会根据已有的依赖图谱,通过文件索引找到引用这个文件的父文件并依次进行并行加载。

收集文件依赖关系的代码如下所示,简单梳理一下逻辑如下

  1. 创建类型为Map 的dependencyTree对象,用来存放每个文件地址和它对应的依赖列表
  2. 在文件请求处理的链路中的resolveResponseImports方法,解析出某个文件所有的依赖,并通过setEntry把它存到dependencyTree中(底层调用es-module-lexer模块的parse方法实现)

备注:resolveResponseImports方法在前面的代码结构剖析中说过,我们下面还会细讲

代码片段A

// hmr-server-engine.ts
export class EsmHmrEngine {
  clients: Set<WebSocket> = new Set();
  dependencyTree = new Map<string, Dependency>();
  constructor(options: {server?: http.Server | http2.Http2Server} = {}) {
    // ...
    wss.on('connection', (client) => {
      this.registerListener(client);
    });
  }
  
  registerListener(client: WebSocket) {
    client.on('message', (data) => {
      const message = JSON.parse(data.toString());
      // 监听前端的hotAccept事件
      if (message.type === 'hotAccept') {
        const entry = this.getEntry(message.id, true) as Dependency;
      }
    });
  }  

  getEntry(sourceUrl: string, createIfNotFound = false) {
    const result = this.dependencyTree.get(sourceUrl);
    if (result) {  return result; }
    if (createIfNotFound) {
      const newEntry = {
        dependencies: new Set(),
        dependents: new Set(),
        // ...
      };
      this.dependencyTree.set(sourceUrl, newEntry);  
      return newEntry;
    }
    return null;
  }  
   
  setEntry(sourceUrl: string, imports: string[], isHmrEnabled = false) {
    const result = this.getEntry(sourceUrl, true)!;
    const outdatedDependencies = new Set(result.dependencies);
    result.isHmrEnabled = isHmrEnabled;
    for (const importUrl of imports) {
      this.addRelationship(sourceUrl, importUrl);
    }
  }
  
  addRelationship(sourceUrl: string, importUrl: string) {
    if (importUrl !== sourceUrl) {
      let importResult = this.getEntry(importUrl, true)!;
      importResult.dependents.add(sourceUrl);
      const sourceResult = this.getEntry(sourceUrl, true)!;
      sourceResult.dependencies.add(importUrl);
    }
  }
}              

代码片段B

async function resolveResponseImports(...) {
  // ...  
  if (responseFileExt === '.js') {
    // ...  
    hmrEngine.setEntry(originalReqPath, resolvedImports, isHmrEnabled);
  }
}               

到这里,snowpack dev的代码结构部分已经讲完,读累了的观众可以先回去了。

不过如果你对代码的细节感兴趣的话,请看 — —

## 代码细节讲解

上面我们讲解了snowpack的dev部分的整体代码结构和逻辑流程,下面我们将具体阐述各个部分的细节。和之前一样,我们将分成两大部分讲解,一是http请求响应的处理,二是websocket实现的HMR。

1.http请求响应的处理

1.1 createServer

snowpack创建http服务器的过程是这样的:

  1. 首先判断配置里config.devOptions.secure是否为true,如果为true则从snowpack.crt和snowpack.key读取证书和秘钥,存储值到credentials变量中
  2. 然后通过判断credentials是否存在,如果存在,就通过http2.createSecureServer或https.createServer创建服务器
  3. 如果credentials不存在,就采用http.createServer方法创建服务
import http from 'http';
import http2 from 'http2';
import https from 'https';

let credentials: {cert: Buffer; key: Buffer};
if (config.devOptions.secure) {
  const [cert, key] = await Promise.all([
    fs.readFile(path.join(cwd, 'snowpack.crt')),
    fs.readFile(path.join(cwd, 'snowpack.key')),
  ]);
  credentials = {cert, key};
}


const createServer = (requestHandler: http.RequestListener | Http2RequestListener) => {
  if (credentials && config.proxy.length === 0) {
    return http2.createSecureServer(
      {...credentials!, allowHTTP1: true},
      requestHandler as Http2RequestListener,
    );
  } else if (credentials) {
    return https.createServer(credentials, requestHandler as http.RequestListener);
  }

  return http.createServer(requestHandler as http.RequestListener);
};

无论采取哪一种方式创建服务器,它们接收的请求处理函数都是一样的,都是requestHandler。

1.2 buildFile

当从请求url中解析到文件路径(srcPath)后,接下来要做的就是用config.plugins中配置的插件,对路径下的文件进行预先的加载和转换处理。

type SnowpackBuildMap = Record<string, SnowpackBuiltFile>;
 
export async function buildFile(
  srcPath: string,
  buildFileOptions: BuildFileOptions,
): Promise<SnowpackBuildMap> {
  // Pass 1: Find the first plugin to load this file, and return the result
  const loadResult = await runPipelineLoadStep(srcPath, buildFileOptions);
  // Pass 2: Pass that result through every plugin transform() method.
  const transformResult = await runPipelineTransformStep(loadResult, srcPath, buildFileOptions);
  // 返回最终的转换结果
  return transformResult;
}         

我们可以看到,buildFile分成两个步骤,先后对文件进行处理:

  1. 找到第一个包含load方法的插件,运行它加载代码文件并返回一个SnowpackBuildMap类型的数据
  2. 使用plugins中所有含有transform方法的插件,把SnowpackBuildMap中所有预加载文件处理一遍

注意这两者的区别:

  • load是只执行一次就结束了,只有第一个遇到的plugins的load会被实际使用到
  • transform则是每个plugin的都执行(如果有),而且是对load生成的所有文件都执行

runPipelineLoadSteprunPipelineTransformStep的代码如下所示

async function runPipelineLoadStep(
  srcPath: string,
  {isExitOnBuild, plugins, ...}: BuildFileOptions,
): Promise<SnowpackBuildMap> {
  // 遍历plugins  
  for (const step of plugins) {
    // 如果plugin没有load方法就跳过  
    if (!step.load) {
      continue;
    }
    // 调用plugin对象中定义的load方法进行处理
    const result = await step.load({
      fileExt: srcExt,
      filePath: srcPath,
      // ...其他参数
    });
    // 返回第一个有load方法的plugin处理的结果
    // 这里会根据result类型不同分为多个case处理,逻辑类似,故省略
    if (typeof result === 'string') {
        const mainOutputExt = step.resolve.output[0];
        return {
          [mainOutputExt]: {
            code: result,
          },
        };
    }
  }    
}

async function runPipelineTransformStep(
  output: SnowpackBuildMap,
  srcPath: string,
  { plugins, sourceMaps, ...}: BuildFileOptions,
): Promise<SnowpackBuildMap> {  
    const rootFilePath = srcPath.replace(srcExt, '');
    const rootFileName = path.basename(rootFilePath);
    // 遍历plugins
    for (const step of plugins) {
      // 跳过没有transform的plugin  
      if (!step.transform) {
        continue;
      }
      // 遍历上一步生成的预加载文件,依次transform
      for (const destExt of Object.keys(output)) {
         const code = output[destExt].code;
         const fileName = rootFileName + destExt;
         const filePath = rootFilePath + destExt;
         const result = await step.transform({
            contents: code,
            filePath: fileName,
            urlPath: `./${path.basename(rootFileName + destExt)}`,
            // ...其他参数
         });
         // 这里会根据result类型不同分为多个case处理,逻辑类似,故省略
         if (typeof result === 'string' || Buffer.isBuffer(result)) {
           output[destExt].code = result;
           output[destExt].map = undefined;
         }
      }    
    } 
    return output;     
}             

1.3 finalizeResponse

在buildFile调用之后,返回值output会传给finalizeResponse调用处理,而finalizeResponse中主要是调用两个方法: wrapResponse和resolveResponseImports。下面我们就来分别讲解

async function finalizeResponse(fileLoc: string,requestedFileExt: string, output: SnowpackBuildMap,): Promise<string | Buffer | null> 
 { 
   const {code, map} = output[requestedFileExt];
   let finalResponse = code;
   
   const hasAttachedCss = requestedFileExt === '.js' && !!output['.css'];
   let finalResponse = code;
   finalResponse = await wrapResponse(finalResponse, { 
     hasCssResource: hasAttachedCss,
     sourceMap: map,
     sourceMappingURL: path.basename(requestedFile.base) + '.map',
  });
  finalResponse = await resolveResponseImports(fileLoc, requestedFileExt, finalResponse);
  return finalResponse;
}                  

1.4 wrapResponse

wrapResponse的原理其实是很简单粗暴的:说白了就是字符串拼接,类似于:

result = "aaaaa" + code + "bbbbb"

不过会根据文件的类型( js | css | html )不同分别做处理:

wrapResponse的原理其实是很简单粗暴的:说白了就是字符串拼接,类似于: result = "aaaaa" + code + "bbbbb"

不过会根据文件的类型( js | css | html )不同分别做处理:

  • 如果是html文件: 调用wrapHtmlResponse方法: 向html内部插入包含HMR脚本链接的<script>标签
  • 如果是css文件:调用cssSourceMappingURL方法: 向代码顶部添加包含sourceMap的映射地址的注释
  • 如果是js文件:则处理会相对复杂一些,我们下面再来细讲

#对html文件的处理

对html文件的处理包含两部分:

  • 替换模版变量,例如在html中写入的%PUBLIC_URL%,%MODE%,将它们替换为配置中指定的值
  • 往html中添加用于热重载(HMR)的<script>标签,并写入加载的链接。

细节请看下面wrapHtmlResponse方法的定义

export function wrapHtmlResponse({code, hmr, isDev, config, mode}) {
  code = code.replace(/\/?%PUBLIC_URL%\/?/g, isDev ? '/' : config.buildOptions.baseUrl);
  code = code.replace(/%MODE%/g, mode);
  if (hmr) {
    const hmrScript = `<script type="module" src="${getMetaUrlPath('hmr.js', config)}"></script>`;
    code = appendHTMLToBody(code, hmrScript);
  }
  return code;
}

所以我们在开发时看到的index.html是下面这样的

#对js文件的处理

在文章最开头我们就介绍过了,由于浏览器ESM不支持import css文件,所以snowpack通过将css文件转化为css.proxy.js文件解决这一问题。所以wrapResponse方法中处理的JS文件即有可能是“纯粹”的JS文件,也有可能是xx.css.proxy.js, xx.svg.proxy.js等伪JS文件

如果是“纯粹”的JS文件,snowpack会给JS文件的顶部引入两个变量

  • HMR上下文对象 import.meta.hot
  • 环境变量 import.meta.env
export function wrapImportMeta({code, hmr, env, config, }) {
  if (!code.includes('import.meta')) { return code; }
  return (
    (hmr
      ? `import * as  __SNOWPACK_HMR__ from '${getMetaUrlPath('hmr.js',config)}';\n` + 
        `import.meta.hot = __SNOWPACK_HMR__.createHotContext(import.meta.url);\n`
      : ``) +
    (env
      ? `import __SNOWPACK_ENV__ from '${getMetaUrlPath('env.js', config)}';\n` +
       `import.meta.env = __SNOWPACK_ENV__;\n`
      : ``) + '\n' 
      + code
  );
}

这意味着,我们可以在开发中使用env和hot变量并起到如下作用

// 只在开发环境运行的代码
if (import.meta.env.MODE === 'development') {   }    
// 引入下面三行代码就给当前文件引入了Hot Module reload功能。
if (import.meta.hot) {
  import.meta.hot.accept();
}

所以我们最终看到的JS文件是这样的

如果是“伪js文件”,snowpack会调用wrapImportProxy方法进行处理。

对于xx.css.proxy.js,处理如下:

  • 通过import.meta.hot.accept方法调用引入HMR
  • 通过动态创建style元素并插入html的方式转换css文件
  • 调用 wrapImportMeta方法,添加import.meta.hot和import.meta.env变量
// 在wrapImportProxy方法中调用
function generateCssImportProxy({code: string, hmr: boolean, config: SnowpackConfig }) {
  const cssImportProxyCode = `${ 
  hmr ? 
  `import.meta.hot.accept();
   import.meta.hot.dispose(() => {
     document.head.removeChild(styleEl);
   });\n`
      : ''
   }
  const code = ${JSON.stringify(code)};
  const styleEl = document.createElement("style");
  const codeEl = document.createTextNode(code);
  styleEl.type = 'text/css';
  styleEl.appendChild(codeEl);
  document.head.appendChild(styleEl);`;
  
  return wrapImportMeta({code: cssImportProxyCode, hmr, env: false, config});
}

所以开发中看到的css.proxy.js文件是这样的:

对于其他的资源的proxy文件,直接把资源文件的地址导出一下就可以了,如对于logo.svg.proxy.js

function generateDefaultImportProxy(url: string) {
  return `export default ${JSON.stringify(url)};`;
}

在浏览器中

1.5 resolveResponseImports

对于resolveResponseImports这一方法,我们在前面的HMR中简单介绍过了,它的功能主要有两个:

  • 解析当前文件的所有依赖并存储依赖列表,从而在HMR的时候,能够找到从入口文件到当前修改文件中间的依赖链路上的所有文件,一一用mtime后缀标记并重新加载。
  • 获取目标文件的mtime,替换链路上所有和它相关的import地址,例如把a.js替换成a.js?mtime=xxxxxx

resolveResponseImports的代码已经嵌套比较深一些,不便于展示整体结构,所以下面只截取部分代码:

它使用了es-module-lexer这一社区NPM模块去解析import

const {parse} = require('es-module-lexer');

// 在resolveResponseImports内部调用
export async function scanCodeImportsExports(code: string): Promise<any[]> {
  const [imports] = await parse(code);
  return imports.filter((imp: any) => {
    //imp.d = -2 = import.meta.url = we can skip this for now
    if (imp.d === -2) {
      return false;
    }
    // imp.d > -1 === dynamic import
    if (imp.d > -1) {
      const importStatement = code.substring(imp.s, imp.e);
      return !!matchDynamicImportValue(importStatement);
    }
    return true;
  });
}

1.6 sendFile

再然后就到了最后一步了:处理http头部然后将文件发送给浏览器

  1. 处理条件请求:验证请求头部的if-none-match和请求体的ETag是否相等,如果相等说明内容没有更新,返回304
  2. 获取acceptEncoding并判断是否进行gZip压缩
  3. 处理范围请求,主要是根据range首部进行处理
const sendFile = (req: http.IncomingMessage,res: http.ServerResponse,body: string | Buffer, fileLoc: string, ext = '.html') => {
  body = Buffer.from(body);
  const ETag = etag(body, {weak: true});
  const contentType = mime.contentType(ext);
  const headers: Record<string, string> = {
    'Accept-Ranges': 'bytes',
    'Access-Control-Allow-Origin': '*',
    'Content-Type': contentType || 'application/octet-stream',
    ETag,
    Vary: 'Accept-Encoding',
  };
  
  if (req.headers['if-none-match'] === ETag) {
    res.writeHead(304, headers);
    res.end();
    return;
  }

  // Handle gzip compression
  if (/\bgzip\b/.test(acceptEncoding) && stream.Readable.from) {
    const bodyStream = stream.Readable.from([body]);
    headers['Content-Encoding'] = 'gzip';
    res.writeHead(200, headers);
    stream.pipeline(bodyStream, zlib.createGzip(), res);
    return;
  }

  // Handle partial requests
  const {range} = req.headers;
  if (range) {
    const {size: fileSize} = statSync(fileLoc);
    const [rangeStart, rangeEnd] = range.replace(/bytes=/, '').split('-');

    const start = parseInt(rangeStart, 10);
    const end = rangeEnd ? parseInt(rangeEnd, 10) : fileSize - 1;
    const chunkSize = end - start + 1;

    const fileStream = createReadStream(fileLoc, {start, end});
    res.writeHead(206, {
      ...headers,
      'Content-Range': `bytes ${start}-${end}/${fileSize}`,
      'Content-Length': chunkSize,
    });
    fileStream.pipe(res);
    return;
  }

  res.writeHead(200, headers);
  res.write(body);
  res.end();
};



About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK