Giter Club home page Giter Club logo

blog's People

Contributors

wjchumble avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

blog's Issues

手把手带你实现一个 Webpack 的 Bundle 打包机制

前言

我想这两年,应该是「Webpack」受冲击最明显的时间段。前有「Snowpack」基于浏览器原生ES Module 提出,后有「Vite」站在「Vue3」肩膀上的迅猛发展,真的是后浪推前浪,前浪....

并且,「Vite」主推的实现技术不是一点点新,典型的一点使用「esbuild」来充当「TypeScript」的解释器,这一点是和目前社区内绝大多数打包工具是不同的。

在下一篇文章,我将会介绍什么是「esbuild」,以及其带来的价值。

但是,虽说后浪确实很强,不过起码近两年来看「Webpack」所处的地位是仍然不可撼动的。所以,更好地了解「Webpack」相关的原理,可以加强我们的个人竞争力。

那么,回到今天的正题,我们就来从零实现一个「Webpack」的 Bundler 打包机制。

1 Bundler 打包背景

Bundler 打包背景,即它是什么?Bundler 打包指的是我们可以将模块化的代码通过构建模块依赖图解析代码执行代码等一系列手段来将模块化的代码聚合成可执行的代码

在平常的开发中,我们经常使用的就是 ES Module 的形式进行模块间的引用。那么,为了实现一个 Bundler 打包,我们准备这样一个例子:

目录

|—— src
    |-- person.js
    |-- introduce.js
    |-- index.js    ## 入口
|—— bundler.js      ## bundler 打包机制

代码

// person.js
export const person = 'my name is wjc'
// introduce.js
import { person } from "./person.js";

const introduce = `Hi, ${person}`;
export default introduce;
// index.js
import introduce from "./introduce.js";

console.log(introduce);

除开 bundler.js 打包机制实现文件,另外我们创建了三个文件,它们分别进行了模块间的引用,最终它们会被 Bundler 打包机制解析生成可执行的代码。

接下来,我们就来一步步地实现 Bundler 打包机制。

2 单模块解析

Bundler 的打包实现第一步,我们需要知道每个模块中的代码,然后对模块中的代码进行依赖分析、代码转化,从而保证代码的正常执行。

首先,从入口文件 index.js 开始,获取其文件的内容(代码):

const fs = require("fs")

const moduleParse = (file = "") => {
  const rawCode = fs.readFileSync(file, 'utf-8')
}

获取到模块的代码后,我们需要知道它依赖了哪些模块?这个时候,我们需要借助两个 babel 的工具:@babel/parser@babel/traverse。前者负责将代码转化为「抽象语法树 AST」,后者可以根据模块的引用构建依赖关系。

@babel/parser 将模块的代码解析成「抽象语法树 AST」:

const rawCode = fs.readFileSync(file, 'utf-8')
const ast = babelParser(rawCode, {
  sourceType: "module"
})

@babel/traverse 根据模块的引用标识 ImportDeclaration 来构建依赖:

const dependencies = {};
traverse(ast, {
  ImportDeclaration({ node }) {
    const dirname = path.dirname(file);
    const absoulteFile = `./${path
      .join(dirname, node.source.value)
      .replace("\\", "/")}`;
    dependencies[node.source.value] = absoulteFile;
  },
});

这里,我们通过 @babel/traverse 来将入口 index.js 依赖的模块放到 dependencies 中:

// dependencies
{ './intro.js' : './src/intro.js' }

但是,此时 ast 中的代码还是初始 ES6 的代码,所以,我们需要借助 @babel/preset-env 来将其转为 ES5 的代码:

const { code } = babel.transformFromAst(ast, null, {
  presets: ["@babel/preset-env"],
});

index.js 转化后的代码:

"use strict";
var _introduce = _interopRequireDefault(require("./introduce.js "));
function _interopRequireDefault(obj) { 
  return obj && obj.__esModule ?
	obj : {
		"default": obj
	};
}
console.log(_introduce["default"]);

到此,我们就完成了对单模块的解析,完整的代码如下:

const moduleParse = (file = "") => {
  const rawCode = fs.readFileSync(file, "utf-8");
  const ast = babelParser.parse(rawCode, {
    sourceType: "module",
  });
  const dependencies = {};
  traverse(ast, {
    ImportDeclaration({ node }) {
      const dirname = path.dirname(file);
      const absoulteFile = `./${path
        .join(dirname, node.source.value)
        .replace("\\", "/")}`;
      dependencies[node.source.value] = absoulteFile;
    },
  });
  const { code } = babel.transformFromAst(ast, null, {
    presets: ["@babel/preset-env"],
  });

  return {
    file,
    dependencies,
    code,
  };
};

接下来,我们就开始模块依赖图的构建。

2 构建模块依赖图

众所周知,「Webpack」的打包过程会构建一个模块依赖图,它的形成无非就是从入口文件出发,通过它的引用模块,进入该模块,继续单模块的解析,不断重复这个过程。大致的逻辑图如下:

所以,在代码层面,我们需要从入口文件出发,先调用 moduleParse() 解析它,然后再遍历获取其对应的依赖 dependencies,以及调用 moduleParse()

const buildDependenceGraph = (entry) => {
  const entryModule = moduleParse(entry);
  const rawDependenceGraph = [entryModule];
  for (const module of rawDependenceGraph) {
    const { dependencies } = module;
    if (Object.keys(dependencies).length) {
      for (const file in dependencies) {
        rawDependenceGraph.push(moduleParse(dependencies[file]));
      }
    }
  }
  // 优化依赖图
  const dependenceGraph = {};
  rawDependenceGraph.forEach((module) => {
    dependenceGraph[module.file] = {
      dependencies: module.dependencies,
      code: module.code,
    };
  });

  return dependenceGraph;
};

最终,我们构建好的模块依赖图会放到 dependenceGraph。现在,对于我们这个例子,构建好的依赖图会是这样:

{ 
  './src/index.js':
   { 
     dependencies: { './introduce.js': './src/introduce.js' },
     code: '"use strict";\n\nvar...'     
    },
  './src/introduce.js':{ 
    dependencies: { 
      './person.js': './src/person.js' 
    },
    code: '"use strict";\n\nObject.defineProperty(exports,...' 
  },
  './src/person.js':
   { 
     dependencies: {},
     code: '"use strict";\n\nObject.defineProperty(exports,...' 
    } 
}

3 生成可执行代码

构建完模块依赖图后,我们需要根据依赖图将模块的代码转化成可以执行的代码。

由于 @babel/preset-env 处理后的代码用到了两个不存在的变量 requireexports。所以,我们需要定义好这两个变量。

require 主要做这两件事:

  • 根据模块名,获取对应的代码并执行。
eval(dependenceGraph[module].code)
  • 处理模块名,由于引用的时候是相对路径,这里需要转成绝对路径,并且递归执行依赖模块代码
function _require(relativePath) {
  return require(dependenceGraph[module].dependencies[relativePath]);
}

export 则用于存储定义的变量,所以我们定义一个对象来存储。完整的生成代码函数 generateCode 定义:

const generateCode = (entry) => {
  const dependenceGraph = JSON.stringify(buildDependenceGraph(entry));
  return `
  (function(dependenceGraph){
    function require(module) {
      function localRequire(relativePath) {
        return require(dependenceGraph[module].dependencies[relativePath]);
      };
      var exports = {};
      (function(require, exports,  code) {
        eval(code);
      })(localRequire, exports, dependenceGraph[module].code);
      return exports;
    }
    require('${entry}');
  })(${dependenceGraph});
  `;
};

4 完整的 bundler 打包机制实现代码

完整的 Bunlder 打包实现代码:

const fs = require("fs");
const path = require("path");
const babelParser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const babel = require("@babel/core");

const moduleParse = (file = "") => {
  const rawCode = fs.readFileSync(file, "utf-8");
  const ast = babelParser.parse(rawCode, {
    sourceType: "module",
  });
  const dependencies = {};
  traverse(ast, {
    ImportDeclaration({ node }) {
      const dirname = path.dirname(file);
      const absoulteFile = `./${path
        .join(dirname, node.source.value)
        .replace("\\", "/")}`;
      dependencies[node.source.value] = absoulteFile;
    },
  });
  const { code } = babel.transformFromAst(ast, null, {
    presets: ["@babel/preset-env"],
  });

  return {
    file,
    dependencies,
    code,
  };
};

const buildDependenceGraph = (entry) => {
  const entryModule = moduleParse(entry);
  const rawDependenceGraph = [entryModule];
  for (const module of rawDependenceGraph) {
    const { dependencies } = module;
    if (Object.keys(dependencies).length) {
      for (const file in dependencies) {
        rawDependenceGraph.push(moduleParse(dependencies[file]));
      }
    }
  }
  // 优化依赖图
  const dependenceGraph = {};
  rawDependenceGraph.forEach((module) => {
    dependenceGraph[module.file] = {
      dependencies: module.dependencies,
      code: module.code,
    };
  });
  return dependenceGraph;
};

const generateCode = (entry) => {
  const dependenceGraph = JSON.stringify(buildDependenceGraph(entry));
  return `
  (function(dependenceGraph){
    function require(module) {
      function localRequire(relativePath) {
        return require(dependenceGraph[module].dependencies[relativePath]);
      };
      var exports = {};
      (function(require, exports,  code) {
        eval(code);
      })(localRequire, exports, dependenceGraph[module].code);
      return exports;
    }
    require('${entry}');
  })(${dependenceGraph});
  `;
};

const code = generateCode("./src/index.js");

最终,我们拿到的 code 就是 Bundler 打包后生成的可执行代码。接下来,我们可以将它直接复制到浏览器的 devtool 中执行,查看结果。

结语

虽然,这个 Bundler 打包机制的实现,只是简易版的,它只是大致地实现了整个「Webpack」的 Bundler 打包流程,并不是适用于所有用例。但是,在我看来很多东西的学习都应该是从易到难,这样的吸收效率才是最高的。

现代 Monorepo 工程技术选型,聊聊我的思考

前言

相信很多关注 Monorepo 生态的同学,应该大都看过这篇文章 monorepo.tools,其中列举了现存的几个主流的 Monorepo 相关的工具:

相应地,在这篇文章中也对各类工具进行了一一介绍。并且,我相信每个看过这篇文章的同学,都会留下这么个疑问:这么多 Monorepo Tool,我要如何进行选型?

这里,我给出的答案是 PNPM + Turborepo + Changesets。那么,又为什么是这 3 者呢?下面,我将会分别围绕这 3 个技术展开,来一一解答这个选型的原因以及怎么做。

PNPM

PNPM 的动机(Motivation),如它在官方文档介绍的所说:“Saving disk space and boosting installation speed”,节省磁盘空间和提高安装速度。除开这个动机描述的显著优点外,PNPM 内置了对 Monorepo 的支持,并解决了很多令人诟病的问题。

其中,比较经典的就是 Phantom dependencies(幻影依赖)。由于,默认情况下 yarnnpm 安装的依赖都是会被提升。所以,有时候你可能会遇到 Monorepo 项目中的某个包中的 package.json 没有安装这个依赖,结果实际代码中却使用了这个依赖...

虽说,PNPM 可以解决这个问题,但是,默认情况下 PNPM 安装的依赖也是会被提升的。如果,需要 PNPM 禁止依赖提升,我们可以通过在 Monorepo 项目工作区下的 .npmrc 文件中 配置,例如只提升 lodash

hoist-pattern[]=*lodash*

当然,还有一些其他的问题,有兴趣的同学可以看 ELab 团队写的这篇文章《Monorepo 的这些坑,我们帮你踩过了!》

那么,在简单解答了为什么用 PNPM 后,下面我们来看一下要怎么用?

Workspace 配置

要使用 PNPM 的 Monorepo 很简单,只需要在 Monorepo 项目的工作区下新建 pnpm-workspace.yaml 文件并配置:

packages:
  - 'packages/**'

接下来,则是记忆常用依赖和多包任务执行相关的命令。由于,我们的技术选型中有 Turborepo,它会负责多包任务的执行。所以,这里只需要记忆常用依赖相关的命令

常用依赖相关命令

pnpm i

在 PNPM 中,安装依赖可以用 pnpm i 来完成。在 Monorepo 的场景下,默认情况下 pnpm i 会安装所有的依赖(包括 packages/*)。此外,pnpm i 还需要用到 3 个选项(Option):

  • --filter <package>,安装依赖到指定的 package,不声明要安装的依赖包则默认安装 package.json 中的所有依赖
  • --prod, P,安装依赖到 dependencies
  • --dev, D,安装依赖到 devDependencies

pnpm remove

在 PNPM 中,删除在 package.json 中的某个依赖,可以用 pnpm remove 完成。它的选项(Option)使用和 pnpm i 大同小异。其中,不同地是当我们在工作区想要删除 packages 中所有包的 package.json 中的某个依赖的时候,需要使用 -r,例如移除所有包中的 lodash

pnpm remove lodash -r

当然,可能还有同学有一些其他的诉求,有兴趣的同学可以移步文档了解,这里不做展开。

Changesets

经常维护开源项目的同学都知道的一点,每次包(Package)的发布,需要修改 package.json 的 version 字段,以及同步更新一下本次发布修改的 CHANGELOG.md。

这么一来,就会凸显一个问题,每次发布都需要手动地去更新 version、更新 CHANGELOG.md,未免有点繁琐。并且,用过 Lerna 的同学,应该都知道 Lerna 内置了对这块的支持。

但是,无论是 PNPM 又或者是下面要说的 Turborepo 都不支持这块,所以 2 者的官方文档都给大家推荐了用于支持这块能力的工具,例如 ChangesetsBeachballAuto 等。

那么,这里我们要介绍的就是 Changesets。下面,我们来看一下在前面建好的 PNPM 的 Monorepo 项目中如何使用 Changesets。首先,需要执行在 Monorepo 项目的工作区下,执行如下 2 个命令:

pnpm i -DW @changesets/cli
pnpm changeset init

前者是安装 Changesets 的 CLI,后者是初始化 .changeset 文件夹以及对应的文件:

.changeset
  |-- config.json
  |__ README.md

这里,我们来看一下 config.json 文件:

{
  "$schema": "https://unpkg.com/@changesets/[email protected]/schema.json",
  "changelog": "@changesets/cli/changelog",
  "commit": false,
  "linked": [],
  "access": "restricted",
  "baseBranch": "master",
  "updateInternalDependencies": "patch",
  "ignore": []
}

除开 $schema 这个不需要修改的字段, config.json 文件中列了 7 个字段,各个字段分别代表的作用为:

  • changelog 设置 CHANGELOG.md 生成方式,可以设置 false 不生成,也可以设置为定义生成行为的文件地址或依赖名称,例如 Changsets 提供的 changelog-git。其中,定义生成行为的文件固定代码模版为:
async function getReleaseLine() {}

async function getDependencyReleaseLine() {}

export default {
  getReleaseLine,
  getDependencyReleaseLine
}
  • commit 设置是否把执行 changeset addchangeset publish 操作时对修改用 Git 提交

  • linked 设置共享版本的包,而不是独立版本的包,例如一个组件库中主题和单独的组件的关系,也就是修改 Version 的时候,共享的包需要同步一起更新版本

  • access 设置执行 npm publish--access 选项,通常情况下我们是公共的包,所以设置 public 即可(注意,它会被 package.json 中的 access 字段重写)

  • baseBranch 设置默认的 Git 分支,例如现在 GitHub 的默认分支应该是 main

  • updateInternalDependencies 设置互相依赖的包版本更新机制,它是一个枚举(major|minor|patch),例如设置为 minor 时,只有当依赖的包新了 minor 版本或者才会对应地更新 package.json 的 dependenciesdevDependencies 中对应依赖的版本

  • ignore 设置不需要发布的包,这些会被 Changesets 忽略

在初始化 .changeset 文件夹后,就可以正常使用 changeset 相关的命令,主要是这 3 个命令:

  • pnpm chageset 用于生成本次修改的要添加到 CHANGELOG.md 中的描述
  • pnpm changeset version 用于生成本次修改后的包的版本
  • pnpm changeset publish 用于发布包

此外,如果是在业务场景下,我们通常需要把包发到公司私有的 NPM Registry,而这有很多种配置方式。但是,需要注意的是 Changesets 只支持在每个包中声明 publicConfig.registry 或者配置 process.env.npm_config_registry,对应的代码会是这样:

// https://github.com/changesets/changesets/blob/main/packages/cli/src/commands/publish/npm-utils.ts
function getCorrectRegistry(packageJson?: PackageJSON): string {
  const registry =
    packageJson?.publishConfig?.registry ?? process.env.npm_config_registry;

  return !registry || registry === "https://registry.yarnpkg.com"
    ? "https://registry.npmjs.org"
    : registry;
}

可以看到,如果在前面说的这 2 种情况下获取不到 registry 的话,Changesets 都是按公共的 Registry 去查找或者发布包的。

Turborepo

说起 Turborepo,可能大家会有点陌生。但是,对于 Vercel 我想大家都知道(毕竟 Rich HarrisSebastian Markbåge 等都加入了),Turbrepo 则是 Vercel 旗下的一个开源项目。Turborepo 是用于为 JavaScript/TypeScript 的 Monorepo 提供一个极快的构建系统,简单地理解就是用 Turborepo 来执行 Monorepo 项目的中构建(或者其他)任务会非常快

关于 Turborepo 其他优势,其官方文档写的很详尽,有兴趣的同学可以自行了解~

所以,你可以理解成是选择 Turborepo 负责 Monorepo 项目多包任务执行的原因。而在 Turborepo 中执行多包任务是通过 turbo run <script>。不过,turbo runlerna run 直接使用有所不同,它需要配置 turbo.json 文件,注册每个需要执行的 script 命令。

在 Turborepo 中有个 Pipelines 的概念,它是由 turbo.json 文件中的 pipline 字段的配置描述,它会在执行 turbo run 命令的时候,根据对应的配置进行有序的执行缓存输出的文件

举个例子,通常情况下我们一个 Monorepo 项目中的每个包可能会有 devbuildtestclean 等 4 个命令,那么对应的 turbo.json 的配置会是这样:

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "clean": {
      "dependsOn": ["^clean"]
    },
    "test": {
      "dependsOn": ["build", "lint"]
    },
    "dev": {
      "cache": false
    }
  }
}

可以看到,pipeline 中的每个 key 则对应着每个需要执行的 turbo run 命令的名称,其中 dependsOnoutputscache 等 3 个字段分别作用为:

  • dependsOn 表示当前命令所依赖的命令,^ 表示 dependenciesdevDependencies 的所有依赖都执行完 build,才执行 build
  • outputs 表示命令执行输出的文件缓存目录,例如我们常见的 distcoverage
  • cache 表示是否缓存,通常我们执行 dev 命令的时候会结合 watch 模式,所以这种情况下关闭掉缓存比较切合实际需求

这样一来,我们就可以使用诸如 turbo run build test 的命令,它则会按 pipeline 的配置依次执行对应的命令。

当然,如果你想每个命令都支持单独执行,可以直接配置为 {} 即可。此外,如果要使用 turbo run 命令,还需要在 package.json 中声明 packageManage 字段为指定的包管理工具及版本,例如 "packageManager": "[email protected]"

结语

阅读到此处,我想大家应该理解了 PNPM + Turborepo + Changesets 这个技术选型的原因以及要怎么做。当然,这个选型只是我个人的思考所得出的答案,相信也有同学仍然钟情于 Lerna,又或者喜欢 Rush 一把梭,这些观点并无对错,本质上这也是编程的魅力所在,各个轮子都有其存在的价值

最后,如果文中存在表达不当或错误的地方,欢迎各位同学评论交流~

攻克 HTTP 基础知识,多图预警

HTTP

报文

HTTP 报文可以分为请求报文响应报文

请求报文:

  • 请求行(方法、URI、HTTP版本)
  • 请求首部字段
  • 通用首部字段
  • 实体首部字段
  • 其他构成

响应报文:

  • 状态行
  • 响应首部字段
  • 通用首部字段
  • 实体首部字段
  • 其他构成

URI 和 URL

URI Uniform Resource Identifier(统一资源标识符),用字符串标识某一互联网资源

URL Uniform Resource Locator(统一资源定位符),用于表示资源的地点(即在互联网上所处的位置)

URL 是 URI 的子集

请求方法(9种)

GET

HTTP GET 方法请求指定的资源。使用 GET 的请求应该只用于获取数据。

POST

HTTP POST 方法 发送数据给服务器. 请求主体的类型由 Content-Type 实体首部字段指定.

DELETE

HTTP DELETE 请求方法用于删除指定的资源。

OPTIONS

HTTP 的 OPTIONS 方法 用于获取目的资源所支持的通信选项。客户端可以对特定的 URL 使用 OPTIONS 方法,也可以对整站(通过将 URL 设置为“*”)使用该方法。

PUT

HTTP PUT 请求方法使用请求中的负载创建或者替换目标资源。

PUT 与 POST 方法的区别在于,PUT方法是幂等的:调用一次与连续调用多次是等价的(即没有副作用),而连续调用多次POST方法可能会有副作用,比如将一个订单重复提交多次。

CONNECT

在 HTTP 协议中,CONNECT 方法可以开启一个客户端与所请求资源之间的双向沟通的通道。它可以用来创建隧道(tunnel)。

例如,CONNECT 可以用来访问采用了 SSL (HTTPS) 协议的站点。客户端要求代理服务器将 TCP 连接作为通往目的主机隧道。之后该服务器会代替客户端与目的主机建立连接。连接建立好之后,代理服务器会面向客户端发送或接收 TCP 消息流。

HEAD

HTTP HEAD 方法 请求资源的头部信息, 并且这些头部与 HTTP GET 方法请求时返回的一致. 该请求方法的一个使用场景是在下载一个大文件前先获取其大小再决定是否要下载, 以此可以节约带宽资源.

HEAD 方法的响应不应包含响应正文. 即使包含了正文也必须忽略掉. 虽然描述正文信息的 entity headers, 例如 Content-Length 可能会包含在响应中, 但它们并不是用来描述 HEAD 响应本身的, 而是用来描述同样情况下的 GET 请求应该返回的响应.

PATCH

在HTTP协议中,请求方法 PATCH 用于对资源进行部分修改。

在HTTP协议中, PUT 方法已经被用来表示对资源进行整体覆盖, 而 POST 方法则没有对标准的补丁格式的提供支持。不同于 PUT 方法,而与 POST 方法类似,PATCH 方法是非幂等的,这就意味着连续多个的相同请求会产生不同的效果。

另外一个支持 PATCH 方法的隐含迹象是 Accept-Patch 首部的出现,这个首部明确了服务器端可以接受的补丁文件的格式。

TRACE

HTTP TRACE 方法 实现沿通向目标资源的路径的消息环回(loop-back)测试 ,提供了一种实用的 debug 机制。

状态码

在 HTTP 协议中,状态码的作用是当客户端向服务端发送请求时,描述返回的请求结果。通过状态码,用户可以知道服务器是正常处理了请求,还是发生错误。

而状态码是由两个部分组成:3 位数数字、原因短语,例如 200 OK
在状态码中,三位数字的第一个数字代表了响应的类别,具体的响应类别有以下 5 种:

  类别 原因短语
1xx 任务信息状态码 接收的请求正在处理
2xx 成功状态码 请求正常处理完毕
3xx 重定向状态码 需要进行附加操作以完成请求
4xx 客户端错误状态码 服务端无法处理请求
5xx 服务器错误状态码 服务器处理请求出错

截屏2020-11-27 下午12 17 31

1XX 任务信息

100 Continue

HTTP 状态码为 100 Continue 时,则表示目前为止请求正常, 客户端应该继续请求, 如果已完成请求则忽略。

101 Switching Protocol

HTTP 状态码为 101 Switching Protocol 时,则表示服务端根据客户端升级协议的请求(Upgrade 请求头),正在切换协议。

2XX 成功

200 OK

HTTP 状态码为 200 OK 时,则表示客户端发送的请求在服务器被正常处理了。相应地在响应报文中,会根据请求方法的不同返回不同的实体内容。例如 GET 方法请求时,对应的请求资源的实体会作为响应返回。

204 No Content

HTTP 状态码为 204 No Content 时,则表示服务器接收的请求已处理,并且在响应报文的实例中没有主体部分,即可以理解为没有返回的内容或信息。

206 Partial Content

HTTP 状态码为 206 Partial Content 时,则表示客户端对服务器发起了范围请求,并且服务器已接收并处理完成该请求,在响应报文中包含由 Content-Range 指定范围的实体内容。

3XX 重定向

301 Moved Permanently

HTTP 状态码为 301 Permanetly 时,表示请求的资源已被分配至新的 URI,以后应使用这个新的 URI 进行相应的请求。

302 Found

HTTP 状态码为 302 Found 时,同样也是表示请求的资源已被分配至新的 URI,所不同于 301 的是,它只是暂时的,即只需要这一次请求新的 URI,即可(需要注意的是 302 禁止 POST 变换成 GET)。

303 See Other

HTTP 状态码为 303 See Other 时,则表示请求的资源存在另一个 URI,应使用 GET 请求方法获得请求相应资源。

304 Not Modified

HTTP 状态码为 304 Not Modified 时,则表示服务器允许访问资源,但是由于客户端请求时未满足条件,而直接返回 304 NotModified,即在响应报文的主体中不包含任何内容。

307 Temporary Redirect

HTTP 状态码为 307 Temporary Redirect 时,与 302 相同的行为,只是它不会禁止从 POST 转为 GET。

4XX 客户端错误

400 Bad Request

HTTP 状态码为 4xx 时,表明客户端是请求资源发生错误的原因所在。

401 Unauthoried

HTTP 状态码为 400 Bad Request 时,表示请求报文中存在语法错误,即客户端需要修改请求,再次发送请求。

403 Forbidden

HTTP 状态码为 403 Forbidden 时,表示客户端发送的请求被服务器拒绝了。

404 Not Found

HTTP 状态码为 404 Not Found 时,表示服务器上找不到客户端所要请求的资源。

5XX 服务器错误

500 Internal Server Error

HTTP 状态码为 500 Internal Server Error 时,表示在请求过程中服务器发生了错误,例如可能是服务器存在 Bug 或其他临时的原因。

503 Service Unavailable

HTTP 状态码为 503 Unavailable 时,表示服务器暂时处于超负载或停机维护,此时无法处理请求。

常见首部字段(四种)

请求首部字段

首部字段名 说明
Accept 用户代理可处理的媒体类型( text/html,application/xhtml+xml 等)
Accept-Charset 优先的字符集
Accept-Encoding 优先的内容编码
Accept-Language 优先的语言
Authorization Web 认知信息
Expect 期待服务器特定的行为
From 用户的邮箱地址
Host 请求资源所在的地址
If-Match 比较实体标记(ETag,常用于 Web 应用的缓存)
If-Modified-Since 比较实体的更新时间
If-None-Match 比较实标记(和 If-Match 相反,即没有 Match 到)
If-Range 资源未更新时发送实体 Byte 范围的请求
If-Unmodified-Since 比较资源的更新时间
Max-Forwards 最大传输逐跳数
Proxy-Authorization 代理服务器要求的客户端信息
Range 实体的字节范围请求
Referer 请求 URI 的原始获取方
TE 传输编码的优先级
User-Agent HTTP 客户端程序的信息(简称 UA)

响应首部字段

首部字段名 说明
Accept-Ranges 是否接收字节范围的请求
Age 推算资源创建经过时间
ETag 资源的匹配信息
Location 令客户端重定向至指定 URI
Proxy-Authenticate 代理服务器对客户端的认证信息
Retry-After 对再次发起请求的时机要求
Server HTTP 服务器的安装信息
Vary 代理服务器缓存的管理信息
WWW-Authenticate 代理服务器对客户端的认知信息

实体首部字段

首部字段名 说明
Allow 资源可支持的 HTTP 方法
Content-Encoding 实体主体支持的编码方式
Content-Language 实体主体的自然语言
Content-Length 实体主体的大小(Byte)
Content-Location 替代资源对应的 URI
Content-MD5 实体主体的报文摘要
Content-Range 实体主体的位置范围
Content-Type 实体主体的媒体类型
Expires 实体主体过期的日期时间
Last-Modified 资源的最后修改日期时间

通用首部字段

首部字段名 说明
Cache-Control 控制缓存的行为
Date 创建报文的日期时间
Connection 逐跳首部、连接的管理
Progma 报文指令
Trailer 报文末端的首部一览
Transfer-Encoding 指定报文主体的传输编码方式
Upgrade 升级为其他协议
Via 代理服务器通知
Warning 错误通知

缓存

强缓缓存

描述:即服务端一方设定的可缓存内容,这个过程不需要沟通,只要这个缓存有效,那么你就可以去读取它。

缺点:其实这样简单的理解定义,就可以看出它的弊端,当服务端缓存的资源变了,可是浏览器缓存的资源还有效,那么这个资源的更新就没有及时同步,这无疑是不友好的。

强缓存有两种:ExpiresCache-Control

Expires

看到这个单词,应该都很容易的联想到 CookieExpires,它在 Cookie 中作用就是设置 Cookie 的有效期。同样地,在这里它是 HTTP/1 中用来描述缓存资源有效时间的 HTTP 实体首部字段,它的值为 GMT 时间,由服务端定义好通过响应报文返回给前端。

截屏2020-11-27 下午1 28 27

缺点:由服务端定义好时间,这就会发生服务端和客户端时间不一致的问题。

Cache-Control

Cache-Control 它是 HTTP/1.1 中提出用于缓存的 HTTP 通用首部字段。它则代表资源在一段时间内可以读取缓存,而不用重新请求。而与 Expires 不同,它的值为 max-age = **s,并且它还可以搭配一些指令(8 个)实现一些特定的效果:

  • private,表示 HTTP 请求从浏览器发送到最终的服务器这个过程,只有浏览器缓存,中间任何传递节点不能缓存(代理服务器之类的)
  • public,表示都可以缓存,无论浏览器、服务器或者代理服务器。
  • no-store,表示不需要缓存。
  • no-cache,表示不使用强缓存,使用协商缓存。
  • max-age,表示缓存有效的最大时间,单位为 s。
  • s-maxage,表示代理服务器的缓存有效的最大时间。
  • max-stale,表示浏览器可接受的失效缓存的最大失效时间。
  • max-refresh,表示浏览器可接受的最短更新时间,不过这个时间是当前的 age 加上 max-refresh

ExpiresCache-Control 同时存在时,Cache-Control 的优先级较高

协商缓存

描述:即这个缓存的过程需要通过双方的确认,才能决定缓存资源是否可用,可用则返回状态码 304 Not Modified,不可用则返回状态码 200 OK和更新后的资源。

缺点:因为缓存的最终确定需要浏览器和服务器双方的确定,所以性能较低、耗时较长。

Last-Modified

Last-Modified 是 HTTP/1.0 中用于协商缓存的 HTTP 响应首部字段,它表示该资源的最后修改时间,它会和 If-Modified-Since 一起使用。

这个过程大致是这样的,服务端在响应报文中添加 Last-Modified 首部字段即相应的值(同样是 GMT 时间),然后浏览器在接受响应后,会将资源进行缓存和记录 Last-Modified,在下次请求相同资源的时候添加 If-Modified-Since HTTP 请求首部字段 携带之前记录的 Last-Modified 的值一起发送到服务端,服务端识别到 If-Modified-Since 字段,并与该资源上次修改的时间进行比较,如果相同则返回状态 304 Not-Modified,如果不同则发送状态码 200 OK 以及在报文实体中携带上更新过的资源。

缺点:

  • 在浏览器本地可以修改资源,并且此时该资源对应的 Last-Modified 也会随着变化,那么此时就会造成服务端返回状态码 200 OK 情况和返回没有更新的资源(实际上资源并没有变化),即这个请求完全没有意义。
  • Last-Modified 无法观察低于秒时的文件的修改,那么这就会造成文件已经更新了,但是服务端却认为它没有更新状态 304 Not-Modified,让浏览器去读缓存,即资源更新不精确。

ETag

ETag 是 HTTP/1.1 中用于协商缓存的 HTTP 响应首部字段,它是由服务端为该资源生成的 Hash,它会和 If-No-Match 一起使用。

它的过程会是这样,服务端在响应报文中添加 ETag 首部字段及对应资源的 Hash,同样地,然后浏览器在接受响应后,会将资源进行缓存和记录 ETag,在下次请求相同资源时通过 If-No-Match HTTP 请求首部字段 携带之前记录的 ETag 的值一起发送到服务端,服务端识别到 ETag 字段,并与该资源此时的 Hash值进行比较,如果相同返回状态码 304 Not-Modified,如果不同则发送状态码 200 OK 以及在报文实体中携带上更新过的资源。

缺点:

  • ETag 需要服务端计算资源生成 Hash,这个过程无疑是比较慢的,所以性能较低。

ETagIf-Modified 同时存在时,ETag 的优先级较高

Cookie

CORS(跨域资源)

HTTPS

HTTPS = HTTP + 加密 + 完整性保护 + 身份认证

HTTP 1.x

HTTP 2.x

HTTP 3.x

从浏览器输入 URL 到页面展示过程发生了什么?

从浏览器输入 URL 到页面展示过程发生了什么?

一、Chrome 多进程架构

首先,在开始讲解整个过程前,我们需要认识一下 Chrome 多进程架构。因为,从浏览器输入 URL 到页面渲染的整个过程都是由 Chrome 架构中的各个进程之间的配合完成。

Chrome 的多进程架构:

  • 浏览器进程,它负责用户界面(地址栏、菜单等等)、子进程的管理(例如,进程间通信和数据传递)、存储等等
  • 渲染进程,它负责将接收到的 HTML 文档和 JavaScript 等转化为用户界面
  • 网络进程,它负责网络资源的请求,例如 HTTP请求、WebSocket 模块
  • GPU(图形处理器)进程,它负责对 UI 界面的展示
  • 插件进程,它负责对插件的管理

二、过程详解

2.1 解析输入

发生这个过程的前提,用户在地址栏中输入了 URL,而地址栏会根据用户输入,做出如下判断:

  • 输入的是非 URL 结构的字符串,则会用浏览器默认的搜索引擎搜索该字符串
  • 输入的是 URL 结构字符串,则会构建完整的 URL 结构,浏览器进程会将完整的 URL 通过进程间通信,即 IPC,发送给网络进程

2.2 请求过程

在网络进程接收到 URL 后,并不是马上对指定 URL 进行请求。首先,我们需要进行 DNS 解析域名得到对应的 IP,然后通过 ARP 解析 IP 得到对应的 MACMedia Access Control Address)地址。

域名是我们取代记忆复杂的 IP 的一种解决方案,而 IP 地址才是目标在网络中所被分配的节点。MAC 地址是对应目标网卡所在的固定地址。

1. DNS 解析

DNS 解析域名的过程分为以下几个步骤:

  • 询问浏览器 DNS 缓存
  • 询问本地操作系统 DNS 缓存(即查找本地 host 文件)
  • 询问 ISPInternet Service Provider)互联网服务提供商(例如电信、移动)的 DNS 服务器
  • 询问根服务器,这个过程可以进行递归和迭代两种查找的方式,两者都是先询问顶级域名服务器查找

2. 通信过程

首先,建立 TCP 连接,即三次握手过程

  • 客户端发送标有 SYN 的数据包,表示我将要发送请求。
  • 服务端发送标有 SYN/ACK 的数据包,表示我已经收到通知,告知客户端发送请求。
  • 客户端发送标有 ACK 的数据包,表示我要开始发送请求,准备被接受。

然后,利用 TCP 通道进行数据传输

  • 服务端接收到数据包,并发送确认数据包已收到的消息到客户端,不断重复这个过程
  • 客户端在发送一个数据包后,未接收到服务端的确定消息,则重新发送该数据包,即 TCP 的重发机制
  • 当接收完所有的数据包后,接收端会按照 TCP 头中的需要进行排序,形成完整的数据

最后,断开 TCP 连接,即四次握手过程

  • 客户端发送请求,申请断开连接,进入等待阶段,此时不会发送数据,但是会继续接收数据。
  • 服务端接收请求后,告知客户端已明白,此时服务端进入等待状态,不会再接收数据,但是会继续发送数据。
  • 客户端收到后,进入下一阶段等待。
  • 服务端发送完剩余的数据后,告知客户端可以断开连接,此时服务端不会发送和接收数据。
  • 客户端收到后,告知服务端我开始断开连接。
  • 服务端收到后,开始断开连接。

而这整个过程的客户端则是网络进程。并且,在数据传输的过程还可能会发生的重定向的情况,即当网络进程接收到状态码为 3xx 的响应报文,则会根据响应报文首部字段中的 Location 字段的值进行重新向,即会重新发起请求

3. 数据处理

当网络进程接收到的响应报文状态码,进行相应的操作。例如状态码为 200 OK 时,会解析响应报文中的 Content-Type 首部字段,例如我们这个过程 Content-Type 会出现 application/javascripttext/csstext/html,即对应 Javascript 文件、CSS 文件、HTML 文件。

详细的 MIME 类型讲解可以看 MDN

2.3 创建渲染进程

当前需要渲染 HTML 时,则需要创建渲染进程,用于后期渲染 HTML。而对于渲染进程,如果是同一站点是可以共享一个渲染进程,例如 a.abc.comc.abc.com 可以共享一个渲染渲染进程。否则,需要重新创建渲染进程

需要注意的是,同站指的是顶级域名二级域名相等

2.4 开始渲染

在创建完渲染进程后,网络进程会将接收到的 HTML、JavaScript 等数据传递给渲染进程。而在渲染进程接收完数据后,此时用户界面上会发生这几件事:

  • 更新地址栏的安全状态
  • 更新地址栏的 URL
  • 前进后退此时 enable,显示正在加载状态
  • 更新网页

2.5 渲染过程

大家都知道页面渲染的过程也是面试中单独会考的点,并且时常会由这个点延申出另一个问题,即如何避免回流和重绘。

渲染过程,是整个从理器输入 URL 到页面渲染过程的最后一步。而页面渲染的过程可以分为 9 个步骤:

  • 解析 HTML 生成 DOM
  • 解析 CSS 生成 CSSOM
  • 加载或执行 JavaScript
  • 生成渲染树(Render Tree
  • 布局
  • 分层
  • 生成绘制列表
  • 光栅化
  • 显示

2.5.1 构建 DOM 树

由于网络进程传输给渲染进程的是 HTML 字符串,所以,渲染进程需要将 HTML 字符串转化成 DOM 树。例如:

需要注意的是这个 DOM 树不同于 Chrome-devtoolElement 选项卡的 DOM 树,它是存在内存中的,用于提供 JavaScriptDOM 的操作。

2.5.2 构建 CSSOM

构建 CSSOM 的过程,即通过解析 CSS 文件、style 标签、行内 style 等,生成 CSSOM。而这个过程会做这几件事:

  • 规范 CSS,即将 color: blue 转化成 color: rgb() 形式,可以理解成类似 ES6ES5 的过程
  • 计算元素样式,例如 CSS 样式会继承父级的样式,如 font-sizecolor 之类的。

CSS Object Model 是一组允许用 JavaScript 操纵 CSSAPI。详细 API 讲解可以看 MDN

2.5.3 加载 JavaScript

通常情况下,在构建 DOM 树或 CSSOM 的同时,如果也要加载 JavaScript,则会造成前者的构建的暂停。当然,我们可以通过 defersync 来实现异步加载 JavaScript。虽然 defersync 都可以实现异步加载 JavaScript,但是前者是在加载后,等待 CSSOMDOM 树构建完后才执行 JavaScript,而后者是在异步加载完马上执行,即使用 sync 的方式仍然会造成阻塞。

JavaScript 执行的过程,即编译和运行 JavaScript 的过程。由于 JavaScript解释型的语言。所以这个过程会是这样的:

  • 针对每句代码进行分行处理,即 Token
  • 根据 Token,生成 ASTAbstract Sytanx Tree) 抽象语法树和创建上下文
  • 解释器解析和执行 AST,生成字节码。
  • 编译器针对需要反复执行的代码,生成对应的机器码,提高运行效率

2.5.4 生成渲染树(Render Tree)

在有了 DOM 树和 CSSOM 之后,需要将两者结合生成渲染树 Render Tree,并且这个过程会去除掉那些 display: node 的节点。此时,渲染树就具备元素和元素的样式信息。

2.5.5 布局

根据 Render Tree 渲染树,对树中每个节点进行计算,确定每个节点在页面中的宽度、高度和位置。

需要注意的是,第一次确定节点的大小和位置的过程称为布局,而第二次才被称为回流

2.5.6 分层

由于层叠上下文的存在,渲染引擎会为具备层叠上下文的元素创建对应的图层,而诸多图层的叠加就形成了我们看到的一些页面效果。例如,一些 3D 的效果、动画就是基于图层而形成的。

值得一提的是,对于内容溢出存在滚轮的情况也会进行分层

2.5.7 生成绘制列表

对于存在图层的页面部分,需要进行有序的绘制,而对于这个过程,渲染引擎会将一个个图层的绘制拆分成绘制指令,并按照图层绘制顺序形成一个绘制列表。

2.5.8 光栅化

有了绘制列表后,渲染引擎中的合成线程会根据当前视口的大小将图层进行分块处理,然后合成线程会对视口附近的图块生成位图,即光栅化。而渲染进程也维护了一个栅格化的线程池,专门用于将图块转为位图。

栅格化的过程通常会使用 GPU 加速,例如使用 wil-changeopacity,就会通过 GPU 加速显示

2.5.9 显示

当所有的图块都经过栅格化处理后,渲染引擎中的合成线程会生成绘制图块的指令,提交给浏览器进程。然后浏览器进程将页面绘制到内存中。最后将内存绘制结果显示在用户界面上。

而这个整个从生成绘制列表、光栅化、显示的过程,就是我们常说的重绘的过程

TypeScript 高级类型,你了解几个?

前言

不可否认,如今 TypeScript 已成为一个前端工程师的所需要具备的基本技能。严谨的类型检测,一方面是提高了程序的可维护性健壮性,另一方面也在潜移默化地提高我们的编程思维,即逻辑性

那么,今天我将会通过结合实际开发场景Vue 3.0 源码中的部分类型定义来简单聊聊 TypeScript 中的高级类型。

interface

interface 被用于对所有具有结构的数据进行类型检测。例如,
在实际开发当中,我们会定义一些对象或数组来描述一些视图结构。

定义对象

常见的有对表格的列的定义:

const columns = [
  {key: "username", title: "用户名"},
  {key: "age", title: "年龄"},
  {key: "gender", title: "性别"}
]

而这个时候,我们就可以通过定义一个名为 column 的接口:

interface column {
  key: string,
  title: string
}

// 使用
const columns: column[] = [
  {key: "username", title: "用户名"},
  {key: "age", title: "年龄"},
  {key: "gender", title: "性别"}
]

定义函数

我们平常开发中使用的 axios,它的调用方式会有很多种,例如 axios.request()axios.get()axios.put()。它本质上是定义了这么一个接口来约束 axios 具备这些方法,它看起来会是这样:

export interface Axios {
  request(config: AxiosRequestConfig): AxiosPromise
  get(url: string, config?: AxiosRequestConfig): AxiosPromise
  delete(url: string, config?: AxiosRequestConfig): AxiosPromise
  head(url: string, config?: AxiosRequestConfig): AxiosPromise
  options(url: string, config?: AxiosRequestConfig): AxiosPromise
  post(url: string, data?: any, config?: AxiosRequestConfig): AxiosPromise
  put(url: string, data?: any, config?: AxiosRequestConfig): AxiosPromise
  patch(url: string, data?: any, config?: AxiosRequestConfig): AxiosPromise
}

继承

可复用性是我们平常在写程序时需要经常思考的地方,例如组件的封装、工具函数的提取、函数设计模式的使用等等。而对于接口也同样如此,它可以通过继承来复用一些已经定义好的 interfaceclass

这里我们以 Vue 3.0 为例,它在 compiler 阶段,将 AST Element 节点 Node 分为了多种 Node,例如 ForNodeIFNodeIfBranchNode 等等。而这些特殊的 Node 都是继承了 Node

Nodeinteface 接口定义:

export interface Node {
  type: NodeTypes
  loc: SourceLocation
}

IFNodeinterface 接口定义:

export interface IfNode extends Node {
  type: NodeTypes.IF
  branches: IfBranchNode[]
  codegenNode?: IfConditionalExpression
}

可以看到 Node 的接口定义是非常纯净的,它描述了 Node 所需要具备最基本的属性:type 节点类型、loc 节点在 template 中的起始位置信息。而 IFNode 则是在 Node 的基础上扩展了 branchescodegenNode 属性,以及重写了 Nodetype 属性为 NodeTypes.IF

这里简单介绍一下 IFNode 的两个属性:

  • branches 表示它对应的 elseelse if 的节点,它可能是一个或多个,所以它是一个数组。
  • codegenNode 则是 Vue 3.0 的 AST Element 的一大特点,它描述了该节点的一些属性,例如 isBlockpatchFlagdynamicProps 等等,这些会和 runtime 的时候靶向更新和靶向更新密切相关。

近段时间,我也在写一篇关于 Vue 3.0 如何实现 runtime + compile 优雅地实现靶向更新和静态提升的文章,应该会在下周末竣工。

小结

对于 interface 的介绍和使用,我们这里点到即止。当然,它还有很多高级的使用例如结合泛型、定义函数类型等等。有兴趣的同学可以自行去了解这方面的实战。

交叉类型和联合类型

交叉类型

交叉类型故名思意,有着交叉之效。我们可以通过交叉类型来实现多个类型的合并。例如,在 Vue3 中,compile 阶段除了会进行 baseParse 之外,还会进行 transform,这样最后的 AST 才会进行 generate 生成可执行的代码。所以,compile 阶段它就会对应多个 options,即它也是一个交叉类型:

export type CompilerOptions = ParserOptions & TransformOptions & CodegenOptions

而这个 CompilerOptions 类型别名会用于 baseCompiler 阶段:

export function baseCompile(
  template: string | RootNode,
  options: CompilerOptions = {}
): CodegenResult {}

而为什么是多个 options 的交叉,是因为 baseCompiler 只是最基础的 compiler,上面还有更高级的 compiler-domcompiler-ssr 等等。

联合类型

同样地,联合类型其有着合的效果。即有时候,你希望一个变量的类型是 String
或者是 number 的时候,你就可以使用联合类型来约束这个变量。例如:

let numberOrString: number | string
numberOrString = 1
numberOrString = '1'

并且,在实际的开发中使用联合类型,我们通常会遇到这样的提示,例如:

interface student {
  name: string
  age: number
}
interface teacher {
  name: string
  age: number
  class: string
}
let person: student | tearcher = {} as any

person.class = "信息161"
// property 'person' does not exit on type `student`

这个时候,我们可以利用类型断言,告诉 TypeScript 我们知道 person 是什么:

(person as teacher).class = "信息161"

小结

对于交叉类型和联合类型,应该是属于我们平常开发中会使用频繁的部分。并且,从它们的概念上,不难理解,它们本质上就是数学中的并集交集,只不过在基本类型上有着不同的表现而已。

类型保护与区分类型

类型保护

在讲联合类型的时候,我们说了它本质上是交集,这也导致了我们不能直接使用交集之外的属性或方法。所以,我们得通过在不同情况下使用类型断言来告知 TypeScript 它是什么,从而使用交集之外的属性。

但是,频繁地使用类型断言无疑降低了代码的可读性。而针对这一问题,TypeScript 提供了类型保护的机制来减少类型断言,它是一个主谓宾句,例如仍然是上面的例子,我们可以实现这样的类型保护:

function isTeacher(person: Tearcher | Student): person is Teacher {
   return (<Tearcher>person).class !== undefined
}

然后通过 isTeacher 这个函数来判断当前类型,再进行相应地属性访问:

if (isTeacher(person)) {
  // 这里访问 teacher 独有的属性
  person.class = "信息162"
} else {
  person.name = "wjc"
}

typeof 与 instanceof

有了类型保护,这个时候我们就会遇到这样的问题:如果我的联合类型中存在多个类型,那么岂不是得定义多个类似 isTeacher 这样的借助类型保护的函数?幸运的是,在 TypeScript 使用 typeofinstanceof 可以自动实现类型保护和区分。

typeof

在 TypeScript 中对基础类型使用 typeof 时,会自动进行类型保护,例如:

function isNumberOrString(param: string | number) {
    if (typeof param === 'string') {
        return param.split('')
    } else {
        return param++
    }
}

instanceof

不同于 typeof 只能对基础类型进行类型保护,instanceof 可以实现对所有类型的类型保护。它是通过构造函数来实现类型保护。

interface Person {
    name: string
    age: string
  }
  class Teacher implements Person {
    name: string
    age: string
    constructor(name: string, age: string) {
        this.name = name
        this.age = age
    }
    teach() {
      console.log("i am a teacher.")
    }
  }
  class Student implements Person {
    name: string
    age: string
    constructor(name: string, age: string) {
        this.name = name
        this.age = age
    }
    study() {
      console.log("i am a student.")
    }
  }
  const person: Student | Teacher = null as any

 if (person instanceof Teacher) {
     person.teach()
 } else {
     person.study()
 }

小结

对于 typeofinstanceof 其实在 JavaScript 中也是老生常谈的知识点,因为传统的类型检测我们会更偏向使用 Object.String.prototype.toString() 来实现。但是,反而在 TypeScript 中,它们两者反而混的如鱼得水。

类型别名

类型别名,我想大家脑海中第一时间想到的就是 Webpack 中的 alias 为路径配置别名。但是,TypeScript 中的类型别名与它只是形同,但是意不同。通俗点讲,就是通过它可以给类型起一个名称:

type age = number
const getAge = () => age

// 等同于
interface Age {
  getAge():number
}

字面量类型

字面量类型是指我们可以通过使用类型别名和联合类型来实现枚举类型,例如:

type method = get | GET | post | POST | PUT | put | DELETE | delete

索引类型与映射类型

对于索引类型和映射类型,这里我们通过 loadash 中常用的一个函数 pluck 来讲解:

在 JavaScript 中实现:

function pluck(object, names) {
  return names.map(name => object[name])
}

在 TypeScript 中实现:

function puck<T, K extends keyof T>(object: T, names: K[]): T[K][] {
  return names.map(name => object[name])
}

这里我们为 puck 函数定义了两个泛型变量 TK,其中 K 是继承于 T 中所有属性名的类型,所以形参中 names 被约束为 T 中属性的数组,这个过程被称为类型索引。而对于 puck 函数的返回值 T[K][] ,则代表返回的值是一个数组,并且数组值被约束为 T 中属性值为 K 的值,这个过程被称为索引访问

理解这两种概念可能会有点晦涩,但是对于每一者都分开去理解过程会比较有逻辑性。

结语

虽然,TypeScript 已成为一项前端工程师的必备技能。但是,相信很多小伙伴还是用的 Javascript 比较多。所以,可能会存在困扰,我该如何提高 TypeScript 编程能力?其实,这个问题很简单,开源的时代,今天我们很多问题都可以通过阅读一些开源的项目源码来解决。这里,我推荐大家可以尝试着去阅读 Vue3.0 的源码,相信通过阅读,你的 TypeScript 编程能力会有质的飞跃。

写作不易,如果你觉得有收获的话,可以帅气三连击!!!

参考

灵魂拷问,你真的懂 JavaScript 中的变量提升吗?

引言

对于变量提升这个问题,我想从事前端的同学都或多或少认为我懂这个。曾经,我也是这样认为的,我懂变量提升,并且可以从变量在 Chrome 中的内存分配讲起,以及中间发生了什么。

但是,在一次面试中,我遇到了几个一起面前端的同学(当然技术水平参差不齐,并不是很高),在和他们聊这次笔试中的变量提升的问题时,发现大家都支支吾吾的,很多讲的都是值的覆盖

当时的面试题是这样的:

function fn(a) {
    console.log(a)
    var a = 2
    function a() {}
    console.log(a)
}
fn(1)

这个题目,最终会输出function a(){}2。那么,为什么是这个答案,这个过程发生了什么很重要。所以,今天我们就来彻底刨析一下变量提升的过程。

一、变量在内存中的分配

在分析整个过程前,我们先来回顾一下 JavaScript 中变量在内存中的分配。

大家都知道的是,对于原始类型会存储在栈空间中,对于引用类型会将引用存储在栈空间中,将数据存储在堆空间中

其实这个过程还牵扯到函数上下文的创建,而每一个函数上下文中又会创建一个变量环境、词法环境。有兴趣的同学可以去看李斌老师的浏览器工作原理与实践

所以,我们来看一个简单的栗子,分析一下它在内存中的分配:
栗子:

var a = 1
var b = 2
var student = {name: 'wjc', age: 22}

它内存中的分配:

二、运行前的简单编译

众所周知,JavaScript 是一门动态类型的语言,即它是在运行时确定变量的类型,不同于静态类型语言的先编译再运行的过程。但是,事实是 V8 引擎在解析运行 JavaScript 之前是会进行一次简单的编译,也就是我们通常所说的初始化过程。

这个初始化过程,会做这几件事:

  • 区分执行代码和变量声明代码
  • 变量声明代码划分为赋值代码和初始化代码
  • 初始化代码有两种情况,一是对变量(原生类型、对象类型)初始化为 undefined;二是对函数的初始化,即直接指向函数在堆空间中的内存

那么,我们就来看一个简单的栗子:

console.log(a)
sayHi()
var a = 2
function sayHi() {
    console.log('Hi')
}

那么按照我们上面所说,这段代码的赋值只有 var a = 2,函数声明只有进行编译阶段的代码会是这样的:

// 编译代码
var a = undefined
var sayHi = function () {
    console.log('Hi')
}

此时,它在内存中的分布:

然后,在执行阶段的代码会是这样:

// 执行代码
console.log(a)
sayHi()
a = 2

所以,也就是当我们真正执行的时候会走执行代码,所以很显然会输出:

undefined
Hi

而当走完所有执行代码后,此时内存是这样的:

我想通过这个栗子,大家应该大致搞懂变量提升的过程。但是,仍然存在一个较为特殊的情况,就是当函数形参存在时的变量提升,也就是我们文章开头提及的面试题。

三、函数形参的编译执行

首先,我们需要对函数调用做一个简单的理解,在我们平常调用函数的时候,真正会经历两个步骤

  • 如果此时存在形参,则进行函数形参的编译和执行过程
  • 然后进入函数体,进行函数体内部的编译和执行

可以看到这里我们提到了当函数存在形参时,会先进行函数形参的编译和执行过程。

这里我们就来分析文章开头这个栗子:

function fn(a) {
    console.log(a)
    var a = 2
    function a() {}
    console.log(a)
}
fn(1)

首先,此时是存在函数形参的,那么函数形参的编译和执行会是这样:

var a = undefined
a = 1

然后,才会进行函数体的编译和执行:

// 编译
a = function a() {} // 重点!!!
// 执行
console.log(a)
a = 2
console.log(a)

可以看到的是,如果函数体内的变量名和形参的变量名重复时,则不会进行普通变量的编译赋值 undefined 的过程。但是,如果存在该变量是函数时,那么则会进行函数变量的编译赋值,即直接指向函数在堆空间中的地址。

所以,我们这个栗子在编译后,可以看作是这样的:

function fn() {
    var a = undefined
    a = 1
    a = function a() {}
    console.log(a)
    a = 2
    console.log(2)
}

很显然,它会输出会输出function a(){}2

结语

不知大家在深度理解过变量提升过程后,是否有和我一样的感受就是学习编程的本质是追溯本源。现今,虽然我们可以用 ES6letconst 来声明变量来避免 var 的种种缺陷。但是,如果因为这样而不去思考 var 为什么会存在这些缺陷。我想这是非常遗憾的。

一文读懂,如何工程化实现 macOS App 公证过程

前言

对于 macOS App 开发者来说,我们通常情况下可能会选择在网络上分发 App。但是,站在使用者的角度,如果下载的 App 没有经过 Apple Notary Service 公证(Notarizate)过,这在安装的时候系统则会提示“无法打开 xxx App,因为无法验证开发者”:

那么,这个时候的解决方法就是修改系统偏好——>安全性与隐私的设置,选择仍要打开该 App:

虽然,这样可以让使用者安装 App,但是,这并不是真正在解决这个问题的本质。从根上解决应该是让我们要分发到网络上的 App 通过 Apple Notary Service 公证(Notarizate),这样一来他人下载安装我们应用的时候则不会出现无法打开的提示,而是:

所以,今天本文也将围绕「macOS App 公证」展开如何通过手动或者自动化(Shell、工具)实现公证(Notarizate)过程。

1. 手动公证

首先,我们先了解下如何手动公证?手动公证过程可以通过 Xcode 提供的 GUI 界面操作完成。同样地,首先我们需要构建 .xarchive 文件:

构建完后,Xcode 会弹出窗口让你选择 Distribute App 或 Validate App,这里我们选择前者:

接着,不同于之前分发 App Store,我们需要选择 Developer ID,它表示的是在 App Store 之外分发 App:

然后,我们需要选择 Upload ——> Development Team -> Manually manage signing,此时需要选择 Develop ID 对应的 Distribution 证书和 Provisioning Profile:

最后,则选择 Next ——> Upload,然后则会将我们的 App 上传到 Apple Notary Service 进行公证,通过则会提示我们可以分发 App。

如果,了解过 App Store 分发相关的同学应该熟悉这个手动操作的过程,因为使用 Xcode 公证 App 的过程和 App Store 分发大同小异。所以,在通过简单了解使用 Xcode 手动实现公证过程后,接下来,我们来认识下如何自动化实现公证的过程?

2. 自动化公证

自动化公证则指的是我们通过代码实现前面介绍到的使用 GUI 手动公证的过程。那么,这里我列出了 4 种现在社区中实现的可以对 macOS App 自动化公证的方式:

  • altool --notarize-app 使用的旧的 Apple Notary Service,适用于 Xcode 12 以及更早的版本,但是需要注意的是Apple 将会在 2023 年秋季停止对它的支持
  • notarytool 使用的新的 Apple Notary Service,只适用于 Xcode 13 及以上的版本,相比较 altool --notarize-app 快了 10 倍
  • electron-notarize 用 JavaScript 实现的用于公证的工具,支持 notarytoolaltool --notarize-app 等 2 种公证方式
  • fastlane 用 Ruby 实现的一个可以便捷地帮你完成证书管理、代码签名和发布等相关的工具,适用于 iOS、macOS 和 Android 应用

当然,更进一步的话我们可以把这个自动化公证也加入到 CI/CD 过程中,有兴趣的同学可以自行了解相关实现。所以,接着下面将会对这 4 个的使用做对应的展开介绍,首先是 altool --notarize-app

2.1 altool --notarize-app

altool 是一个内置于 Xcode 中的命令行工具,用于验证 App 的二进制文件并将其上传至 App Store 或者对 App 进行公证(Notarize)。而 altool --notarize-app 也是最早大家使用的实现自动化公证应用的方式,这在社区中也可以看到大量基于它的实践。

altool --notarize-app 则主要是将应用上传到 Apple Notary Service,但是并不会告知你公证成功与否,所以通常需要结合 altool --notarization-info 命令一起使用(核对公证成功与否),整个公证的过程会是这样:

可以看到,首先我们需要使用 altool --notarize-app 将应用上传公证,然后获取本次公证的 UUID:

xcrun altool --notarize-app \
# .app 的压缩包或 .dmg
--file ./Output/Apps/FEKit.zip \
--primary-bundle-id "com.xxxx.xxxx" \
# Apple ID
--username "xxxxx" \
# 应用专用密码,这可以在 https://appleid.apple.com/account/manage 申请
--password "xxxxx" \

然后,终端中则会输出本次公证上传操作的信息和 RequestUUID,前者用于表示本次操作执行是否成功,后者可以用于 altool --notarization-info 命令查询本次公证过程的信息:

No errors uploading 'Output/Apps/FEKit.zip'.
RequestUUID = xxxxxxx-xxx-xxxx-xxxx-xxxxx

进行完公证上传操作后,Apple Notary Service 则会对本次公证执行相关的操作,而这需要一定的时间,所以我们需要(定时)轮询执行 altool --notarization-info 命令实时地获取公证成功失败与否的信息

# 加载 Apple ID($user)和 App 专用密码($pwd)
source "./Build/app_store_user_pwd.sh"

# 标识公证执行过程成功失败与否,失败 1,0 成功
success=1
i=0
while true; do
    let i+=1
    echo "Checking notarize progress...$i"
    # 获取 altool --notarization-info 执行的输出信息
    process=$(xrun altool --notarization-info $uuid --username $user --password $pwd 2>&1)
    echo "${progress}"
    # 如果上次命令执行结果 $? 不等于 0(表示失败),或者命令输出信息中包含 Invalid
    if [ $? -ne 0 ] || [[ "${progress}" =~ "Invalid"]] then
        echo "Error with notarization. Exiting"
        break
    if
    # 如果命令输出信息中包含 success 表示成功
    if [[ "${progress}" =~ "success"]]; then
        success=0
        break
    else
        echo "Not completed yet. Sleeping for 30 seconds.\n"
    fi
    sleep 30
done

if [ $success -eq 0 ]; then
    echo "Notarize successed."
if

其中,$uuid 则是执行 altool --notarize-app 命令后获取的返回结果:

echo xcrun altool --notarize-app \
--file xxxx \
--primary-bundle-id "com.xxxx.xxxx" \
--username $user \
--password $pwd \
2>&1 | grep RequestUUID |  awk '{print $3}'

这里我们来看下大家比较陌生的 2>&1grep RequestUUIDawk '{print $3}' 等 3 个命令的作用:

  • 2>&1 是为了将标准错误 stderr 输出重定向到标准输出 stdout
  • grep RequestUUID 匹配标准输出中所有包含 RequestUUID 的行
  • awk '{print $3}' 打印出第 3 列的结果,在 RequestUUID = xxxxxxx-xxx-xxxx-xxxx-xxxxx 就是 RequestUUID 的值 xxxxxxx-xxx-xxxx-xxxx-xxxxx
  • | 管道,用于将上个命令的输出通过管道输入到下一个命令

2.2 notarytool

相比较 altool --notarization-info 而言,notarytool 使用起来心智负担少一些,并且快于前者很多,我们只需要记忆一些 Option,使用一行命令 xcrun notarytool 则可以实现上传公证和过程信息获取的过程:

xcrun notarytool submit ./Output/Apps/FEKit.zip \
# Apple ID
--apple-id $user \
--team-id $teamId \
# 应用专用密码
--password $pwd \
-v \
-f "json"

其中,--team-id 指的是用户 ID(由数字和字母组成),这可以在本地 KeyChain 的证书中查看(或者 Apple 证书后台),-v--verbose 的缩写,指的是输出公证过程的信息,-f "json" 则是表示最终结果以 JSON 的格式输出,例如:

{
  "path":"\/Users\/wujingchang\/Documents\/project\/demo\/FEKit\/Output\/Apps\/FEKit.zip",
  "message":"Successfully uploaded file",
  "id":"xxxxxxxxxxxxxxxxxxxx"
}

需要注意的是这里使用的是 Appple ID 和应用专用密码的方式做与公证服务的请求认证 Authentication,此外你还可以通过以下 3 种 Option 来进行认证:

  • --keychain-profile <keychain-profile>,使用 xcrun notarytool store-credentials 预先在本地钥匙串中新建一个应用程序密码,例如叫 AC_PASSWORD,那么在使用 notarytool submit 命令的时候则可以直接使用 --keychain-profile AC_PASSWORD 代替之前的 --apple-id $user --team-id $teamId --password $pwd
  • --keychain <keychain>,不同于前者 --keychain-profile 这里是输入的 AC_PASSWORD 文件所在的位置
  • --key <key-id> --key-id <key-id> --issuer <issuer>,这使用的是 App Store Connect API keys 的方式进行认证,本质上是生成一个和 App Store Connect 约定好的 JWT,然后每次请求的时候携带上它,从而通过认证

2.3 electron-notarize

electron-notarize 则是一个用 JavaScript 实现的公证工具,它的原理则是使用的 child_process 执行前面我们提及的 altool --notarization-infonotarytool 这 2 个命令。

electron-notarize 具名导出了 notarize 函数,我们只需要使用它以及指定的 Option 则可以完成公证的过程,这里我们来看下其函数的实现(伪代码):

// src/index.ts
export async function notarize({ appPath, ...otherOptions }: NotarizeOptions) {
  if (otherOptions.tool === 'notarytool') {
    // ...
    await notarizeAndWaitForNotaryTool({
      appPath,
      ...otherOptions,
    });
  } else {
    // ....
    const { uuid } = await startLegacyNotarize({
      appPath,
      ...otherOptions,
    });
    // ...
    await delay(10000);
    // 获取公证过程信息
    await waitForLegacyNotarize({ uuid, ...otherOptions });
  }

  await stapleApp({ appPath });
}

可以看到,notarize 函数会根据 Option 中传入的 toolnotarytoollegacy 来执行不同的命令来完成公证,这里前者是 notarytool 后者则是 altool --notarization-info。所以,如果我们要用 notarytool 的方式进行公证会是这样:

import { notarize } from "electron-notarize"

await notarize({
  appPath: "./Output/Apps/FEKit.zip",
  // Apple ID
  appleId: "xxxxxx",
  // 应用专用密码
  appleIdPassword: "xxxxxxxx",
  teamId: "xxxxxxxxxxx",
  tool: "notarytool"
})

其中,如果你不希望密码直接明文暴露在代码中的话,electron-notarize 也支持了前面我们说的 --keychain--keychain-profile 等 3 个 Option,你可以根据自己的需要选择对应的认证方式。

2.4 fastlane

fastlane 是一个可以便捷地帮你完成证书管理、代码签名和发布等相关的工具,适用于 iOS、macOS 和 Android 应用。那么,我们也就可以使用 fastlane 来完成 macOS App 的公证。

首先,肯定是安装 fastlane,关于这方面的介绍官方文档讲解的很是详尽,这里就不重复论述。而当你安装好 fastlane,则可以在应用项目的根目录执行 fastlane init 来初始化它相关的配置,在初始化的过程会让你选择使用 fastlane 的方式,这里我们选择手动配置即可。

然后,它会在项目根目录下创建一个 fastlane/Fastfile 目录和文件,后续我们在执行 fastlane xxx 命令的时候则会根据该文件的代码实现执行具体的操作,默认生成的 Fastfile 文件的配置会是这样:

default_platform(:ios)

platform :ios do
  desc "Description of what the lane does"
  lane :custome_lane do
    # add actions here: https://docs.fastlane.tools/actions
  end
end

其中,default_platform 用于定义一个默认的平台 Platform,例如当我们有 2 个平台(iOS 和 macOS)的时候,它的的配置需要这样:

default_platform(:ios)

platform :ios do
  desc "Description of what the lane does"
  lane :custome_lane do
    # add actions here: https://docs.fastlane.tools/actions
  end
end
platform :mac do
  desc "Description of what the lane does"
  lane :custome_lane do
    # add actions here: https://docs.fastlane.tools/actions
  end
end

此时,如果我们执行 fastlane custome_lane,由于这里平台默认为 ios,所以则会执行 platorm:ios 下的 custome_lane,反之执行 fastlane mac custome_lane,则是 platform :mac 下的 custome_lane。那么,对于前面我们这个例子而言只需要 platform:mac

default_platform(:ios)

platform :mac do
  desc "Description of what the lane does"
  lane :custome_lane do
    # add actions here: https://docs.fastlane.tools/actions
  end
end

接着,则可以在 platform:mac 写我们需要实现的自动化分发 App Store 相关的代码。fastlane 便捷之处在于它实现了很多开箱即用的 Action,这里我们需要使用 notarize 这个 Action,它可以用于完成 macOS App 的公证:

default_platform(:mac)

platform :mac do
  desc "Notarizes a macOS app"
  lane :notarize_app do
    notarize(
      package: "./Output/Apps/FEKit.zip",
      use_notarytool: "xcrun notarytool",
      bundle_id: "com.xxxx.xxxx",
      username: "xxxxxxxxxxxxxx",
      verbose: true,
    )
  end
end

其中,在使用 notarize 的时候需要注意的是,这里只是声明了你 App Store 的用户名 username,而专用密码需要预先在系统环境变量中添加 FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD(其他认证方式,有兴趣的同学可以自行了解),然后 fastlane 在执行 notarize_app Action 时会去读取该环境变量,从而进行并完成后续的公证过程。

结语

看到这里,我想可能会有同学会问:“这几种实现公证的工具,选择哪个比较好?”,这里比较建议的是选择 fastlane,因为,除开前面提及它的使用方式非常便捷的优点,它具备的能力也很多,不仅仅可以做 App 公证,还可以做 App Store 分发、证书和版本管理等,所以,选择 fastlane 将来也可以支持我们别的诉求,何乐不为呢?

最后,如果文中存在表达不当或错误的地方,欢迎各位同学提 Issue ~

揭秘,Vue3 性能优化之 Non-reactive Object

前言

在 Vue2 中,有一个老生常谈的话题,如何避免 data 中一个复杂对象(自身或属性对象)被默认被创建为响应式(Non-reactive Object)的过程? 举个例子,有一个 Vue2 的组件的 data

<script>
export default {
  data() {
    return {
      list: [
        {
          title: 'item1'
          msg: 'I am item1',
          extData: {
            type: 1
          }
        },
        ...
      ]
    }
  }
}
</script>

这里我们希望 list.extData 不被创建为响应式的对象,相信很多同学都知道,我们可以通过 Object.defineProperty 设置对象 list.extDataconfigurable 属性为 false 来实现。

而在 Vue2 中,我们可以这么做,但是回到 Vue3,同样的问题又要怎么解决呢? 我想这应该是很多同学此时心中持有的疑问。所以,下面让我们一起来由浅至深地去解开这个问题。

1 认识 Reactivity Object 基础

首先,我们先来看一下 Reactivity Object 响应式对象,它是基于使用 Proxy 创建一个原始对象的代理对象和使用 Reflect代理 JavaScript 操作方法,从而完成依赖的收集和派发更新的过程。

然后,我们可以根据需要通过使用 Vue3 提供的 refcomputereactivereadonly 等 API 来创建对应的响应式对象。

这里,我们来简单看个例子:

import { reactive } from '@vue/reactivity'
const list = reactive([
  {
    title: 'item1'
    msg: 'I am item1',
    extData: {
      type: 1
    }
  }
])

可以看到,我们用 reactive 创建了一个响应式数据 list。并且,在默认情况下 list 中的每一项中的属性值为对象的都会被处理成响应式的,在这个例子就是 extData,我们可以使用 Vue3 提供的 isReactive 函数来验证一下:

console.log(`extData is reactive: ${isReactive(list[0].extData)}`)
// 输出 true

控制台输出:

可以看到 extData 对应的对象确实是被处理成了响应式的。假设,list 是一个很长的数组,并且也不需要 list 中每一项的 extData 属性的对象成为响应式的。那么这个默然创建响应式的对象过程,则会产生我们不期望有的性能上的开销(Overhead)

既然,是我们不希望的行为,我们就要想办法解决。所以,下面就让我们从源码层面来得出如何解决这个问题。

2 源码中对 Non-reactivity Object 的处理

首先,我们可以建立一个简单的认知,那就是对于 Non-reactivity Object 的处理肯定是是发生在创建响应式对象之前,我想这一点也很好理解。在源码中,创建响应式对象的过程则都是由 packages/reactivity/src/reactive.ts 文件中一个名为 createReactiveObject 的函数实现的。

2.1 createReactiveObject

这里,我们先来看一下 createReactiveObject 函数的签名:

// core/packages/reactivity/reactive.ts
function createReactiveObject(
  target: Target,
  isReadonly: boolean,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>,
  proxyMap: WeakMap<Target, any>
) {}

可以看到 createReactiveObject 函数总共会接收 5 个参数,我们分别来认识这 5 个函数形参的意义:

  • target 表示需要创建成响应式对象的原始对象
  • isReadonly 表示创建后的响应式对象是要设置为只读
  • baseHandlers 表示创建 Proxy 所需要的基础 handler,主要有 getsetdeletePropertyhasownKeys
  • collectionHandlers 表示集合类型(MapSet 等)所需要的 handler,它们会重写 adddeleteforEach 等原型方法,避免原型方法的调用中访问的是原始对象,导致失去响应的问题发生
  • proxyMap 表示已创建的响应式对象和原始对象的 WeekMap 映射,用于避免重复创建基于某个原始对象的响应式对象

然后,在 createReactiveObject 函数中则会做一系列前置的判断处理,例如判断 target 是否是对象、target 是否已经创建过响应式对象(下面统称为 Proxy 实例)等,接着最后才会创建 Proxy 实例。

那么,显然 Non-reactivity Object 的处理也是发生 createReactiveObject 函数的前置判断处理这个阶段的,其对应的实现会是这样(伪代码):

// core/packages/reactivity/src/reactive.ts
function createReactiveObject(...) {
  // ...
  const targetType = getTargetType(target)
  if (targetType === TargetType.INVALID) {
    return target
  }
  // ...
}

可以看到,只要使用 getTargetType 函数获取传入的 target 类型 targetType 等于 TargetType.INVALID 的时候,则会直接返回原对象 target,也就是不会做后续的响应式对象创建的过程。

那么,这个时候我想大家都会有 2 个疑问:

  • getTargetType 函数做了什么?
  • TargetType.INVALID 表示什么,这个枚举的意义?

下面,让我们分别来一一解开这 2 个疑问。

2.2 getTargetType 和 targetType

同样地,让我们先来看一下 getTargetType 函数的实现:

// core/packages/reactivity/src/reactive.ts
function getTargetType(value: Target) {
  return value[ReactiveFlags.SKIP] || !Object.isExtensible(value)
    ? TargetType.INVALID
    : targetTypeMap(toRawType(value))
}

其中 getTargetType 主要做了这 3 件事:

  • 判断 target 上存在 ReactiveFlags.SKIP 属性,它是一个字符串枚举,值为 __v_ship,存在则返回 TargetType.INVALID
  • 判断 target 是否可扩展 Object.isExtensible 返回 truefalse,为 true 则返回 TargetType.INVALID
  • 在不满足上面 2 者的情况时,返回 targetTypeMap(toRawType(value))

从 1、2 点可以得出,只要你在传入的 target 上设置了 __v_ship 属性、或者使用 Object.preventExtensionsObject.freezeObject.seal 等方式设置了 target 不可扩展,那么则不会创建 target 对应的响应式对象,即直接返回 TargetType.INVALIDTargetType 是一个数字枚举,后面会介绍到)。

在我们上面的这个例子就是设置 extData

{
  type: 1,
  __v_ship: true
}

或者:

Object.freeze({
  type: 1
})

那么,在第 1、2 点都不满足的情况下,则会返回 targetTypeMap(toRawType(value)),其中 toRawType 函数则是基于 Object.prototype.toString.call 的封装,它最终会返回具体的数据类型,例如对象则会返回 Object

// core/packages/shared/src/index.ts
const toRawType = (value: unknown): string => {
  // 等于 Object.prototype.toString.call(value).slice(8, -1)
  return toTypeString(value).slice(8, -1)
}

然后,接着是 targetTypeMap 函数:

// core/packages/reactivity/src/reactive.ts
function targetTypeMap(rawType: string) {
  switch (rawType) {
    case 'Object':
    case 'Array':
      return TargetType.COMMON
    case 'Map':
    case 'Set':
    case 'WeakMap':
    case 'WeakSet':
      return TargetType.COLLECTION
    default:
      return TargetType.INVALID
  }
}

可以看到,targetTypeMap 函数实际上是对我们所认识的数据类型做了 3 个分类:

  • TargetType.COMMON 表示对象 Object、 数组Array
  • TargetType.COLLECTION 表示集合类型,MapSetWeakMapWeakSet
  • TargetType.INVALID 表示不合法的类型,不是对象、数组、集合

其中,TargetType 对应的枚举实现:

const enum TargetType {
  INVALID = 0,
  COMMON = 1,
  COLLECTION = 2
}

那么,回到我们上面的这个例子,由于 list.extDatatoRawType 函数中返回的是数组 Array,所以 targetTypeMap 函数返回的类型则会是 TargetType.COMMON(不等于 TargetType.INVALID),也就是最终会为它创建响应式对象。

因此,在这里我们可以得出一个结论,如果我们需要跳过创建响应式对象的过程,则必须让 target 满足 value[ReactiveFlags.SKIP] || !Object.isExtensible(value) 或者命中 targetTypeMap 函数中的 default 逻辑。

结语

阅读到这里,我想大家都明白了如何在创建一个复杂对象的响应式对象的时候,跳过对象中一些嵌套对象的创建响应式的过程。并且,这个小技巧在某些场景下,不可否认的是一个很好的优化手段,所以提前做好必要的认知也是很重要的。

最后,如果文中存在表达不当或错误的地方,欢迎各位同学提 Issue ~

Vue3 Ref 语法糖,告别 .value 的写法

前言

近期,Vue3 提了一个 Ref Sugar 的 RFC,即 ref 语法糖,目前还处理实验性的(Experimental)阶段。在 RFC 的动机(Motivation)中,Evan You 介绍到在 Composition API 引入后,一个主要未解决的问题是 refsreactive 对象的使用。而到处使用 .value 可能会很麻烦,如果在没使用类型系统的情况下,也会很容易错过:

let count = ref(1)

function add() {
	count.value++
}

所以,一些用户会更倾向于只使用 reactive,这样就不用处理使用 refs.value 问题。而 ref 语法糖的作用是让我们在使用 ref 创建响应式的变量时,可以直接获取和更改变量本身,而不是使用 .value 来获取和更改对应的值。简单的说,站在使用层面可以告别使用 refs 时的 .value 问题:

let count = $ref(1)

function add() {
	count++
}

那么,ref 语法糖目前要怎么在项目中使用?它又是怎么实现的?这是我第一眼看到这个 RFC 建立的疑问,相信这也是很多同学持有的疑问。所以,下面让我们来一一揭晓。

1 Ref 语法糖在项目中的使用

由于 ref 语法糖目前还处于实验性的(Experimental)阶段,所以在 Vue3 中不会默认支持 ref 语法糖。那么,这里我们以使用 Vite + Vue3 项目开发为例,看一下如何开启对 ref 语法糖的支持。

在使用 Vite + Vue3 项目开发时,是由 @vitejs/plugin-vue 插件来实现对 .vue 文件的代码转换(Transform)、热更新(HMR)等。所以,我们需要在 vite.config.js 中给 @vitejs/plugin-vue 插件的选项(Options)传入 refTransform: true

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue({
    refTransform: true
  })]
})

那么,这样一来 @vitejs/plugin-vue 插件内部会根据传入的选项中 refTransform 的值判断是否需要对 ref 语法糖进行特定的代码转换。由于,这里我们设置的是 true,显然它是会对 ref 语法糖执行特定的代码转换。

接着,我们就可以在 .vue 文件中使用 ref 语法糖,这里我们看一个简单的例子:

<template>
	<div>{{count}}</div>
	<button @click="add">click me</button>
</template>

<script setup>
let count = $ref(1)

function add() {
	count++
}
</script>

对应渲染到页面上:

可以看到,我们可以使用 ref 语法糖的方式创建响应式的变量,而不用思考使用的时候要加 .value 的问题。此外,ref 语法糖还支持其他的写法,个人比较推荐的是这里介绍的 $ref 的方式,有兴趣的同学可以去 RFC 上了解其他的写法。

那么,在了解完 ref 语法糖在项目中的使用后,我们算是解答了第一个疑问(怎么在项目中使用)。下面,我们来解答第二个疑问,它又是怎么实现的,也就是在源码中做了哪些处理?

2 Ref 语法糖的实现

首先,我们通过 Vue Playground 来直观地感受一下,前面使用 ref 语法糖的例子中的 <script setup> 块(Block)在编译后的结果:

import { ref as _ref } from 'vue'

const __sfc__ = {
  setup(__props) {
  let count = _ref(1)

  function add() {
    count.value++
  }
}

可以看到,虽然我们在使用 ref 语法糖的时候不需要处理 .value,但是它经过编译后仍然是使用的 .value。那么,这个过程肯定不难免要做很多编译相关的代码转换处理。因为,我们需要找到使用 $ref 的声明语句和变量,给前者重写为 _ref,给后者添加 .value

而在前面,我们也提及 @vitejs/plugin-vue 插件会对 .vue 文件进行代码的转换,这个过程则是使用的 Vue3 提供的 @vue/compiler-sfc 包(Package),它分别提供了对 <script><template><style> 等块的编译相关的函数。

那么,显然这里我们需要关注的是 <script> 块编译相关的函数,这对应的是 @vue/compiler-sfc 中的 compileScript() 函数。

2.1 compileScript() 函数

compileScript() 函数定义在 vue-nextpackages/compiler-sfc/src/compileScript.ts 文件中,它主要负责对 <script><script setup> 块内容的编译处理,它会接收 2 个参数:

  • sfc 包含 .vue 文件的代码被解析后的内容,包含 scriptscriptSetupsource 等属性
  • options 包含一些可选和必须的属性,例如组件对应的 scopeId 会作为 options.id、前面提及的 refTransform

compileScript() 函数的定义(伪代码):

// packages/compiler-sfc/src/compileScript.ts
export function compileScript(
  sfc: SFCDescriptor,
  options: SFCScriptCompileOptions
): SFCScriptBlock {
  // ...
  return {
    ...script,
    content,
    map,
    bindings,
    scriptAst: scriptAst.body
  }
} 

对于 ref 语法糖而言,compileScript() 函数首先会获取选项(Option)中 refTransform 的值,并赋值给 enableRefTransform

const enableRefTransform = !!options.refTransform

enableRefTransform 则会用于之后判断是否要调用 ref 语法糖相关的转换函数。那么,前面我们也提及要使用 ref 语法糖,需要先给 @vite/plugin-vue 插件选项的 refTransform 属性设置为 true,它会被传入 compileScript() 函数的 options,也就是这里的 options.refTransform

接着,会从 sfc 中解构出 scriptSetupsourcefilename 等属性。其中,会先用源文件的代码字符串 source 创建一个 MagicString 实例 s,它主要会用于后续代码转换时对源代码字符串进行替换、添加等操作,然后会调用 parse() 函数来解析 <script setup> 的内容,即 scriptSetup.content,从而生成对应的抽象语法树 scriptSetupAst

let { script, scriptSetup, source, filename } = sfc
const s = new MagicString(source)
const startOffset = scriptSetup.loc.start.offset
const scriptSetupAst = parse(
  scriptSetup.content,
  {
    plugins: [
      ...plugins,
      'topLevelAwait'
    ],
    sourceType: 'module'
  },
  startOffset
)

parse() 函数内部则是使用的 @babel/parser 提供的 parser 方法进行代码的解析并生成对应的 AST。对于上面我们这个例子,生成的 AST 会是这样:

{
  body: [ {...}, {...} ],
  directives: [],
  end: 50,
  interpreter: null,
  loc: {
    start: {...}, 
    end: {...}, 
    filename: undefined, 
    identifierName: undefined
  },
  sourceType: 'module',
  start: 0,
  type: 'Program'
}

注意,这里省略了 bodystartend 中的内容

然后,会根据前面定义的 enableRefTransform 和调用 shouldTransformRef() 函数的返回值(truefalse)来判断是否进行 ref 语法糖的代码转换。如果,需要进行相应的转换,则会调用 transformRefAST() 函数来根据 AST 来进行相应的代码转换操作:

if (enableRefTransform && shouldTransformRef(scriptSetup.content)) {
  const { rootVars, importedHelpers } = transformRefAST(
    scriptSetupAst,
    s,
    startOffset,
    refBindings
  )
}

在前面,我们已经介绍过了 enableRefTransform。这里我们来看一下 shouldTransformRef() 函数,它主要是通过正则匹配代码内容 scriptSetup.content 来判断是否使用了 ref 语法糖:

// packages/ref-transform/src/refTransform.ts
const transformCheckRE = /[^\w]\$(?:\$|ref|computed|shallowRef)?\(/

export function shouldTransform(src: string): boolean {
  return transformCheckRE.test(src)
}

所以,当你指定了 refTransformtrue,但是你代码中实际并没有使用到 ref 语法糖,则在编译 <script><script setup> 的过程中也不会执行ref 语法糖相关的代码转换操作,这也是 Vue3 考虑比较细致的地方,避免了不必要的代码转换操作带来性能上的开销。

那么,对于我们这个例子而言(使用了 ref 语法糖),则会命中上面的 transformRefAST() 函数。而 transformRefAST() 函数则对应的是 packages/ref-transform/src/refTransform.ts 中的 transformAST() 函数。

所以,下面我们来看一下 transformAST() 函数是如何根据 AST 来对 ref 语法糖相关代码进行转换操作的。

2.2 transformAST() 函数

transformAST() 函数中主要是会遍历传入的原代码对应的 AST,然后通过操作源代码字符串生成的 MagicString 实例 s 来对源代码进行特定的转换,例如重写 $ref_ref、添加 .value 等。

transformAST() 函数的定义(伪代码):

// packages/ref-transform/src/refTransform.ts
export function transformAST(
  ast: Program,
  s: MagicString,
  offset = 0,
  knownRootVars?: string[]
): {
  // ...
  walkScope(ast)
  (walk as any)(ast, {
    enter(node: Node, parent?: Node) {
      if (
        node.type === 'Identifier' &&
        isReferencedIdentifier(node, parent!, parentStack) &&
        !excludedIds.has(node)
      ) {
        let i = scopeStack.length
        while (i--) {
          if (checkRefId(scopeStack[i], node, parent!, parentStack)) {
            return
          }
        }
      }
    }
  })
  
  return {
    rootVars: Object.keys(rootScope).filter(key => rootScope[key]),
    importedHelpers: [...importedHelpers]
  }
}

可以看到 transformAST() 会先调用 walkScope() 来处理根作用域(root scope),然后调用 walk() 函数逐层地处理 AST 节点,而这里的 walk() 函数则是使用的 Rich Haris 写的 estree-walker

下面,我们来分别看一下 walkScope()walk() 函数做了什么。

walkScope() 函数

首先,这里我们先来看一下前面使用 ref 语法糖的声明语句 let count = $ref(1) 对应的 AST 结构:

可以看到 let 的 AST 节点类型 type 会是 VariableDeclaration,其余的代码部分对应的 AST 节点则会被放在 declarations 中。其中,变量 count 的 AST 节点会被作为 declarations.id ,而 $ref(1) 的 AST 节点会被作为 declarations.init

那么,回到 walkScope() 函数,它会根据 AST 节点的类型 type 进行特定的处理,对于我们这个例子 let 对应的 AST 节点 typeVariableDeclaration 会命中这样的逻辑:

function walkScope(node: Program | BlockStatement) {
  for (const stmt of node.body) {
    if (stmt.type === 'VariableDeclaration') {
      for (const decl of stmt.declarations) {
        let toVarCall
        if (
          decl.init &&
          decl.init.type === 'CallExpression' &&
          decl.init.callee.type === 'Identifier' &&
          (toVarCall = isToVarCall(decl.init.callee.name))
        ) {
          processRefDeclaration(
            toVarCall,
            decl.init as CallExpression,
            decl.id,
            stmt
          )
        }
      }
    }
  }
}

这里的 stmt 则是 let 对应的 AST 节点,然后会遍历 stmt.declarations,其中 decl.init.callee.name 指的是 $ref,接着是调用 isToVarCall() 函数并赋值给 toVarCall

isToVarCall() 函数的定义:

// packages/ref-transform/src/refTransform.ts
const TO_VAR_SYMBOL = '$'
const shorthands = ['ref', 'computed', 'shallowRef']
function isToVarCall(callee: string): string | false {
  if (callee === TO_VAR_SYMBOL) {
    return TO_VAR_SYMBOL
  }
  if (callee[0] === TO_VAR_SYMBOL && shorthands.includes(callee.slice(1))) {
    return callee
  }
  return false
}

在前面我们也提及 ref 语法糖可以支持其他写法,由于我们使用的是 $ref 的方式,所以这里会命中 callee[0] === TO_VAR_SYMBOL && shorthands.includes(callee.slice(1)) 的逻辑,即 toVarCall 会被赋值为 $ref

然后,会调用 processRefDeclaration() 函数,它会根据传入的 decl.init 提供的位置信息来对源代码对应的 MagicString 实例 s 进行操作,即将 $ref 重写为 ref

// packages/ref-transform/src/refTransform.ts
function processRefDeclaration(
    method: string,
    call: CallExpression,
    id: VariableDeclarator['id'],
    statement: VariableDeclaration
) {
  // ...
  if (id.type === 'Identifier') {
    registerRefBinding(id)
    s.overwrite(
      call.start! + offset,
      call.start! + method.length + offset,
      helper(method.slice(1))
    )
  } 
  // ...
}

位置信息指的是该 AST 节点在源代码中的位置,通常会用 startend 表示,例如这里的 let count = $ref(1),那么 count 对应的 AST 节点的 start 会是 4、end 会是 9。

因为,此时传入的 id 对应的是 count 的 AST 节点,它会是这样:

{
  type: "Identifier",
  start: 4,
  end: 9,
  name: "count"
}

所以,这会命中上面的 id.type === 'Identifier' 的逻辑。首先,会调用 registerRefBinding() 函数,它实际上是调用的是 registerBinding(),而 registerBinding 会在当前作用域 currentScope 上绑定该变量 id.name 并设置为 true ,它表示这是一个用 ref 语法糖创建的变量,这会用于后续判断是否给某个变量添加 .value

const registerRefBinding = (id: Identifier) => registerBinding(id, true)
function registerBinding(id: Identifier, isRef = false) {
  excludedIds.add(id)
  if (currentScope) {
    currentScope[id.name] = isRef
  } else {
    error(
      'registerBinding called without active scope, something is wrong.',
      id
    )
  }
}

可以看到,在 registerBinding() 中还会给 excludedIds 中添加该 AST 节点,而 excludeIds 它是一个 WeekMap,它会用于后续跳过不需要进行 ref 语法糖处理的类型为 Identifier 的 AST 节点。

然后,会调用 s.overwrite() 函数来将 $ref 重写为 _ref,它会接收 3 个参数,分别是重写的起始位置、结束位置以及要重写为的字符串。而 call 则对应着 $ref(1) 的 AST 节点,它会是这样:

{
  type: "Identifier",
  start: 12,
  end: 19,
  callee: {...}
  arguments: {...},
  optional: false
}

并且,我想大家应该注意到了在计算重写的起始位置的时候用到了 offset,它代表着此时操作的字符串在源字符串中的偏移位置,例如该字符串在源字符串中的开始,那么偏移量则会是 0

helper() 函数则会返回字符串 _ref,并且在这个过程会将 ref 添加到 importedHelpers 中,这会在 compileScript() 时用于生成对应的 import 语句:

function helper(msg: string) {
  importedHelpers.add(msg)
  return `_${msg}`
}

那么,到这里就完成了对 $ref_ref 的重写,也就是此时我们代码的会是这样:

let count = _ref(1)

function add() {
	count++
}

接着,则是通过 walk() 函数来将 count++ 转换成 count.value++。下面,我们来看一下 walk() 函数。

walk() 函数

前面,我们提及 walk() 函数使用的是 Rich Haris 写的 estree-walker,它是一个用于遍历符合 ESTree 规范的 AST 包(Package)。

walk() 函数使用起来会是这样:

import { walk } from 'estree-walker'

walk(ast, {
  enter(node, parent, prop, index) {
    // ...
  },
  leave(node, parent, prop, index) {
    // ...
  }
});

可以看到,walk() 函数中可以传入 options,其中 enter() 在每次访问 AST 节点的时候会被调用,leave() 则是在离开 AST 节点的时候被调用。

那么,回到前面提到的这个例子,walk() 函数主要做了这 2 件事:

1.维护 scopeStack、parentStack 和 currentScope

scopeStack 用于存放此时 AST 节点所处的作用域链,初始情况下栈顶为根作用域 rootScopeparentStack 用于存放遍历 AST 节点过程中的祖先 AST 节点(栈顶的 AST 节点是当前 AST 节点的父亲 AST 节点);currentScope 指向当前的作用域,初始情况下等于根作用域 rootScope

const scopeStack: Scope[] = [rootScope]
const parentStack: Node[] = []
let currentScope: Scope = rootScope

所以,在 enter() 的阶段会判断此时 AST 节点类型是否为函数、块,是则入栈 scopeStack

parent && parentStack.push(parent)
if (isFunctionType(node)) {
  scopeStack.push((currentScope = {}))
  // ...
  return
}
if (node.type === 'BlockStatement' && !isFunctionType(parent!)) {
  scopeStack.push((currentScope = {}))
  // ...
  return
}

然后,在 leave() 的阶段判断此时 AST 节点类型是否为函数、块,是则出栈 scopeStack,并且更新 currentScope 为出栈后的 scopeStack 的栈顶元素:

parent && parentStack.pop()
if (
  (node.type === 'BlockStatement' && !isFunctionType(parent!)) ||
  isFunctionType(node)
) {
  scopeStack.pop()
  currentScope = scopeStack[scopeStack.length - 1] || null
}

2.处理 Identifier 类型的 AST 节点

由于,在我们的例子中 ref 语法糖创建 count 变量的 AST 节点类型是 Identifier,所以这会在 enter() 阶段命中这样的逻辑:

if (
    node.type === 'Identifier' &&
    isReferencedIdentifier(node, parent!, parentStack) &&
    !excludedIds.has(node)
  ) {
    let i = scopeStack.length
    while (i--) {
      if (checkRefId(scopeStack[i], node, parent!, parentStack)) {
        return
      }
    }
  }

if 的判断中,对于 excludedIds 我们在前面已经介绍过了,而 isReferencedIdentifier() 则是通过 parenStack 来判断当前类型为 Identifier 的 AST 节点 node 是否是一个引用了这之前的某个 AST 节点。

然后,再通过访问 scopeStack 来沿着作用域链来判断是否某个作用域中有 id.name(变量名 count)属性以及属性值为 true,这代表它是一个使用 ref 语法糖创建的变量,最后则会通过操作 ss.appendLeft)来给该变量添加 .value

function checkRefId(
    scope: Scope,
    id: Identifier,
    parent: Node,
    parentStack: Node[]
): boolean {
  if (id.name in scope) {
    if (scope[id.name]) {
      // ...
      s.appendLeft(id.end! + offset, '.value')
    }
    return true
  }
  return false
}

结语

通过了解 ref 语法糖的实现,我想大家应该会对语法糖这个术语会有不一样的理解,它的本质是在编译阶段通过遍历 AST 来操作特定的代码转换操作。并且,这个实现过程的一些工具包(Package)的配合使用也是非常巧妙的,例如 MagicString 操作源代码字符串、estree-walker 遍历 AST 节点等。

最后,如果文中存在表达不当或错误的地方,欢迎各位同学提 Issue ~

Esbuild 入口文件及启动过程|源码解读

前言

又回到了经典的一句话:“先知其然,而后使其然”。相信很多同学都知道了 esbuild,其以飞快的构建速度闻名于众。并且,esbuild 作者 Evan Wallace 也在官网的 FAQ专门介绍了为什么 esbuild 会这么快?(有兴趣的同学可以自行了解 https://esbuild.github.io/faq/)

那么,回到今天本文,将会从 esbuild 源码的目录结构入手,围绕以下 2 点和大家一起走进 esbuild 底层的世界:

  • 初识 Esbuild 构建的入口
  • Esbuild 构建的入口做了什么

1 初识 Esbuild 构建的入口

在 Go 中,是以 package (包)来划分模块,每个 Go 的应用程序都需要包含一个入口 package main,即 main.go 文件。那么,显然 esbuild 本身也是一个 Go 应用,即它的入口文件同样也是 main.go 文件。

而对于 esbuild,它的目录结构:

|—— cmd
|—— docs
|—— images
|—— internal
|—— lib
|—— npm
|—— pkg
|—— require
|—— scripts
.gitignore
go.mod
go.sum
Makefile
README.md
version.txt

似乎一眼望去,并没有我们想要的 main.go 文件,那么我们要怎么找到整个应用的入口?

学过 C 的同学,应该知道 Make 这个构建工具,它可以用于执行我们定义好的一系列命令,来实现某个构建目标。并且,不难发现的是上面的目录结构中有一个 Makefile 文件,它则是用来注册 Make 命令的。

而在 Makefile 文件中注册规则的基础语法会是这样:

<target> : <prerequisites> 
[tab]  <commands>

这里,我们来分别认识一下各个参数的含义:

  • target 构建的目标,即使用 Make 命令的目标,例如 make 某个目标名
  • prerequisites 前置条件,通常是一些文件对应的路径,一旦这些文件发生变动,在执行 Make 命令时,就会进行重新构建,反之不会
  • tab 固定的语法格式要求,命令 commands 的开始必须为一个 tab
  • commands 命令,即执行 Make 命令构建某个目标时,对应会执行的命令

那么,下面我们来看一下 esbuild 中 Makefile 文件中的内容:

ESBUILD_VERSION = $(shell cat version.txt)

# Strip debug info
GO_FLAGS += "-ldflags=-s -w"

# Avoid embedding the build path in the executable for more reproducible builds
GO_FLAGS += -trimpath

esbuild: cmd/esbuild/version.go cmd/esbuild/*.go pkg/*/*.go internal/*/*.go go.mod
	CGO_ENABLED=0 go build $(GO_FLAGS) ./cmd/esbuild

test:
	make -j6 test-common

# These tests are for development
test-common: test-go vet-go no-filepath verify-source-map end-to-end-tests js-api-tests plugin-tests register-test node-unref-tests

# These tests are for release (the extra tests are not included in "test" because they are pretty slow)
test-all:
	make -j6 test-common test-deno ts-type-tests test-wasm-node test-wasm-browser lib-typecheck
....

注意:这里只是列出了 Makefile 文件中的部分规则,有兴趣的同学可以自行查看其他规则~

可以看到,在 Makefile 文件中注册了很多规则。而我们经常使用的 esbuild 命令,则对应着这里的 esbuild 目标。

根据上面对 Makefile 的介绍以及结合这里的内容,我们可以知道的是 esbuild 命令的核心是由 cmd/esbuild/version.go cmd/esbuild/*.gopkg/*/*.gointernal/*/*.go go.mod 这三部分相关的文件实现的。

那么,通常执行 make esbuild 命令,其本质上是执行命令:

CGO_ENABLED=0 go build $(GO_FLAGS) ./cmd/esbuild

下面,我们来分别看一下这个命令做了什么(含义):

CGO_ENABLED=0

CGO_ENABLED 是 Go 的环境(env)信息之一,我们可以用 go env 命令查看 Go 支持的所有环境信息。

而这里将 CGO_ENABLED 设为 0 是为了禁用 cgo,因为默认情况下,CGO_ENABLED1,也就是开启 cgo 的,但是 cgo 是会导入一些包含 C 代码的文件,那么也就是说最后编译的结果会包含一些外部动态链接,而不是纯静态链接

cgo 可以让你在 .go 文件中使用 C 的语法,这里不做详细的展开介绍,有兴趣的同学可以自行了解

那么,这个时候大家可能会思考外部动态链接静态链接之间的区别是什么?为什么需要纯静态链接的编译结果?

这是因为外部动态链接会打破你最后编译出的程序对平台的适应性。因为,外部动态链接存在一定的不确定因素,简单的说也许你现在构建出来的应用是可以用的,但是在某天外部动态链接的内容发生了变化,那么很可能会对你的程序运行造成影响。

go build $(GO_FLAGS) ./cmd/esbuild

go build $(GO_FLAGS) ./cmd/esbuild 的核心是 go build 命令,它是用于编译源码文件、代码包、依赖包等操作,例如我们这里是对 ./cmd/esbuild/main.go 文件执行编译操作。

到这里,我们就已经知道了 esbuild 构建的入口是 cmd/esbuild/main.go 文件了。那么,接下来就让我们看一下构建的入口都做了哪些事情?

2 Esbuild 构建的入口做了什么?

虽然,Esbuild 构建的入口 cmd/esbuild/main.go 文件的代码总共才 268 行左右。但是,为了方便大家理解,这里我将拆分为以下 3 点来分步骤讲解:

  • 基础依赖的 package 导入
  • --help 的文字提示函数的定义
  • main 函数具体都做了哪些

2.1 基础依赖的 package 导入

首先,是基础依赖的 package 导入,总共导入了 8 个 package

import (
	"fmt"
	"os"
	"runtime/debug"
	"strings"
	"time"

	"github.com/evanw/esbuild/internal/api_helpers"
	"github.com/evanw/esbuild/internal/logger"
	"github.com/evanw/esbuild/pkg/cli"
)

这 8 个 package 分别对应的作用:

  • fmt 用于格式化输出 I/O 的函数
  • os 提供系统相关的接口
  • runtime/debug 提供程序在运行时进行调试的功能
  • strings 用于操作 UTF-8 编码的字符串的简单函数
  • time 用于测量和展示时间
  • github.com/evanw/esbuild/internal/api_helpers 用于检测计时器是否正在使用
  • github.com/evanw/esbuild/internal/logger 用于格式化日志输出
  • github.com/evanw/esbuild/pkg/cli 提供 esbuild 的命令行接口

2.2 --help 的文字提示函数的定义

任何一个工具都会有一个 --help 的选项(option),用于告知用户能使用的具体命令。所以,esbuild 的 --help 文字提示函数的定义也具备同样的作用,对应的代码(伪代码):

var helpText = func(colors logger.Colors) string {
	return `
` + colors.Bold + `Usage:` + colors.Reset + `
  esbuild [options] [entry points]

` + colors.Bold + `Documentation:` + colors.Reset + `
  ` + colors.Underline + `https://esbuild.github.io/` + colors.Reset + `

` + colors.Bold
  ...
}

这里会用到我们上面提到的 logger 这个 packageColors 结构体,它主要用于美化在终端输出的内容,例如加粗(Bold)、颜色(RedGreen):

type Colors struct {
	Reset     string
	Bold      string
	Dim       string
	Underline string

	Red   string
	Green string
	Blue  string

	Cyan    string
	Magenta string
	Yellow  string
}

而使用 Colors 结构体创建的变量会是这样:

var TerminalColors = Colors{
	Reset:     "\033[0m",
	Bold:      "\033[1m",
	Dim:       "\033[37m",
	Underline: "\033[4m",

	Red:   "\033[31m",
	Green: "\033[32m",
	Blue:  "\033[34m",

	Cyan:    "\033[36m",
	Magenta: "\033[35m",
	Yellow:  "\033[33m",
}

2.3 main 函数主要都做了哪些

在前面,我们也提及了每个 Go 的应用程序都必须要有一个 main package,即 main.go 文件来作为应用的入口。而在 main.go 文件内也必须声明 main 函数,来作为 package 的入口函数。

那么,作为 esbuild 的入口文件的 main 函数,主要是做这 2 件事:

1. 获取输入的选项(option),并进行处理

使用我们上面提到的 os 这个 package 获取终端输入的选项,即 os.Args[1:]。其中 [1:] 表示获取数组从索引为 1 到最后的所有元素构成的数组。

然后,会循环 osArgs 数组,每次会 switch 判断具体的 case,对不同的选项,进行相应的处理。例如 --version 选项,会输出当前 esbuild 的版本号以及退出:

fmt.Printf("%s\n", esbuildVersion)
os.Exit(0)

这整个过程对应的代码会是这样:

osArgs := os.Args[1:]
argsEnd := 0
for _, arg := range osArgs {
  switch {
  case arg == "-h", arg == "-help", arg == "--help", arg == "/?":
    logger.PrintText(os.Stdout, logger.LevelSilent, os.Args, helpText)
    os.Exit(0)

  // Special-case the version flag here
  case arg == "--version":
    fmt.Printf("%s\n", esbuildVersion)
    os.Exit(0)
    ...
  default:
    osArgs[argsEnd] = arg
    argsEnd++
  }
}

并且,值得一提的是这里会重新构造 osArgs 数组,由于选项是可以一次性输入多个的,
但是 osArgs 会在后续的启动构建的时候作为参数传入,所以这里处理过的选项会在数组中去掉。

2. 调用 cli.Run(),启动构建

对于使用者来说,我们切实关注的是使用 esbuild 来打包某个应用,例如使用 esbuild xxx.js --bundle 命令。而这个过程由 main 函数最后的自执行函数完成。

该函数的核心是调用 cli.Run() 来启动构建过程,并且传入上面已经处理过的选项。

func() {
  ...
  exitCode = cli.Run(osArgs)
}()

并且,在正式开启构建之前,会根据继续处理前面的选项相关的逻辑,具体会涉及到 CPU 跟踪、堆栈的跟踪等,这里不作展开介绍,有兴趣的同学自行了解。

结语

好了,到这里我们就大致过了一遍 esbuild 构建的入口文件相关源码。站在没接触过 Go 的同学角度看可能稍微有点晦涩,并且有些分支逻辑,文中并没有展开分析,这会在后续的文章中继续展开。但是,总体上来看,打开一个新的窗户看到了不一样的风景,这不就是我们作为工程师所希望经历的嘛 😎。最后,如果文中存在表达不当或错误的地方,欢迎各位同学提 Issue~

macOS App IDs, Certificates & Profiles 基础和自动化管理

前言

在前面「macOS App 自动化分发 App Store 探索与实践」「一文带你读懂何为 macOS App 公证,以及如何自动化实现」 2 篇文章中,分别给大家介绍了 macOS 分发自动化实现相关的内容。其中,我们总是会提及证书、App IDs 和 Provisioning Profile 等相关内容,并且证书和 Profiles 在不同的分发渠道(网络或 App Store)都有所区分。

虽然,直接通过 Apple Developer 的账户(Account)后台可以查看前面提及的内容。但是,由于这个的前提是要求我们注册 Apple Developer Program(付费 688),所以,存在一定的使用之前的成本(费用),那么或多或少就有同学对此存在一定的盲区,以及在了解后也会遇到协作和管理的问题。

那么,今天本文也将会从证书、App IDs、Provisioning Profile 作为开始,带大家一起从理论基础出发,再到如何使用工具(fastlane match)实现自动化管理。

1 证书、App IDs、Provisioning Profile

Apple Developer 的账户(Account)后台,有专门用于查看 Certificates, Identifiers & Profiles 的页面,你可以在这里管理你的证书 Certificates、App IDs(Identifiers)和 Provisioning Profile:

下面,我们分别来看下这 3 者。那么,首先是 App IDs(Identifiers)。

1.1 App IDs(Identifiers)

App IDs(Identifiers),它是 Identifiers 的一种,用于使得你的 App、App Extension 或 App Clip 能够访问可用服务,以及可以在 Provisioning Profile 中标识你的 App。

也就是说,App IDs 是一个应用的唯一标识,是必须要创建的,它的命名遵循 reverse-domain 的风格,例如 com.domainname.appname。并且,我们可以通过查看 macOS 上 App 的包内容(应用程序->右键-> info.plist),例如 macOS 上的微信的 App IDs 会是 com.tencent.xinWeChat

1.2 证书 Certificates

然后,接着是证书 Certificates,证书分为这 2 类:

  • 软件 Software,包括用于应用(iOS、macOS 和 watchOS 等)代码签名、Apple Store 分发(Distribution)、安装包(Installer package)签名等证书
  • 服务 Services,包括苹果推送服务( Apple Push Notification service )、苹果支付处理证书(Aple Pay Payment Processing Certificate)等证书

并且,值得一提的是证书的创建使用的是一种 创建证书签名请求 的方式,这种方式遵循的非对称加密(RSA),它会在本地创建一对公用密钥和专用密钥(以下统称私钥 Private Key),其中,公钥用于加密证书,私钥是保存在创建者本地的钥匙串 KeyChain 中。

那么,如果你想将证书分享给别人使用,但是由于非对称加密的限制(证书是加密的),我们还需要提供解密证书对应的私钥,所以,通常情况下我们是在钥匙串中导出个人信息交换(.p12)文件,它会包含证书本身和解密它需要的私钥:

此外,关于各个证书的详细作用有兴趣的同学可以自行了解。这里,我们来看下软件 Software 证书中 macOS 开发和分发相关的 4 个证书:

  • Mac Development,用于 macOS 应用开发版本的代码签名处理,例如,如果你有一个 FEKit.app 文件没有经过应用的签名,则可以使用 codesign 命令进行签名:
codesign -s "Development" ./FEKit.app
  • Developer ID Application,用于 macOS 应用正式版本的代码签名处理:
codesign -s "Developer ID Application: Jingchang wu(xxxxxxxxxx)" ./FEKit.app
  • Mac Installer Distribution,用于对安装包(Install Package)进行代码签名以提交到 App Store

  • Mac App Distribution,用于对应用程序进行代码签名,以及配置分发 Provisioning Profile 用于提交到 App Store

1.3 Provisioning Profile

最后,则是 Provisioning(配置)Profile,Provisioning Profile 分为这 2 类:

  • 开发 Development,用于配置可以安装开发版本应用的设备有哪些,也就是在创建 Development 下的某类 Provisioning Profile 文件的时候需要填入测试设备的硬件 UUID(关于本机->系统报告)

  • 分发 Distribution,用于配置应用分发相关,例如提交 AppStore、使用 Apple Services 等

那么,对于 macOS 而言,我们则需要创建 **macOS App Development(配置开发版本应用的测试设备)、
Mac App Store(提交 AppStore)和 Developer ID(在网络上分发)**等 3 个 Provisioning Profile。

2 fastlane match 自动化管理

通常,我们一个项目会有多个开发者 Developer,那如何在多个开发者之间共享同一份 Mac Development、Developer ID Application 等证书或 Provisioning Profile 文件,就成为了一个比较琐碎的问题

所以,fastlane 提供了一个名为 sync_code_signing 的 Action 用于解决上面提及的这个问题,它的 alias 是 match,也就是我们可以通过 fastlane match xxx 之类的命令来完成证书和 Provisioning Profile 文件的维护(创建、更新)和拉取(同步到开发者本地),而这些操作则是使用的 Apple Store Connect API 提供的接口实现的。

其中,由于 fastlane 需要指定的可以用于存储证书和 Provisioning Profile 文件的地方,你可以使用 Git Repo、Google CloudAmazon S3 其中的 1 种作为存储的选择,这里我们选择用 GitLab 的 Repo。

那么,首先是在你的项目中执行 fastlane match init 命令,它会提示你要选择的存储方式:

这里我们选择 Git,然后需要输入 Repo 的地址,最后它会在当前项目路径的 ./fastlane 文件目录中创建 Matchfile 文件:

git_url("https://gitlab.com/xxxxx/xxxxxxxxxx.git")

storage_mode("git")

type("development") # The default type, can be: appstore, adhoc, enterprise or development

# app_identifier(["tools.fastlane.app", "tools.fastlane.app2"])
# username("[email protected]") # Your Apple Developer Portal username

可以看到,默认设置了 git_url 为前面输入的 Git Repo 的地址(以下统称 Match Repo),然后 storage_mode 设置的为 git,并且默认会设置 typedevelopment,这意味着在执行 fastlane match development --readonly 命令的时候会获取所有 development 相关的证书和 Provisioning Profile 文件(如果没有则会重新创建)。

由于,很多情况下我们使用 fastlane match <type> 相关的命令的时候,我们可能已经创建好了各类的证书和 Provisioning Profile 文件。所以,不能直接使用 fastlane match <type> 的命令,这样会给你撤销之前已创建好的这些文件并创建一个新的

因此,我们需要使用 fastlane match import 命令来将本地的证书(.cer)、个人信息交换(.p12)和 Provisioning Profile 文件手动导入到 Match Repo 中,例如我们需要把 Developer ID Application 相关的证书、p12、Provisioning Profile 文件导入:

1.首先,修改下之前的 Matchfile 文件的内容:

# 注意,默认为 master 分支
git_url("https://gitlab.com/xxxxx/xxxxxxxxxx.git")

storage_mode("git")
# 指定证书、.p12、Provisioning Profile 的类型
type("developer_id")
# 指定我们的 App IDs
app_identifier("com.xxx.xxxx")

2.接着,将本地的上面提及的文件在钥匙串中分别导出到文件系统中。

3.最后,使用 fastlane match import 命令,此时会让你输入各个文件所在的位置,然后需要输入密码用于证书的加密,也就是说其他人从 Match Repo 拉取的时候需要输入对应的密码用于解密,接着则还会让你输入 Apple Developer Program 账户来对这些文件进行验证,如果你不期望验证可以使用 --skip_certificate_matching true Option 来跳过这个步骤。

在完成上述文件的导入后,在 Match Repo 中可以看到新增了 /certs/developer_id_application/ 和 /profiles/developer_id/ 等 2 个目录结构,后续我们导入别的的证书(.cer)、个人信息交换(.p12)和 Provisioning Profile 文件则会一并添加到 /certs 或 /profiles 目录下:

certs
  |———— developer_id_application
        |__ xxxxx.cert
        |__ xxxxx.p12
profiles
  |———— developer_id
        |__ xxxxxxxxxxxxxxxxxxx.provisionprofile

然后,如果有同学在开发过程中需要使用到 Developer ID Application 相关的证书、私钥(.p12)和 Provisioning Profile 文件,则可以通过 fastlane match developer_id --readonly 命令获取这些文件到本地的钥匙串中,其中 --readonly 只拉取 Match Repo 中存在的 developer_id 相关文件,不会自动创建一个新的。

结语

通过阅读,对于之前一直没有接触过这方面知识的同学,我想已经对证书、App IDs 和 Provisioning Profile 等相关内容都或多或少有了一定的了解。并且,通过简单地了解 fastlane match 提供的能力,也可以让团队在这方面的协作效率和维护管理都得到了提高。此外,值得一提的是 fastlane match 提供了很多参数,来支持更多个性化的操作,例如过期自动创建、撤销指定类型的证书等,所以有兴趣的同学也推荐在文档中继续了解一番。

最后,如果文中存在表达不当或错误的地方,欢迎各位同学提 Issue ~

从零到一,带你彻底搞懂 vite 中的 HMR 原理(源码分析)

前言

距离尤大大在微博宣布「vite」的出现,不知不觉间已经过了一段时间了。

当时,「vite」只是支持对 .vue 文件的即时编译和 importrewrite,相应地「Plugin」也没有几个。并且,最初在「GitHub」上「vite」的 slogan 是这样的:

—— No-bundle Dev Server for Vue 3 Single-File Components.

可以看到,起初介绍「vite」是一个不需要打包的开发阶段的服务器。但是,现在再回首,这句 slogan 已经消失了,而「vite」也已经处于 「beta」 阶段。并且,不仅仅是一个开发阶段的服务器这么简单。相应地也实现了很多「Feature」,例如:Web Assembly、JSX
、CSS Pre-processors、Dev Server Proxy 等等。

有兴趣了解这些「Feature」的同学,可以移步GitHub自行阅读

这两个月的时间,「vite」发展的劲头是非(xue)常(bu)猛(dong)的。并且,也出现了很多关于「vite」的文章,可以说是:“ 如雨后春笋般,络绎不绝 ”。

那么,作为一名「Vue」爱好者,我同样对「vite」充满了好奇。所以,回到本次文章,我会先浅析 webpack-dev-server的「HMR」,然后再循序渐进地讲解「vite」在「HMR」这个过程做了什么。

Webpack 的 HMR 过程

提及「HMR」,不可避免地是会想起现在我们家喻户晓webpack-dev-server 中的「HMR」。所以,我们先来了解一番webpack-dev-server的「HMR」。

首先,我们先对「HMR」建立一个基础的认知。「HMR」 全称即 Hot Module Replacement。相比较「live load」,它具有以下优点:

  • 可以实现局部更新,避免多余的资源请求,提高开发效率
  • 在更新的时候可以保存应用原有状态
  • 在代码修改和页面更新方面,实现所见即所得

而在 webpack-dev-server 中实现「HMR」的核心就是 HotModuleReplacementPlugin ,它是「Webpack」内置的「Plugin」。在我们平常开发中,之所以改一个文件,例如 .vue 文件,会触发「HMR」,是因为在 vue-loader 中已经内置了使用 HotModuleReplacementPlugin 的逻辑。它看起来会是这样

  1. Helloworld.vue
<template>
  <div>hello world</div>
</template>
<script lang="ts">
  import { Vue, Component } from 'vue-property-decorator'
  @Component
  export default class Helloworld extends Vue() {}
</script>
  1. main.js(手动实现「HMR」效果)
import Vue from 'vue'
import HelloWorld from '_c/HelloWorld'

if (module.hot) {
  module.hot.accept('_c/HelloWorld', ()=>{
    // 拉取更新过的 HelloWorld.vue 文件
  })
}

new Vue({
  el: '#app',
  template: '<HelloWorld/>'
  component: { HelloWorld }
})

那么,这个就是 webpack-dev-server 实现「HMR」的本质吗?显然不是,上面说的只是,如果你要通过 webpack-dev-server 实现「HMR」,你可以这么写来实现。

如果究其底层实现,是有两个关键的点

1.与本地服务器建立「socket」连接,注册 hashok 两个事件,发生文件修改时,给客户端推送 hash 事件。客户端根据 hash 事件中返回的参数来拉取更新后的文件。

2.HotModuleReplacementPlugin 会在文件修改后,生成两个文件,用于被客户端拉取使用。例如:

hash.hot-update.json

{
	"c": {
		"chunkname": true
	},
	"h": "d69324ef62c3872485a2"
}

chunkname.d69324ef62c3872485a2.hot-update.js,这里的 chunkname 即上面 c 中对于 key

webpackHotUpdate("main",{
   "./src/test.js":
  (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    eval(....)
  })
})

当然,在这之前还会涉及到对原模块代码的注入,让它具备拉取文件的能力。而这其中实现的细节就不去扣了,要不然有点喧兵夺主的感觉。

基于 native ES Module 的 devServer

基于 native ES Module 的 devServer 是「vite」实现「HMR」的重要一环。总体来说,它会做这么两件事:

  • 初始化本地服务器
  • 加载并执行对应的 Plugin,例如 sourceMapPluginmoduleRewritePluginhtmlRewritePlugin 等等。

所以,本质上,devServer 做的最重要的一件事就是加载执行 Plugin。目前,「vite」总共具备了 11Plugin

这里大致列举几点 Plugin 会做:

  • 拦截请求,处理「ES Module」语法相关的代码,转化为浏览器可识别的「ES Module」语法,例如第三方模块的 import 转化为 /@module/vue.js
  • .ts.vue 进行即时的编译以及 sassless 的预编译
  • 建立模块间的导入导出关系,即 importeeMap和客户端建立 socket 连接,用于实现「HMR」

这里就列举 devServer 几个常见的 Plugin 需要做的事,至于其他像 wasmPluginwebWorkerPlugin 之类的 Plugin 会做些什么,有兴趣的同学可以自行去了解。

然后,我们再从代码地角度看看它是怎么实现我们上述所说的:

1.首先,我们执行 vite 命令.实际上是运行 cli.js 这个文件,这里我摘取了其中核心的逻辑:

(async () => {
  const { help, h, mode, m, version, v } = argv
  ...
  const envMode = mode || m || defaultMode
  const options = await resolveOptions(envMode)
  // 开发环境下,我们会命中 runServer
  if (!options.command || options.command === 'serve') {
    runServe(options)
  } else if (options.command === 'build') {
    runBuild(options)
  } else if (options.command === 'optimize') {
    runOptimize(options)
  } else {
    console.error(chalk.red(`unknown command: ${options.command}`))
    process.exit(1)
  }
})()
async function runServe(options: UserConfig) {
  // 在 createServer() 的时候会对 HRM、serverConfig 之类的进行初始化
  const server = require('./server').createServer(options)
  ...
}

可以看到,在自执行函数中,我们会命中 runServer() 的逻辑,而它的核心是调用 server.js 文件中的 createServer()

createServer 方法:

export function createServer(config: ServerConfig): Server {
  const {
    ...,
    enableEsbuild = true
  } = config

  const app = new Koa<State, Context>()
  const server = resolveServer(config, app.callback())
  const watcher = chokidar.watch(root, {
    ignored: [/\bnode_modules\b/, /\b\.git\b/]
  }) as HMRWatcher
  const resolver = createResolver(root, resolvers, alias)

  const context: ServerPluginContext = {
    ...
    watcher
    ...
  }

  app.use((ctx, next) => {
    Object.assign(ctx, context)
    ctx.read = cachedRead.bind(null, ctx)
    return next()
  })

  const resolvedPlugins = [
    ...,
    moduleRewritePlugin,
    hmrPlugin,
    ...
  ]
  // 核心逻辑执行 hmrPlugin
  resolvedPlugins.forEach((m) => m && m(context))

  const listen = server.listen.bind(server)
  server.listen = (async (port: number, ...args: any[]) => {
    ...
    }) as any

  return server
}

createServer 方法做了这么几件事:

  • 创建一个 koa 实例
  • 创建监听除了 node_modules 之外的文件的 watcher,并传入 context
  • context 上下文传入并调用每一个 Plugin

到这里,「vite」的 devServer 的创建过程就已经完成。那么,接下来我们去领略一番属于「vite」的「HMR」过程!

vite 的 HMR 过程

在「vite」中「HMR」的实现是以 serverPluginHmr 这个 Plugin 为核心实现。这里我们以 .vue 文件的修改触发的「HMR」为例,这个过程会涉及三个 PluginserverPluginHtmlserverPluginHmrserverPluginVue,这个过程看起来会是这样:

serverPluginHtml

从前面的流程图可以看到,首先是 serverPluginHtml 这个 Plugin 向 index.html 中注入了获取 hmr 模块的代码:

export const htmlRewritePlugin: ServerPlugin = ({
  root,
  app,
  watcher,
  resolver,
  config
}) => {
  const devInjectionCode =
    `\n<script type="module">\n` +
    `import "${hmrClientPublicPath}"\n` +
    `window.process = { env: { NODE_ENV: ${JSON.stringify(
      config.mode || 'development'
    )} }}\n` +
    `</script>\n`

  const scriptRE = /(<script\b[^>]*>)([\s\S]*?)<\/script>/gm
  const srcRE = /\bsrc=(?:"([^"]+)"|'([^']+)'|([^'"\s]+)\b)/

  async function rewriteHtml(importer: string, html: string) {
    ...
    html = html!.replace(scriptRE, (matched, openTag, script) => {
      ...
      return injectScriptToHtml(html, devInjectionCode)
  }

  app.use(async (ctx, next) => {
    await next()
    ...

    if (ctx.response.is('html') && ctx.body) {
      const importer = ctx.path
      const html = await readBody(ctx.body)
      if (rewriteHtmlPluginCache.has(html)) {
        ...
      } else {
        if (!html) return
        // 在这里给 index.html 文件注入代码块
        ctx.body = await rewriteHtml(importer, html)
        rewriteHtmlPluginCache.set(html, ctx.body)
      }
      return
    }
  })

}

所以,当我们访问一个 「vite」 启动的项目的时候,我们会在「network」中看到服务器返回给我们的 index.html 中的代码会多了这么一段代码:

<script type="module">
import "/vite/hmr"
window.process = { env: { NODE_ENV: "development" }}
</script>

而这一段代码,也是确保我们后续正常触发「HMR」的关键点。因为,在这里浏览器会向服务器发送请求获取 vite/hmr 模块,然后,在 serverPluginHmr 中会拦截 ctx.path===‘/vite/hmr’ 的请求,建立 socket 连接。那么,接下来我们看看 serverPluginHmr 是进行这些过程的。

serverPluginHmr

上面我们说了 serverPluginHmr 它会劫持导入 /vite/hmr 的请求,然后返回 client.js 文件。所以,我们分点来细致化地分析这个过程:

1.读取 cliten.js 文件,劫持导入 /vite/hmr 的请求

 export const hmrClientFilePath = path.resolve(
   __dirname,
   '../../client/client.js'
 )
 export const hmrClientPublicPath = `/vite/hmr`
 const hmrClient = fs
    .readFileSync(hmrClientFilePath, 'utf-8')
    .replace(`__SW_ENABLED__`, String(!!config.serviceWorker))

  app.use(async (ctx, next) => {
    if (ctx.path === hmrClientPublicPath) {
      ctx.type = 'js'
      ctx.status = 200
      ctx.body = hmrClient.replace(`__PORT__`, ctx.port.toString())
    } else {
      ...
    }
  })

这里通过 readFileSync() 读取 client.js 文件,然后分别 replace 读取到的文件内容(字符串),一个是用于判断是否支持 serviceWorker,另一个用于建立 socket 连接时的端口设置。

2.定义 send 方法,并赋值给 watcher.send,用于其他 Plugin 在热更新时向浏览器推送更新信息:

const send = (watcher.send = (payload: HMRPayload) => {
    const stringified = JSON.stringify(payload, null, 2)
    debugHmr(`update: ${stringified}`)

    wss.clients.forEach((client) => {
      // OPEN 表示已经建立连接
      if (client.readyState === WebSocket.OPEN) {
        client.send(stringified)
      }
    })
  })

3.client.js,它会做这两件事:

  • 建立和服务器的 socket 连接,监听 message 事件,拿到服务器推送的 data,例如我们只修改 .vue 文件,它的 data 类型定义会是这样:
export interface UpdatePayload {
  type: 'js-update' | 'vue-reload' | 'vue-rerender' | 'style-update'
  path: string
  changeSrcPath: string
  timestamp: number
}
  • 对不同的 data.type 执行不同的逻辑,目前存在 type 有:vue-reloadvue-rerenderstyle-updatestyle-removejs-updatecustomfull-reload。本次我们只分析 vue-rerender 的逻辑,其实现的核心代码如下:
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws'
const socketUrl = `${socketProtocol}://${location.hostname}:${__PORT__}`
const socket = new WebSocket(socketUrl, 'vite-hmr')
// 监听 message 事件,拿到服务端推送的 data
socket.addEventListener('message', async ({ data }) => {
  const payload = JSON.parse(data) as HMRPayload | MultiUpdatePayload
  if (payload.type === 'multi') {
    payload.updates.forEach(handleMessage)
  } else {
    // 通常情况下会命中这个逻辑
    handleMessage(payload)
  }
})
async function handleMessage(payload: HMRPayload) {
  const { path, changeSrcPath, timestamp } = payload as UpdatePayload
  ...
  switch (payload.type) {
    ...
    case 'vue-rerender':
      const templatePath = `${path}?type=template`
      ...
      import(`${templatePath}&t=${timestamp}`).then((m) => {
        __VUE_HMR_RUNTIME__.rerender(path, m.render)
        console.log(`[vite] ${path} template updated.`)
      })
      break
    ...
  }
}

其实,这个过程还用到了 serviceWorker 做一些处理,但是看了下提交记录是 wip,所以这里就没有分析这个逻辑,有兴趣的同学可以自己了解。

serverPluginVue

前面,我们讲了 serverPluginHtmlserverPluginHmr 在 HMR 过程会做的一些前期准备。然后,我们这次分析的修改 .vue 文件触发的「HMR」逻辑,它的开始是在
serverPluginVue

首先,它会解析 .vue 文件,做一些 compiler 处理,然后通过 watcher 监听 .vue 文件的修改:

watcher.on('change', (file) => {
  if (file.endsWith('.vue')) {
    handleVueReload(file)
  }
})

可以看到在 change 事件的回调中调用了 handleVueReload(),针对我们这个 case,它会是这样:

const handleVueReload = (watcher.handleVueReload = async (
    filePath: string,
    timestamp: number = Date.now(),
    content?: string
  ) => {
    const publicPath = resolver.fileToRequest(filePath)
    const cacheEntry = vueCache.get(filePath)
    const { send } = watcher
    ...
    let needRerender = false
    ...
    if (!isEqualBlock(descriptor.template, prevDescriptor.template)) {
      needRerender = true
    }
    ...
    if (needRerender) {
      send({
        type: 'vue-rerender',
        path: publicPath,
        changeSrcPath: publicPath,
        timestamp
      })
    }
  })

handleVueReload() 它会针对 .vue 文件中,不同情况走不同的逻辑。这里,我们只是修改了 .vue 文件,给它加了一行代码,那么此时就会命中 isEqualBlock()false 的逻辑,所以 needRerendertrue,最终通过 send() 方法向浏览器推送 typevue-rerender 以及携带修改的文件路径的信息。然后,我们前面 client.js 中监听 message 的地方就会拿到对应的 data,再通过 import 发起获取该模块的请求。

小结

到这里,整个「vite」实现「HMR」的逻辑已经分析结束了。当然,这次只是针对 .vue 文件的修改来分析整个 HMR 的逻辑,相应地还有 .js.css 的文件的修改触发的 HMR 的逻辑,但是,可以说的是只要理解这个过程是如何进行的,那么每一个 case 的分析,也只是依葫芦画瓢

并且,其实在「HMR」的过程中还有一些辅助变量和概念,例如 hrmBoundariesimport chainchild importer 等等,它们都是用于帮助更好地进行 HMR 处理。所以,这里我就没有提及这些。有兴趣的同学可以自己去看源码中的讲解这些辅助变量和概念的意义

结语

其实,在当初尤大大发微博的时候,我就想着写一篇关于「vite」源码分析的文章。这两个月,我也经历一些起起伏伏,所以,一直到现在才开始交上这份答卷。但是,现在再看「vite」源码,已经不是仅仅 devServer 这么简单了,所以这次只分析了「HMR」这个点的实现,其他方面后续有时间应该会继续写其他方面,例如 rewritebundlecompiler 等等

Vue.js——作用域 CSS 的那点事

前言

首先,我们来回忆一下「CSS 作用域」这一概念,它的本质是通过让每一个选择器成为一个「unique」的存在,这样就自然而然地形成了作用域。

而提到「Vue」中「作用域 CSS」,我想大家应该立即想到以 scoped 的方式形成的带有作用域的 css。但是,值得一提的是,在「Vue」中还支持了一种「作用域 CSS」,即「CSS Module」。

提及 「CSS Module」,想必大家会有点陌生,相信有很多同学在平常开发中都是用 scoped 来实现「Vue」组件中的「作用域CSS」。所以,今天我们就来详细认知一下这两者。

一、scoped 作用域

「scoped 作用域」是「Vue」通过「postcss」来实现对每一个在 scoped 标签中定义的选择器的特殊作用域标识,例如:

<template>
  <div id="app">
    <div class="out-box">
    </div>
  </div>
</template>

<style lang="scss" scoped>
#app {
  .out-box {
    width: 200px;
    height: 200px;
    background-color: #faa;
  }
}
</style>

标识后展示在页面上的:

<div data-v-7ba5bd90 id="app">
    <div data-v-7ba5bd90 class="out-box">
    </div>
</div>

<style>
#app .out-box[data-v-7ba5bd90] {
    width: 200px;
    height: 200px;
    background-color: #faa;
}
</style>

可以看到,本质上是在原有的「选择器」的基础上通过「postcss」加上了一串 attr

并且,在我们平常开发中,很常见的场景就是我们在使用一些已有的组件或第三方组件时,我们需要对原有组件的样式进行一些微小的改动。那么,这个时候就需要使用穿透来实现样式的改动,例如:

<style>
div >>> .out-box {
    background-color: #aaf;
}
</style>

这里需要注意的是 >>> 只是一种穿透方式,并不是所有场景下都是可以用 >>> 实现。例如,「iView」需要使用 /deep/ 的方式,「ElementUI」需要使用 ::v-deep 的方式。

二、CSS Module 作用域

相比较「scoped 作用域」,「CSS Modeul 作用域」它所具备的能力更强,所以内容也相对较多。

什么是 CSS Module

「CSS Module」指的是可以将一个定义好的「CSS」文件以变量的形式导入,然后通过使用这个变量对「HTML」中的元素进行样式的修饰,例如:

a.css

.box {
    width: 100%;
    height: 100%;
    background-color: #afa;
}

b.js

import style from './a.css'

const boxElem = document.createElement('div');
boxElem.className = style.box

然后,渲染到页面的时候,它对应的 HTML 看起来会是这样:

<div class="a-box_jlpNx"></div>

可以看出,此时的「类选择器」同样是随机生成的,这也是「CSS Module」形成作用域的所在。

值得一提的是,「CSS Module」还具备其他的能力,例如可以定义全局的「选择器」,写起来会是这样:

:global(.title) {
  color: green;
}

接下来的使用和局部的一样。至于,其他用法,有兴趣的同学可以去 GitHub 上自行阅读

CSS Module 在 Vue 中的应用

回到本文所说的,在「Vue」中也对「CSS Module」做了相应的支持,当我们在 style 标签上添加 module 属性时,「Vue」 会在当前「组件实例」上注入一个计算属性 $style,然后我们可以通过 $style 来使用我们定义好的「选择器」,例如:

HelloWorld.vue

<template>
  <div>
    <div :class="$style['inner-box']"></div>
  </div>
</template>

<style lang="scss" module>
.inner-box {
  width: 100px;
  height: 100px;
  background-color: #aaf;
}
</style>

然后,它渲染到页面时,对于的 HTML 会是这样:

<div class="HelloWorld_inner-box_jlpNx"></div>

那么,这个时候,又回到和「scoped 作用域」一样的问题,使用了「CSS Module」来定义组件的样式,那么我在使用它的时候,如何进行覆盖?

标准的答案,对于「CSS Module」并没有覆盖的说法,有的只是为一个组件设置不同的主题。

但是,如果真的需要弄,那只能通过对该模块对应的 style 标签中定义你需要的样式,然后根据传入组件的 props 来动态绑定 class

那么,为组件设置主题,我们需要在设计组件的时候,对这个组件预留 props ,并将该 props 添加到 $style 中,然后在这个组件中的相应元素中使用,例如:

Box.vue 组件

<template>
  <div>
    <div :class="$style[themeColor]"></div>
  </div>
</template>

<script>
export default {
  props: {
    themeColor: {
        type: String,
        default: 'line'
    }
  }
};
</script>

<style lang="scss" module>
.line {
  width: 100px;
  height: 100px;
  background-color: #aaf;
}
.card {
  background-color: #aaf;
}
</style>

然后,我们在使用该组件的时候通过 props 传入 line 或者 card,从而实现切换组件不同的背景色。

三、两种方式的优劣势

「scoped 作用域」:

  • 对组件没有硬性要求
  • 不易于管理组件样式,需要借助第三方变量定义支持
  • 易于覆盖组件样式,即通过穿透来实现对样式的覆盖

「CSS Module 作用域」:

  • 适合于高度沉淀下的组件使用
  • 易于管理组件样式,即可以通过 style 管理组件中的选择器
  • 组件样式无法通过外部直接覆盖

这里所说的管理,是指通过JavaScript便捷地控制组件样式。

结语

其实,对比「scoped」和「CSS Module」两者,各有千秋。至于,要用哪一着得看具体需求,建议大项目中可以使用「CSS Module」,小项目的话用用「scoped」应该绰绰有余。

给自己的 GitHub Profile 做个贪吃蛇游戏

前言

相信很多同学都自定了自己的 GitHub Profile,但是作为这个世界上最富有想象力的一个群体,我们总在不断地推成出新(造轮子)。

那么,对于 GitHub Profile 也是一样,这里我们直接看今天要实现的一个 GitHub Profile,贪吃蛇游戏:

这个 GitHub Profile 的想法来自 Platane ,它是根据 GitHub 的 Contributions 图来生成贪吃蛇游戏,最终会以 Gif 或 SVG 的形式展示。那么,下面我们就来亲自体验一番给自己的 GitHub Profile 设置贪吃蛇游戏!

设置 Git Action

Git Action 主要是根据你在当前仓库下的 .github/workflows 目录下的 .yml 文件生成对应的 Workflow。

那么,这里我们在任意一个仓库的 .github/workflows 目录下新建 .yml 文件,然后添加如下配置:

name: generate-snake

on:
  schedule:
    # 每 6 小时,执行一次
    - cron: "0 */6 * * *"
  workflow_dispatch:

jobs:
  generate-snake:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: Platane/snk@master
        with:
          # 你的 GitHub 的名称
          github_user_name: WJCHumble
          # 生成的 .gif 图存储的位置
          gif_out_path: dist/github-snake.gif
          # 生成的 .gif 图存储的位置
          svg_out_path: dist/github-snake.svg
      - uses: crazy-max/[email protected]
        with:
          target_branch: output
          build_dir: dist
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

接着,你就可以在该仓库的 Action 面板中,看到新建好的名为 generate-snake 的 Workflow:

启动 Git Action

由于我们在 .yml 配置了 workflow_dispatch 事件,所以我们可以直接通过点击面板中的 Run workflow 按钮来启动 Workflow:

等待 Workflow 运行结束后,我们就可以在该仓库的 output 分支中看到创建好的 dist 目录下会有 github-snake.gifgithub-snake.svg 这 2 个文件。

这里是我最后生成的:

添加到你的 GitHub Profile

每个人的 GitHub Profile 就是以自己 GitHub Name 命名的仓库的 README.md。

那么,我们需要做的是在前面的 dist 目录下右键复制 Gif 图的地址,然后再添加到 GitHub Profile 的 README.md 中。

Gif 图的地址看起来会是这样:

https://github.com/WJCHumble/WJCHumble/blob/output/github-snake.gif?raw=true

至于为什么是复制 Gif 图的地址,而不是上传到自己的图床?

因为,我们前面设置的 Workflow 是每 6 个小时会执行一次,也就是这个 Gif 图也会随之更新,即同步你的 GitHub Contributions 生成贪吃蛇游戏。

结语

此外,Platane 还提供了在线地址可以输入指定的 GitHub Name 来获取其对应的 GitHub Contributions 生成贪吃蛇游戏的图片,有兴趣的同学可以体验一番。

最后,如果文中存在表达不当或错误的地方,欢迎各位同学提 Issue~

如何实现一个 esbuild 插件?从入门到上手

前言

随着 Vite 2.0 的发布,其底层的设计也不断地被大家所认知。其中,大家十分津津乐道的就是采用 esbuild 来做 Dev 环境下的代码转换(快到飞起 😲)。

与此同时,这也给 esbuild 带来了很多曝光。并且,esbuild 生态也陆续出现了一些新的插件(Plugin),例如 esbuild-plugin-aliasesbuild-plugin-webpack-bridge 等。

那么,回到正题,今天我将和大家一起从 esbuild 插件基础知识出发,手把手教学如何实现一个 esbuild 插件 🚀!

1 认识 esbuild 插件基础

在 esbuild 中,插件被设计为一个函数,该函数需要返回一个对象(Object),对象中包含 namesetup 等 2 个属性:

const myPlugin = options => {
  return {
    name: "my",
    setup(build) {
      // ....
    }
  }
}

其中,name 的值是一个字符串,它表示你的插件名称
setup 的值是一个函数,它会被传入一个参数 build(对象)。

build 对象上会暴露整个构建过程中非常重要的 2 个函数:onResolveonLoad,它们都需要传入 Options(选项)和 CallBack(回调)等 2 个参数。

其中,Options 是一个对象,它包含 filter(必须)和 namespace 等 2 个属性:

interface OnResolveOptions {
  filter: RegExp;
  namespace?: string;
}

而 CallBack 是一个函数,即回调函数。插件实现的关键则是在 onResolveonLoad 中定义的回调函数内部做一些特殊的处理。

那么,接下来我们先来认识一下 Options 的 2 个属性:namespacefilter(划重点,它们非常重要 😲)

1.1 namespace

默认情况下,esbuild 是在文件系统上的文件(File Modules)相对应的 namespace 中运行的,即此时 namespace 的值为 file

esbuild 的插件可以创建 Virtual Modules,而 Virtual Modules 则会使用 namespace 来和 File Modules 做区分。

注意,每个 namespace 都是特定于该插件的。

并且,这个时候,我想可能有同学会问:什么是 Virtual Modules 😲?

简单地理解,Virtual Modules 是指在文件系统中不存在的模块,往往需要我们构造出 Virtual Modules 对应的模块内容。

1.2 filter

filter 作为 Options 上必须的属性,它的值是一个正则。它主要用于匹配指定规则的导入(import)路径的模块,避免执行不需要的回调,从而提高整体打包性能。

那么,在认识完 namespacefilter 后。下面我们来分别认识一下 onResolveonLoad 中的回调函数。

1.3 onResolve 的回调函数

onResolve 函数的回调函数会在 esbuild 构建每个模块的导入路径(可匹配的)时执行。

onResolve 函数的回调函数需要返回一个对象,其中会包含 pathnamespaceexternal 等属性。

通常,该回调函数会用于自定义 esbuild 处理 path 的方式,例如:

  • 重写原本的路径,例如重定向到其他路径

  • 将该路径所对应的模块标记为 external,即不会对改文件进行构建操作(原样输出)

1.4 onLoad 的回调函数

onLoad 函数的回调函数会在 esbuild 解析模块之前调用,主要是用于处理并返回模块的内容,并告知 esbuild 要如何解析它们。并且,需要注意的是 onLoad 的回调函数不会处理被标记为 external 的模块。

onLoad 函数的回调函数需要返回一个对象,该对象总共有 9 个属性。这里我们来认识一下较为常见 3 个属性:

  • contents 处理过的模块内容
  • loader 告知 esbuild 要如何解释该内容(默认为 js)。例如,返回的模块内容是 CSS,则声明 loadercss
  • resolveDir 是在将导入路径解析为文件系统上实际路径时,要使用的文件系统目录

那么,到这里我们就已经简单认识完有关 esbuild 插件的基础知识了 😎。
所以,下面我们从实际应用场景出发,动手实现一个 esbuild 插件。

2 动手实现一个 esbuild 插件

这里我们来实现一个删除代码中 console 语句的 esbuild 插件。因为,这个过程需要识别和删除 console 对应的 AST 节点。所以,需要使用 babel 提供的 3 个工具包:

  • @babel/parserparse 函数解析代码生成 AST(抽象语法树)
  • @babel/traverse 遍历 AST,访问需要进行操作的 AST 节点
  • @babel/coretransformFromAst 函数将 AST 转化为代码

那么,首先是创建整个插件的整体结构,如插件名称、setup 函数:

module.exports = options => {
  return {
    name: "auto-delete-console",
    setup(build) {
    }
  }
}

其次,由于我们这个插件主要是对代码内容进行操作。所以,需要使用 onLoad 函数,并且要声明 filter/\.js$/,即只匹配 JavaScript 文件:

module.exports = options => {
  return {
    name: "auto-delete-console",
    setup(build) {
      build.onLoad({ filter: /\.js$/ }, (args) => {
      }
    }
  }
}

而在 onLoad 函数的回调函数中,我们需要做这 4 件事:

1.获取文件内容

onLoad 函数的回调函数会传入一个参数 args,它会包含此时模块的文件路径,即 args.path

所以,这里我们使用 fs.promises.readFile 函数来读取该模块的内容:

build.onLoad({ filter: /\.js$/ }, async (args) => {
  const source = await fs.promises.readFile(args.path, "utf8")
}

2.转化代码生成 AST

因为,之后我们需要找到并删除 console 对应的 AST 节点。所以,需要使用 @babel/parserparse 函数将模块的内容(代码)转为 AST:

build.onLoad({ filter: /\.js$/ }, async (args) => {
  const ast = parser.parse(source)
}

3.遍历 AST 节点,删除 console 对应的 AST 节点

接着,我们需要使用 @babel/traverse 来遍历 AST 来找到 console 的 AST 节点。但是,需要注意的是我们并不能直接就可以找到 console 的 AST 节点。因为,console 属于普通的函数调用,并没有像 await 一样有特殊的 AST 节点类型(AwaitExpression)。

不过,我们可以先使用 CallExpression 来直接访问函数调用的 AST 节点。然后,判断 AST 节点的 callee.object.name 是否等于 console,是则调用 path.remove 函数删除该 AST 节点:

build.onLoad({ filter: /\.js$/ }, async (args) => {
  traverse(ast, {
    CallExpression(path) {
      //...
      const memberExpression = path.node.callee
      if (memberExpression.object && memberExpression.object.name === 'console') {
        path.remove()
      }
    }
  })
}

4.转化 AST 生成代码

最后,我们需要使用 @babel/coretransformFromAst 函数将处理过的 AST 转为代码并返回:

build.onLoad({ filter: /\.js$/ }, async (args) => {
  //...
  const { code } = core.transformFromAst(ast)
  return {
    contents: code,
    loader: "js"
  }
}

那么,到这里我们就完成了一个删除代码中 console 语句的 esbuild 插件,用一句话概括这个过程:“没有比这更简单的了 😃”。

整个插件实现的全部代码如下:

const parser = require("@babel/parser")
const traverse = require("@babel/traverse").default
const core = require("@babel/core")
const esbuild = require("esbuild")
const fs = require("fs")

module.exports = options => {
  return {
    name: "auto-delete-console",
    setup(build) {
      build.onLoad({ filter: /\.js$/ }, async (args) => {
        const source = await fs.promises.readFile(args.path, "utf8")
        const ast = parser.parse(source)

        traverse(ast, {
          CallExpression(path) {
            const memberExpression = path.node.callee
            if (memberExpression.object && memberExpression.object.name === 'console') {
              path.remove()
            }
          }
        })

        const { code } = core.transformFromAst(ast)
        return {
          contents: code,
          loader: "js"
        }
      }
    }
  }
}

结语

总体上来说,esbuild 的插件设计是十分简约和强大的。这一点,如果写过 webpack 插件的同学对比一下,我想应该深有体会 😶。

并且,通过阅读本文,相信大家都可以随手甩出一个 esbuild 的插件,很可能将来官方提供的插件列表就有你实现的插件 😎。

刨根问底,揭开 Vue 中 Scope CSS 实现的幕后(原理)

前言

我想大家都对 Vue 的 Scope CSS 耳熟能详了,但是说起 Vue 的 Scope CSS 实现的原理,很多人应该会说不就是给 HTML、CSS 添加属性吗 🙃️?

确实是这样的,不过这只是最终 Scope CSS 呈现的结果。而这个过程又是如何实现的?我想能回答上一二的同学应该不多。

那么,回到今天本文,我将会围绕以下 3 点,和大家一起从 Vue 的 Scope CSS 的最终呈现结果出发,深入浅出一番其实现的底层原理:

  • 什么是 Scope CSS
  • vue-loader 处理组件(.vue 文件)
  • Patch 阶段应用 ScopeId 生成 HTML 的属性

1 什么是 Scope CSS

Scope CSS 即作用域 CSS,组件化所密不可分的一部分。Scope CSS 使得我们可以在各组件中定义的 CSS 不产生污染。例如,我们在 Vue 中定义一个组件:

<!-- App.vue -->
<template>
  <div class="box">scoped css</div>
</template>
<script>
  export default {};
</script>
<style scoped>
  .box {
    width: 200px;
    height: 200px;
    background: #aff;
  }
</style>

通常情况下,在开发环境我们的组件会先经过 vue-loader 的处理,然后结合运行时的框架代码渲染到页面上。相应地,它们对应的 HTML 和 CSS 分别会是这样:

HTML 部分:

<div data-v-992092a6>scoped css</div>

CSS 部分:

.box[data-v-992092a6] {
  width: 200px;
  height: 200px;
  background: #aff;
}

可以看到 Scope CSS 的本质是基于 HTML 和 CSS 选择器的属性,通过分别给 HTML 标签和 CSS 选择器添加 data-v-xxxx 属性的方式实现。

2 vue-loader 处理组件(.vue 文件)

前面,我们也提及了在开发环境下一个组件(.vue 文件)会先由 vue-loader 来处理。那么,针对 Scope CSS 而言,vue-loader 会做这 3 件事:

  • 解析组件,提取出 templatescriptstyle 对应的代码块
  • 构造并导出 export 组件实例,在组件实例的选项上绑定 ScopId
  • style 的 CSS 代码进行编译转化,应用 ScopId 生成选择器的属性

注意,这里讲的只是 vue-loader 对 .vue 文件的处理部分,不涉及 HMR、配合 Devtool 的逻辑,有兴趣的同学可以自行了解~

然而,之所以 vue-loader 有这么多的能力,主要是因为 vue-loader 的底层使用了 Vue 官方提供的包(package) @vue/component-compiler-utils,其提供了解析组件(.vue 文件)、编译模版 template、编译 style等 3 种能力。

那么,下面我们就先来看一下 vue-loader 是如何使用 @vue/component-compiler-utils 来解析组件提取 templatescriptstyle 的。

2.1 提取 template、script、style

vue-loader 提取 template、script、style 的过程主要是使用了 @vue/component-compiler-utils 包的 parse 方法,这个过程对应的代码(伪代码)会是这样:

// vue-loader/lib/index.js
const { parse } = require("@vue/component-compiler-utils");

module.exports = function (source) {
  const loaderContext = this;
  const { sourceMap, rootContext, resourcePath } = loaderContext;
  const sourceRoot = path.dirname(path.relative(context, resourcePath));
  const descriptor = parse({
    source,
    compiler: require("vue-template-compiler"),
    filename,
    sourceRoot,
    needMap: sourceMap,
  });
};

我们来逐点分析一下这段代码,首先,会获取当前上下文 loaderContext,它会包含 webpack 打包过程核心对象 compilercompilation 等。

其次,再构建文件资源入口 sourceRoot,一般情况下它指的是 src 文件目录,它主要用于构建 source-map 使用。

最后,则会使用 @vue/component-compiler-utils 提供的 parse 方法来解析 source(组件代码)。这里,我们先来看一下 parse 方法的几个参数:

  • soruce 源代码块,这里是组件对应的代码,即包含了 templatestylescript
  • compiler 编译核心对象,它是一个 CommonJS 模块(vue-template-compiler),parse 方法内部会使用它提供的 parseComponent 方法来解析组件
  • filename 当前组件的文件名,例如 App.vue
  • sourceRoot 文件资源入口,用于构建 source-map 使用
  • needMap 是否需要 source-mapparse 方法内部会根据 needMap 的值(truefalse,默认为 true)来判断是否生成 scriptstyle 对应的 source-map

parse 方法的执行则会返回一个对象给 desciptor,它会包含 templatestylescript 分别对应的代码块。

那么,可以看到的是 vue-loader 解析组件的过程,几乎外包给了 Vue 提供的工具包(package)。并且,我想这个时候肯定会有同学问:这些和 Vue 的 Scope CSS 有几毛钱关系 🙃️?

有很大的关系!因为 Vue 的 Scope CSS 可不是无米之炊,它实现的前提是组件被解析了,然后再分别处理 templatestyle 部分的代码!

那么,显然到这里我们已经完成了对组件的解析。接着,则需要构造和导出组件实例~

2.2 构造和导出组件实例

vue-loader 在解析完组件后,会分别处理并生成 templatescriptstyle 的导入 import 语句,再调用 normalizer 方法正常化(normalizer)组件,最后将它们拼接成代码字符串:

let templateImport = `var render, staticRenderFns`;
if (descriptor.template) {
  // 构造 template 的 import 语句
}
let scriptImport = `var script = {}`;
if (descriptor.script) {
  // 构造 script 的 import 语句
}
let stylesCode = ``;
if (descriptor.styles.length) {
  // 构造 style 的 import 语句
}
let code =
  `
${templateImport}
${scriptImport}
${stylesCode}

import normalizer from ${stringifyRequest(`!${componentNormalizerPath}`)}
var component = normalizer(
  script,
  render,
  staticRenderFns,
  ${hasFunctional ? `true` : `false`},
  ${/injectStyles/.test(stylesCode) ? `injectStyles` : `null`},
  ${hasScoped ? JSON.stringify(id) : `null`},
  ${isServer ? JSON.stringify(hash(request)) : `null`}
  ${isShadow ? `,true` : ``}
)
  `.trim() + `\n`;

其中,templateImportscriptImportstylesCode 等构造好的 templatescriptstyle 部分的导入 import 语句看起来会是这样:

import {
  render,
  staticRenderFns,
} from "./App.vue?vue&type=template&id=7ba5bd90&scoped=true&";
import script from "./App.vue?vue&type=script&lang=js&";
// 兼容命名方式的导出
export * from "./App.vue?vue&type=script&lang=js&";
import style0 from "./App.vue?vue&type=style&index=0&id=7ba5bd90&scoped=true&lang=css&";

不知道同学们注意 ⚠️ 到没,templatestyle 的导入 import 语句都有这么一个共同的部分 id=7ba5bd90&scoped=true,这表示此时组件的 templatestyle 是需要 Scope CSS 的,并且 scopeId7ba5bd90

当然,这仅仅是告知后续的 templatestyle 编译时需要注意生成 Scope CSS,也就是 Scope CSS 的第一步!那么,接着则会调用 normalizer 方法来对该组件进行正常化(Normalizer)处理:

import normalizer from "!../node_modules/vue-loader/lib/runtime/componentNormalizer.js";
var component = normalizer(
  script,
  render,
  staticRenderFns,
  false,
  null,
  "7ba5bd90",
  null
);
export default component.exports;

注意,normalizer 是重命名了原方法 normalizeComponent,以下统称 normalizeComponent~

我想同学们应该都注意到了,此时 scopeId 会作为参数传给 normalizeComponent 方法,而传给 normalizeComponent 的目的则是为了在组件实例的 options绑定 scopeId。那么,我们来看一下 normalizeComponent 方法(伪代码):

function normalizeComponent (
  scriptExports,
  render,
  staticRenderFns,
  functionalTemplate,
  injectStyles,
  scopeId,
  moduleIdentifier, /* server only */
  shadowMode /* vue-cli only */
) {
  ...
  var options = typeof scriptExports === 'function'
    ? scriptExports.options
    : scriptExports
  // scopedId
  if (scopeId) {
    options._scopeId = 'data-v-' + scopeId
  }
  ...
}

可以看到,这里的 options._scopeId 会等于 data-v-7ba5bd90,而它的作用主要是用于在 patch 的时候,为当前组件的 HTML 标签添加名为 data-v-7ba5bd90 的属性。因此,这也是 template 为什么会形成带有 scopeId 的真正所在

2.3 编译样式 Style,应用 ScopId 生成选择器的属性

在构造完 Style 对应的导入语句后,由于此时 import 语句中的 query 包含 vue,则会被 vue-loader 内部的 Pitching Loader 处理。而 Pitching Loader 则会重写 import 语句,拼接上内联(inline)的 Loader,这看起来会是这样:

export * from '
"-!../node_modules/vue-style-loader/index.js??ref--6-oneOf-1-0
!../node_modules/css-loader/dist/cjs.js??ref--6-oneOf-1-1
!../node_modules/vue-loader/lib/loaders/stylePostLoader.js
!../node_modules/postcss-loader/src/index.js??ref--6-oneOf-1-2
!../node_modules/cache-loader/dist/cjs.js??ref--0-0
!../node_modules/vue-loader/lib/index.js??vue-loader-options!./App.vue?vue&type=style&index=0&id=7ba5bd90&scoped=true&lang=css&"
'

然后,webpack 会解析出模块所需要的 Loader,显然这里会解析出 6 个 Loader:

[
  { loader: "vue-style-loader", options: "?ref--6-oneOf-1-0" },
  { loader: "css-loader", options: "?ref--6-oneOf-1-1" },
  { loader: "stylePostLoader", options: undefined },
  { loader: "postcss-loader", options: "?ref--6-oneOf-1-2" },
  { loader: "cache-loader", options: "?ref--0-0" },
  { loader: "vue-loader", options: "?vue-loader-options" }
]

那么,此时 webpack 则会执行这 6 个 Loader(当然还有解析模块本身)。并且,这里会忽略 webpack.config.js 中符合规则的 Normal Loader(vue-style-loader 还会忽略前置 Loader)。

不了解内联 Loader 的同学,可以看一下这篇文章【webpack进阶】你真的掌握了loader么?- loader十问

而对于 Scope CSS 而言,最核心的就是 stylePostLoader。下面,我们来看一下 stylePostLoader 的定义:

const { compileStyle } = require("@vue/component-compiler-utils");
module.exports = function (source, inMap) {
  const query = qs.parse(this.resourceQuery.slice(1));
  const { code, map, errors } = compileStyle({
    source,
    filename: this.resourcePath,
    id: `data-v-${query.id}`,
    map: inMap,
    scoped: !!query.scoped,
    trim: true,
  });

  if (errors.length) {
    this.callback(errors[0]);
  } else {
    this.callback(null, code, map);
  }
};

从 stylePostLoader 的定义中,我们知道它是使用了 @vue/component-compiler-utils 提供的 compileStyle 方法来完成对组件 style 的编译。并且,此时会传入参数 iddata-v-${query.id},即 data-v-7ba5bd90,而这也是 style 中声明的选择器的属性为 scopeId 的关键点

而在 compileStyle 函数内部,则是使用的我们所熟知 postcss 来完成对 style 代码的编译和构造选择器的 scopeId 属性。至于如何使用 postcss 完成这个过程,这里就不做过多介绍,有兴趣的同学自行了解哈~

3 Patch 阶段应用 ScopeId 生成 HTML 的属性

不知道同学们是否还记得在 3.2 构造并导出组件实例的时候,我们讲了在组件实例的 options 上绑定 _scopeId 是实现 template 的 Scope 的关键点!但是,当时我们并没有介绍这个 _scopeId 到底是如何应用到 template 上的元素的 😲?

如果,你想在 vue-loader 或者 @vue/component-compiler-utils 的代码中找到这个答案,我可以和你说找一万年都找不到! 因为,真正应用 _scopeId 的过程是发生在 Vue 运行时的框架代码中(没想到吧 😵)。

了解过 Vue 模版编译过程的同学,我想应该都知道 template 会被编译成 render 函数,然后会根据 render 函数创建对应的 VNode,最后再由 VNode 渲染成真实的 DOM 在页面上:

而 VNode 到真实 DOM 这个过程是由 patch 方法完成的。假设,此时我们是第一次渲染 DOM,这在 patch 方法中会命中 isUndef(oldVnode)true 的逻辑:

function patch (oldVnode, vnode, hydrating, removeOnly) {
  if (isUndef(oldVnode)) {
    // empty mount (likely as component), create new root element
    isInitialPatch = true
    createElm(vnode, insertedVnodeQueue)
  } 
}

因为第一次渲染 DOM,所以压根不存在什么 oldVnode 😶

可以看到,此时会执行 createElm 方法。而在 createElm 方法中则会创建 VNode 对应的真实 DOM,并且它还做了一件很重要的事,调用 setScope 方法应用 _scopeId 在 DOM 上生成 data-v-xxx 的属性!对应的代码(伪代码):

// packages/src/core/vdom/patch.js
function createElm(
  vnode,
  insertedVnodeQueue,
  parentElm,
  refElm,
  nested,
  ownerArray,
  index
) {
  ...
  setScope(vnode);
  ...
}

setScope 方法中则会使用组件实例的 options._scopeId 作为属性来添加到 DOM 上,从而生成了 template 中的 HTML 标签上名为 data-v-xxx 的属性。并且,这个过程会由 Vue 封装好的工具函数 nodeOps.setStyleScope 完成,它的本质是调用 DOM 对象的 setAttribute 方法:

// src/platforms/web/runtime/node-ops.js
export function setStyleScope (node: Element, scopeId: string) {
  node.setAttribute(scopeId, '')
}

结语

如果,有在网上查找过关于 vue-loader 和 Scope CSS 的文章的同学会发现,很多文章都是说在 @vue/component-compiler-utils 包的 compilerTemplate 方法中应用 scopeId 生成了 template 中 HTML 标签的属性。但是,通过阅读本文,我们会发现这两者压根没有任何关系(SSR 的情况除外)!

并且,我想同学们也注意到了一点,本文中提及的 Vue 运行时框架的代码是 Vue 2.x 版本的(不是 Vue3)。所以,有兴趣的同学可以借助本文提供的路线推敲一下 Vue3 中 Scope CSS 的过程,相信你会收获满满 😎。最后,如果本文中存在表达不当或错误的地方,欢迎各位同学提 Issue~

如何基于文档的内容实现 AI 对话功能,以 Documate 为例

前言

在 ChatGPT 出现之时,社区内也出现过 把 React 官方文档投喂给它 ,然后对它进行提问的实践。但是,由于每次 ChatGPT 对话能接受的文本内容对应的 Token 是有上限的,所以这种使用方式存在一定的手动操作成本和不能复用的问题。

Documate 的出现则是通过工具链的集成,仅需使用 CLI 提供的命令和部署服务端的代码,就可以很轻松地实现上述的数据投喂模型 + 提问 ChatGPT 过程的自动化,让你的文档(VitePressDocusaurusDocsify)站点具备 AI 对话功能。

Documate 的官网文档对如何使用它进行现有文档站点的接入介绍的很为详尽,并且其作者(月影)也专门写了【黑科技】让你的 VitePress 文档站支持 AI 对话能力文章介绍,对接入 Documate 感兴趣的同学可以自行阅读文档或文章。

相信很多同学和我一样,对如何基于文档的内容实现 AI Chat 留有疑问,那么接下来,本文将围绕 Documate 的实现原理分别展开介绍:

  • Documate 运行机制
  • Documate 服务端

一、Documate 运行机制

Documate 主要由 2 部分构成,Documate CLI服务端(Backend)接口实现,其中 Documate CLI 主要职责是获取本地文档过程的文档和构造指定结构的文档数据,最终上传数据到 upload 接口,而服务端主要职责是提供 uploadask 接口,它们分别的作用:

  • upload 接口,接收来自 CLI 上传的文档数据,对数据进行 Token 化、分片入库等操作
  • ask 接口,接收来自文档站点的提问内容,校验内容合法性、生成内容的矢量坐标,基于所有文档进行矢量搜索、进行 Chat 提问获取结果并返回等操作

整体实现机制如下图所示:

其中,关于 Documate CLI 主要支持了 initupload 等 2 个命令,init 负责向文档工程注入 Documate 运行的基础工程配置,upload 负责上传文档工程的文档内容到服务端,2 者的实现并不复杂,有兴趣的同学可以自行了解。

相比较 CLI,在 Documate 的服务端实现的一系列能力是支持文档内容对话的关键技术点,那这些能力又是如何通过代码实现的?下面,我们来分别从代码层面深入认识下 Documate 服务端的各个能力的实现。

二、Documate 服务端

Documate 服务端主要负责接收并存储 documate upload 命令上传的文档内容、根据对话的提问内容返回与之关联的回答:

其中,后者需要使用 OpenAI 提供的 Text Embeddings 来实现 AI 对话的功能,所以,我们先来对 OpenAI Text Embeddings 建立一个基础的认知。

2.1 OpenAI Text Embeddings

OpenAI 的开发者平台 提供了很多功能的 API 给开发者调用:

基于文档内容的 AI 对话的实现本质是根据关键词搜索得到答案,所以需要使用到 Embeddings,Embedding 主要用于衡量文本字符串之间的关联性,一个 Embedding 是由浮点数字构成的矢量数组,例如 [0.938293, 0.284951, 0.348264, 0.948276, 0.564720]。2 个矢量之间的距离表示它们的关联性。距离小表示它们之间的关联性高,反之关联性低。

2.2 文档内容存储

文档内容的存储主要分为 2 个步骤:

1、根据模型每次能接受的 Token 最大长度去对内容进行分片 chunks

OpenAI 的模型调用所能接收的 Token 的长度是有限的,对应的 text-embedding-ada-002 模型可接收的 Token 最大长度是 1536。所以,在接收到 CLI 上传的文档内容后,需要根据 Token 的最大长度 1536 来对文档内容进行分片:

const tokenizer = require('gpt-3-encoder');
// Split the page content into chunks base on the MAX_TOKEN_PER_CHUNK
function getContentChunks(content) {
  // GPT-2 and GPT-3 use byte pair encoding to turn text into a series of integers to feed into the model.
  const encoded = tokenizer.encode(content);
  const tokenChunks = encoded.reduce(
    (acc, token) => (
      acc[acc.length - 1].length < MAX_TOKEN_PER_CHUNK
        ? acc[acc.length - 1].push(token)
        : acc.push([token]),
      acc
    ),
    [[]],
  );
  return tokenChunks.map(tokens => tokenizer.decode(tokens));
}

首先,会先使用 gpt-3-encoder 来对文档内容进行 Byte Pair Encoding,将文档从文本形式转成一系列数字,从而用于后续投喂(Feed)给模型。其中, BPE 算法 的实现:

  • 把文本内容拆分成一个个字符,计算字符出现频率
  • 合并相邻重复出现的字符和对应的出现频率
  • 对最终拆分的字符编码成数字,也就是 Token 的值,然后构造字符到数字映射的一个词汇表
  • 根据词汇表将原有的文本内容转为对应的 Token 表示

由于 BPE 编码后的结果 encoded 是一个 Token 数组,且模型每次能投喂是有最大长度的限制,所以根据 Token 最大长度进行分片,也就是代码中的 accacc 初始值是一个二维数组,每个值是一个 Token,每个元素数组主要用于存储模型最大 Token 限制下的数据,即将一个大的 Token 分片成模型允许传入的小 Token。

对文档内容进行分片的目的是用于后续将文档内容投喂(Feed)给模型的时候是有效(不会超出 Token 最大长度)和连续的。

2、构造指定的数据结构 ChunkItem 存入数据库中,ChunkItem 数据结构

因为,将文档的所有内容全部投喂给模型是有成本(Token 计费)并且收益低(问答内容关联性低),所以,需要在提问的环节通过矢量数据库查询的方式,查出关联的文档内容,然后再将对应的文档内容投喂给模型,模型根据对关联文档上下文和问题给出合理的回答。

那么,在前面根据 BPE 生成的 Token 和分片生成的基础上,需要将该结果按指定的数据结构(路径、标题等)存入数据库中,用于后续提问的时候查询矢量数据库:

const aircode = require('aircode');
const PagesTable = aircode.db.table('pages');

// 根据 BPE 和模型的 Token 上限限制去划分 chunk
const chunks = getContentChunks(content);
// 构造出存到数据库中的数据结构
const pagesToSave = chunks.map((chunk, index) => ({
  project,
  // 文档文件路径
  path,
  title,
  // 文件内容生成的 hash 值
  checksum,
  chunkIndex: index,
  // 内容
  content: chunk,
  embedding: null,
}))

// Save the result to database
for (let i = 0; i < pagesToSave.length; i += 100) {
  await PagesTable.save(pagesToSave.slice(i, i + 100));
}

这里会使用到 AirCode 提供的表操作的 PagesTable.save API,用于将构造好的数据入库。

2.3 根据提问进行 AI 对话

OpenAI 要求输入的内容是需要符合它们规定的内容政策的,所以需要先对输入的问题进行内容检查,OpenAI 也提供相应的 API 用于检查内容安全,而 OpenAI 的 API 调用可以通过 OpenAI Node 来实现:

const OpenAI = require('openai');

// 创建 OpenAI 的实例
const openai = new OpenAI({
    apiKey: process.env.OPENAI_API_KEY,
});

// 提问内容
const question = params.question.trim();
// https://platform.openai.com/docs/api-reference/moderations
const { results: moderationRes } = await openai.moderations.create({
    input: question,
});
if (moderationRes[0].flagged) {
    console.log('The user input contains flagged content.', moderationRes[0].categories);
    context.status(403);
    return {
    error: 'Question input didn\'t meet the moderation criteria.',
    categories: moderationRes[0].categories,
    };
}

如果,返回的结果 moderationsRes[0].flagged 则视为不符合,标识为错误的请求。反之符合,接着使用 Embeddings 来获取提问内容所对应的矢量坐标:

// https://platform.openai.com/docs/api-reference/embeddings/object
const { data: [ { embedding }] } = await openai.embeddings.create({
    model: 'text-embedding-ada-002',
    input: question.replace(/\n/g, ' '),
});

那么,有了矢量坐标后,我们需要用先前存储到数据库的分片文本创建矢量数据库,这可以使用 Orama 完成,它提供了全文和矢量搜索的能力。

首先,需要先从数据库中查询出所有的文档数据:

const { project = 'default' } = params;
const pages = await PagesTable
  .where({ project })
  .projection({ path: 1, title: 1, content: 1, embedding: 1, _id: 0 })
  .find();

然后,通过 Orama 提供的 create 方法初始化一个矢量数据库 memDB,并且将文档内容 pages 插入到数据库中:

const memDB = await create({
  // 建立好索引的 `schema`
  schema: {
    path: 'string',
    title: 'string',
    content: 'string',
    embedding: 'vector[1536]',
  },
});
await insertMultiple(memDB, pages);

有了文档内容对应的矢量数据库后,我们就可以用前面 Emdeedings 根据提问内容生成的矢量坐标进行搜索,使用 Orama 提供的 searchVector 进行搜索:

const { hits } = await searchVector(memDB, {
  vector: embedding,
  property: 'embedding',
  similarity: 0.8,  // Minimum similarity. Defaults to `0.8`
  limit: 10,        // Defaults to `10`
  offset: 0,        // Defaults to `0`
});

那么,为什么使用的是矢量搜索而不是文本搜索? 因为,矢量搜索的作用是为了搜索到和文本对应的矢量位置相近的内容,用于生成上下文本 GPT 整理最终的回答。

其中 hits 的数据结构:

{
  count: 1,
  elapsed: {
    raw: 25000,
    formatted: '25ms',
  },
  hits: [
    {
      id: '1-19238',
      score: 0.812383129,
      document: {
        title: 'The Prestige',
        embedding: [0.938293, 0.284951, 0.348264, 0.948276, 0.564720],
      }
    }
  ]
}

由于,先前将文档内容根据 Embeddings 的 Token 最大长度分片进行存储,所以,这里需要将 hits 中的数据获取的内容组合起来:

let tokenCount = 0;
let contextSections = '';

for (let i = 0; i < hits.length; i += 1) {
  const { content } = hits[i].document;
  // 注意 encode,用于组合分片
  const encoded = tokenizer.encode(content);
  tokenCount += encoded.length;
  
  // 判断是否达到 token 上限
  if (tokenCount >= MAX_CONTEXT_TOKEN && contextSections !== '') {
    break;
  }

  contextSections += `${content.trim()}\n---\n`;
}

到这里,我们已经有了问题和问题关联的内容,可以用它们构造一个 Prompt 用于后续 AI 对话使用:

const prompt = `You are a very kindly assistant who loves to help people. Given the following sections from documatation, answer the question using only that information, outputted in markdown format. If you are unsure and the answer is not explicitly written in the documentation, say "Sorry, I don't know how to help with that." Always trying to anwser in the spoken language of the questioner.

Context sections:
${contextSections}

Question:
${question}

Answer as markdown (including related code snippets if available):`

下面,我们就可以调用 OpenAI 的 API 进行 AI 对话:

const messages = [{
  role: 'user',
  content: prompt,
}];

const response = await openai.chat.completions.create({
  messages,
  model: 'gpt-3.5-turbo',
  max_tokens: 512,
  temperature: 0.4,
  stream: true,
})

其中,response 是一个 OpenAI API 调用返回的自定义数据结构的 Streaming Responses,直接将 response 返回给 ask 接口请求方肯定是不合理的(请求方只需要拿到答案)。那么,这里可以使用这里可以使用 Vercel 团队实现的 ai 提供的 OpenAIStream 函数来完成:

const { OpenAIStream } = require('ai');

const stream = OpenAIStream(response);
return stream;

OpenAIStream 会自动将 OpenAI Completions 返回的结果解析成可以正常读取的 Streaming Resonsese,如果使用的是 AirCode 则可以直接返回 stream,如果使用的是普通的 Node Server,可以进一步使用 ai 提供的 streamToResponse 函数来将 stream 转为 ServerResponse 对象:

const { OpenAIStream, streamToResponse } = require('ai');

const stream = OpenAIStream(response);
streamToResponse(stream);

结语

通过学习 Documate 内部的实现原理,我们可以知道了如何从实际的问题出发,结合使用 OpenAI API 提供的模型解决问题。在这个基础上,我们也可以去做别的场景探索,让 AI 成为现在或者将来我们解决问题的一种技术手段或尝试,而不是仅仅局限于会使用 ChatGPT 提问和获取答案。

macOS App 自动化分发 App Store 探索与实践

前言

提及自动化一词,我想很多同学会想到 CI/CD,这 2 者之间确实是存在一定的联系,可以简单理解成父、子集合之间的关系。

而正如文章标题所言,近期我在研究 macOS App 自动化分发 App Store 的事情,通俗点讲就是希望把原先手动构建 .xcarchive 文件、导出 .pkg 文件以及上传 App Store 的操作转为用 Shell 脚本自动化完成这些步骤。其中,增加的 Shell 脚本会基于现有的 CI/CD 的实现,加入到适当的位置。

那么,今天本文也会从 CI 基础出发,循序渐进地带着大家认识下 macOS App 自动化分发 App Store 实现的所以然。

1 认识持续集成(CI)基础

持续集成,Continuous Integration,简称 CI。这里我们来看下 Wikipedia 上对 CI 的介绍:

—— In software engineering, continuous integration (CI) is the practice of merging all developers' working copies to a shared mainline several times a day. Grady Booch first proposed the term CI in his 1991 method, although he did not advocate integrating several times a day. Extreme programming (XP) adopted the concept of CI and did advocate integrating more than once per day – perhaps as many as tens of times per day.

通常情况下,这在我们实际开发场景中,CI 指的是将项目的构建过程集成到某个单独的软件的实践。例如,在前面所说 macOS App 自动化分发 App Store 的 Shell 实现会加入到现有的 CI/CD 过程,它的 CI 的过程会是这样:

  • 开发人员(构建者)触发 Jenkins 的 Job
  • 执行 Job,这会由 Job 所在 Jenkins 的 Slave Node(构建机)执行,生产制品,例如一个 .dmg 或者 .pkg 文件
  • 上传制品到制品库

其中,比较关键的则是构建机(Slave Node),我们的整个构建过程的脚本实现都会在构建机上执行,例如后面要讲的自动化分发 App Store 的实现。而这里脚本使用的是 Shell 编写,当然也可以用 Google 的 zx,有兴趣的同学可以自行了解,这里不做展开。

由于,在认识 macOS App 自动化分发 App Store 之前,我们需要先知道 macOS App 手动分发的过程是怎么样的,以便于后续用自动化脚本一一实现手动分发的步骤。

2 手动分发(Distribute)

macOS App 手动分发 App Store 的过程,通俗点讲就是使用 Xcode 提供的 GUI 界面操作完成。但是,在进行正式的操作使用的前提是要有一个可以发布到 App Store 配置完备的 macOS App,这要求你需要满足以下 3 点:

  • 注册成为 Apple Developer Program,在 Apple Store 中下载 Apple Developer,然后在应用的账户中注册成为一个“尊贵的 688 会员”
  • https://developer.apple.com/ 后台,分别在 Certificates, Identifiers & ProfilesApp Store Connect 创建证书相关(Bundle Identifier、Provision Profile、Signing Certificate)和注册 App
  • 本地初始化创建一个简单的 macOS App,并关联上前面创建的 Bundle Identifier、Provision Profile、Signing Certificate

关于第 1 点,我想应该没什么难理解的。下面,我们从创建一个 macOS App 出发来串联第 2、3 点要做的事情。

2.1 前置准备(创建一个完备的应用)

创建一个 macOS App 的项目,可以通过 Xcode 快速创建一个,打开 Xcode ——> Create a new Xcode Project ——> 选择创建应用的 Platform(macOS)——> 填写项目名称、Team、Organization Identifier 等信息 ——> 在 General 中选择 App Category 和 App Icons,这里我创建的应用叫 FEKit。

使用 Xcode 打开该项目的 .xcodeproj 文件,或者在终端输入,在我们这个例子则是:

open FEKit.xcodeproj

选择构建的目标,这里我们选择 macOS 作为协议(Scheme)和目标(Target):

配置应用的 Bundle Identifier、Provision Profile、Signing Certificate,这可以在 Apple Developer 后台或者在 Xcode 的 Preferences 中添加 Apple ID 来创建,无论使用其中哪种方式创建的,都会在 Apple Developer 后台的 Certificates, Identifiers & Profiles 中展示:

其中,如果我们要分发 App Store,则需要这 2 个证书:

  • Mac App Distribution,用于签名分发 App Store 的应用和配置对应的 Provisioning Profile
  • Mac Installer Distribution,用于签名应用的安装包和提交到 App Store

当然,除开这 2 个证书,我们还需要在 Certificates, Identifiers & Profiles 页面的 Identifiers 和 Profiles 中分别创建 App ID 和 Provisioning Profile,这 2 个步骤比较简单(这里不做展开)。

在证书、Indentifiers、Profiles 创建完后,则可以下载证书(.cer)和 Provisioning Profile(.provisionprofile)文件到本地,然后分别双击打开,其中证书则会加载到电脑登录对应的钥匙串(keychain)中,而 Provisioning Profile 则会被 Xcode 使用。并且,值得一提的是每个证书都是加密的,需要配套的密钥来解密使用,也就是一个证书(.cer 文件)对应一个密钥(.p12 文件),这个密钥则是由证书创建者生成的,所以,你在创建证书的时候需要选择一个 .certSigningRequest 文件:

然后在加载证书(.cer 文件)到本地,并且确保有证书对应的密钥( .p12 文件)后,最终登录的钥匙串中的证书会是这样:

2.2 Xcode 手动分发 App Store

接着,我们则可以使用 Xcode 的 Production -> Archive 来构建 .xcarchive 文件:

构建完后,Xcode 会弹出窗口让你选择 Distribute App 或 Validate App:

这里,我们选择 Distribution App -> App Store Connect -> Export -> 选择 Development Team -> Manually manage signing,此时会要求我们选择前面提及的 Distribution 证书、Installer 证书和 Provisioning Profile:

选择 Next -> Export 后,需要选择导出的文件目录,则在该目录下会生成 FEKit.pkg 文件,然后我们可以通过 Transporter 工具来将该文件上传到 App Store Connect(或者前面 Xcode 选择 Export 或 Upload 的时候选择 Upload),之后则可以在 App Store Connect 后台的 TestFlight 查看:

所以,我们通常所说的上传 App Store,指的是上传到 App Store Connect 的 TestFlight,后续再由这里的 App Store 中提交上传文件的审核,审核通过再进行上架的操作。并且,需要注意的是每次上传的 .pkg 文件的版本号都需要比上一次的版本号大一(类似于 NPM 的 Package Version)。

那么,到这里整个手动分发 App Store 的过程就介绍完毕了,总结起来主要是这 3 个步骤:

  • 构建项目生成 .xcarchive 文件
  • 导出 .pkg 文件
  • 上传 .pkg 文件至 App Store Connect

所以,下面我们需要用 Shell 脚本自动化实现这 3 个步骤,也就是 macOS App 自动化分发 App Store。

3 自动化分发(Distribute)

在介绍 macOS App 自动化分发 App Store 实现之前,我们先来认识这 3 个工具:

  • xcodebuild 是 Xcode 的一个命令行工具包,主要用于构建项目相关
  • altool 是一个内置于 Xcode 中的命令行工具,用于验证 App 的二进制文件并将其上传至 App Store 或者对 App 进行公证(Notarize)
  • xcrun 也是 Xcode 的一个命令行工具包,主要用于执行 Xcode 相关的工具链,例如 xrun altoolxrun xcode-select

而在接下来讲解 macOS App 自动化分发 App Store 过程中,则会分别提及使用这些工具提供的能力来完成前面的手动步骤。那么,下面就让我们开始逐步认识下自动化的实现过程,首先是构建 .xcarive 文件。

3.1 构建 .xcarchive 文件

我们可以使用 xcodebuild.xcarchive 命令来构建生成 .xcarchive 文件:

xcodebuild.xcarchive \
-scheme "FEKit (macOS)" \
-configuration Release \
-archivePath ./Output/FEKit

可以看到,这里我们使用了 3 个 Option,它们分别的作用:

  • -scheme 构建的协议,不同的目标 Target 通常对应不同的协议,例如 FEKit (iOS)FEKit (macOS),前者是 IOS,后者是 macOS
  • -configuration 构建的配置,例如 Debug 或 Release,不同的配置对应的证书、签名配置会有不同
  • -archivePath 构建 .xcarchive 导出的目录和文件名,这里则会导出到 Output 目录下并命名为 FEKit.xcarchive

其中,关于项目已有的协议和配置,则可以使用 xcodebuild -list 命令查看。

3.2 导出 .pkg 文件

构建完 .xcarchive 文件后,则需要根据改文件导出 .pkg 文件,这同样可以使用 xcodebuild 提供的 Option 命令完成:

xcodebuild -exportArchive \
-archivePath ./Output/FEKit.xcarchive \
-exportPath ./Output/Pkgs \
-exportOptionsPlist ./Build/ExportOptions.plist

其中,关于 -exportOptionsPlist 则是你导出 .pkg 相关的配置,它会是这样:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>destination</key>
	<string>export</string>
	<key>installerSigningCertificate</key>
	<string>你的 Mac AppStore Install 证书 ID</string>
	<key>manageAppVersionAndBuildNumber</key>
	<true/>
	<key>method</key>
	<string>app-store</string>
	<key>provisioningProfiles</key>
	<dict>
		<key>你的 Bundle ID</key>
		<string>你的 Provisioning Profile 的名称</string>
	</dict>
	<key>signingCertificate</key>
	<string>你的证书 ID</string>
	<key>signingStyle</key>
	<string>manual</string>
	<key>teamID</key>
	<string>你的 Team ID</string>
	<key>uploadSymbols</key>
	<true/>
</dict>
</plist>

当然,如果你不想手动创建或填写这些信息,可以用 Xcode 手动导出 .pkg 操作一次,ExportOptions.plist 文件会自动生成在导出的文件目录下。

执行完前面的命令后,导出的 .pkg 文件则会在 -exportPath 配置的文件路径下,在这里也就是在 /Outputs/Pkgs/ 文件目录下。

3.3 验证和分发 .pkg 文件

接着,则是最后一步验证和分发 .pkg 文件。这一步骤需要使用 xcrunaltool 完成。首先,执行 xcrun altool --validate-app 来验证 .pkg 文件,这个主要用于确认你的应用是否满足上传的条件,例如是否选择 App Category、App Icon 以及版本号递增等,相应的命令则是:

xcrun altool --validate-app \
-f ./Output/Pkgs/FEKit.pkg \
-t macOS \
-u xxxxx \
-p xxxxx \
--show-progress

可以看到,这里使用到了 5 个 Options,它们各自的作用:

  • -f 需要验证的 .pkg 文件所在文件目录位置
  • -t 验证的目标类型,例如 macOS 或 IOS
  • -u 用于连接 App Store Connect 的 Apple Developer 账号(Apple ID)
  • -p 和账号(Apple ID)对应的 App 专用密码
  • --show-progress 用于输出验证过程的执行情况

其中,关于 -p 的 App 专用密码则需要去Apple ID 后台申请。并且,为了避免将密码明文展示在执行的命令中,我们可以单独维护一个文件来存储账号和密码:

#!/bin/bash
# App Developer 账号(Apple ID)
user="xxxxxxxx"
# App 专用密码
pwd="xxxxxx"

相应地,还需要根据 xcrun altool --validate-app 命令执行的结果(成功或失败)做不同的后续处理:

#!/bin/bash
# app_store_user_pwd.sh 可以单独放到一个隐藏目录,这里只是作为例子所以没有放到隐藏目录
source "./app_store_user_pwd.sh"
echo "Run xcrun altool --validate-app..."
xcrun altool --validate-app --f ./FEKit.pkg -t macOS -u $user -p $pwd --show-progress
if [ $? -eq 0 ]; then
    echo "validate-app success"
    # 执行上传的命令
else
    echo "validate-app fail"
    exit -1;
fi

其中,$? 表示上个命令执行结果,0 表示成功,1 表示失败,所以这里我们使用 if [ $? -eq 0 ]; then 判断验证命令执行结果是否等于 0,是则进行后续上传的处理,不是则输出验证失败的信息并退出。

那么,如果在验证通过 .pkg 文件后,我们则可以上传 .pkg 文件至 App Store Connect,这需要执行 xcrun altool --upload-app 命令:

xcrun altool --upload-app \
-f ./Output/Pkgs/FEKit.pkg \
-t macOS \
-u xxxxx \
-p xxxxx \
--show-progress

可以看到上传的命令和验证的命令使用上大同小异,只有第一个 Option 不同。那么,到这里整个实现自动化分发 App Store 的过程已经介绍完了,由于前面都是分步骤讲解的,所以 Shell 脚本的实现都是分开的,这里我们把上面讲到的 Shell 实现都合并到一个 .sh 文件中:

#!/bin/bash
echo "Run xcodebuild archive..."

xcodebuild archive \
-scheme "FEKit (macOS)" \
-configuration Release \
-archivePath ./Output/FEKit

ARCHIVE_FILE=./Output/FEKit.xcarchive
if [ ! -e "$ARCHIVE_FILE" ]; then
    echo ".xarchive doesn't exist";
    exit -1;
fi

echo "Run xcodebuild -exportArchive..."
xcodebuild -exportArchive \
-archivePath ./Output/FEKit.xcarchive \
-exportPath ./Output/Pkgs \
-exportOptionsPlist ./Build/ExportOptions.plist

PKG_FILE=./Output/Pkgs/FEKit.pkg
if [ ! -e "$PKG_FILE" ]; then
    echo ".pkg doesn't exist";
    exit -1;
fi

source "./Build/app_store_user_pwd.sh"
xcrun altool --validate-app --f $PKG_FILE -t macOS -u $user -p $pwd --show-progress
if [ $? -eq 1 ]; then
    echo "altool validate-app fail"
    exit -1;
fi

echo "altool validate-app success"
xcrun altool --upload-app --f $PKG_FILE -t macOS -u $user -p $pwd --show-progress
if [ $? -eq 0 ]; then
    echo "altool --upload-app success"
else
    echo "altool --upload-app fail"
fi

结语

最后,这里我们用一个流程图再回顾下整个自动化分发 App Store 的过程:

并且,我想可能有同学会问,作为前端我们需要懂这个吗?个人认为是需要的,因为在一些场景下,比如说做 React Native 或 Electron 开发的时候,不可避免地就会接触到原生应用的签名、构建、公证(Notarize)和分发 App Store 的概念,所以,通过亲身地体验一番原生应用实现这些的过程还是有一定收益的(知其然使其然)。此外,社区中也有支持开箱即用的实现前面步骤的工具 fastlane,有兴趣的同学可以了解下。

使用 Vue3 的 complier-core 玩转模版编译

前言

近期,在团队内推自动化表单,主要是为了去掉后台项目中繁多的表单代码。众所周知,表单一直都是后台代码的一个痛点,因为它的代码就是一个字 “长”...所以,作为一名 21 世纪的前端工程师,我们要时刻反省如何提效(能不写代码就不写代码)。

自动化表单的主要设计理念是围绕一个渲染器,通过配置对象来生成对应的表单。那么,这个时候就遇到了一个问题,对象和 UI 之间是脱离的,这就好比很多人习惯用 template 的方式写「Vue」,而不是更好性能的 render 函数,因为前者更加语义化~

那么,有办法实现语义化吗?答案是:当然可以。我们可以规定一个简易的「模版」语法,通过编译「模版」生成对应的 AST 抽象语法树,它的本质也是对象。那么,这个时候刚好“牛头对上马嘴了”,渲染器再基于这个 AST 来渲染表单,从而完成「模版」到 AST 到表单的转化过程~

并且,提及「模版」语法,我想大家立马会想起「Vue」的「模版」(template)语法。所以,今天我们也将借助「Vue」的核心编译能力 compiler-core 来玩转模版编译!

本次文章将分为以下三个部分进行:

  • 了解「Monorepo」以及它在 Vue3 中的运用。
  • 了解 compiler-core 的内部运行原理,掌握模版编译基础。
  • 开搞,玩转模版编译(乞丐版国际化)。

正文开始~

一、Monorepo 以及它在 Vue3 中的运用

首先,我们先来了解一下什么是「Monorepo」,维基百科上对它的介绍:

———— In revision control systems, a monorepo is a software development strategy where code for many projects is stored in the same repository.

简单理解,「Monorepo」指一种将多个项目放到一个仓库的一种管理项目的策略。当然,这只是概念上的理解。而对于实际开发中的场景,「Monorepo」的使用通常是通过 yarn 的 workspaces 工作空间,又或者是 lerna 这种第三方工具库来实现。使用「Monorepo」的方式来管理项目会给我们带来以下这些好处:

  • 只需要一个仓库,就可以便捷地管理多个项目。
  • 可以管理不同项目中的相同第三方依赖,做到依赖的同步更新。
  • 可以使用其他项目中的代码,清晰地建立起项目间的依赖关系。

「Vue3」正是采用的 yarn 的 workspaces 工作空间的方式管理整个项目,而 workspaces 的特点就是在 package.json 中会有这么两句不同于普通项目的声明:

{
    "private": true,
    "workspaces": [
        "packages/*"
    ]
}

可以看到,packages 文件目录下根据「Vue3」实现所需要的能力划分了不同的项目。并且,这里的 compiler-core 目录则是我们本小节要介绍的 compiler-core。所以,packages 下的项目结构会是这样:

那么,了解什么是「Monorepo」以及其在「Vue3」中的运用后,接下来我们开始了解 compiler-core 的内部运行原理~

二、compiler-core 的内部运行原理 🔧

compiler-core 负责「Vue3」中核心编译相关的能力,这包括解析(parse)模板、转化 AST 抽象语法树(transform)、代码生成(generate)等三个过程,它们之间的工作流如下图所示:

可以看到,「Vue3」会先解析模版生成对应的 AST 抽象语法树,其次再 transform 抽象语法树,对 AST 做一些特殊处理,例如打上 shapeFlagpatchFlag 等操作,最后,generate 根据抽象语法树来生成对应的可执行代码,即 render 函数。

不知道什么是 shapeFlagpatchFlag 的同学可以看这两篇文章:《compile 和 runtime 结合的 patch 过程》《从编译过程,理解静态节点提升》

那么,在「Vue3」源码层面,它们都是运行在 baseCompiler 方法中:

// packages/compiler-core/src/compiler.ts
export function baseCompile(
  template: string | RootNode,
  options: CompilerOptions = {}
): CodegenResult {
  ...
  const ast = isString(template) ? baseParse(template, options) : template
  ...
  transform(
    ast,
    extend({}, options, {
      prefixIdentifiers,
      nodeTransforms: [
        ...nodeTransforms,
        ...(options.nodeTransforms || []) // user transforms
      ],
      directiveTransforms: extend(
        {},
        directiveTransforms,
        options.directiveTransforms || {} // user transforms
      )
    })
  )

  return generate(
    ast,
    extend({}, options, {
      prefixIdentifiers
    })
  )
}

可以看到 baseCompiler 进行模版编译相关操作的也就是 baseParsetransformgenerate 这三个方法,它们也分别对应着上面所说的三个阶段。那么,接下来我们将会借助这三者来玩转的模版编译!

三、开搞,玩转模版编译

既然要玩转模版编译,那么我们就搞点有趣的(*操作)。我们来实现一个栗子,通过它渲染模版,我们会对文字内容做替换操作,即乞丐版国际化。

3.1 乞丐版国际化

我们定义一个函数它会根据 key 返回指定的语言 lang 下的文字:

function getWords(key, lang = "EN") {
   const map = new Map([
        ["CN", {
           hi: "你好",
         }],
        ["EN", {
           hi: "hello"
        }]
    ])
    
    return map.get(lang)[key]
}

然后,我们需要对「模版」中出现的 hi 字符串转化为特殊语言下的文字。这里我们需要借助 compiler-core 的提供的四个方法:

  • baseParse 解析「模版」生成 AST 抽象语法树。
  • getBaseTransformPreset 用于创建基础的 transform 函数(需要注意它是必须的)。
  • transform 转化 AST 抽象语法树,可以实现对 AST 节点的替换、删除操作。
  • generate 根据转化后的 AST 抽象语法树生成 render 函数。
const compiler = require("@vue/compiler-core");

function render(template, lang = "CN") {
  const ast = compiler.baseParse(template)
  const transform = (rootNode) => {
      if (rootNode.type === 2) {
          rootNode.content = getWords(rootNode.content)
      }
  }
  const prefixIdentifiers = true

  const [nodeTransforms, directiveTransforms] = compiler.getBaseTransformPreset(
      prefixIdentifiers
    )
  compiler.transform(ast, {
      prefixIdentifiers,
      nodeTransforms: [
          ...nodeTransforms,
          myTransfrom
      ],
  })

  const render = compiler.generate(ast)
  
  return render.code
}

3.2 开箱使用,体验整个过程

我们直接定义一个模版字符串,并将该模版字符串作为参数传给到上面定义好的 render 函数。

const template = `<div>hi</div>`
const renderStr = render(template)

这里我们打印一下生成的 render 函数字符串 renderStr

 'const _Vue = Vue\n' +
  '\n' +
  'return function render(_ctx, _cache) {\n' +
  '  with (_ctx) {\n' +
  '    const { createVNode: _createVNode, openBlock: _openBlock, createBlock: _createBlock } = _Vue\n' +
  '\n' +
  '    return (_openBlock(), _createBlock("div", null, "你好"))\n' +
  '  }\n' +
  '}'

然后,我们执行这一段代码生成的 HTML 会是这样:

<div>你好<div>

如果,我们在调用前面定义的 render 函数时,传入的 langEN,那么输出的 HTML 的会是这样:

<div>hello<div>

结语

文中介绍的使用 compiler-core 玩转模版编译的栗子只是极简的,如果要具体要具体到业务场景,那就要 fork 一份 compiler-core 来处理一些自定义的操作,这样生成的 AST 才更加贴合我们自己的需求,这期间应该需要一些时间去理解 compiler-core 中更加底层的东西。

所以,这也是为什么文章标题是【前端进阶】的缘故,因为本次介绍的内容涉及到编译的场景,它的最佳演变是形成一种自己规定「模版语法」,你也可以称之为简易版的「DSL」。

详解,从后端导出文件到前端(Blob)下载过程

前言

对于不是从事音视频方面的同学来说,很多情况下都是通过 window.location.href 来下载文件。这种方式,一般是前后端的登录态是基于 Cookie + Session 的方式,由于浏览器默认会将本地的 cookie 塞到 HTTP 请求首部字段的 Set-Cookie 中,从而实现来带用户的 SessionId,所以,我们也就可以用 window.location.href 来打开一个链接下载文件。

当然,还有一种情况,不需要登录态的校验(比较che)。

众所周知,还有另一种登录态的处理方式 JWT (JSON Web Token)。这种情况,一般会要求,前端在下载文件的时候在请求首部字段中添加 Token 首部字段。那么,这样一来,我们就不能直接通过 window.location.href 来下载文件。

不过,幸运的是我们有 Blob,它是浏览器端的类文件对象,基于二进制数据,我们可以通过它来优雅地处理文件下载,不限于音视频、PDF、Excel 等等。所以,今天我们就从后端导出文件到 HTTP 协议、非简单请求下的预检请求、以及最后的 Blob 处理文件,了解一番何为其然、如何使其然?

后端(Koa2)导出文件(Excel)

首先,我们从后端导出文件讲起。这里,我选择 Koa2 来实现 Excel 的导出,然后搭配 node-xlsx 这个库,从而实现 Excel 的二进制数据的导出。它看起来会是这样:

const xlsx = require("node-xlsx")

router.get("/excelExport", async function (ctx, next) {
    // 数据查询的结果
    const res = [
      ["name", "age"],
      ["五柳", "22"],
    ];
    // 生成 excel 对应的 buffer 二进制文件
    const excelFile = xlsx.build([{name: "firstSheet", data: res}])
    // 设置文件名相关的响应首部字段
    ctx.set("Content-Disposition", "attachment;filename=test.xlsx;filename*=UTF-8")
    // 返回给前端
    ctx.body = buffer;
  });

这里就不对数据库查询做展开,只是模拟了一下查询后的结果 res

然后我们用浏览器请求一下这个接口,我们会看到在响应首部(Response Headers)字段中的 Content-Typeapplication/octet-stream,它是二进制文件默认的 MIME 类型。

Connection: keep-alive
Content-Disposition: attachment;filename=test.xlsx;filename*=UTF-8
Content-Length: 14584
Content-Type: application/octet-stream
Date: Sun, 23 Aug 2020 11:33:16 GMT

MIME 类型,即 Multipurpose Internal Mail Extension,用于表示文件、文档或字节流。

HTTP 协议认识二进制文件流

如果,我们没有参与后端返回 Excel 的这个过程。那么,HTTP 协议可以帮助我们减少交流,并且懂得我们前端需要如何进行相应的处理。这里会涉及到三个 HTTP 实体首部字段:

  • Content-Type
  • Content-Length
  • Content-Disposition

那么,我们分别来看看它们在 HTTP 文件传输过程中的特殊意义

Content-Type

Content-Type 我想这个老生常谈的实体首部字段,大家不会陌生。它用来说明实体主体内容对象的媒体类型,遵循 type/subtype 的结构。常见的有 text/plaintext/htmlimage/jpegapplication/jsonapplication/javascript 等等。

在我们这里二进制文件,它没有特定的 subtype,即都是以 application/octet-stream 作为 Content-Type 的值。即如上面我们所看到:

Content-Type: application/octet-stream

所以,只要我们熟悉 Content-Type,那么在开发中的交流成本就可以减少。

Content-Length

Content-Length 又是一个眼熟的实体首部字段,它表示传输的实体主体的大小,单位为字节。而在我们这个栗子,表示传输的 Excel 二进制文件大小为 14584

Content-Disposition

Content-Disposition 这个实体首部字段,我想前端同学大多数是会有陌生感。它用来表示实体主体内容是用于显示在浏览器内作为文件下载。它对应的 value 有这么几个内容:

  • formdata,表示实体主体是 formdata 的形式。
  • inline,表示实体主体内容显示在浏览器内。
  • attachment,表示实体主体内容作为文件下载。
  • filename,表示文件编码格式或文件名,例如 filename*=UTF-8 表示文件的编码,filename=test.xlsx 表示下载时的文件名。
  • name,表示 formdata 上传文件时,对应 typefileinputname 值,例如 <input type="file" name="upload" />,此时对应的 name 则为 upload

需要注意的是,对于 Content-Dispositionformdata 它仅仅是一个信息提示的作用,并不是实现实体主体内容为 formdata,这是 Content-Type 负责的。

那么回到,今天这个栗子,它的 Content-Disposition 为:

Content-Disposition: attachment;filename=test.xlsx;filename*=UTF-8

所以,现在我们知道它主要做了这么几件事:

  • 告知浏览器需要将二进制文件作为附件下载
  • 附件的文件名为 test.xlsx
  • 附件对应的编码为 UTF-8

Blob 优雅地处理文件(Excel)下载

为什么说是优雅?因为,Blob 它可以处理很多类型文件,并且是受控的,你可以控制从接收到二进制文件流、到转化为 Blob、再到用其他 API 来实现下载文件。因为,如果是 window.location.href 下载文件,诚然也可以达到一样的效果,但是你无法在拿到二进制文件流到下载文件之间做个性化的操作。

并且,在复杂情况下的文件处理,Blob 必然是首要选择,例如分片上传下载音视频文件的拼接等等。所以,在这里我也推崇大家使用 Blob 处理文件下载。

并且,值得一提的是 XMLHttpRequest 默认支持了设置 responseType,通过设置 reposponseTypeblob,可以直接将拿到的二进制文件转化为 Blob

当然 axios 也支持设置 reponseType,并且我们也可以设置 responseTypearraybuffer,但是我想没这个必要拐弯抹角。

然后,在拿到二进制文件对应的 Blob 对象后,我们需要进行下载操作,这里我们来认识一下这两种使用 Blob 实现文件下载的方式。

URL.createObjectURL

在浏览器端,我们要实现下载文件,无非就是借助 a 标签来指向一个文件的下载地址。所以 window.location.href 的本质也是这样。也因此,在我们拿到了二进制文件对应的 Blob 对象后,我们需要为这个 Blob 对象创建一个指向它的下载地址 URL

URL.createObjectURL 方法则可以实现接收 FileBlob 对象,创建一个 DOMString,包含了对应的 URL,指向 BlobFile 对象,它看起来会是这样:

"blob:http://localhost:8080/a48aa254-866e-4c66-ba79-ae71cf5c1cb3"

完整的使用 BlobURL.createObjectURL 下载文件的 util 函数:

export const downloadFile = (fileStream, name, extension, type = "") => {
  const blob = new Blob([fileStream], {type});
  const fileName = `${name}.${extension}`;
  if ("download" in document.createElement("a")) {
    const elink = document.createElement("a");
    elink.download = fileName;
    elink.style.display = "none";
    elink.href = URL.createObjectURL(blob);
    document.body.appendChild(elink);
    elink.click();
    URL.revokeObjectURL(elink.href);
    document.body.removeChild(elink);
  } else {
    navigator.msSaveBlob(blob, fileName);
  }
};

FileReader

同样地,FileReader 对象也可以使得我们对 Blob 对象生成一个下载地址 URL,它和 URL.createObject 一样可以接收 FileBlob 对象。

这个过程,主要由两个函数完成 readAsDataURLonload,前者用于将 BlobFile 对象转为对应的 URL,后者用于接收前者完成后的 URL,它会在 e.target.result 上。

完整的使用 BlobFileReader 下载文件的 util 函数:

const readBlob2Url  =  (blob, type) =>{
  return new Promise(resolve => {
    const reader = new FileReader()
    reader.onload = (e) => {
      resolve(e.target.result)
    }
    reader.readAsDataURL(blob)
  })
}

结语

如果,仅仅是用一个 Blob 这个浏览器 API 处理文件下载,可能带给你的收益并没有多少。但是,通过了解从后端文件导出、HTTP 协议、Blob 处理文件下载这整个过程,就可以构建一个完整的技术思维体系,从而获取其中的收益。唯有知其然,方能使其然。 这也是前段时间看到的很符合我们作为一个不断学习的从业者的态度。也因此,良好的技术知识储备,能让我们拥有很好的编程思维和设计**。

文章对应的 DEMO,我已经上传到 GitHub 上了,有兴趣的同学可以去 clone 下来了解。

常用文件访问权相关命令,以及 macOS launchd

前言

在维护 Jenkins Slave Node(这里指 macOS 构建机)的过程,不可避免地是你会遇到一些文件访问权限和进程常驻的问题。所以,如果要解决这些问题,就要求你了解 Linux 文件访问权(包括文件和目录,以下统称文件)和什么是守护进程(macOS launchd)。

那么,回到今天本文,也将会从常用 Linux 文件访问权相关命令开始,一步一步带你了解这些其中的所以然。

一、Linux 文件访问权相关命令

首先,我们需要建立的基础认知是在 Linux 系统中,文件的访问者身份划分这 3 类:

  • 文件属主,某个用户,例如 root
  • 文件所属组,某个用户组,例如 staffwheel
  • 其他用户,不是上面 2 者

下面,我们再来分别认识下在查看和修改文件访问权过程会使用到的命令。

1.1 ls -l, ls -la

ls 这个命令,我想应该很多同学都知道,可以用于查看某个目录下有哪些文件,例如:

wujingchang@wujingchangdeMacBook-Pro ant-design % ls
AUTHORS.txt			README-zh_CN.md			package-lock.json
CHANGELOG.en-US.md		README.md			package.json
CHANGELOG.zh-CN.md		SECURITY.md			renovate.json
CNAME				components			scripts
CODE_OF_CONDUCT.md		docs				site
LICENSE				index-style-only.js		tests
README-ja_JP.md			index-with-locales.js		tsconfig.json
README-pt_BR.md			index.js			tsconfig.node.json
README-sp_MX.md			jest-puppeteer.config.js	typings
README-uk_UA.md			node_modules			webpack.config.js

ls-l Option 可以用于查看文件属主、所属组、读写权限和文件大小等信息:

wujingchang@wujingchangdeMacBook-Pro ant-design % ls -l
-rw-r--r--     1 wujingchang  staff    50839 11  9 12:50 AUTHORS.txt
-rw-r--r--     1 wujingchang  staff   320126 11  9 12:50 CHANGELOG.en-US.md
-rw-r--r--     1 wujingchang  staff   334949 11  9 12:50 CHANGELOG.zh-CN.md
-rw-r--r--     1 wujingchang  staff       16 11  9 12:50 CNAME
-rw-r--r--     1 wujingchang  staff     3254 11  9 11:39 CODE_OF_CONDUCT.md
-rw-r--r--     1 wujingchang  staff     1099 11  9 11:39 LICENSE
-rw-r--r--     1 wujingchang  staff    10155 11  9 12:50 README-ja_JP.md
-rw-r--r--     1 wujingchang  staff     9903 11  9 11:39 README-pt_BR.md
-rw-r--r--     1 wujingchang  staff     9801 11  9 12:50 README-sp_MX.md
-rw-r--r--     1 wujingchang  staff    10623 11  9 11:39 README-uk_UA.md
-rw-r--r--     1 wujingchang  staff     9596 11  9 11:39 README-zh_CN.md
# 省略若干目录

其中,如果当你还想隐藏文件的信息的时候则可以使用 ls -la。那么,可以看到这里主要 9 列,分别表示的含义为:读写权限信息、链接目录数和文件路径长度(例如 /demo/test 此时为 2)、属主、所属组、文件大小、创建月份、创建日、创建时间、文件名称,例如 AUTHORS.txt

# 读写权限    链接目录数和文件路径长度  属主          所属组   文件大小 创建月 日  时间   文件名
-rw-r--r--  1                      wujingchang  staff  50839   11    9  12:50 AUTHORS.txt

很显然,我们关注的是第一列(文件读写权限信息),而它则是由长度为 10 的字符组成:

  • 第 1 位 表示文件还是目录,例如文件是 -、目录是 d 和链接目录是 l
  • 第 2~4 位表示属主的权限,其中 r 表示读(Read)权限,w 表示写(Write)权限,x 表示执行(execute)权限,- 则表示无权限
  • 第 5~7 位表示所属组的权限
  • 第 8 ~ 10 位表示其他用户的权限

例如,这里 AUTHORS.txt 的读写权限信息 -rw-r--r-- 则表示:

  • - 普通文件
  • rw- 可读、可写、不可执行(因为是一个普通文件不是可执行程序)
  • r-- 可读、不可写、不可执行,也就是说同样是 staff 组的用户(除开 wujingchang)只能读这个文件不能写
  • r-- 同上

1.2 chmod

chmod 是 change mode 的缩写,表示改变文件的权限。我们可以通过 chmod + 和要获取的权限 rwx 来更改具体的文件权限,例如 chmod +w 表示增加可写的权限,反之 -w 则表示移除可写的权限。

同样是 AUTHORS.txt 文件,我们把原本的可读权限去掉:

wujingchang@wujingchangdeMacBook-Pro ant-design % chmod -r AUTHORS.txt

需要注意的是,-r 默认修改的是属主的访问权限,我们可以通过 u-r(属主)、g-r(所属组)、o-r(其他用户)的方式移除指定的读取权限。

那么,现在 AUTHORS.txt 文件的权限信息则是:

wujingchang@wujingchangdeMacBook-Pro ant-design % ls -l AUTHORS.txt
--w-------  1 wujingchang  staff  50839 11  9 12:50 AUTHORS.txt

可以看到原先的 r 都被移除了,此时,如果尝试打开 open AUTHORS.txt,则会提示:

此外,除了使用字符 rwx 的方式修改,我们还可以用数字的方式修改(4 可读,2 可写,1 可执行),例如同样是移除 AUTHORS.txt 的读权限:

wujingchang@wujingchangdeMacBook-Pro ant-design % chmod 020 AUTHORS.txt

1.3 chown

chown是 change ownership 的缩写,表示我们可以更改文件的访问权。例如,我们可以修改 AUTHORS.txt 文件的属主为 root

wujingchang@wujingchangdeMacBook-Pro ant-design % sudo chown root AUTHORS.txt

注意由于 root 是超级用户,所以需要 sudo。然后,此时 AUTHORS.txt 的属主则会变成 root

wujingchang@wujingchangdeMacBook-Pro ant-design % ls -l AUTHORS.txt 
-rw-r--r--  1 root  staff  50839 11  9 12:50 AUTHORS.txt

那现在我们能使用 open AUTHORS.txt 打开吗?答案是可以,因为 wujingchangroot 属于同一个组 staff,而 -rw-r--r-- 的第 4~7 位 r-- 表示可读、不可写不可执行,并且其他用户也是 r--。所以,此时仍然是可以打开 AUTHORS.txt 文件,但是打开后会处于只读的模式:

那么,如果希望在修改文件属主的同时也修改文件所属组,可以在使用 chown 的时候通过 user:group 的方式修改:

wujingchang@wujingchangdeMacBook-Pro ant-design % sudo chown root:wheel AUTHORS.txt
wujingchang@wujingchangdeMacBook-Pro ant-design % ls -ls AUTHORS.txt   
104 -rw-r--r--  1 root  wheel  50839 11  9 12:50 AUTHORS.txt

然后,则是其他用户的可读权限,这可以通过 chmod 修改:

wujingchang@wujingchangdeMacBook-Pro ant-design % open AUTHORS.txt
wujingchang@wujingchangdeMacBook-Pro ant-design % sudo chmod o-r AUTHORS.txt
wujingchang@wujingchangdeMacBook-Pro ant-design % ls -ls AUTHORS.txt
104 -rw-r-----  1 daemon  wheel  50839 11  9 12:50 AUTHORS.txt
The application cannot be opened for an unexpected reason, error=Error Domain=NSOSStatusErrorDomain Code=-5000 "afpAccessDenied: Insufficient access privileges for operation " UserInfo={_LSLine=3863, LSErrorDict={
    Action = odoc;
    Documents =     (
        "AUTHORS.txt"
    );
    ErrorCode = "-5000";
    FullPaths =     (
        "/Users/wujingchang/Documents/repo/ant-design/AUTHORS.txt"
    );
}, _LSFunction=_LSOpenStuffCallLocal}

1.4 chgrp

chgrp 是 change group 的缩写,表示我们可以更改文件的所属组的访问权。也就是说,除开使用 chmod 的时候指明用户组的方式修改所属组的访问权,我们还可以通过 chgrp 来直接修改 AUTHORS.txt 文件的所属组:

wujingchang@wujingchangdeMacBook-Pro ant-design % sudo chgrp wheel AUTHORS.txt 

二、Launchd

通常,我们在注册连接 Jenkins Slave Node 的时候是希望它常驻在内存中。这在 Windows 机器上,我们可以通过 Services 的方式实现。而在 macOS 上,我们可以通过launchd 提供的守护进程相关的能力实现。

launchd 则是 Apple Inc 创建的一个初始化和操作系统服务管理守护进程。在 Launchd 中有 2 个主要的程序 launchd 和 launchctl,前者用于启动系统和运行服务,后者用于控制服务。

所以,在 macOS 启动过程,会加载 launchd,launchd 则会运行 /etc/rc,扫描系统 System、用户 User 下、全局的 /Library/LaunchDaemons 和 /Library/LaunchAgents 目录下的脚本,根据需要在 plist 上调用 launchctl,然后 launchd 启动登录窗口。

其中 LaunchDaemonsLaunchAgents 处于不同的文件位置所执行的用户有所不同:

类型 文件位置 运行的用户
User Agents ~/Library/LaunchAgents 当前登录用户
Global Agents /Library/LaunchAgents 当前登录用户
Global Daemons /Library/LaunchDaemons root 或者指定的用户
Global Agents /System/Library/LaunchAgents 当前登录用户
Aystem Daemons /System/Library/LaunchDaemons root 或者指定的用户

可以看到,LaunchAgents 是只能以当前的登录用户运行,而 LaunchDaemons 可以指定特定的用户,并且需要注意的是只有 LaunchAgents 可以访问 macOS GUI 的。

如果,我们期望通过 launchd 运行指定的程序(launch Job 或 Service,以下统称 launch Job),则需要在上述的文件目录下配置以 .plist 扩展名的 XML 文件,这通常也被称为 Plist 文件。那么,下面我们来一起看下如何创建一个 launchd Job?

2.1 launchd Job 创建

用于创建 launchd Job 的 Plist 文件配置提供了诸多 Key-Value 来实现指定的声明,这里我们来看 4 个比较常用的 Key:

  • Label 用于表示 Daemon 的名称,唯一标识
  • ProgramArguments 用于表示 Daemon 启动时需要执行的命令行相关
  • UserName 用于表示启动 Daemon 的用户,例如你可以声明为 wujingchang
  • KeepAlive 用于表示是否 Daemon 需要按需运行还是必须运行

那么,以 Jenkins Slave Node 的连接为例,它需要我们在构建机器(macOS 机器)上通过指定的 Java 命令建立和 Jenkins 服务器的远程连接,这看起来会是这样:

java -jar agent.jar -jnlpUrl http://xxxxxxx/jenkins-agent.jnlp -secret xxxxxxxx -workDir D:\jenkins

所以,它的 lauchd Job 的配置会是:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
	<dict>
		<key>Label</key>
		<string>com.jenkins.slavenode</string>
		<key>ProgramArguments</key>
		<array>
      <string>java</string>
      <string>-jar</string>
      <string>-jnlpUrl</string>
      <string>http://xxxxxxx/jenkins-agent.jnlp</string>
      <string>-secret</string>
      <string>xxxxxxxx</string>
      <string>-workDir</string>
      <string>D:\jenkins</string>
    </array>
    <key>UserName</key>
    <string>wujingchang</string>
		<key>KeepAlive</key>
		<true/>
	</dict>
</plist>

并且,需要注意的是 launchd Job 的 Plist 文件需要按前面提及的 LaunchDaemons 的文件位置存放(保证开机自启的行为),例如这里的 wujingchang 是当前用户,那么就把 Plist 文件放到 ~/Library/LaunchDaemons 文件目录下。

2.2 launchctl 命令使用

在创建好 launchd Job 的 Plist 文件后,后续在机器开启的过程则会执行对应的 Job。那么,也就是说我们刚创建的 launchd Job 需要等待下次开机才能运行。

所以,如果你新创建的 launchd Job 也需要马上运行,那么则可以使用 launchctl 提供的命令,常用的 launchctl 命令有:

  • launchctl load 加载启动 launchd Job
  • launchctl unload 卸载停止 launchd Job
  • launchctl list 查看已启动的访问 lauchd Job 信息

那么,回到上面的例子,我们可以使用 launchctl load 命令运行刚创建的 launch Job:

launchctl load ~/Library/LaunchDaemons/slavenode.plist

此外,我们还可以通过 brew services 相关的命令来启动和停止 launchd Job,具体大家可以通过 brew services --help 了解,这里不做展开。

结语

通过,了解文件访问权修改和 launchd 相关的知识,可以让我们在 Jenkins 排障的维护过程处理访问权限更得心应手,避免由于知识盲区的存在导致问题迟迟不能解决或者解决的成本较高的情况出现。并且,需要注意的是每个修改文件访问权相关的命令(chmodchownchgrp)都支持使用 -h Option 查看相关的介绍,例如 chown -h

wujingchang@wujingchangdeMacBook-Pro @ant-desgin % chown -h  
usage: chown [-fhnvx] [-R [-H | -L | -P]] owner[:group] file ...
       chown [-fhnvx] [-R [-H | -L | -P]] :group file ...

最后,如果文中存在表达不当或错误的地方,欢迎各位同学提 Issue ~

从过去到现在,聊聊 Tree-shaking 是什么?

前言

Tree-shaking 这一术语在前端社区内,起初是 Rich Harris 在 Rollup 中提出。简单概括起来,Tree-shaking 可以使得项目最终构建(Bundle)结果中只包含你实际需要的代码。

而且,说到 Tree-shaking,不难免提及 Dead Code Elimation,相信很多同学在一些关于 Tree-shaking 的文章中都会看到诸如这样的描述:Tree-shaking 是一项 Dead Code Elimation(以下统称 DCE)技术。

那么,既然有了 DCE 这一术语,为什么又要造一个 Tree-shaking 术语?存在既有价值,下面,让我们一起来看看 Rich Harris 是如何回答这个问题的。

1 Tree shaking Vs Dead Code Elimaion

在当时 Rich Haris 针对这一提问专门写了这篇文章《Tree-shaking versus dead code elimination》,文中表示 DCE 和 Tree-shaking 最终的目标是一致的(更少的代码),但是它们仍然是存在区别的。

Rich Haris 举了个做蛋糕的例子,指出 DCE 就好比在做蛋糕的时候直接把鸡蛋放入搅拌,最后在做好的蛋糕中取出蛋壳,这是不完美的做法,而 Tree-shaking 则是在做蛋糕的时候只放入我想要的东西,即不会把蛋壳放入搅拌制作蛋糕。

因此,Tree-shaking 表达的不是指消除 Dead Code,而是指保留 Live Code。即使最终 DCE 和 Tree-shaking 的结果是一致的,但是由于 JavaScript 静态分析的局限性,实际过程并不同。并且,包含有用的代码可以得到更好的结果,从表面看(做蛋糕的例子)这也是一种更符合逻辑的方法。

此外,当时 Rich Haris 也认为 Tree-shaking 可能不是一个很好的名称,考虑过用 Live Code Inclusion 这个短语来表示,但是似乎会造成更多的困惑......让我们看一下 Rich Haris 的原话:

I thought about using the ‘live code inclusion’ phrase with Rollup, but it seemed that I’d just be adding even more confusion seeing as tree-shaking is an existing concept. Maybe that was the wrong decision?

所以,我想到这里同学们应该清楚一点,Tree-shaking 和 DCE 只是最终的结果是一致的,但是 2 者实现的过程不同,Tree-shaking 是保留 Live Code,而 DCE 是消除 Dead Code。

并且,当时 Rich Harris 也指出 Rollup 也不是完美的,最好的结果是使用 Rollup + Uglify 的方式。不过,显然现在的 Rollup v2.55.1 已经臻至完美。那么,接下来让我们沿着时间线看看 Tree-shaking 的演变~

2 Tree-shaking 的演变

Tree-shaking 在最初被提出的时候它只会做一件事,那就是利用 ES Module 静态导入的特点来检测模块内容的导出、导入以及被使用的情况,从而实现保留 Live Code 的目的。

也许这个时候你会问 Tree-shaking 不是还会消除 Dead Code 吗?确实,但是也不一定,如果你使用的是现在的 Rollup v2.55.1,它是会进行 DCE,即消除 Dead Code。但是,如果你用的是 Webpack 的话,那就是另一番情况了,它需要使用 Uglify 对应的插件来实现 DCE。

下面,我们以 Rollup 为例,聊聊过去和现在的 Tree-shaking。

2.1 过去的 Tree shaking

在早期, Rollup 提出和支持 Tree-shaking 的时候,它并不会做额外的 DCE,这也可以在 15 年 Rich Haris 写的那篇文章中看出,当时他也提倡大家使用 Rollup + Uglify。所以,这里让我们一起把时间倒回 Rollup v0.10.0 的 Tree-shaking。

回到 Rollup v0.10.0 版本,你会发现非常有趣的一点,就是它的 GitHub README 介绍是这样的:

Rollup 的命名来源于一首名为《Roll up》的说唱歌曲,我想这应该出乎了很多同学的意料。不过话说 Evan You 也喜欢说唱,然后我(你)也喜欢说唱,所以这也许可以论证我(你)选择前端似乎没错?这里附上这首歌,你可以选择听这首歌来拉近 Rollup 的距离。

传送门:https://www.youtube.com/watch?v=UhQz-0QVmQ0

下面,我们使用 Rollup v0.10.0 版本来做一个简单示例来验证一下前面说的。并且,在这个过程中需要注意,如果你的 Node 版本过高会导致一些不兼容,所以建议用 Node v11.15.0 来运行下面的例子。

首先,初始化项目和安装基础的依赖:

npm init -y
npm i [email protected] -D

然后,分别新建 3 个文件:

utils.js

export const foo = function () {
  console.log("foo");
};

export const bar = function () {
  console.log("bar");
};

main.js

import { foo, bar } from "./utils.js";

const unused = "a";

foo();

index.js

const rollup = require("rollup");

rollup
  .rollup({
    entry: "main.js",
  })
  .then(async (bundle) => {
    bundle.write({
      dest: "bundle.js",
    });
  });

其中,main.js 是构建的入口文件,然后 index.js 负责使用 Rollup 进行构建,它会将最终的构建结果写入到 bundle.js 文件中:

// bundle.js
const foo = function () {
  console.log("foo");
};

const unused = "a";

foo();

可以看到,在 bundle.js保留了 utils.js 中的 foo() 函数(因为被调用了),而导入的 uitls.js 中的 bar() 函数(没有被调用)则不会保留,并且定义的变量 ununsed 虽然没有被使用,但是仍然保留了下来。

所以,通过这么一个小的示例,我们可以验证得知 Rollup 的 Tree-shaking 最初并不支持 DCE,它仅仅只是在构建结果中保留你导入的模块中需要的代码

2.2 现在的 Tree shaking

前面,我们从过去的 Tree shaking 开始了解,大致建立起了对 Tree shaking 的初印象。这里我们来看一下现在 Rollup 官方上对 Tree shaking 的介绍:

Tree-shaking,也被称为 Live Code Inclusion,是指 Rollup 消除项目中实际未使用的代码的过程,它是一种 Dead Code Elimation 的方式,但是在输出方面会比其他方法更有效。该名称源自模块的抽象语法树(Abstract Sytanx Tree)。该算法首先会标记所有相关的语句,然后通过摇动语法树来删除所有的 Dead Code。它在**上类似于 GC(Garbage Collection)中的标记清除算法。尽管,该算法不限于 ES Module,但它们使其效率更高,因为它允许 Rollup 将所有模块一起视为具有共享绑定的大抽象语法树。

从这段话,我们可以很容易地发现随着时间的推移,Rollup 对 Tree-shaking 的定义已经不仅仅是 ES Module 相关,此外它还支持了 DCE。所以,有时候我们看到一些文章介绍 Tree-shaking 实现会是这样:

  • 利用 ES Module 可以进行静态分析的特点来检测模块内容的导出、导入以及被使用的情况,保留 Live Code
  • 消除不会被执行没有副作用(Side Effect) 的 Dead Code,即 DCE 过程

那么,在前面我们已经知道 Tree-shaking 基于 ES Module 静态分析的特点会做的事情。所以,这里我们来仔细看一下第 2 点,换个角度看,它指的是当代码没有被执行,但是它会存在副作用,这个时候 Tree shaking 就不会把这部分代码消除。

那么,显然对副作用建立良好的认知,可以让项目中代码能更好地被 Tree shaking。所以,下面让我们来通过一个简单的例子来认识一下副作用。

2.2 副作用(Side Effect)

在 Wiki 上对副作用(Side Effect)做出的介绍:

在计算机科学中,如果操作、函数或表达式在其本地环境之外修改某些状态变量值,则称其具有副作用。

把这段话转换成我们熟悉的,它指的是当你修改了不包含在当前作用域的某些变量值的时候,则会产生副作用。这里我们把上面的例子稍作修改,把 sayHi() 函数的形参删掉,改为直接访问定义好的 name 变量:

utils.js

export const name = "wjc";

export const sayHi = function () {
  console.log(`Hi ${name}`);
};

main.js

import { sayHi } from "./maths.js";

sayHi();

然后,我们把这个例子通过 Rollup 提供的 REPL 来 Tree shaking 一下,输出的结果会是这样:

const name = "wjc";

const sayHi = function () {
  console.log(`Hi ${name}`);
};

sayHi();

可以看到,这里我们并没有直接导入 utils.js 文件中的 name 变量,但是由于在 sayHi() 函数中访问了它作用域之外的变量 name,产生了副作用,所以最后输出的结果也会有 name 变量。

当然,这仅仅只是一个非常简单的产生副作用的场景,也是很多同学不会犯的错误。此外,一个很有趣的场景就是使用 Class 关键字声明的类经过 Babel 转换为 ES5 的代码(为了保证 Class 可枚举)后会产生副作用。

对上面提到的这个问题感兴趣的同学,可以看这篇文章 你的 Tree-Shaking 并没什么用 仔细了解,这里就不做重复论述了~

结语

写这篇文章的动机主要是出于对 Tree shaking 和 DCE 这两个术语十分相似,但是 Tree-shaking 必然有其存在的意义,所以就诞生了这篇文章。虽然,文章中并没有涉及 Tree-shaking 的底层实现,但是我想有时候搞清楚一些模糊的概念的优先级是优于了解其底层实现的

并且,通过对比 2015 年 Rich Harris 在提出 Tree-shaking 的初衷,到现在 Tree shaking 所具备的能力来说,随着时间的演变 Rollup 的 Tree-shaking 默认也支持了 DCE,这也难免会造成一些同学对 Tree-shaking 的理解产生混乱。所以,如果想要追溯本源(Tree-shaking 由来)的同学,我还是蛮推荐仔细阅读一下 《Tree-shaking versus dead code elimination》这篇文章的。

Es-Module-Lexer,ES Module 语法的词法分析利器

前言

说到词法分析,我想很多同学第一时间想到的可能是 Babel、Acorn 等工具。不可否认,它们都很强大 😶。

但是,具体到今天这个话题 ES Module 语句的词法分析而言,es-module-lexer 会胜过它们很多!

那么,今天我们将围绕以下 2 点,深入浅出一番 es-module-lexer:

  • 认识 es-module-lexer
  • 实际场景下如何应用 es-module-lexer

1 认识 es-module-lexer

es-module-lexer 是一个可以对 ES Module 语句进行词法分析的工具包。它压缩后之后只有 4 KiB,其底层通过内联(Inline) WebAssembly 的方式来实现对 ES Module 语句的快速词法分析

1KiB = 1,024Byte

那么,具体会有多快?根据官方给的例子,Angular1(720 KiB)使用 Acorn 解析所需要的时间为 100 ms,而 es-module-lexer 解析只需要 5 ms,也就是前者的 1/20 😵。

并且,es-module-lexer 的使用也非常简单,它提供了 init Promise 对象和 parse 方法,下面我们来看一下它们分别做了什么?

1.1 init(Promise 对象)

init 必须parse() 方法前 Resolve(解析),它的实现可以分为 3 个步骤:

  • 首先,调用 WebAssembly.compile() 方法编译 WebAssembly 二进制代码到为 WebAssembly.ModulePromise 对象
  • 然后,再调用 WebAssembly.Instantiate() 方法创建一个实例
  • 最后,则可以在实例上访问 exports 属性来获取调用的模块提供的方法

这个过程对应的代码:

let wasm;
const init = WebAssembly.compile(
  (binary => typeof window !== 'undefined' && typeof atob === 'function' ? Uint8Array.from(atob(binary), x => x.charCodeAt(0)) : Buffer.from(binary, 'base64'))
  ('WASM_BINARY')
).then(WebAssembly.instantiate)
 .then(({ exports }) => { wasm = exports; });

而这里的二进制代码,则是由 C 实现的对 ES Module 语句进行词法分析的代码编译得来。
并且,可以看到实例的 exports 会被赋值给 wasm

1.2 parse() 方法

parse() 方法则会使用在上面得到的 WebAssembly.Module 提供的方法(即 wasm)来实现对 ES Module 语法的词法分析。

这个过程对应的代码(伪代码):

function parse (source, name = '@') {
  if (!wasm)
    return init.then(() => parse(source));
  // 调用 wasm 上的方法进行对应的操作
  return [imports, exports, !!wasm.f()];
}

注意,这里不对 wasm 上提供的方法进行分析,有兴趣的同学可以自行了解~

可以看到,如果我们在调用 parse() 方法之前没有 Resolve(解析)initparse() 方法会自己先 Resolve(解析) init。然后,在 .then 中调用并返回 parse() 方法,所以在这种情况下,parse() 方法会返回一个 Promise 对象。

当然,不管任何情况下,parse() 方法的本质是返回一个数组(长度为 3)。并且,和我们使用密切相关的主要是 importsexports

importsexports 都是一个数组,其中每个元素(对象)代表一个导入语句的解析后的结果,具体会包含导入或导出的模块的名称、在源代码中的位置等信息。

接下来,我们通过一个简单的例子来认识一下 es-module-lexer 的基本使用。

1.3 基本使用

首先,我们基于 es-module-lexer 定义一个 parseImportSyntax() 方法:

const { init, parse } = require("es-module-lexer")

async function parseImportSyntax(code = "") {
  try {
    await init
    
    const importSpecifier = parse(code)
    return importSpecifier
  } catch(e) {
    console.error(e)
  }
}

可以看到 parseImportSyntax() 方法会返回 parse 后的结果。假设,此时我们需要解析导入 ant-design-vue 的 Button 组件的语句:

const code = `import { Button } from 'ant-design-vue'`
parseImportSyntax(code).then(importSpecifier => {
  console.log(importSpecifier)
})

对应的输出:

[ 
  [ 
    { 
      n: 'ant-design-vue', 
      s: 24, 
      e: 38, 
      ss: 0, 
      se: 39, 
      d: -1 
    } 
  ], 
  [], 
  true 
]

由于,我们只声明了导入语句,所以最后解析的结果只有 imports 内有元素,该元素(对象)的每个属性对应的含义:

  • n 表示模块的名称
  • s 表示模块名称在导入语句中的开始位置
  • e 表示模块名称在导入语句中的结束位置
  • ss 表示导入语句在源代码中的开始位置
  • se 表示导入语句在源代码中的结束位置
  • d 表示导入语句是否为动态导入,如果是则为对应的开始位置,否则默认为 -1

那么,在简单了解完 es-module-lexer 的实现原理和使用后,我想同学们可能会思考它在实际场景下中要如何运用?(请继续阅读 😎)

2 实际场景下如何应用 es-module-lexer

在同学们可能还没意识到哪里用到了 es-module-lexer 的时候,其实它已经走进了我们平常的开发中。

那么,这里我们以 vite-plugin-style-import 插件为例,认识一下它又是如何使用 es-module-lexer 的?(别走开,接下来会非常有趣 😋)

2.1 浅析 vite-plugin-style-import 原理

在正式开讲 es-module-lexer 在 vite-plugin-style-import 中的使用之前,我们需要知道 vite-plugin-style-import 做了什么?

它解决了我们按需引入组件时,需要手动引入对应组件样式的问题。例如,在使用 ant-design-vue 的时候,按需引入 Button 只需要声明:

import { Button } from "ant-design-vue"

然后,经过 vite-plugin-style-import 处理后对应的代码片段:

import { Button } from 'ant-design-vue';
import 'ant-design-vue/es/button/style/index.js';

而这个过程的实现可以分为以下 3 个步骤:

  • 使用 es-module-lexer 对源代码的导入(import)语句进行词法分析

  • 根据配置文件 vite.config.js 中的 vite-plugin-style-import 的配置项来构造样式文件的导入语句

  • 根据环境(会区分 Dev 或 Prod),选择性地注入特定的代码到源代码中

2.2 使用 es-module-lexer 的黑魔法

1.3 基础使用小节的部分,我们讲了 es-module-lexer 解析导入语句时,只会返回导入模块相关的信息,那么这在 vite-plugin-style-import 中显然是不够的!

因为,vite-plugin-style-import 还需要知道此时导入了该模块的什么组件,这样才能去拼接生成对应的样式文件的导入语句。

那么,这个时候使用 es-module-lexer 的黑魔法就来了,我们可以将原来的导入语句的 import 替换为 export,然后 es-module-lexer 就会解析出导出的组件信息(想不到吧 😲)!

例如,同样是上面导入 ant-design-vue 的 Button 的例子,替换 import 后会是这样:

export { Button } from "ant-design-vue"

这个时候,使用 es-module-lexer 解析后返回的结果:

[
  [
    { 
      n: 'ant-design-vue', 
      s: 24, 
      e: 38, 
      ss: 0, 
      se: 39, 
      d: -1 
    }
  ],
  [ 'Button' ],
  true
]

可以看到,Button 被放到了解析结果的(数组第二个元素) exports 中,这样一来我们就知道了使用导入模块的组件有哪些 😎。

而这个过程,在 vite-plugin-style-import 中是由 transformImportVar() 方法完成的:

function transformImportVar(importStr: string) {
  if (!importStr) {
    return [];
  }

  const exportStr = importStr.replace('import', 'export').replace(/\s+as\s+\w+,?/g, ',');
  let importVariables: readonly string[] = [];
  try {
    importVariables = parse(exportStr)[1];
    debug('importVariables:', importVariables);
  } catch (error) {
    debug('transformImportVar:', error);
  }
  return importVariables;
}

结语

很有趣的一点是 awesome-vite 上有两个支持按需引入组件样式文件的插件。通过阅读,我想同学们应该知道用哪个了吧 😎!
最后,用一句话总结 es-module-lexer 的优点,那就是:“快到飞起”。如果,文中存在表达不当或错误的地方,欢迎同学提 Issue~

设置 NPM Registry 的 4 种姿势

前言

前段时间,由于团队使用的 Monorepo 工程使用的工具是 Lerna,所以在思考如何改造的问题,最终整体的技术选型是 PNPM + Changeset + Turborepo。相应地,就需要在这个选型的背景下支持原先使用到的 Lerna 的能力。

其中,比较有意思的就是需要把 Package 发布到私有 Registry。因为,这里选择了 Changeset,所以最后执行发布的命令会是:

pnpm changset publish

那这个时候,就牵扯到一个问题,项目中的私有 Registry 要配置在哪?这里我们不着急找答案,先来了解一下配置私有 Registry 的 4 种姿势。

1 Global registry

我们可以通过设置 npm 或者 pnpmconfig 来设置 Global Registry,例如:

# npm
npm config set registry=http://localhost:2000

# or pnpm
pnpm config set registry=http://localhost:2000

这样一来,在代码层面就可以通过 process.env.npm_config_registry 读取到这里的配置。

2 .npmrc

无论是 npmpnpm 默认都会从项目的 .npmrc 文件中读取配置,所以当我们需要 包的发布要走私有 Registry 的时候,可以这样设置:

registry = http://localhost:2000

3 --registry

在执行 npm publishpnpm publish 的时候,我们也可以通过带上 --registry Option 来告知对应的包管理工具要将包发布的 Registry 是什么,例如:

# npm
npm publish --registry=http://localhost:2000

# or pnpm
pnpm publish --registry=http://localhost:2000

4 PublishConfig

PublishConfig 指的是我们可以在要执行 publish 命令的项目 package.json 中的 publishConfig.registry 来告知 npmpnpm 要发布的 Registry,例如:

{
  ...
  "publishConfig": {
    "registry": "http://localhost:2000"
  }
  ...
}

5 Changeset publish 原理

在了解完 4 种设置私有 Registry 的姿势后,我们回到文章开始的这个问题,如何让 pnpm changeset publish 知道要把发布包到指定的私有 Registry?如果,你觉得以上 4 种任选一种即可,那你可能需要踩些坑。

首先,我们需要知道的是 pnpm changeset publish 命令的本质是执行 changesetpublish。那么,也就是上面的 4 种设置 Registry 的方式,很可能不是每种都生效的,因为 Changeset 有一套自己的 publish 机制。而这个过程它主要会做这 3 件事:

1.首先,获取 Package Info,它会从指定的 Registry 获取 Package Info。举个例子,如果是获取 rollup 的 Package Info,那么在这里会是这样:

npm info rollup --registry="https://registry.npmjs.org/" --json

2.其次,根据上面拿到的 Package Info 中的 versions 字段(它是一个包含所有已发布的版本的数组),对比本地的 package.jsonversion 字段,判断当前版本是否已发布

3.最后,如果未发布当前版本,则会根据当前使用的包管理工具执行 publish 命令,并且此时会构造一个它认为应该发布的 Registry 地址,然后重写 env 上的配置,对应的代码会是这样:

// packages/cli/src/commands/publish/npm-utils
const envOverride = {
  npm_config_registry: getCorrectRegistry()
};
let { code, stdout, stderr } = await spawn(
  // 动态的包管理工具,例如 pnpm、npm、yarn
  publishTool.name,
  ["publish", opts.cwd, "--json", ...publishFlags],
  {
    env: Object.assign({}, process.env, envOverride)
  }
);

可以看到,整个 changeset publish 的过程还是很简单易于理解的。并且,非常重要的是这个过程牵扯到 Registry 获取的都是由一个名为 getCorrectRegistry() 的函数完成的,它的定义会是这样:

// packages/cli/src/commands/publish/npm-utils.ts
function getCorrectRegistry(packageJson?: PackageJSON): string {
  const registry =
    packageJson?.publishConfig?.registry ?? process.env.npm_config_registry;

  return !registry || registry === "https://registry.yarnpkg.com"
    ? "https://registry.npmjs.org"
    : registry;
}

那这么一看,我想大家都明白了,为什么前面提及 4 种设置 Registry 的方式,很可能不是每种都生效的。

因为,在 Changeset 中只支持了 publishConfigenv 配置 Registry 的方式,所以如果你尝试其他 2 种方式就会 publishhttps://registry.yarnpkg.comhttps://registry.npmjs.org,并且在第一步获取 Package Info 的时候可能就会失败。

结语

我想文章虽短,但是传达的知识点还是蛮有趣的。至于前面所说的 PNPM + Changeset + Turborepo 技术选型,起码目前我体验起来还是很丝滑的,无论是在依赖安装、多包任务执行、Version Or Publish 等方面都很优秀。所以,有兴趣的同学倒是可以尝试一下。

最后,如果文中存在表达不当或错误的地方,欢迎各位同学提 Issue ~

跨端开发的最佳实践——Chameleon(变色龙 )

前言

近期,在公司落地了一个社区内比较 新(踩)的(坑) 跨端框架 Chameleon,当时的需求是先实现快应用端,之后需要支持 H5、微信小程序、字节小程序等。由于公司用的技术栈是以 Vue.js 为主,在做了一番技术选型后,最终选择了 Chameleon(开始疯狂踩坑 😳)

这个时候,可能会有人问:为什么不选 uniapp?这么说吧,uniapp -> 快应用就不是一个坑了,是个炸弹 💣。后面,我会讲解为什么不选 uniapp 😲

不过,理性评价,Chameleon 确实是一个优秀的跨端框架。至于优秀在哪?(请继续阅读 😶)

本次文章将会分为以下三个部分,通过介绍、对比现有前端跨端框架,来逐点讲述 Chameleon 优秀之处 😍:

  • 为什么我们需要跨端?
  • 爆炸发展的前端跨端框架
  • Chameleon 未来的跨端方案

1. 😷 为什么我们需要跨端?

当然,了解这个缘由的同学,可以跳过这个小节哈~

这个问题,我们可以分为两个维度去解释:

需求的多变性

现今,小程序实在是太多了...而且,对于日异变更的需求,今天可能和你说的是微信小程序,过了一段时间,可能会和你说同样的产品,你写个快应用版的 😲。对于需求方来说,他们并不关注你是怎么实现的不同端,并且,可能还会觉得,你做个一样的东西只是平台不一样那不是很快吗

技术的维护性

从技术维护维度思考,这是为了我们更好地维护代码,尽量通过维护一套代码来实现不同端的产品,例如微信小程序、字节小程序、快应用、H5 等等。所以,选择跨端框架开发的方式,给我们带来的好处:

  • 保证上新功能时的稳定
  • 出现问题时的,快速 touble shot
  • 减少写重复性的代码
  • and so on...

2. 💥 爆炸发展的前端跨端框架

现在,前端可选择的跨端框架有很多,Taro、uniapp、kbone、mpvue、Chameleon 等等。当然,其中部分还是有技术限制的,例如腾讯的 Kbone 只能支持 web 端和微信小程序同构,京东的 Taro 支持的 DSL 是 React,Uniapp 支持的 DSL 是 Vue。所以,框架虽多,但是还是那句大白话,适合自己所处技术团队的才是最好的 🤓️。

接下来,我们来简单认识一下支持 Vue 的 DSL 的四个跨端框架 mp-vue、uniapp、kbone、Chameleon:

2.1 mpvue

mpvue 是 fork 自 [email protected] 版本,其保留了 Vue2.x 的一部分东西,例如使用 flow 来做静态类型检测、默认的一些语法等。mpvue 设计思路大体上是修改 Vue2.x 运行时和模版编译的部分,针对不同的平台对编译生成的 AST(抽象语法树)做语法转化到指定端的语法,从而完成对不同端的代码生成。

友情提示:mpvue 的 GitHub commit 最近一次 commit 是 17 个月前

2.2 uniapp

uniapp 我想很多同学都耳熟能详,估计有些接触的前端同学,就是因为这个,毕竟 DCloud 也算是“大名鼎鼎”。前面,也提及 mpvue 最近的一次的 commit 已经是 17 个月前,而 uniapp 可以支持把 mpvue 的项目转为 uniapp 的项目(估计能多不少使用者 😏)。

uniapp 在跨端方面确实是很优秀,引入了条件编译的机制来支持不同端的表现,例如我们要在 H5 和微信小程序两个不同端显示用户头像,前者可以通过微信提供的数据显示用户头像,后者可以直接使用 open-data 组件获取。那么,通过条件编译我们可以这样写:

<!-- #ifdef MP-WEIXIN -->
   <open-data class="img-avatar" type="userAvatarUrl"></open-data>
<!-- #endif -->
<!-- #ifdef H5 -->
   <img :src="avatar" class="avatar" />
<!-- #endif -->

条件编译,只会将符合这个条件的平台下的代码写入该平台的项目代码中

并且,uniapp 能跨端的类型也是很完善的,也支持快应用。但是,不同于其他小程序,快应用是自绘渲染引擎,而不是 webview 渲染。所以,目前 uniapp 对于快应用只支持了 vivo、oppo、华为这三种厂商。

因此,这也是我起初做技术选型的时候,放弃 uniapp 的原因。不过 uniapp 还支持转 webview 版的快应用,但是 webview 版的快应用又不是所有厂商都支持的...

2.3 kbone

kbone 是腾讯团队推出的一套支持 Web 端和微信小程序端进行同构的跨端框架。但是,显然相比较前面两者,kbone 的使用场景就略少一些,因为其受限于只支持 Web 端和微信小程序端。不过,如果需求只是 H5 和微信小程序,显然 kbone 是一个不错的选择,毕竟原装正版(腾讯)的技术支持。

那么,接下来就轮到 Chameleon 了,主角即将登场 😎~

3. 🐲 Chameleon 未来的跨端方案

Chameleon 作为跨端框架中的新兴势力,它同样有一套自己的 DSL(特色领域语言),即也可以称它为 CML。但是,不同于上述我们介绍的跨端框架,Chameleon 扩展多端的方式是采用的多态协议的方式,这里我们引用一下官方的介绍

Chamleon 通过定义统一的语言框架 + 统一多态协议,从多端(对应多个独立服务)业务中抽离出自成体系、连续性强、可维护强的“前端中台服务”。

3.1 多态协议

官方的介绍中出现了一个大家可能没有接触过的一个名词多态协议。那么,Chameleon 的多态协议是什么?

首先,我们从理论层面了解一下什么是多态协议,Chameleon 的多态协议设计的想法 💡 源于 Apache Thrift - 可伸缩的跨语言服务开发框架。那么,什么是 Apache Thrift ?

Apache Thrift 是一个采用接口描述语言定义并创建服务,支持可扩展的跨语言服务开发的框架。用大白话讲,就是 Apache Thrift 使得不同语言(Java、C、PHP)创建的服务可以被互相调用。

而 Chameleon 框架的核心机制之一 —— 多态协议则是借鉴了这种设计,提供了多态接口多态组件的方式来扩展第三方端(微信小程序、快应用、字节小程序等)。例如,我们可以通过定义多态接口来实现不同端的特定 API,然后在业务代码层面直接使用定义好的多态接口就可以实现一个方法调用在不同端下的特定 API,这看起来会是这样:

并且,对于目前 Chameleon 默认支持的跨端平台来说,其编译生成的代码只是支持了 Chameleon 官方提供的基础组件和 API,例如在微信和快应用中常用到的组件 listtext,API setStoragegetStorage 等等。

那么,这些不同端是如何基于 Chameleon 规定的基础组件和 API 来扩展实现的呢?接下来,我会以 Chameleon 扩展快应用端的实现为例,带大家深入浅出一番其中的所以然 😲 ~

3.2 如何扩展一个新端

首先,不得不说的一点就是 Chameleon 可以完美支持跨快应用的开发,但是这个过程有一点点坑,不过踩掉就行~

那么,这里我们来了解一下基于 Chameleon 扩展快应用需要做什么?这个过程主要是由 6 个 packages 完成:

|—— cml-quickapp-api  			## 实现 CML 提供的 api
|—— cml-quickapp-plugin			## 实现编译相关处理,例如生成 .ux 文件、manifest.json 文件
|—— cml-quickapp-runtime        	## 实现 App、Page、Component 组件实例
|—— cml-quickapp-store			## 实现 CML 的状态管理
|—— cml-quickapp-ui			## 实现 CML 的普通组件
|—— cml-quickapp-ui-builtin		## 实现 CML 的 Native 组件

实际上快应用是 7 个 package,还有一个 cml-quickapp-mixins,它会对一些指令和事件(例如 c-model、touchstart)做兼容处理。

可以看到扩展一个端,我们至少需要 6 个 packages,而这 6 个 packages 完成了这 4 点:

而其中每一点需要实现的细节,官方文档已经讲解很详细了,这里就不重复论述。本小节只是简单介绍一下每个 packages 的职责,让同学们可以具备 debugger 源码的一点思路,有兴趣的同学可以自行了解其中的代码细节。

接下来,我们讲讲在业务开发中要如何扩展某个端的特定 API,即多态接口。

3.3 如何扩展某个端的 API(多态接口)

假设,此时我们处于快应用开发的场景,需要为应用添加桌面。而快应用添加桌面的 API 不在 Chameleon 官方文档规定的 API 中,所以这个时候就需要实现多态接口,即我们要需要自定义快应用添加桌面的 API。

首先,创建 shortcut.interface 文件,在该文件中定义 API 的类型:

<script cml-type="interface">
  type successCallBack = (res: boolean) => void
  type failCallBack = (res: boolean) => void
  type obj = {
    success: successCallBack,
    fail: failCallBack
  }

  interface UtilsInterface {
    installShortCut(obj): void;
    hasInstalled(obj): void;
  }
</script>

相信大家可以看出来,其实接口的类型定义和 TypeScript 中的类型定义大同小异。

然后,在 shortcut.interface 文件中,定义添加桌面的类,其中包含两个方法 installShortCut()hasInstalled(),在方法内部可以直接使用快应用的原生 API:

<script cml-type="quickapp">
class Method implements UtilsInterface {
  installShortCut(obj) {
    quickapp.shortcut.install(obj)
  }
  hasInstalled(obj) {
    quickapp.shortcut.hasInstalled(obj)
  }
}

export default new Method()
</script>
<script cml-type="web">
class Method implements UtilsInterface {
  installShortCut(obj) {
  }
  hasInstalled(obj) {
  }
}
export default new Method();
</script>
...

并且,这里需要注意 ⚠️ 2 点:

  • 为了保证其他端的正常运行,shortcut.interface 文件中同样需要声明其他端对应的方法,这里我们可以是一个空函数
  • 不能直接在方法的内部使用 shortcut API。因为,在快应用中 shortcut 是绑定在 system 上的,而 system 在编译时会被注入代码中以 quickapp 存在,所以我们必须通过 quickapp.shortcut 的方式调用

最后,我们就可以在业务代码中使用添加桌面的 API,并且它只会在快应用下有所表现:

<template>
  <page title="index">
    <text @click="addShortCut"></text>
  </page>
</template>
<script>
import shortCut from "../../components/utils/shortcut.interface";
  
class Index {
    methods = {
      addShortCut() {
        shortCut.installShortCut({
          success: (res) => {
            console.log(res)
          },
          fail: (err) => {
            console.log(err)
          }
        })
      }
    }
}
</script>

而扩展某个端的组件(多态组件)和扩展 API 大同小异。chameleon-tool 也提供了命令 cml init component 来初始化一个多态组件模版,这一块官方文档也详细介绍了,这里就不做论述哈。

3.4 小结

多态组件和多态接口都是多态协议的一部分,多态协议还支持多态模版,这会类似于 uniapp 的条件编译,可以指定 template 中展示的组件所属的端(仅作用于 root element)。并且,我想懂得了如何使用多态组件、多态接口、多态模版,那么使用 Chameleon 开发可以说是随心所欲。

最方便的是万不得已时我们可以改一点源码哈哈 😄

结语

Chameleon 这个框架给我的感受,确实是又爱又狠 😷。因为,当时在项目开发过程中,快应用团队写的扩展有几个问题,我为了保证项目的正常上线,所以只能硬着头皮魔改源码抗着项目往前走 😳,最终项目也成功上线。然后,在周末的时间,我也整理了一些对 Chameleon 框架的理解,以及修复了几个快应用扩展存在的问题(顺手提了个 PR)。

最后,欢迎 👏 打算在业务中尝试 Chameleon 的同学一起交流心得(加我微信或公众号),如果在快应用实现中遇到了坑,我想我应该可以帮助一二。

参考

深入浅出主流的几款小程序跨端框架原理

Chameleon 统一的语言框架 + 多态协议

Apache Thrift - 可伸缩的跨语言服务开发框架

Chameleon 扩展新端标准

下一个时代的打包工具 esbuild

前言

关注「Vite」底层实现的同学,我想应该清楚它使用「esbuild」来实现对 .tsjsx.js 代码的转化。当然,在「Vite」之前更早使用「esbuild」的就是「Snowpack」。不过,相比较「Vite」拥有的巨大社区,显然「Snowpack」的关注度较小。

「Vite」的核心是基于浏览器原生的 ES Module。但是,相比较传统的打包工具和开发工具而言,它做出了很多改变,采用「esbuild」来支持 .tsjsx.js 代码的转化就是其中之一。

那么,接下来我们就步入今天的正题,What is esbuild, and how to use it?

1 什么是 esbuild

「esbuild」官方的介绍:它是一个「JavaScript」Bundler 打包和压缩工具,它可以将「JavaScript」和「TypeScript」代码打包分发在网页上运行。

目前「esbuild」支持的功能:

  • 加载器
  • 压缩
  • 打包
  • Tree shaking
  • Source map 生成
  • 将 JSX 和较新的 JS 语法移植到 ES6
  • ...

这里,我们列出了几点常关注的,至于其他,有兴趣的同学可以移步官方文档自行了解。

目前对于「JavaScript」语法转化不支持的特性有:

  • Top-level await
  • async await
  • BigInt
  • Hashbang 语法

需要注意的是对于不支持转化的语法会原样输出

2 对比现有的打包工具

「esbuild」的作者对比目前现阶段类似的工具做了基准测试。最后的结果是:

对于这些基准测试,esbuild 比我测试的其他 JavaScript 打包程序 快至少 100 倍。

100 倍,可以说快到飞起了...而「esbuild」快的原因,这里我分两个层面解释:

2.1 官方解释

  • 它是用「Go」语言编写的,该语言可以编译为本地代码。
  • 解析,生成最终文件和生成 source maps 全部完全并行化。
  • 无需昂贵的数据转换,只需很少的几步即可完成所有操作。
  • 该库以提高编译速度为编写代码时的第一原则,并尽量避免不必要的内存分配。

2.2 语言层面解释

  • 现阶段的类似工具,底层的实现都是基于「JavaScript」,其受限于本身是一门解释型的语言,并不能充分利用 CPU。
  • 「Chrome V8」引擎虽然对「JavaScript」的运行做了优化,引进「JIT」的机制,但是部分代码实现机器码与「esbuild」全部实现机器码的形式,性能上的差距不可弥补。

当然,语言层面仅仅是官方解释中的一点的展开,其他解释有时间等后续分析其源码实现后讲解。

3 esbuild API 详解

虽然,「esbuild」早已开源和使用,但是官方文档只是简单介绍了如何使用,而对于 API 介绍部分是欠缺的,建议读者自己去阅读源码中的定义。

「esbuild」总共提供了四个函数:transformbuildbuildSyncService。下面,我们从源码定义的角度来认识一下它们。

3.1 transform

transform 可以用于转化 .js.tsxts 等文件,然后输出为旧的语法的 .js 文件,它提供了两个参数:

  • 第一个参数(必填,字符串),指需要转化的代码(模块内容)。
  • 第二个参数(可选),指转化需要的选项,如源文件路径 sourcefile、需要加载的 loader,其中 loader 的定义:
type Loader = 'js' | 'jsx' | 'ts' | 'tsx' | 'css' | 'json' | 'text' | 'base64' | 'file' | 'dataurl' | 'binary';

transform 会返回一个 Promise,对应的 TransformResult 为一个对象,它会包含转化后的旧的 js 代码、sourceMap 映射、警告信息:

interface TransformResult {
  js: string;
  jsSourceMap: string;
  warnings: Message[];
}

3.2 build

build 实现了 transform 的能力,即代码转化,并且它还会将转换后的代码压缩并生成 .js 文件到指定 output 目录。build 只提供了一个参数(对象),来指定需要转化的入口文件、输出文件、loader 等选项:

interface BuildOptions extends CommonOptions {
  bundle?: boolean;
  splitting?: boolean;
  outfile?: string;
  metafile?: string;
  outdir?: string;
  platform?: Platform;
  color?: boolean;
  external?: string[];
  loader?: { [ext: string]: Loader };
  resolveExtensions?: string[];
  mainFields?: string[];
  write?: boolean;
  tsconfig?: string;
  outExtension?: { [ext: string]: string };

  entryPoints?: string[];
  stdin?: StdinOptions;
}

build 函数调用会输出 BuildResult,它包含了生成的文件 outputFiles 和提示信息 warnings

interface BuildResult {
  warnings: Message[];
  outputFiles?: OutputFile[];
}

但是,需要注意的是 outputFiles 只有在 writefalse 的情况下才会输出,它是一个 Uint8Array

3.3 buildSync

buidSync 顾名思义,相比较 build 而言,它是同步的构建方式,即如果使用 build 我们需要借助 async await 来实现同步调用,而使用 buildSync 可以直接实现同步调用。

3.4 Service

Service 的出现是为了解决调用上述 API 时都会创建一个子进行来完成的问题,如果存在多次调用 API 的情况出现,那么就会出现性能上的浪费,这一点在文档中也有讲解。

所以,使用了 Service 来实现代码的转化或打包,则会创建一个长期的用于共享的子进程,避免了性能上的浪费。而在「Vite」中也正是使用 Service 的方式来进行 .ts.js.jsx 代码的转化工作。

Service 定义:

interface Service {
  build(options: BuildOptions): Promise<BuildResult>;
  transform(input: string, options?: TransformOptions): Promise<TransformResult>;
  stop(): void;
}

可以看到,Service 的本质封装了 buildtransformstop 函数,只是不同于单独调用它们,Service 底层的实现是一个长期存在可供共享的子进程。

但是,在实际使用上,我们并不是直接使用 Service 创建实例,而是通过 startService 来创建一个 Service 实例:

const {
  startService,
  build,
} = require("esbuild")
const service = await startService()

try {
  const res = await service.build({
    entryPoints: ["./src/main.js"],
    write: false
  })
  console.log(res)
} finally {
  service.stop()
}

并且,在使用 stop 的时候需要注意,它会结束这个子进程,这也意味着任何在此时处于 pendingPromise 也会被终止。

4 实现一个小而美的 Bundler 打包

在简单地认识「esbuild」,我们就来实现一个小而美的 Bunder 打包:

1.初始化项目和安装「esbuild」:

mkdir esbuild-bundler & npm init -y & npm i esbuild

2.目录结构:

|——— src
     |—— main.js  #项目入口文件
|——— index.js     #bundler实现核心文件

3.index.js

(async () => {
  const {
    startService,
    build,
  } = require("esbuild")
  const service = await startService()

  try {
    const res = await service.build({
      entryPoints: ["./src/main.js"],
      outfile: './dist/main.js',
      minify: true,
      bundle: true,
    })
  } finally {
    service.stop()
  }
})()

4.运行一下 node index 即可体验一下闪电般的 bundler 打包!

结语

想必看完这篇文章,大家对「esbuild」应该建立起一个基础的认知。并且,文中的源码只是基于「Go」实现的底层能力上的,而真正的底层实现还是得看「Go」是如何实现的,由于脱离了大家熟知的前端,所以就不做介绍。那么,在一下篇文章中,我将会讲解在「Vite」的源码设计中是怎么使用 esbuild 来实现 .tsjsx.js 语法解析,以及我们如何自定义 plugin 来实现一些代码转化。

通过 Vite 的 create-app 学习如何实现一个简易版 CLI

前言

前段时间,尤雨溪回答了一个广大网友都好奇的一个问题:Vite 会不会取代 Vue CLI?

答案是:是的!

那么,你开始学 Vite 了吗?用过 Vite 的同学应该都熟悉,创建一个 Vite 的项目模版是通过 npm init @vitejs/app 的方式。而 npm init 命令是在 [email protected] 开始支持的,实际上它是先帮你安装 Vite 的 @vitejs/create-app 包(package),然后再执行 create-app 命令。

至于 @vitejs/create-app 则是在 Vite 项目的 packages/create-app 文件夹下。其整体的目录结构:

// packages/create-app
|———— template-lit-element
|———— template-lit-element-ts
|———— template-preact
|———— template-preact-ts
|———— template-react
|———— template-react-ts
|———— template-vanilla
|———— template-vue
|———— template-vue-ts
index.js
package.json

Vite 的 create-app CLI(以下统称为 create-app CLI)具备的能力不多,目前只支持基础模版的创建,所以全部代码加起来只有 160 行,其整体的架构图:

可以看出确实非常简单,也因此 create-app CLI 是一个很值得入门学习如何实现简易版 CLI 的例子

那么,接下来本文将会围绕以下两个部分带着大家一起通过 create-app CLI 来学习如何实现一个简易版的 CLI

  • create-app 中使用到的库(minimistkolorist

  • 逐步拆解、分析 create-app CLI 源码

create-app CLI 中使用到的库

create-app CLI 实现用到的库(npm)确实很有意思,既有我们熟悉的 enquirer(用于命令行的提示),也有不熟悉的 minimistkolorist。 那么,后面这两者又是拿来干嘛的?下面,我们就来了解一番~

minimist

minimist 是一个轻量级的用于解析命令行参数的工具。说起解析命令行的工具,我想大家很容易想到 commander。相比较 commander 而言,minimist 则以轻取胜!因为它只有 32.4 kB,commander 则有 142 kB,即也只有后者的约 1/5。

那么,下面我们就来看一下 minimist 的基础使用。

例如,此时我们在命令行中输入:

node index.js my-project

那么,在 index.js 文件中可以使用 minimist 获取到输入的 myproject 参数:

var argv = require('minimist')(process.argv.slice(2));
console.log(argv._[0]); 
// 输出 my-project

这里的 argv 是一个对象,对象中 _ 属性的值则是解析 node index.js 后的参数所形成的数组。

kolorist

kolorist 是一个轻量级的使命令行输出带有色彩的工具。并且,说起这类工具,我想大家很容易想到的就是 chalk。不过相比较 chalk 而言,两者包的大小差距并不明显,前者为 49.9 kB,后者为 33.6 kB。不过 kolorist 可能较为小众,npm 的下载量大大不如后者 chalk,相应地 chalk 的 API 也较为详尽。

同样的,下面我们也来看一下 kolorist 的基础使用。

例如,当此时应用发生异常的时候,需要打印出红色的异常信息告知用户发生异常,我们可以使用 kolorist 提供的 red 函数:

import { red } from 'kolorist'

console.log(red("Something is wrong"))

又或者,可以使用 kolorist 提供的 stripColors 来直接输出带颜色的字符串:

import { red, stripColors } from 'kolorist'

console.log(stripColors(red("Something is wrong"))

逐步拆解、分析 create-app CLI 源码

了解过 CLI 相关知识的同学应该知道,我们通常使用的命令是在 package.json 文件的 bin 中配置的。而 create-app CLI 对应的文件根目录下该文件的 bin 配置会是这样:

// pacakges/create-app/package.json
"bin": {
  "create-app": "index.js",
  "cva": "index.js"
}

可以看到 create-app 命令则由这里注册生效,它指向的是当前目录下的 index.js 文件。并且,值得一提的是这里注册了 2 个命令,也就是说我们还可以使用 cva 命令来创建基于 Vite 的项目模版(想不到吧 😲)。

create-app CLI 实现的核心就是在 index.js 文件。那么,下面我们来看一下 index.js 中代码的实现~

基础依赖引入

上面我们也提及了 create-app CLI 引入了 minimistenquirekolorist 等依赖,所以首先是引入它们:

const fs = require('fs')
const path = require('path')
const argv = require('minimist')(process.argv.slice(2))
const { prompt } = require('enquirer')
const {
  yellow,
  green,
  cyan,
  magenta,
  lightRed,
  stripColors
} = require('kolorist')

其中,fspath 是 Node 内置的模块,前者用于文件相关操作、后者用于文件路径相关操作。接着就是引入 minimistenquirerkolorist,它们相关的介绍上面已经提及,这里就不重复论述~

定义项目基础模版(颜色)和文件

/packages/create-app 目录中,我们可以看出 create-app CLI 为我们提供了 9 种项目基础模版。并且,在命令行交互的时候,每个模版之间的颜色各有不同,即 CLI 会使用 kolorist 提供的颜色函数来为模版定义好对应的颜色

const TEMPLATES = [
  yellow('vanilla'),
  green('vue'),
  green('vue-ts'),
  cyan('react'),
  cyan('react-ts'),
  magenta('preact'),
  magenta('preact-ts'),
  lightRed('lit-element'),
  lightRed('lit-element-ts')
]

其次,由于 .gitignore 文件的特殊性,每个项目模版下都是先创建的 _gitignore 文件,在后续创建项目的时候再替换掉该文件的命名(替换为 .gitignore)。所以,CLI 会预先定义一个对象来存放需要重命名的文件

const renameFiles = {
  _gitignore: '.gitignore'
}

定义文件操作相关的工具函数

由于创建项目的过程中会涉及和文件相关的操作,所以 CLI 内部定义了 3 个工具函数:

copyDir 函数

copyDir 函数用于将某个文件夹 srcDir 中的文件复制到指定文件夹 destDir中。它会先调用 fs.mkdirSync 函数来创建制定的文件夹,然后枚举从 srcDir 文件夹下获取的文件名构成的数组,即 fs.readdirSync(srcDir)

其对应的代码如下:

function copyDir(srcDir, destDir) {
  fs.mkdirSync(destDir, { recursive: true })
  for (const file of fs.readdirSync(srcDir)) {
    const srcFile = path.resolve(srcDir, file)
    const destFile = path.resolve(destDir, file)
    copy(srcFile, destFile)
  }
}

copy 函数

copy 函数则用于复制文件或文件夹 src 到指定文件夹 dest。它会先获取 src 的状态 stat,如果 src 是文件夹的话,即 stat.isDirectory()true 时,则会调用上面介绍的 copyDir 函数来复制 src 文件夹下的文件到 dest 文件夹下。反之,src 是文件的话,则直接调用 fs.copyFileSync 函数复制 src 文件到 dest 文件夹下。

其对应的代码如下:

function copy(src, dest) {
  const stat = fs.statSync(src)
  if (stat.isDirectory()) {
    copyDir(src, dest)
  } else {
    fs.copyFileSync(src, dest)
  }
}

emptyDir 函数

emptyDir 函数用于清空 dir 文件夹下的代码。它会先判断 dir 文件夹是否存在,存在则枚举该问文件夹下的文件,构造该文件的路径 abs,调用 fs.unlinkSync 函数来删除该文件,并且当 abs 为文件夹时,则会递归调用 emptyDir 函数删除该文件夹下的文件,然后再调用 fs.rmdirSync 删除该文件夹。

其对应的代码如下:

function emptyDir(dir) {
  if (!fs.existsSync(dir)) {
    return
  }
  for (const file of fs.readdirSync(dir)) {
    const abs = path.resolve(dir, file)
    if (fs.lstatSync(abs).isDirectory()) {
      emptyDir(abs)
      fs.rmdirSync(abs)
    } else {
      fs.unlinkSync(abs)
    }
  }
}

CLI 实现核心函数

CLI 实现核心函数是 init,它负责使用前面我们所说的那些函数、工具包来实现对应的功能。下面,我们就来逐点分析 init 函数实现的过程:

1. 创建项目文件夹

通常,我们可以使用 create-app my-project 命令来指定要创建的项目文件夹,即在哪个文件夹下:

let targetDir = argv._[0]
cwd = process.cwd()
const root = path.join(cwd, targetDir)
console.log(`Scaffolding project in ${root}...`)

其中,argv._[0] 代表 create-app 后的第一个参数,root 是通过 path.join 函数构建的完整文件路径。然后,在命令行中会输出提示,告述你脚手架(Scaffolding)项目创建的文件路径:

Scaffolding project in /Users/wjc/Documents/project/vite-project...

当然,有时候我们并不想输入在 create-app 后输入项目文件夹,而只是输入 create-app 命令。那么,此时 tagertDir 是不存在的。CLI 则会使用 enquirer 包的 prompt 来在命令行中输出询问:

? project name: > vite-project

你可以在这里输入项目文件夹名,又或者直接回车使用 CLI 给的默认项目文件夹名。这个过程对应的代码:

if (!targetDir) {
  const { name } = await prompt({
    type: "input",
    name: "name",
    message: "Project name:",
    initial: "vite-project"
  })
  targetDir = name
}

接着,CLI 会判断该文件夹是否存在当前的工作目录(cwd)下,如果不存在则会使用 fs.mkdirSync 创建一个文件夹:

if (!fs.existsSync(root)) {
  fs.mkdirSync(root, { recursive: true })
}

反之,如果存在该文件夹,则会判断此时文件夹下是否存在文件,即使用 fs.readdirSync(root) 获取该文件夹下的文件:

const existing = fs.readdirSync(root)

这里 existing 会是一个数组,如果此时数组长度不为 0,则表示该文件夹下存在文件。那么 CLI 则会询问是否删除该文件夹下的文件:

Target directory vite-project is not empty. 
Remove existing files and continue?(y/n): Y

你可以选择通过输入 yn 来告知 CLI 是否要清空该目录。并且,如果此时你输入的是 n,即不清空该文件夹,那么整个 CLI 的执行就会退出。这个过程对应的代码:

if (existing.length) {
  const { yes } = await prompt({
    type: 'confirm',
    name: 'yes',
    initial: 'Y',
    message:
      `Target directory ${targetDir} is not empty.\n` +
      `Remove existing files and continue?`
  })
  if (yes) {
    emptyDir(root)
  } else {
    return
  }
}

2. 确定项目模版

在创建好项目文件夹后,CLI 会获取 --template 选项,即当我们输入这样的命令时:

npm init @vitejs/app --template 文件夹名

如果 --template 选项不存在(即 undefined),则会询问要选择的项目模版:

let template = argv.t || argv.template
if (!template) {
  const { t } = await prompt({
    type: "select",
    name: "t",
    message: "Select a template:",
    choices: TEMPLATES
  })
  template = stripColors(t)
}

由于,TEMPLATES 中只是定义了模版的类型,对比起 packages/create-app 目录下的项目模版文件夹命名有点差别(缺少 template 前缀)。例如,此时 template 会等于 vue-ts,那么就需要给 template 拼接前缀和构建完整目录:

const templateDir = path.join(__dirname, `template-${template}`)

所以,现在 templateDir 就会等于当前工作目录 + template-vue-ts

3. 写入项目模版文件

确定完需要创建的项目的模版后,CLI 就会读取用户选择的项目模版文件夹下的文件,然后将它们一一写入此时创建的项目文件夹下:

可能有点绕,举个例子,选择的模版是 vue-ts,自己要创建的项目文件夹为 vite-project,那么则是将 create-app/template-vue-ts 文件夹下的文件写到 vite-project 文件夹下。

const files = fs.readdirSync(templateDir)
for (const file of files.filter((f) => f !== 'package.json')) {
  write(file)
}

由于通过 fs.readdirSync 函数返回的是该文件夹下的文件名构成的数组 ,所以这里会通过 for of 枚举该数组,每次枚举会调用 write 函数进行文件的写入。

注意此时会跳过 package.json 文件,之后我会讲解为什么需要跳过 package.json 文件。

write 函数则接受两个参数 filecontent,其具备两个能力:

  • 对指定的文件 file 写入指定的内容 content,调用 fs.writeFileSync 函数来实现将内容写入文件

  • 复制模版文件夹下的文件到指定文件夹下,调用前面介绍的 copy 函数来实现文件的复制

write 函数的定义:

const write = (file, content) => {
  const targetPath = renameFiles[file]
    ? path.join(root, renameFiles[file])
    : path.join(root, file)
  if (content) {
    fs.writeFileSync(targetPath, content)
  } else {
    copy(path.join(templateDir, file), targetPath)
  }
}

并且,值得一提的是 targetPath 的获取过程,会针对 file 构建完整的文件路径,并且兼容处理 _gitignore 文件的情况。

在写入模版内的这些文件后,CLI 就会处理 package.json 文件。之所以单独处理 package.json 文件的原因是每个项目模版内的 package.jsonname 都是写死的,而当用户创建项目后,name 都应该为该项目的文件夹命名。这个过程对应的代码会是这样:

const pkg = require(path.join(templateDir, `package.json`))
pkg.name = path.basename(root)
write('package.json', JSON.stringify(pkg, null, 2))

其中,path.basename 函数则用于获取一个完整路径的最后的文件夹名

最后,CLI 会输出一些提示告诉你项目已经创建结束,以及告诉你接下来启动项目需要运行的命令:

console.log(`\nDone. Now run:\n`)
if (root !== cwd) {
  console.log(`  cd ${path.relative(cwd, root)}`)
}
console.log(`  npm install (or \`yarn\`)`)
console.log(`  npm run dev (or \`yarn dev\`)`)
console.log()

结语

虽然 Vite 的 create-app CLI 的实现仅仅只有 160 行的代码,但是它也较为全面地考虑了创建项目的各种场景,并做对应的兼容处理。简而言之,十分小而美。所以,我相信大家经过学习 Vite 的 create-app CLI 的实现,都应该可以随手甩出(实现)一个 CLI 的代码 😎 ~

Vite 依赖预构建,缩短数倍的冷启动时间

前言

前段时间,Vite 做了一个优化依赖预构建(Dependency Pre-Bundling)。简而言之,它指的是 Vite 会在 DevServer 启动前对需要预构建的依赖进行构建,然后在分析模块的导入(import)时会动态地应用构建过的依赖

这么一说,我想大家可能立马会抛出一个疑问:Vite 不是 No Bundle 吗?确实 Vite 是 No Bundle,但是依赖预构建并不是意味着 Vite 要走向 Bundle,我们不要急着下定义,因为它的存在必然是有着其实际的价值

那么,今天本文将会围绕以下 3 点来和大家一起从疑问点出发,深入浅出一番 Vite 的依赖预构建过程:

  • 什么是依赖预构建

  • 依赖预构建的作用

  • 依赖预构建的实现(源码分析)

一、什么是依赖预构建

当你在项目中引用了 vuelodash-es,那么你在启动 Vite 的时候,你会在终端看到这样的输出内容:

而这表示 Vite 将你在项目中引入的 vuelodash-es 进行了依赖预构建!这里,我们通过大白话认识一下 Vite 的依赖预构建:

  • 默认情况下,Vite 会将 package.json 中生产依赖 dependencies 的部分启用依赖预构建,即会先对该依赖进行构建,然后将构建后的文件缓存在内存中(node_modules/.vite 文件下),在启动 DevServer 时直接请求该缓存内容。

  • 在 vite.config.js 文件中配置 optimizeDeps 选项可以选择需要或不需要进行预构建的依赖的名称,Vite 则会根据该选项来确定是否对该依赖进行预构建。

  • 在启动时添加 --force options,可以用来强制重新进行依赖预构建。

需要注意,强制重新依赖预构建指的是忽略之前已构建的文件,直接重新构建。

所以,回到文章开始所说的疑问,这里我们可以这样理解依赖预构建,它的出现是一种优化,即没有它其实 No Bundle 也可以,有它更好(xiang)! 而且,依赖预构建并非无米之炊,Vite 也是受 Snowpack 的启发才提出的。

那么,下面我们就来了解一下依赖预构建的作用是什么,即优化的意义~

二、依赖预构建的作用

对于依赖预构建的作用,Vite 官方也做了详细的介绍。那么,这里我们通过结合图例的方式来认识一下,具体会是两点:

1. 兼容 CommonJS 和 AMD 模块的依赖

因为 Vite 的 DevServer 是基于浏览器的 Natvie ES Module 实现的,所以对于使用的依赖如果是 CommonJS 或 AMD 的模块,则需要进行模块类型的转化(ES Module)。

2. 减少模块间依赖引用导致过多的请求次数

通常我们引入的一些依赖,它自己又会一些其他依赖。官方文档中举了一个很经典的例子,当我们在项目中使用 lodash-es 的时候:

import { debounce } from "lodash-es"

如果在没用依赖预构建的情况下,我们打开页面的 Dev Tool 的 Network 面板:

可以看到此时大概有 600+ 和 lodash-es 相关的请求,并且所有请求加载花了 1.11 s,似乎还好?现在,我们来看一下使用依赖预构建的情况:

此时,只有 1 个和 lodash-es 相关的请求(经过预构建),并且所有请求加载才花了 142 ms,缩短了足足 7 倍多的时间! 而这里节省的时间,就是我们常说的冷启动时间。

那么,到这里我们就已经了解了 Vite 依赖预构建概念和作用。我想大家都会好奇这个过程又是怎么实现的?下面,我们就深入 Vite 源码来更进一步地认识依赖预构建过程!

三、依赖预构建的实现

在 Vite 源码中,默认的依赖预构建过程会在 DevServer 开启之前进行。这里,我们仍然以在项目中引入了 vuelodash-es 依赖为例。

需要注意的是以下和源码相关的函数都是取的核心逻辑讲解(伪代码)。

3.1 Dev Server 启动前

首先,Vite 会创建一个 DevServer,也就是我们平常使用的本地开发服务器,这个过程是由 createServer 函数完成:

// packages/vite/src/node/server/index.ts
async function createServer(
  inlineConfig: InlineConfig = {}
): Promise<ViteDevServer> {
  ...
  // 通常情况下我们会命中这个逻辑
  if (!middlewareMode && httpServer) {
    // 重写 DevServer 的 listen,保证在 DevServer 启动前进行依赖预构建
    const listen = httpServer.listen.bind(httpServer)
    httpServer.listen = (async (port: number, ...args: any[]) => {
      try {
        ...
        // 依赖预构建相关
        await runOptimize()
      } 
      ...
    }) as any
    ...
  } else {
    await runOptimize()
  }
  ...
}

可以看到在 DevServer 真正启动之前,它会先调用 runOptimize 函数,进行依赖预构建相关的处理(用 bind 进行简单的重写)。

runOptimize 函数:

// packages/vite/src/node/server/index.ts
const runOptimize = async () => {
  // config.optimzizeCacheDir 指的是 node_modules/.vite
  if (config.optimizeCacheDir) {
    ..
    try {
      server._optimizeDepsMetadata = await optimizeDeps(config)
    }
    ..
    server._registerMissingImport = createMissingImpoterRegisterFn(server)
  }
}

runOptimize 函数负责的是调用和注册处理依赖预构建相关的 optimizeDeps 函数,具体来说会是两件事:

1. 进行依赖预构建

optimizeDeps 函数是 Vite 实现依赖预构建的核心函数,它会根据配置 vite.config.js 的 optimizeDeps 选项和 package.json 的 dependencies 的参数进行第一次预构建。它会返回解析 node_moduels/.vite/_metadata.json 文件后生成的对象(包含预构建后的依赖所在的文件位置、原文件所处的文件位置等)。

_metadata.json 文件:

{
  "hash": "bade5e5e",
  "browserHash": "830194d7",
  "optimized": {
    "vue": {
      "file": "/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/.vite/vue.js",
      "src": "/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/vue/dist/vue.runtime.esm-bundler.js",
      "needsInterop": false
    },
    "lodash-es": {
      "file": "/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/.vite/lodash-es.js",
      "src": "/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/lodash-es/lodash.js",
      "needsInterop": false
    }
  }
}

这里,我们来分别认识一下这 4 个属性的含义:

  • hash 由需要进行预构建的文件内容生成的,用于防止 DevServer 启动时重复构建相同的依赖,即依赖并没有发生变化,不需要重新构建。

  • browserHashhash 和在运行时发现的额外的依赖生成的,用于让预构建的依赖的浏览器请求无效。

  • optimized 包含每个进行过预构建的依赖,其对应的属性会描述依赖源文件路径 src 和构建后所在路径 file

  • needsInterop 主要用于在 Vite 进行依赖性导入分析,这是由 importAnalysisPlugin 插件中的 transformCjsImport 函数负责的,它会对需要预构建且为 CommonJS 的依赖导入代码进行重写。举个例子,当我们在 Vite 项目中使用 react 时:

import React, { useState, createContext } from 'react'

此时 react 它是属于 needsInteroptrue 的范畴,所以 importAnalysisPlugin 插件的会对导入 react 的代码进行重写:

import $viteCjsImport1_react from "/@modules/react.js";
const React = $viteCjsImport1_react;
const useState = $viteCjsImport1_react["useState"];
const createContext = $viteCjsImport1_react["createContext"];

之所以要进行重写的缘由是因为 CommonJS 的模块并不支持命名方式的导出。所以,如果不经过插件的转化,则会看到这样的异常:

Uncaught SyntaxError: The requested module '/@modules/react.js' does not provide an export named 'useState'

有兴趣继续往这方面了解的同学可以查看这个 PR https://github.com/vitejs/vite/pull/837,这里就不做过于详细的介绍了~

2. 注册依赖预构建相关函数

调用 createMissingImpoterRegisterFn 函数,它会返回一个函数,其仍然内部会调用 optimizeDeps 函数进行预构建,只是不同于第一次预构建过程,此时会传人一个 newDeps,即新的需要进行预构建的依赖。

那么,显然无论是第一次预构建,还是后续的预构建,它们两者的实现都是调用的 optimizeDeps 函数。所以,下面我们来看一下 optimizeDeps 函数~

3.2 预构建实现核心 optimizeDeps 函数

optimizeDeps 函数被定义在 packages/vite/node/optimizer/index.ts 中,它负责对依赖进行预构建过程:

// packages/vite/node/optimizer/index.ts
export async function optimizeDeps(
  config: ResolvedConfig,
  force = config.server.force,
  asCommand = false,
  newDeps?: Record<string, string>
): Promise<DepOptimizationMetadata | null> {
...
}

由于 optimizeDeps 内部逻辑较为繁多,这里我们拆分为 5 个步骤讲解:

1. 读取该依赖此时的文件信息

既然是构建依赖,很显然的是每次构建都需要知道此时文件内容对应的 Hash 值,以便于依赖发生变化时可以重新进行依赖构建,从而应用最新的依赖内容。

所以,这里会先调用 getDepHash 函数获取依赖的 Hash 值:

// 获取该文件此时的 hash
const mainHash = getDepHash(root, config)
const data: DepOptimizationMetadata = {
  hash: mainHash,
  browserHash: mainHash,
  optimized: {}
}

而对于 data 中的这三个属性,我们在上面已经介绍过了,这里就不重复论述了~

2. 对比缓存文件的 Hash

前面,我们也提及了如果启动 Vite 时使用了 --force Option,则会强制重新进行依赖预构建。所以,当不是 --force 场景时,则会进行比较新旧依赖的 Hash 值的过程:

// 默认为 false
if (!force) {
  let prevData
  try {
    // 获取到此时缓存(本地磁盘)中构建的文件信息
    prevData = JSON.parse(fs.readFileSync(dataPath, 'utf-8'))
  } catch (e) {}
  // 对比此时的 
  if (prevData && prevData.hash === data.hash) {
    log('Hash is consistent. Skipping. Use --force to override.')
    return prevData
  }
}

可以看到如果新旧依赖的 Hash 值相等的时候,则会直接返回旧的依赖内容。

3. 缓存失效或未缓存

如果上面的 Hash 不等,则表示缓存失效,所以会删除 cacheDir 文件夹,又或者此时未进行缓存,即第一次依赖预构建逻辑( cacheDir 文件夹不存在),则创建 cacheDir 文件夹:

if (fs.existsSync(cacheDir)) {
    emptyDir(cacheDir)
  } else {
    fs.mkdirSync(cacheDir, { recursive: true })
  }

需要注意的是,这里的 cacheDir 则指的是 node_modules/.vite 文件夹

前面在讲 DevServer 启动时,我们提及预构建过程会分为两种:第一次预构建和后续的预构建。两者的区别在于后者会传入一个 newDeps,它表示新的需要进行预构建的依赖:

let deps: Record<string, string>, missing: Record<string, string>
if (!newDeps) {
  ;({ deps, missing } = await scanImports(config))
} else {
  // 存在 newDeps 的时候,直接将 newDeps 赋值给 deps
  deps = newDeps
  missing = {}
}

并且,这里可以看到对于前者,第一次预构建,则会调用 scanImports 函数来找出和预构建相关的依赖 depsdeps 会是一个对象:

{
  lodash-es:'/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/lodash-es/lodash.js'
  vue:'/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/vue/dist/vue.runtime.esm-bundler.js'
}

missing 则表示在 node_modules 中没找到的依赖。所以,当 missing 存在时,你会看到这样的提示:

scanImports 函数内部则是调用的一个名为 dep-scan 的内部插件(Plugin)。这里就不讲解 dep-scan 插件的具体实现了,有兴趣的同学可以自行了解哈~

那么,回到上面对于后者(newDeps 存在时)的逻辑则较为简单,会直接给 deps 赋值为 newDeps,并且不需要处理 missing。因为,newDeps 只有在后续导入并安装了新的 dependencies 依赖,才会传入的,此时是不存在 missing 的依赖的( Vite 内置的 importAnalysisPlugin 插件会提前过滤掉这些)。

4. 处理 optimizeDeps.include 相关依赖

在前面,我们也提及了需要进行构建的依赖也会由 vite.config.js 的 optimizeDeps 选项决定。所以,在处理完 dependencies 之后,接着需要处理 optimizeDeps

此时,会遍历前面从 dependencies 获取到的 deps,判断 optimizeDeps.iclude(数组)所指定的依赖是否存在,不存在则会抛出异常:

const include = config.optimizeDeps?.include
  if (include) {
    const resolve = config.createResolver({ asSrc: false })
    for (const id of include) {
      if (!deps[id]) {
        const entry = await resolve(id)
        if (entry) {
          deps[id] = entry
        } else {
          throw new Error(
            `Failed to resolve force included dependency: ${chalk.cyan(id)}`
          )
        }
      }
    }
  }

5. 使用 esbuild 构建依赖

那么,在做好上述和预构建依赖相关的处理(文件 hash 生成、预构建依赖确定等)后。则进入依赖预构建的最后一步,使用 esbuild 来对相应的依赖进行构建:

  ...
  const esbuildService = await ensureService()
  await esbuildService.build({
    entryPoints: Object.keys(flatIdDeps),
    bundle: true,
    format: 'esm',
    ...
  })
  ...

ensureService 函数是 Vite 内部封装的 util,它的本质是创建一个 esbuildservice,使用 service.build 函数来完成构建过程。

此时,传入的 flatIdDeps 参数是一个对象,它是由上面提及的 deps 收集好的依赖创建的,它的作用是为 esbuild 进行构建的时候提供多路口(entry),flatIdDeps 对象:

{
  lodash-es:'/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/lodash-es/lodash.js'
  moment:'/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/moment/dist/moment.js'
  vue:'/Users/wjc/Documents/FE/demos/vite2.0-demo/node_modules/vue/dist/vue.runtime.esm-bundler.js'
}

好了,到此我们已经分析完了整个依赖预构建的实现 😲(手动给看到这的大家👍)。

那么,接下来在 DevServer 启动后,当模块需要请求经过预构建的依赖的时候,Vite 内部的 resolvePlugin 插件会解析该依赖是否存在 seen 中(seen 中会存储构建过的依赖映射),是则直接应用 node_modules/.vite 目录下对应的构建后的依赖,避免直接去请求构建前的依赖的情况出现,从而缩短冷启动的时间。

结语

通过了解 Vite 依赖预构建的作用、实现等相关知识,我想大家应该不会再去纠结 Bundle 或者 No Bundle 的问题了,仍然是那句话,存在即有价值。并且,依赖预构建这个知识点在面试场景下,可能也是一个很有趣的考题 😎。

Vue 3 的 SFC Style CSS Variable Injection 提案实现的背后

前言

在 5月22日的 Vue Conf 21 上,尤大介绍在介绍单文件组件(SFC)在编译阶段的优化的时候,讲了 SFC Style CSS Variable Injection 这个提案,即 <style> 动态变量注入。简单地讲,它可以让你在 <style> 中通过 v-bind 的方式使用 <script> 中定义好的变量。

这么一听,似乎很像 CSS In JS?确实,从使用的角度是和 CSS In JS 很类似。但是,大家都知道的是 CSS In JS 在一些场景下,存在一定的性能问题,而 <style> 动态变量注入却不存类似的问题。

那么, <style> 动态变量注入又是怎么实现的?我想这是很多同学都会抱有的一个疑问,所以,今天就让我们来彻底搞懂何为 <style> 动态变量注入,以及它实现的背后做了哪些事情。

1 什么是 <style> 动态变量注入

<style> 动态变量注入,根据 SFC 上尤大的总结,它主要有以下 5 点能力:

  • 不需要明确声明某个属性被注入作为 CSS 变量(会根据)
  • 响应式的变量
  • 在 Scoped/Non-scoped 模式下具备一样的表现
  • 不会污染子组件
  • 普通的 CSS 变量的使用不会被影响

下面,我们来看一个简单使用 <style> 动态变量注入的例子:

<template>
  <p class="word">{{ msg }}</p>
  <button @click="changeColor">
    click me
  </button>
</template>

<script setup>
import { ref } from "vue"
  
const msg = 'Hello World!'
let color = ref("red")
const changeColor = () => {
  if (color.value === 'black') {
    color.value = "red"
  } else {
    color.value = "black"
  }
}
</script>

<style scoped>
  .word {
    background: v-bind(color)
  }
</style>

对应的渲染到页面上:

从上面的代码片段,很容易得知当我们点击 click me 按钮,文字的背景色就会发生变化:

而这就是 <style> 动态变量注入赋予我们的能力,让我们很便捷地通过 <script> 中的变量来操作 <template> 中的 HTML 元素样式的动态改变

那么,这个过程又发生了什么?怎么实现的?有疑问是件好事,接着让我们来一步步揭开其幕后的实现原理。

2 <style> 动态变量注入的原理

在文章的开始,我们讲了 <style> 动态变量注入的实现是源于在单文件(SFC)在编译阶段的优化。不过,这里并不对SFC 编译的全部过程进行讲解,不了解的同学可以看我之前写的文章 从编译过程,理解 Vue3 静态节点提升过程

那么,下面让我们聚焦 SFC 在编译过程对 <style> 动态变量注入的处理,首先是这个过程实现的 2 个关键点。

2.1 SFC 编译对 <style> 动态变量注入的处理

SFC 在编译过程对 <style> 动态变量注入的处理实现,主要是基于的 2 个关键点。这里,我们以上面的例子作为示例分析:

  • 在对应 DOM 上绑定行内 style,通过 CSS var() 在 CSS 中使用在行内 style 上定义的自定义属性,对应的 HTML 部分:

    CSS 部分:
  • 通过动态更新 color 变量来实现行内 style 属性值的变化,进而改变使用了该 CSS 自定义属性的 HTML 元素样式

那么,显然要完成这一整个过程,不同于在没有 <style> 动态变量注入前的 SFC 编译,这里需要对 <style><script> 增加相应的特殊处理。下面,我们分 2 点来讲解:

1.SFC 编译 <style> 相关处理

大家都知道的是在 Vue SFC 的 <style> 部分编译主要是由 postcss 完成的。而这在 Vue 源码中对应着 packages/compiler-sfc/sfc/compileStyle.ts 中的 doCompileStyle() 方法。

这里,我们看一下其针对 <style> 动态变量注入的编译处理,对应的代码(伪代码):

export function doCompileStyle(
  options: SFCAsyncStyleCompileOptions
): SFCStyleCompileResults | Promise<SFCStyleCompileResults> {
  const {
    ...
    id,
    ...
  } = options
  ...
  const plugins = (postcssPlugins || []).slice()
  plugins.unshift(cssVarsPlugin({ id: shortId, isProd }))
  ...
}

可以看到,在使用 postcss 编译 <style> 之前会加入 cssVarsPlugin 插件,并给 cssVarsPlugin 传入 shortId(即 scopedId 替换掉 data-v 内的结果)和 isProd(是否处于生产环境)。

cssVarsPlugin 则是使用了 postcss 插件提供的 Declaration 方法,来访问 <style> 中声明的所有 CSS 属性的值,每次访问通过正则来匹配 v-bind 指令的内容,然后再使用 replace() 方法将该属性值替换为 var(--xxxx-xx),表现在上面这个例子会是这样:

cssVarsPlugin 插件的定义:

const cssVarRE = /\bv-bind\(\s*(?:'([^']+)'|"([^"]+)"|([^'"][^)]*))\s*\)/g
const cssVarsPlugin: PluginCreator<CssVarsPluginOptions> = opts => {
  const { id, isProd } = opts!
  return {
    postcssPlugin: 'vue-sfc-vars',
    Declaration(decl) {
      // rewrite CSS variables
      if (cssVarRE.test(decl.value)) {
        decl.value = decl.value.replace(cssVarRE, (_, $1, $2, $3) => {
          return `var(--${genVarName(id, $1 || $2 || $3, isProd)})`
        })
      }
    }
  }
}

这里 CSS var() 的变量名即 --(之后的内容)是由 genVarName() 方法生成,它会根据 isProdtruefalse 生成不同的值:

function genVarName(id: string, raw: string, isProd: boolean): string {
  if (isProd) {
    return hash(id + raw)
  } else {
    return `${id}-${raw.replace(/([^\w-])/g, '_')}`
  }
}

2.SFC 编译 <script> 相关处理

如果,仅仅站在 <script> 的角度,显然是无法感知当前 SFC 是否使用了 <style> 动态变量注入。所以,需要从 SFC 出发来标识当前是否使用了 <style> 动态变量注入。

packages/compiler-sfc/parse.ts 中的 parse 方法中会对解析 SFC 得到的 descriptor 对象调用 parseCssVars() 方法来获取 <style> 中使用到 v-bind 的所有变量。

descriptor 指的是解析 SFC 后得到的包含 scriptstyletemplate 属性的对象,每个属性包含了 SFC 中每个块(Block)的信息,例如 <style> 的属性 scoped 和内容等。

对应的 parse() 方法中部分代码(伪代码):

function parse(
  source: string,
  {
    sourceMap = true,
    filename = 'anonymous.vue',
    sourceRoot = '',
    pad = false,
    compiler = CompilerDOM
  }: SFCParseOptions = {}
): SFCParseResult {
  //...
  descriptor.cssVars = parseCssVars(descriptor)
  if (descriptor.cssVars.length) {
    warnExperimental(`v-bind() CSS variable injection`, 231)
  }
  //...
}

可以看到,这里会将 parseCssVars() 方法返回的结果(数组)赋值给 descriptor.cssVars。然后,在编译 script 的时候,根据 descriptor.cssVars.length 判断是否注入 <style> 动态变量注入相关的代码。

在项目中使用了 <style> 动态变量注入,会在终端种看到提示告知我们这个特性仍然处于实验中之类的信息。

而编译 script 是由 package/compile-sfc/src/compileScript.ts 中的 compileScript 方法完成,这里我们看一下其针对 <style> 动态变量注入的处理:

export function compileScript(
  sfc: SFCDescriptor,
  options: SFCScriptCompileOptions
): SFCScriptBlock {
  //...
  const cssVars = sfc.cssVars
  //...
  const needRewrite = cssVars.length || hasInheritAttrsFlag
  let content = script.content
  if (needRewrite) {
    //...
    if (cssVars.length) {
      content += genNormalScriptCssVarsCode(
        cssVars,
        bindings,
        scopeId,
        !!options.isProd
      )
    }
  }
  //...
}

对于前面我们举的例子(使用了 <style> 动态变量注入),显然 cssVars.length 是存在的,所以这里会调用 genNormalScriptCssVarsCode() 方法来生成对应的代码。

genNormalScriptCssVarsCode() 的定义:

// package/compile-sfc/src/cssVars.ts
const CSS_VARS_HELPER = `useCssVars`
function genNormalScriptCssVarsCode(
  cssVars: string[],
  bindings: BindingMetadata,
  id: string,
  isProd: boolean
): string {
  return (
    `\nimport { ${CSS_VARS_HELPER} as _${CSS_VARS_HELPER} } from 'vue'\n` +
    `const __injectCSSVars__ = () => {\n${genCssVarsCode(
      cssVars,
      bindings,
      id,
      isProd
    )}}\n` +
    `const __setup__ = __default__.setup\n` +
    `__default__.setup = __setup__\n` +
    `  ? (props, ctx) => { __injectCSSVars__();return __setup__(props, ctx) }\n` +
    `  : __injectCSSVars__\n`
  )
}

genNormalScriptCssVarsCode() 方法主要做了这 3 件事:

  • 引入 useCssVars() 方法,其主要是监听 watchEffect 动态注入的变量,然后再更新对应的 CSS Vars() 的值
  • 定义 __injectCSSVars__ 方法,其主要是调用了 genCssVarsCode() 方法来生成 <style> 动态样式相关的代码
  • 兼容非 <script setup> 情况下的组合 API 使用(对应这里 __setup__),如果它存在则重写 __default__.setup(props, ctx) => { __injectCSSVars__();return __setup__(props, ctx) }

那么,到这里我们就已经大致分析完 SFC 编译对 <style> 动态变量注入的处理,其中部分逻辑并没有过多展开讲解(避免陷入套娃的情况),有兴趣的同学可以自行了解。下面,我们就针对前面这个例子,看一下 SFC 编译结果会是什么?

3 从 SFC 编译结果,认识 <style> 动态变量注入实现细节

这里,我们直接通过 Vue 官方的 SFC Playground 来查看上面这个例子经过 SFC 编译后输出的代码:

import { useCssVars as _useCssVars, unref as _unref } from 'vue'
import { toDisplayString as _toDisplayString, createVNode as _createVNode, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock, withScopeId as _withScopeId } from "vue"
const _withId = /*#__PURE__*/_withScopeId("data-v-f13b4d11")

import { ref } from "vue"

const __sfc__ = {
  expose: [],
  setup(__props) {

_useCssVars(_ctx => ({
  "f13b4d11-color": (_unref(color))
}))

const msg = 'Hello World!'
let color = ref("red")
const changeColor = () => {
  if (color.value === 'black') {
    color.value = "red"
  } else {
    color.value = "black"
  }
}

return (_ctx, _cache) => {
  return (_openBlock(), _createBlock(_Fragment, null, [
    _createVNode("p", { class: "word" }, _toDisplayString(msg)),
    _createVNode("button", { onClick: changeColor }, " click me ")
  ], 64 /* STABLE_FRAGMENT */))
}
}

}
__sfc__.__scopeId = "data-v-f13b4d11"
__sfc__.__file = "App.vue"
export default __sfc__

可以看到 SFC 编译的结果,输出了单文件对象 __sfc__render 函数、<style> 动态变量注入等相关的代码。那么抛开前两者,我们直接看 <style> 动态变量注入相关的代码:

_useCssVars(_ctx => ({
  "f13b4d11-color": (_unref(color))
}))

这里调用了 _useCssVars() 方法,即在源码中指的是 useCssVars() 方法,然后传入了一个函数,该函数会返回一个对象 { "f13b4d11-color": (_unref(color)) }。那么,下面我们来看一下 useCssVars() 方法。

3.1 useCssVars() 方法

useCssVars() 方法是定义在 runtime-dom/src/helpers/useCssVars.ts 中:

// runtime-dom/src/helpers/useCssVars.ts
function useCssVars(getter: (ctx: any) => Record<string, string>) {
  if (!__BROWSER__ && !__TEST__) return

  const instance = getCurrentInstance()
  if (!instance) {
    __DEV__ &&
      warn(`useCssVars is called without current active component instance.`)
    return
  }

  const setVars = () =>
    setVarsOnVNode(instance.subTree, getter(instance.proxy!))
  onMounted(() => watchEffect(setVars, { flush: 'post' }))
  onUpdated(setVars)
}

useCssVars 主要做了这 4 件事:

  • 获取当前组件实例 instance,用于后续操作组件实例的 VNode Tree,即 instance.subTree

  • 定义 setVars() 方法,它会调用 setVarsOnVNode() 方法,并 instance.subTree、接收到的 getter() 方法传入

  • onMounted() 生命周期中添加 watchEffect,每次挂载组件的时候都会调用 setVars() 方法

  • onUpdated() 生命周期中添加 setVars() 方法,每次组件更新的时候都会调用 setVars() 方法

可以看到,无论是 onMounted() 或者 onUpdated() 生命周期,它们都会调用 setVars() 方法,本质上也就是 setVarsOnVNode() 方法,我们先来看一下它的定义:

function setVarsOnVNode(vnode: VNode, vars: Record<string, string>) {
  if (__FEATURE_SUSPENSE__ && vnode.shapeFlag & ShapeFlags.SUSPENSE) {
    const suspense = vnode.suspense!
    vnode = suspense.activeBranch!
    if (suspense.pendingBranch && !suspense.isHydrating) {
      suspense.effects.push(() => {
        setVarsOnVNode(suspense.activeBranch!, vars)
      })
    }
  }

  while (vnode.component) {
    vnode = vnode.component.subTree
  }

  if (vnode.shapeFlag & ShapeFlags.ELEMENT && vnode.el) {
    const style = vnode.el.style
    for (const key in vars) {
      style.setProperty(`--${key}`, vars[key])
    }
  } else if (vnode.type === Fragment) {
    ;(vnode.children as VNode[]).forEach(c => setVarsOnVNode(c, vars))
  }
}

对于前面我们这个栗子,由于初始传入的是 instance.subtree,它的 typeFragment。所以,在 setVarsOnVNode() 方法中会命中 vnode.type === Fragment 的逻辑,会遍历 vnode.children,然后不断地递归调用 setVarsOnVNode()

这里不对 FEATURE_SUSPENSE 和 vnode.component 情况做展开分析,有兴趣的同学可以自行了解

而在后续的 setVarsOnVNode() 方法的执行,如果满足 vnode.shapeFlag & ShapeFlags.ELEMENT && vnode.el 的逻辑,则会调用 style.setProperty() 方法来给每个 VNode 对应的 DOM(vnode.el)添加行内的 style,其中 key 是先前处理 <style> 时 CSS var() 的值,value 则对应着 <script> 中定义的变量的值。

这样一来,就完成了整个从 <script> 中的变量变化到 <style> 中样式变化的联动。这里我们用图例来简单回顾一下这个过程:

结语

如果,简单地概括 <style> 动态变量注入的话,可能几句话就可以表达。但是,其在源码层面又是怎么做的?这是很值得深入了解的,通过这我们可以懂得如何编写 postcss 插件、CSS vars() 是什么等技术点。

并且,原本打算留有一个小节用于介绍如何手写一个 Vite 插件 vite-plugin-vue2-css-vars,让 Vue 2.x 也可以支持 <style> 动态变量注入。但是,考虑到文章篇幅太长可能会给大家造成阅读上的障碍。所以,这会在下一篇文章中介绍,不过目前这个插件已经发到 NPM 上了,有兴趣的同学也可以自行了解。

最后,如果文中存在表达不当或错误的地方,欢迎各位同学提 Issue~

作为前端,你需要知道 RxJS(响应式编程-流)

前言

说起「响应式编程」,大家可能并不陌生。但是,直接说「流」这个名称,可能大家会有点愣。其实,「流」的本质和「响应式编程」并不二般,都是衍生于前端经典的设计模式——「观察者模式」。但是,在一定程度上,可以说「流」则是基于这个模式的一个上层抽象,因为它所具备的能力更多、更加强大。

在我的认知里面,我又给「流」划分了一下,「玄学」。

而在我们平常开发中,使用「观察者模式」最经典的就是「Vue」。其中,一方面在「Vue」2x 版本中对应的就是基于 Object.defineProperty() 实现的一套依赖收集派发更新。另一方面,在「Vue」3.0 版本中对应的就是基于 Proxy 实现的一套依赖收集派发更新

这里可以简单提提「Vue」中依赖收集派发更新,在 2x 中对应 watcherdep。在 3.0 中对应 effectdep

那么,回到今天的正题,让我们来领略一下作为「流」中的经典代表之一「RxJS」的魅力。

什么是 RxJS

官方文档介绍:「RxJS」是使用 Observables 的响应式编程的库,它使编写异步或基于回调的代码更容易。

建议,如果不了解 Observable API 的同学,可以移步去学习一番 https://github.com/tc39/proposal-observable,再来继续阅读本文。

这样一看,我们可能会把「RxJS」来和 Promise 作一些列比较,因为 Promise 也是为了使得编写异步或基于回调的代码更容易而出现的。也确实,很多在谈论「RxJS」的文章都会举例子来比较两者的优缺点。

所以,在这里我就不举例比较,就简单列举几点「RxJS」解决了哪些使用 Promise 存在的痛点,如:

  • 控制异步任务的执行顺序。
  • 控制异步任务的开始和暂停。
  • 可以获取异步任务执行的各个阶段的状态。
  • 监听异步任务执行过程中发生的错误,在并发执行的情况下,不会因为一个异步任务出错而影响到其他异步代码的执行。

RxJS 的真实面目(流)

为什么这里说「RxJS」的真实面目?因为,前面所说的解决异步或基于回调的代码繁琐问题,仅仅是「RxJS」的一个很简单的一面,它真正玄幻的一面,在于它「流」的特性。

相信大家在此之前应该也听过「流」的概念,例如「Node」中的 Stream 流、「gulp」中的 Stream 流。所以,这里我们先来回忆一下这两个经典的用到「流」的知识点。然后,再循序渐进地进入「RxJS」中「流」的世界。

Node 中的 Stream

「Node」 中的 Stream 指的是一个「抽象接口」,例如文件读取、「Http Request」都实现了这个接口。并且,每一个「流」都是 EventEmitter 的实例。

EventEmitter 的本质就是一个简单的「观察者模式」,可以进行事件的分发和监听。

那么,我们通过简单的读取文件的例子,回忆一番「Node」中 Stream 的事件分发和监听:

const fs = require("fs")
let data = ''

// 创建文件读取流
const readFileStream = fs.createReadStream('stream.txt', 'UTF8')

// 监听流数据读取的事件
readFileStream.on(data, (chunk) => {
  data += chunk
})

// 监听流数据读取结束的事件
readFileStream.on(end, (chunk) => {
  console.log(`文件读取的结果是:${data}`)
})

// 监听流读取文件异常的事件
readerStream.on('error', (err) => {
   console.error(err.stack)
});

这里可以看出,文件读取流在没监听 data 的时候,我们并不能拿到需要的文件中的数据。

这个对于所有的「流」都是一样,它需要被订阅才能被使用,因为「流」是惰性的

然后,我们再来回忆一下「Node」中 Stream 的「管道流」的概念,它指的是「流」可以通过 pipe 链式地调用,例如把文件读取流直接写入文件写入流中:

const fs = require("fs")

const readFileStream = fs.createReadStream('readStream.txt', 'UTF8')

const writeFileStream = fs.createWriteStream('outStream.txt', 'UTF8')

readFileStream.pipe(writeFileStream)

gulp 中的 Stream

不知道大家注意到没有,「gulp」官方文档是这样介绍的:gulp.js 基于流(stream)的自动化构建工具。而且,相信用过「gulp」的同学,应该知道在「gulp」中是以创建任务的形式使用「流」。

具体「流」涉及到插件使用、文件处理和监控的细节,有兴趣的同学可以移步gulp官方文档了解。

例如,我们想要配置一个 sass 的编译,它会是这样:

const gulp = require('gulp')
const sass = require('gulp-sass')
// 创建任务,对 scss 目录下的扩展名为 .sccss 执行编译并输出到 css 文件下 
gulp.task('sass-compile', function(){
  return gulp.src('scss/*.scss')
    .pipe(sass({outputStyle: 'expanded'}).on('error', sass.logError))
    .pipe(gulp.dest('css'))
})
// 监听文件变化执行 sass-compile 任务
gulp.watch('scss/*.scss', ['sass-compile'])

可以看出,我们引入的 gulp 模块,本身就是 Stream。所以,也正如「gulp 官方文档」所说的一般:gulp.js 基于流(stream)的自动化构建工具。

那么,回忆了「Node」和「gulp」中的 Stream 之后。接下来,我们就进入「RxJS」中 Stream万千世界

RxJS 中的 Stream

这里,我们通过「RxJS」 中的几个关键字来循序渐进地认知它:

1.Observable 可观察对象

数据就在 Observable 中流动,以及我们可以使用各种「操作符」Operators 来对流进行处理,例如过滤、去重。

例如:

import Rx from 'rxjs/Rx'

const observable = Rx.Observable.from([1,2,3])
observable.filter(val => val >= 2)
          .subscribe(val => console.log(val)) // 2 3

2.Observer 观察者

它是一个「对象集合」,存放用于监听可观察对象的回调。

例如:

import Rx from 'rxjs/Rx'

const observable = Rx.Observable.from([1,2,3])

const observer = {
  next: val => console.log(val),
  error: err => console.error(err),
  complete: () => console.log('completed')
}

observable.subscribe(observable)

3.Subscription 订阅

用于取消对可观察对象的订阅,例如在一个组件的生命周期的结束理应取消订阅。

例如:

import Rx from 'rxjs/Rx'
import {Vue, Component} from 'vue-property-decorator'

@Component
export default class Dialog extends Vue {
  private subscription
  
  private created() {
    const observable = Rx.Observable.fromEvent(this.refs.btnCommit, 'click')
    
    this.subscription = observable.subscribe({ next: () => {
      console.log('click')
    }})
  }
  private destoryed() {
    this.subscription.unsubscribe()
  }
}

4.Operators 操作符

它指的是一些数组的工具函数 filter()some()mapflatMap等等。

这里就不举例子了,就和第一个例子使用 filter() 的方式一样。

  1. Subject 主体

它类似于前面提及的「Node」中的 EventEmitter,进行事件的分发,即广播。

需要注意的是,Observable 观察者的事件分发是单播的。

例如:

import Rx from 'rxjs/Rx'

const subject = new Rx.Subject()

subject.subscribe({
  next: val => console.log(`监听对象1:${val}`)
})
subject.subscribe({
  next: val => console.log(`监听对象2:${val}`)
})

subject.next(1)
subject.next(2)
/*
  输出:
  监听对象1:1
  监听对象2:1
  监听对象1:2
  监听对象2:2 
*/

6.Schedules 调度器

它负责对任务的调度,例如控制事件的执行顺序和排序等等。它由这三部分组成:

  • 数据结构,对任务进行顺序存储。
  • 执行上下文,可以表示何时何地执行任务。
  • 一个(虚拟的)时钟,任务的执行会遵循时钟的设置。

例如:

import Rx from 'rxjs/Rx'

const observable = Rx.Observable.create(function (proxyOb) {
  proxyOb.next(1)
  proxyOb.next(2)
  proxyOb.next(3)
  proxyOb.complete()
})
.observeOn(Rx.Scheduler.async)

const observer = {
  next: x => console.log(x),
  error: err => console.error(err),
  complete: () => console.log('completed'),
};

observable.subscribe(finalObserver)

可以看到这里在创建观察者的时候调用了 observerOn(),以及使用了调度器的 async,这里则会按创建时的顺序将所有订阅的 next() 中的代码块放在消息队列中以宏任务 setTimeoutsetInterval 的形式执行,默认时钟设置为 0。

小结

其实,介绍了这几个关键点后,我想大家对于前面提及解决 Promise 的痛点的由来,应该心中明了。并且,针对一些场景 「RxJS」官方文档 也列举了如何使用它,例如:

  • 控制流
  • 进行全局的状态存储
  • 结合第三方状态存储 immutable 使用
  • 结合 React 使用

所以,在这里我就不一一列举了,毕竟文档上已经讲的很好了,照搬讲一遍就没太大必要哈。有兴趣的同学可以自行了解。

结语

这篇文章,其实在一个月之前,我就想着总结分享出来,但是各种因素不了了之。这次恰逢「端午节」,专门腾出几个小时时间思考和总结自己这段时间对「RxJS」和「流」的认知。当然,文章中会存在不足的地方,欢迎大家提「Issue」。

并且,个人认为「流」的存在,在将来很可能会改变一些东西。例如,之前携程的一位前辈写的面向Model编程的前端架构方式,其中就提及需要一种脱离平台的响应式编程处理,列举了如「Redux」、「Vue3.0 reactive API」、「RxJS」等等。有兴趣的同学,可以往这方面继续了解。

使用 Verdaccio 搭建一个企业级 npm 私有库

前言

私有 npm 库,我想是每个团队都会实践和经历的一个阶段。实现私有 npm 的方式有很多种,例如基于私有 Git 仓库、npm 官方的托管(付费)、Verdaccio 等等。但是,综合比较各种因素下来(不要钱、还好用),Verdaccio 都略胜前面两者。

那么,今天本文也将带着大家一起使用 Verdaccio 来搭建一个企业级私有 npm 库!

一、安装、启动

Verdaccio 的安装启动过程较为简单。首先是全局安装 Verdaccio:

npm i -g verdaccio

然后,在终端中输入 verdaccio 命令启动 Verdaccio:

verdaccio

接着 Verdaccio 会在终端中输出提示,输出它的配置文件位置、启动的服务地址等信息:

默认 Verdaccio 启动的服务都会在 4873 这个端口,在浏览器中打开这个地址我们就会看到 Verdaccio 搭建的私有库 npm 的界面:

可以看到,默认的界面风格还是很简洁、美观的。并且,这里会提示我们要登陆、发布 npm 包需要执行的命令。

二、配置修改

虽然,安装和启动好了 Verdaccio。但是,由于 Verdaccio 默认的配置和我们生产的需求不一致,所以我们需要修改一下 Verdaccio 的配置。

在生产环境下,私有 npm 库需要具备以下 3 个功能:

  • 支持对 npm 包的搜索
  • 严格的权限把控,npm 包的访问只能是已注册的用户。并且在一些场景下,需要删除用户
  • 发布 npm 包后,推送到钉钉群,告知哪个 npm 包进行了发布

而 Verdaccio 的配置文件是在 ~/.config/Verdaccio 文件夹的 config.yaml 文件,默认的配置会是这样:

storage: ./storage
plugins: ./plugins
web:
  title: Verdaccio
auth:
  htpasswd:
    file: ./htpasswd
uplinks:
  npmjs:
    url: https://registry.npmjs.org/
packages:
  '@*/*':
    access: $all
    publish: $authenticated
    unpublish: $authenticated
    proxy: npmjs
  '**':
    access: $all
    publish: $authenticated
    unpublish: $authenticated
    proxy: npmjs
server:
  keepAliveTimeout: 60
middlewares:
  audit:
    enabled: true
logs:
  - { type: stdout, format: pretty, level: http }

这里我们来逐个认识一下默认配置中的几个值的含义:

  • storage 已发布的包的存储位置,默认存储在 ~/.config/Verdaccio/ 文件夹下

  • plugins 插件所在的目录

  • web 界面相关的配置

  • auth 用户相关,例如注册、鉴权插件(默认使用的是 htpasswd

  • uplinks 用于提供对外部包的访问,例如访问 npm、cnpm 对应的源

  • packages 用于配置发布包、删除包、查看包的权限

  • server 私有库服务端相关的配置

  • middlewares 中间件相关配置,默认会引入 auit 中间件,来支持 npm audit 命令

  • logs 终端输出的信息的配置

接下来,我们就来修改 Verdaccio 的配置文件中对应的值来一一支持上述功能。

2.1 开启搜索

当我们私有 npm 库存在很多包的时候,我们想要查找某个包就会有些麻烦。而 Verdaccio 是支持搜索功能的,它是由 search 控制的,默认为 false,所以这里我们需要开启它:

search: true

开启之后,我们就可以在私有 npm 库的页面上的搜索栏进行正常的搜索操作。

2.2 权限把控

权限把控指的是我们需要私有 npm 库上发布的包只能团队成员查看,除此之外人员不能看到一切信息。那么,回到 Verdaccio,我们需要做这 2 件事:

  • 限制 npm 包的查看,只能为已注册的用户
  • 禁止用户注册(在团队成员已注册完成后)

相应地,这里我们需要修改配置文件的 pacakgesauth。前面我们也提及了 packages 是用于配置发布包、查看包、删除包相关的权限。我们先再来看看默认的配置:

packages:
  '@*/*':
    access: $all
    publish: $authenticated
    unpublish: $authenticated
    proxy: npmjs
  '**':
    access: $all
    publish: $authenticated
    unpublish: $authenticated
    proxy: npmjs

这里的 key 代表对应权限需要匹配的包名,例如对于第一个,如果我们发布的包名是这样的 @wjc/test 就会命中。每个规则中对应 4 个参数。其中 proxy 代表如果在私有 npm 库找不到,则会代理到 npmjs(对应 unlinks 中的 npmjshttps://registry.npmjs.org/)。而剩下的 3 个参数,都是用来设置包相关的权限,它有三个可选值 $all(所有人)、$anonymous(未注册用户)、$authenticated(注册用户)。那么,下面我们分别看一下这 3 个参数的含义:

  • access 控制包的访问权限

  • publish 控制包的发布权限

  • unpublish 控制包的删除权限

显然,这里我们需要的是只有用户才能具备上述 3 个权限,即都设置为 $authenticated

packages:
  '@*/*':
    access: $authenticated
    publish: $authenticated
    unpublish: $authenticated
    proxy: npmjs
  '**':
    access: $authenticated
    publish: $authenticated
    unpublish: $authenticated
    proxy: npmjs

设置好 packages 后,我们还得更改 auth 的值,因为此时注册用户是没有限制的,也就是说如果你的私有 npm 库部署在外网环境的话,任何人都可以通过 npm adduser 命令注册用户。

显然,这是不允许出现的情况,所以这里我们需要设置 authmax_users-1,它代表的是禁用注册用户:

auth:
  max_users: -1

如果要开启用户注册,设置指定数字(大于 0)即可

2.3 发布包推送钉钉群

发布包推送钉钉群,指的是我们每次发布包可以通过钉钉群的机器人来通知我们发布的包的信息。

首先,这里我们需要先有一个钉钉群的机器人对应的 Webhook(获取方式可以查看钉钉的文档)。然后,在 Verdaccio 的配置文件中添加 notify

notify:
  'dingtalk':
    method: POST
    headers: [{'Content-Type': 'application/json;charset=utf-8'}]
    endpoint: https://oapi.dingtalk.com/robot/send?access_token=****, # 钉钉机器人的 webhook
    content: '{"color":"green","message":"新的包发布了: * {{ name }}*","notify":true,"message_format":"text"}'

其中,methodheaders 分别表示请求的方法和实体的类型。endpoint 表示请求的 Webhook 地址。content 则表示获取发布信息的基础模版,模版中 message 的值会是钉钉群的机器人发送的消息内容(name 表示发布的包名)。

假设,此时我们发布了一个包名为 verdaccio-npm-demo 的私有包,相应地我们会在钉钉群里收到通知:

三、基本使用

既然配置好了 Verdaccio。那么,我们就可以开始发布第一个私有包了 😎。

3.1 注册用户

首先,我们需要注册一个用户:

npm adduser --registry http://localhost:4873/

接着,它会要求你填写用户名、密码和邮箱,用于登陆私有 npm 库:

3.2 删除用户

既然有注册用户,不可避免的需求是在一些场景下,我们需要删除某个用户来禁止其登陆私有 npm 库。

前面也提及了 Verdaccio 默认使用的是 htpasswd 来实现鉴权。相应地,注册的用户信息会存储在 ~/.config/verdaccio/htpasswd 文件中:

wuliu:pWxgur/1w5v1I:autocreated 2021-02-18T07:58:57.827Z

这里一条记录对应一个用户,也就是如果这条记录被删除了,那么该用户就不能登陆了,即删除了该用户。

3.3 添加、切换源

这里我们为了操作方面,通过 nrm 来切换源。没有安装 nrm 的同学,可以先安装一下:

npm i -g nrm

然后,使用 nrm 添加一个源:

npm add mynpm http://localhostm:4873/

这里的 mynpm 代表你这个源的简称,你可以因自己的喜好来命名。

接着,我们可以运行 nrm ls 命令来查看目前存在的源:

可以看到默认情况下 npm 使用的源是 https://registry.npmjs.org/,那么这里我们需要将它切换成私有 npm 库对应的源:

nrm use mynpm

切换好源后,我们之后的 npm i 就会先去私有库查找包,如果不存在则会去 https://registry.npmjs.org/(因为上面配置了 proxy)查找包。

3.4 发布

发布的话就直接在某个需要发布包的项目(假设这里我们的包叫 verdaccio-npm-demo2)的根目录下运行:

npm publish

然后,在私有 npm 库的界面上就可以看到我们发布的包:

结语

当然,Verdaccio 能做的还有很多,例如集成 Git Action 自动发包、自定义鉴权插件等。但是,经过我们上面的一番折腾,私有 npm 库已经初具规模可以投入生产使用了哈 😲。最后,文章中如果存在表达不当或错误的地方,欢迎各位同学提 Issue。

带给你幸福感的 auto-inject-async-catch-loader

前言

前段时间,写了一个 webpack-loader,auto-inject-async-catch-loader,晚上 8 点多发的 npm 包。昨天早上,上 npm 官网一看,才十几个小时下载量 282 次(着实有点意外😯)~

在之前,我写过一篇文章《现场教学,优雅地处理基于 Vue CLI 项目中的 async await 异常》介绍了如何在 Vue CLI 中给代码自动加 try catch,当时在文中也讲了这种做法欠妥。所以,上周末我花了一些时间,重新整理了一下如何使用 babel 的 tool 基于抽象语法树 AST 来实现对 Vue CLI 项目代码自动注入 try catch

一、 auto-inject-try-catch-loader 简介

auto-inject-async-catch-loader 是一个 webpack-loader,基于 babel 提供的 tool 来通过遍历抽象语法树 AST 来实现对 AwaitExpression AST 节点添加 TryStatement AST 节点,从而完成对 async function自动注入 try catch 的机制。auto-inject-async-catch-loader 的工作机制如下图所示:

而 auto-inject-async-catch-loader 这个 loader 是 fork 的 async-catch-loader。所以,这里给上作者一个大大的 Respect 👍。起初,我是想着提个 PR,没必要反复造轮子,但是出于时间问题,就 fork 了一份改了改。

相比较 async-catch-loader ,auto-inject-async-catch-loader 做了以下两点优化:

1.优化了向上查找 parent 的过程,优化后的 traverse 如下所示:

traverse(ast, {
    AwaitExpression(path) {
      // 已经包含 try 语句则直接退出
      if (
        path.findParent(path => t.isTryStatement(path.node))
      ) {
        return;
      }
      // 查找最外层的 async 语句
      const blockParent = path.findParent(path => t.isBlockStatement(path.node) && isAsyncFuncNode(path.parentPath.node))
      const tryCatchAst = t.tryStatement(
        blockParent.node,
        t.catchClause(
          t.identifier(options.identifier),
          t.blockStatement(catchNode)
        ),
        finallyNode && t.blockStatement(finallyNode)
      )
      blockParent.replaceWithMultiple([tryCatchAst])
    }
  });

2.支持使用 TypeScript + Vue CLI 开发的情况。此时 Vue 组件是 Class 语法,例如:

<template></template>
<script lang="ts">
import { Vue, Component } from "@vue-property-decorator"

@Component
export default class Login extends Vue {
  private async created(): Promise<any> {
    await Promise.resolve("user login")
  }
}
</script>

可以看到 createdasync function)是写在 Class 内部的,即其对应的抽象语法树 AST 的 type 为 ClassMethod,所以在 isAsyncFunction 判断函数中增加加了对 ClassMethod 的判断:

const isAsyncFuncNode = node =>
  t.isFunctionDeclaration(node, {
    async: true
  }) ||
  t.isArrowFunctionExpression(node, {
    async: true
  }) ||
  t.isFunctionExpression(node, {
    async: true
  }) ||
  t.isObjectMethod(node, {
    async: true
  }) ||
  t.isClassMethod(node, {
    async: true
  });

二、auto-inject-async-catch-loader 在 Vue CLI 中使用

相信很多同学对于配置 Vue CLI 的 Webpack 一头雾水,这里我就顺带地给大家的讲解一下如何在 Vue CLI 中使用 auto-inject-async-catch-loader。

首先,当然是安装 auto-inject-async-catch-loader 依赖:

npm i auto-inject-async-catch-loader -D

然后,配置 Webpack。而通常情况下,使用 Vue CLI 开发的同学会分为两类:

1.使用 JavaScript 开发

使用 JavaScirpt 开发的同学只需要通过 chainwebpack 选项在 js rule 中添加一个 loader 就行。在 vue.config.js 的 chainWepack 中加入如下配置:

chainWepack: (config) => {
  const jsRule = config.module.rule("js");
  jsRule
    .use("auto-inject-async-catch-loader")
    .loader("auto-inject-async-catch-loader")
    .end()
}

2.使用 TypeScript 开发

使用 TypeScript 开发的同学则需要重写整个 ts rule 的 loader 配置。在 vue.config.ts 的 chainWepack 选项中加入如下配置:

chainWebpack: (config) => {
  const tsRule = config.module.rule("ts");
  tsRule.uses.clear();
  tsRule
    .use("cache-loader")
      .loader("cache-loader")
      .end()
    .use("babel-loader")
      .loader("babel-loader")
      .end()
    .use("auto-inject-async-catch-loader")
      .loader("auto-inject-async-catch-loader")
      .tap(() => {
        return {
          catchCode: 'console.error(e)'
        }
      })
      .end()
    .use("ts-loader")
      .loader("ts-loader")
      .tap(() => {
        return {
                  transpileOnly: true,
                  appendTsSuffixTo: [
                    '\\.vue$'
                  ],
                  happyPackMode: false
                }
      })
      .end()
}

至于为什么需要重写整个 ts 对应的 rule,因为此时项目中代码的 loader 的加载顺序是 ts-loader 到 babel-loader 再到 cache-loader,auto-inject-async-catch-loader 必须要在 babel-loader 之前、ts-loader 之后加载:

而 Vue CLI 的 Webpack 配置是使用 webpack-chain 生成的,但是 webapck-chain 并没有提供在某个 loader 后插入 loader 的方法(只对 plugin 支持 before 和 after 方法)。所以,这里我们需要重写整个 ts rule,保证 ts-loader 到 auto-inject-async-catch-loader 到 babel-loader 再到 caceh-loader 的加载顺序:

结语

最后讲讲后期计划,写一个 babel-plugin 实现同样的功能,用 babel-plugin 实现则会精简地多,因为 babeld-plugin 本身提供了一些变量供于使用,例如可以使用 visitor 遍历抽象语法树 AST。当然,欢迎各位同学使用 auto-inject-async-catch-loader,如果有其他需求也可以给我提 Issue,或者微信(wu1957503379)联系我。

参考

https://github.com/yeyan1996/async-catch-loader

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.