I

icodex | 前端技术博客 | 专注 React、TypeScript、AI 与性能优化 Blog

icodex - 个人网站

JavaScript Intl 对象全面指南

Intl 对象是一个 JavaScript 的国际化 API,提供了精确的字符串对比、数字格式化和日期时间格式化功能。本文将全面介绍 Intl 对象的 API 功能和使用方法。 Intl 对象兼容性说明​ Intl 对象不同于其他 ECMA 262 规范定义的 JS API,它是在 ECMA 402 中定义的。当我们查阅 ECMA 262 和 ECMA 402 规范可以发现它们之间的区别在于: ECMA 262:This Standard defines the ECMAScript xxx general-purpose programming language,本标准定义了 ECMAScript 通用编程语言规范; ECMA 402:This Standard defines the application programming interface for ECMAScript objects that support programs that need to adapt to the linguistic and cultural conventions used by different human languages and countries,本标准定义了 ECMAScript 对象的应用程序编程接口,这些对象支持需要适应不同人类语言和国家使用的语言和文化惯例的程序。 所以 ECMA-262 是 ECMAScript 语言规范(也就是我们熟知的 ES3、ES5、ES6…);而 ECMA-402 则是 JavaScript 的国际化 API 标准,专门定义一些关于国际化处理的 API 规范。 ECMA-402 规范最早在 2012 年 12 月分发布的第一个版本,到今天为止 ECMA-402 规范已经迭代了 12 次,从 2015 年(也就是 ES6)开始每年都会更新。 信息 浏览器兼容上,从 2017 年 9 月(ES7)开始,市面上的主流浏览器都已经支持了 Intl 对象的使用。 下面我们再一起看下 Intl 对象的主要 API。 Intl.Locale​ Intl.Locale 对象表示和操作 语言环境(locale) 信息,语言环境就是描述用户语言、地区和文化偏好的一个标识符,例如 en-US 代表说英语的美国。Intl.Locale 对象可以把这些字符串解析成结构化数据,并允许你提取、修改或扩展。 构造函数​ 使用 new Intl.Locale() 构造函数来创建一个 Intl.Locale 对象实例,该函数有两个参数: tag:必填参数,一个 Unicode BCP 47 规范的语言标签字符串或者一个 Intl.Locale 对象;这个 Unicode BCP 47 语言标签由 5 个部分组成:language[-script][-region][-variants][-extensions] language:必填,例如 "en"、"zh" script :可选,书写系统,例如 "Latn"(拉丁字母)、"Hans"(简体汉字) region:可选,地区代码,例如 "US"、"CN" variants / extensions:可选,用来指定特殊的扩展规则(比如日历、排序方式等) options:一个区域设置的对象,用来指定或覆盖第一个参数的 Unicode 扩展关键字,包括: language:语言子标签,由 23 或者 58 个字母组成,如 en、zh 等,全部数据在这个 XML 中维护; script:书写格式,由 4 个字母组成,如 Hans,全部数据在这个 XML 中维护; region:地区子标签,由 2~3 个字母组成,如 "US", "CN",全部数据在这个 XML 中; variants:一个连字符(-)分隔的唯一变体标签列表,其中每个标签都由 5~8 个字母数字或一个数字后跟 3 个字母数字组成,例如 fonipa 表示国际音标,可以在这个 XML 中 查询; calendar:历法,一个或多个由 3-8 个字母数字组成的段,由连字符连接,例如 chinese 表示中国农历,可以在这个 XML 中查询; collation:排序规则,例如 pinyin 表示按照中文拼音顺序,可以在这个 XML 中查询; numberingSystem:编号系统,例如 arab 表示阿拉伯数字,可以在这个 XML 中查询; caseFirst:大小写排序优先方式,可以设置为 "upper"、"lower"、 "false"; hourCycle:小时制格式,可以设置为 "h23"、 "h12"、"h11" 或实际上未使用的 "h24"; numeric:是否按数字内容排序,true 或 false 属性​ Intl.Locale 对象具有以下属性,其中大部分属性和使用构造函数传递的第二个参数相关。 baseName​ baseName 属性返回 BCP47 语言标签字符串,但是仅包括 language ["-" script] ["-" region] *("-" variant) 四个部分,不包含使用 -u 开头的扩展部分; // Sets language to Japanese, region to Japan, // calendar to Gregorian, hour cycle to 24 hours const japan = new Intl.Locale("ja-JP-u-ca-gregory-hc-24"); console.log(japan.baseName); // "ja-JP" 其余属性和上面的 options 参数一致。 方法​ getCalendars():返回多个历法字符串组成的数组。如果在创建 Intl.Locale 对象时传递了 calendar,那么就直接返回;如果没有指定,则返回该 locale 下所有可用的历法; const zh = new Intl.Locale("zh"); console.log(zh.getCalendars()); // ["gregory", "chinese"] getCollations():返回多个排序规则字符串数组。 const locale = new Intl.Locale("zh"); console.log(locale.getCollations()); // ["pinyin", "stroke", "zhuyin", "emoji", "eor"] getHourCycles():返回小时制字符串数组 console.log(new Intl.Locale("zh").getHourCycles()); // ["h23"] getNumberingSystems():返回编号系统 const zh = new Intl.Locale("zh"); console.log(zh.getNumberingSystems()); // ["latn"] getTextInfo():返回包含表示本地文本方向的 direction 属性的对象 const zh = new Intl.Locale("zh"); console.log(zh.getTextInfo()); // { direction: "ltr" } getTimeZones():返回时区字符串数组。如果创建 Intl.Locale 对象时没有指定 region,那么会返回 undefined。 const zh = new Intl.Locale("zh"); console.log(zh.getTimeZones()); // undefined const zh = new Intl.Locale("zh-CN"); console.log(zh.getTimeZones()); // ['Asia/Shanghai', 'Asia/Urumqi'] Asia/Urumqi是新疆乌鲁木齐时区 getWeekInfo():返回一个包含周信息的对象,包括 firstDay(一周的第一天是哪一天),weekend(哪几天是周末),minimalDays(表示月份或月份的第一周所需的最少天数) const zh = new Intl.Locale("zh"); console.log(zh.getWeekInfo()); // {firstDay: 1, weekend: [6, 7]} maximize():获取此语言环境的语言、文字和区域的最可能值组成的 Intl.Locale 对象 minimize():返回 Intl.Locale 对象,该对象从区域语言标识符(本质上是 的内容)中删除所有语言、文字或地区子标签 toString():返回完整的 Unicode BCP 47 区域设置标识符字符串,区别于 baseName 属性 const zh = new Intl.Locale("zh-Hans-CN", { calendar: "chinese" }); console.log(zh.toString()); // zh-Hans-CN-u-ca-chinese console.log(zh.baseName); // zh-Hans-CN Intl.Collator​ Collator 译为排序器,所以 Intl.Collator 对象用于语言敏感的字符串比较。 构造函数​ 使用 new Intl.Collator() 构造函数来创建一个 Intl.Collator 对象实例。值得注意的是,该构造函数不使用 new 就直接调用。 new Intl.Collator(locales, options); // 或直接调用 Intl.Collator(locales, options); 构造函数有两个可选参数: locales: 语言标签字符串或 Intl.Locale 对象,或者它们组成的数组; options: 包含以下可选属性 usage:比较是否用于对字符串列表进行排序- "sort",或是否按键模糊过滤(对于拉丁字母,不区分变音符号和大小写)- "search";默认为排序; localeMatcher:要使用的区域设置匹配算法,值可以是 "lookup" 和 "best fit",默认值为 "best fit"; collation:特定语言环境的变体排序规则,同上; numeric:是否应使用数字排序规则,比如 "1" < "2" < "10",true 或 false ,默认 false; caseFirst:是否先按大小写排序,可能的值为 "upper"、"lower" 和 "false",默认 false; sensitivity:设置字符串中的哪些差异影响排序结果,默认为 "variant" "base":忽略重音和大小写(a = á = A) "accent":区分重音,忽略大小写(a ≠ á, a = A) "case":区分大小写,忽略重音(a = á, a ≠ A) "variant":区分所有差异,包括重音、大小写等(完全区分) ignorePunctuation:是否忽略标点,true 或 false ; 方法​ Intl.Collator 本身具有一个静态方法 Intl.Collator.supportedLocalesOf() 和两个实例方法 compare() 和 resolvedOptions()。 supportedLocalesOf​ 参数: locales:必填,一个 Unicode BCP 47 规范的语言标签字符串或 Intl.Locale 对象;或者它们任意一种的数组; options:可选参数,一个对象,仅包含 localeMatcher 属性 localeMatcher:可以设置为 "lookup" 或 "best fit";默认为 "best fit",表示使用智能的近似匹配策略,"lookup" 则为严格匹配。 Intl.Collator.supportedLocalesOf(locales, options) 静态方法用来检测传入的语言环境中哪些是当前运行环境真正支持的。这个方法几乎在 Intl 的所有对象中都包含,主要用处有两个: 检测你传入的 locales 数组中的语言标签字符串是否都正确; 检测指定 Intl.xxx 对象是否在当前运行环境下支持所有你指定的语言环境下的国际化转换操作。 比如: // 检查日期格式化器支持哪些 locale console.log( Intl.DateTimeFormat.supportedLocalesOf(["zh-CN", "en-GB", "fr-XX"]) ); // 返回: ["zh-CN", "en-GB"],也就是指定 fr-XX 写错了 compare​ compare(string1, string2):按照指定语言的规则,比较两个字符串,返回一个数字。当 string1 排在 string2 前面时返回 -1,反之则返回 1;如果两个字符串相等,则返回 0; resolvedOptions​ resolvedOptions():返回初始化 Intl.Collator 时计算的 options 对象,如果没有传递 options,则返回 options 内部属性的默认值,例如英文字母比较规则如下 const de = new Intl.Collator("en"); de.resolvedOptions(); // 默认配置 caseFirst: "false"; collation: "default"; ignorePunctuation: false; locale: "en"; numeric: false; sensitivity: "variant"; usage: "sort"; Intl.DateTimeFormat​ Intl.DateTimeFormat 对象用于日期时间格式化。 构造函数​ 使用 new Intl.DateTimeFormat(locales, options) 创建 Intl.DateTimeFormat 对象,也可以不使用 new。 参数说明: locales:Unicode BCP 47 规范的语言标签字符串或 Intl.Locale 对象,或者它们组成的数组; options: localeMatcher:要使用的区域设置匹配算法,值可以是 "lookup" 和 "best fit",默认值为 "best fit"; calendar:历法,例如 chinese 表示中国农历,可以使用 Intl.supportedValuesOf("calendar") 方法获取所有历法枚举值; numberingSystem:编号系统,例如 arab 表示阿拉伯数字,可以使用 Intl.supportedValuesOf("numberingSystem") 获取所有枚举值; hour12:是否使用 12 小时制,true 或 false; hourCycle:小时制格式,可以设置为 "h23"、 "h12"、"h11"或"h24"; timeZone:使用的时区,例如 Asia/Shanghai; weekday:星期几的表示形式 "long":完整的日期名,例如 Monday、 Thursday; "short":日期缩写,例如 Mon、Thu; "narrow":最短日期名,例如 T(Tuesday) era:年代的表示 "long":完整表示,例如 Anno Domini(基督纪元,公元); "short":缩写,例如 AD(Anno Domini); "narrow":最短表示,例如 A(Anno Domini) year:年份表示 "numeric":实际数字 "2-digit":两位数字 month:月份表示 "numeric":实际数字,例如 3(3 月份) "2-digit":两位数字,不够补 0,例如 03、12 "long":完整表示,例如 March(三月份); "short":缩写,例如 Mar; "narrow":最短表示,例如 M day:日期表示 "numeric":实际数字 "2-digit":两位数字,不够补 0 dayPeriod:日期时间段的格式 "long":全称,例如 in the morning(早上); "short":缩写,例如 AM; "narrow":最短表示,例如 a hour:小时表示形式 "numeric":实际数字 "2-digit":两位数字,不够补 0 minute:分钟表示 "numeric":实际数字 "2-digit":两位数字,不够补 0 second:秒表示 "numeric":实际数字 "2-digit":两位数字,不够补 0 fractionalSecondDigits:表示秒的小数部分的位数(多余的数字将被截断),可以是 1、2、3,最多 3 位; timeZoneName:时区名称的本地化表示 "long":全称,例如 Pacific Standard Time(太平洋时间); "short":缩写,例如 PST; "shortOffset":简短的本地化 GMT 格式,例如 GMT-8; "longOffset":长本地化 GMT 格式,例如 GMT-08:00; "shortGeneric":简短的通用非位置格式,例如 PT "longGeneric":长通用非位置格式,例如 Pacific Time formatMatcher:格式化算法,可以是 "basic" 或 "best fit"; dateStyle:要使用的日期格式化样式,可以是 "full"、 "long"、 "medium" 和 "short"; timeStyle:要使用的时间格式化样式,可以是 "full"、 "long"、 "medium" 和 "short"; 方法​ format(date)​ format(date) 用于格式化日期。 参数: date:可以是一个 Date 日期对象或者 Temporal.PlainDateTime 对象,如果不传就是格式化当前本地时间;返回格式化后的字符串。例如获取当前本地时间: 返回值: string:格式化后的指定语言环境下的表示日期的字符串,结构取决于初始化 Intl.DateTimeFormat 对象时的配置,例如格式化当前时间为本地语言可读的字符串 function getLocalDate() { const now = new Date(); const formatter = new Intl.DateTimeFormat(navigator.language, { year: "numeric", month: "long", day: "numeric", hour: "numeric", minute: "numeric", second: "numeric", weekday: "long", }); // 格式化当前日期和时间 return formatter.format(now); } // 'YYYY年M月D日星期W HH:mm:ss' 获取指定秒时间戳的可读字符串: function formatTimestamp(seconds) { const date = new Date(seconds * 1000); // 秒转毫秒 const formatter = new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, // 24小时制 }); return formatter.format(date); } formatTimestamp(1704038400); // '2024/01/01 00:00:00' formatToParts(date)​ formatToParts(date) 返回 Intl.DateTimeFormat 的一个对象数组,该数组包含所返回的格式化字符串的各个部分,对于根据特定语言环境的标记构建自定义字符串非常有用,比如构建 YYYY-MM-DD HH:mm:ss 等格式的字符串可以使用以下方式: const date = new Date("2025-09-04T08:05:09"); // 创建格式化器 const formatter = new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, // 24 小时制 }); // 格式化 const parts = formatter.formatToParts(date); const map = {}; for (const part of parts) { map[part.type] = part.value; } // YYYY-MM-DD HH:mm:ss const formattedDash = `${map.year}-${map.month}-${map.day} ${map.hour}:${map.minute}:${map.second}`; formatRange()​ formatRange(startDate, endDate) 用于格式化日期范围,返回给定日期范围的字符串。 formatRangeToParts()​ formatRangeToParts(startDate, endDate) 返回 Intl.DateTimeFormat 的一个对象数组,该数组包含所返回的格式化字符串的各个部分。 resolvedOptions()​ 计算 Intl.DateTimeFormat 对象的配置。 const region = new Intl.DateTimeFormat("zh-CN"); region.resolvedOptions(); calendar: "gregory"; day: "numeric"; locale: "zh-CN"; month: "numeric"; numberingSystem: "latn"; timeZone: "Asia/Shanghai"; year: "numeric"; Intl.DurationFormat​ Intl.DurationFormat 对象用于格式化时间间隔。 注意 Intl.DurationFormat 是 ES2025 新加入的 API,仅在较新的浏览器版本中支持,比如 chrome > 129 构造函数​ 使用 new Intl.DurationFormat(locales, options) 创建 Intl.DurationFormat 对象,该函数接收两个可选参数: locales:Unicode BCP 47 规范的语言标签字符串或 Intl.Locale 对象,或者它们各自组成的数组; options: localeMatcher:要使用的区域设置匹配算法,值可以是 "lookup" 和 "best fit",默认值为 "best fit"; numberingSystem:编号系统,例如 arab 表示阿拉伯数字,可以使用 Intl.supportedValuesOf("numberingSystem") 查看所有枚举值; style:格式化时长的样式,默认值为 "short" "long":全称,例如 1 hour and 50 minutes; "short":缩写,例如 1 hr, 50 min; "narrow":最短表示,例如 1h 50m; "digital":仅使用数字表示,例如 1:50:00 years:年份格式,可以是 "long"、"short" 或 "narrow",默认为 style 设置的值,没有则为 "short"; yearsDisplay:是否总是显示有几年,可以是 "always" 或 "auto",默认为 "auto";如果指定了 years,则会始终展示; months:月份格式,以下均同上; monthsDisplay:是否总显示有几个月; weeks:周格式; weeksDisplay:是否总显示有几周; days:日格式; daysDisplay:是否总显示有几天; hours:时间格式,可以是 "long"、"short" 、"narrow"、"numeric" 或 "2-digit"; hoursDisplay:是否总显示有几小时; minutes:分钟格式; minutesDisplay:是否总显示有几分钟; seconds:秒格式; secondsDisplay:是否总显示有几秒; milliseconds:毫秒格式; millisecondsDisplay:是否总显示毫秒; microseconds:微秒格式; microsecondsDisplay:是否显示微秒; nanoseconds:纳秒格式; nanosecondsDisplay:是否显示纳秒; fractionalSecondDigits:表示秒的小数部分的位数(多余的数字将被截断),可能的值是从 0 到 9 方法​ Intl.DurationFormat 包含一个静态方法和三个实例方法: supportedLocalesOf​ Intl.DurationFormat.supportedLocalesOf 静态方法同上述的 Intl.Collator.supportedLocalesOf format​ format(duration) 方法用来格式化时间间隔。 参数: duration:必填,一个对象,可包含以下属性,每个属性值必须是正整数;或者也可以是一个 Temporal.Duration 对象 years: 年 months: 月 weeks: 周 days: 日 hours: 时 minutes: 分 seconds: 秒 milliseconds: 毫秒 microseconds: 微秒 nanoseconds: 纳秒 返回值: string:返回格式化后的时间间隔字符串,字符串的结构取决于初始化 Intl.DurationFormat 对象时的配置。 formatToParts​ formatToParts(duration) 将时间间隔转换成每一部分时间组成的对象数组。 参数同上,返回一个包含 type、 value 或 unit 的对象数组,示例: const duration = { hours: 7, minutes: 8, seconds: 9, milliseconds: 123, microseconds: 456, nanoseconds: 789, }; new Intl.DurationFormat("en", { style: "long" }).formatToParts(duration); // Returned value: [ { type: "integer", value: "7", unit: "hour" }, { type: "literal", value: " ", unit: "hour" }, { type: "unit", value: "hours", unit: "hour" }, { type: "literal", value: ", " }, { type: "integer", value: "8", unit: "minute" }, { type: "literal", value: " ", unit: "minute" }, { type: "unit", value: "minutes", unit: "minute" }, { type: "literal", value: ", " }, { type: "integer", value: "9", unit: "second" }, { type: "literal", value: " ", unit: "second" }, { type: "unit", value: "seconds", unit: "second" }, { type: "literal", value: ", " }, { type: "integer", value: "123", unit: "millisecond" }, { type: "literal", value: " ", unit: "millisecond" }, { type: "unit", value: "milliseconds", unit: "millisecond" }, { type: "literal", value: ", " }, { type: "integer", value: "456", unit: "microsecond" }, { type: "literal", value: " ", unit: "microsecond" }, { type: "unit", value: "microseconds", unit: "microsecond" }, { type: "literal", value: ", " }, { type: "integer", value: "789", unit: "nanosecond" }, { type: "literal", value: " ", unit: "nanosecond" }, { type: "unit", value: "nanoseconds", unit: "nanosecond" }, ]; resolvedOptions​ resolvedOptions() 方法获取初始化 Intl.DurationFormat 对象的配置对象,属性同 Intl.DurationFormat 构造函数的 options。 Intl.RelativeTimeFormat​ Intl.RelativeTimeFormat 对象用于格式化相对时间(如"3 天前"、"2 个月后"等),实现语言敏感的相对时间格式化。 构造函数​ 使用 new Intl.RelativeTimeFormat(locales, options) 创建 Intl.RelativeTimeFormat 对象,该函数接收两个可选参数: locales:Unicode BCP 47 规范的语言标签字符串或 Intl.Locale 对象,或者它们各自组成的数组; options: localeMatcher:要使用的区域设置匹配算法,值可以是 "lookup" 和 "best fit",默认值为 "best fit"; numberingSystem:编号系统,例如 "arab" 表示阿拉伯数字,可以使用 Intl.supportedValuesOf("numberingSystem") 查看所有枚举值; style:消息的格式化样式,默认值为 "long" "long":完整样式,例如 "in 1 month"; "short":缩写样式,例如 "in 1 mo."; "narrow":最短样式,例如 "in 1 mo"; numeric:数字格式控制,默认值为 "always" "always":总是使用数字格式,例如 "1 day ago"; "auto":自动选择,当可能时使用更自然的表达,例如 "yesterday"; 方法​ Intl.RelativeTimeFormat 包含一个静态方法和三个实例方法: supportedLocalesOf​ Intl.RelativeTimeFormat.supportedLocalesOf(locales, options) 静态方法返回提供的区域设置中支持的区域设置数组,无需使用运行时的默认区域设置。 format​ format(value, unit) 方法用来将指定间隔的时间格式化,注意这个相对不是相对于当前时间,它只是简单地把你给的数字 + 单位变成合适的相对时间描述。 参数: value:必填,数字,表示相对时间的数值,其含义取决于设置的 unit,比如 unit 为 "day",如果 value 为正数,表示几天前;如果 value 为负数,则表示几天后; unit:必填,字符串,表示时间单位,可选值为: "year" 或 "years":年 "quarter" 或 "quarters":季度 "month" 或 "months":月 "week" 或 "weeks":周 "day" 或 "days":日 "hour" 或 "hours":小时 "minute" 或 "minutes":分钟 "second" 或 "seconds":秒 返回值: string:返回格式化后的相对时间字符串。 const rtf = new Intl.RelativeTimeFormat("zh-CN", { numeric: "auto" }); console.log(rtf.format(-1, "day")); // 昨天 console.log(rtf.format(0, "day")); // 今天 console.log(rtf.format(1, "day")); // 明天 console.log(rtf.format(-1, "week")); // 上周 const rtfNumeric = new Intl.RelativeTimeFormat("zh-CN", { numeric: "always" }); console.log(rtfNumeric.format(-1, "day")); // 1天前 formatToParts​ formatToParts(value, unit) 将相对时间转换成每一部分组成的对象数组。 参数同上,返回一个包含 type 和 value 的对象数组: const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); rtf.formatToParts(-1, "day"); // [ // { type: 'literal', value: 'yesterday' } // ] rtf.formatToParts(1, "day"); // [ // { type: 'literal', value: 'tomorrow' } // ] const rtfNumeric = new Intl.RelativeTimeFormat("en", { numeric: "always" }); rtfNumeric.formatToParts(-1, "day"); // [ // { type: 'integer', value: '1' }, // { type: 'literal', value: ' day ago' } // ] resolvedOptions​ resolvedOptions() 方法获取初始化 Intl.RelativeTimeFormat 对象的配置对象,属性同 Intl.RelativeTimeFormat 构造函数的 options。 Intl.NumberFormat​ Intl.NumberFormat 对象用于对数字进行格式化转换,包括小数、百分比、货币以及单位等多种样式。 构造函数​ 使用 new Intl.NumberFormat(locales, options) 创建 Intl.NumberFormat 对象,可以不使用 new 而直接调用,该函数接收两个可选参数: locales:Unicode BCP 47 语言标签字符串、Intl.Locale 对象,或它们各自组成的数组; options: localeMatcher:区域设置匹配算法,"lookup" 或 "best fit",默认 "best fit"; numberingSystem:编号系统,例如 "latn"、"arab" 等; style:格式化样式,默认 "decimal" "decimal":十进制数字(默认); "percent":百分比(输入 0.5 => 输出 50%); "currency":货币格式(需配合 currency); "unit":单位格式(需配合 unit); currency:ISO 4217 货币代码(如 "CNY"、 "USD"、"CNY"),当 style 为 "currency" 时必填; currencyDisplay:货币的显示形式 "symbol":默认值,表示使用本地化货币标识符,例如人民币是 ¥; "code":表示使用 ISO 4217 货币代码; "narrowSymbol":表示使用货币标识+数值,例如 $100; "name":使用货币名称,例如人民币是人民币,美元则是 "dollar"; currencySign:会计记号 "standard":默认值,使用常规的货币符号格式; "accounting":在负数金额时,会用会计记账常用的 括号 来表示,而不是负号,例如 ($1,234.00),这在一些公司的财报中比较常见 unit:单位(如 "kilometer"、"celsius"、"byte"、"kilometer-per-hour"),当 style 为 "unit" 时必填; unitDisplay:单位显示形式 "long":在数值和单位之间使用空格,且单位为完整名称,例如 16 litres(16 升); ``"short"`:默认值,在数值和单位之间使用空格,例如 16 l; "narrow":在数值和单位之间不使用空格,例如 16l; notation:记数法 "standard":普通数字格式,例如 "1,234,567"; "scientific":科学计数法; "engineering":工程计数法,指数是 3 的倍数; "compact":紧凑记法(缩写),常用于显示大数,例如 1.2M(million) compactDisplay:紧凑记数法的显示样式,"short" 或 "long"(仅当 notation: "compact" 时有效); useGrouping:是否显示分组分隔符,例如千分位逗号分隔 "always":总是显示; "auto":默认值,根据当前语言环境选择是否显示; "min2":当组中至少有2位数字时,显示分组分隔符; true 或 false:显示或不显示 signDisplay:是否显示数字的正负号 "auto":默认值,只在负数时显示,包括 -0; "always":总是显示; "exceptZero":除了 0 正负数字的符号都显示; "negative":只在负数显示; "never":不显示 minimumIntegerDigits:最少整数位数,可以设置为 1~21,默认为 1;在格式化时,小于此数字的整数位数的值将在左侧填充 0; minimumFractionDigits、maximumFractionDigits:最少/最多小数位数,可以设置为 0~100;普通数字和百分比格式的默认值为 0;货币格式的默认值是 ISO 4217 货币代码列表提供的值; minimumSignificantDigits、maximumSignificantDigits:最少/最多有效数字位数,可以设置为 1~21,默认为 1; roundingMode:舍入模式,"ceil"、"floor"、"expand"、"trunc"、"halfCeil"、"halfFloor"、"halfExpand"、"halfTrunc"、"halfEven"; roundingPriority:"auto"、"morePrecision"、"lessPrecision"; roundingIncrement:相对于计算出的舍入幅度应进行舍入的增量,允许值如 1、2、5、10、20、25、50、100、200、250、500、1000、2000、2500、5000,默认值为 1; trailingZeroDisplay:在整数上显示尾随零的方式 "auto":根据 minimumFractionDigits 和 minimumSignificantDigits 保持尾随零; "stripIfInteger":如果分数数字全部为零,则删除分数数字。 方法​ Intl.NumberFormat 提供一个静态方法以及多个实例方法: supportedLocalesOf​ 同上 format​ format(number) 将数字格式化为字符串。 参数: number:可以是 Number、BigInt 或字符串 返回指定语言环境下的字符串,示例: 基础十进制格式化 const nf = new Intl.NumberFormat("zh-CN"); console.log(nf.format(1234567.89)); // 1,234,567.89 百分比 const percent = new Intl.NumberFormat("en", { style: "percent", maximumFractionDigits: 1, }); console.log(percent.format(0.756)); // 75.6% 货币 const cny = new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY", }); console.log(cny.format(1234.5)); // ¥1,234.50 const usdAccounting = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", currencySign: "accounting", }); console.log(usdAccounting.format(-1234.5)); // ($1,234.50) 单位 const speed = new Intl.NumberFormat("en", { style: "unit", unit: "kilometer-per-hour", unitDisplay: "short", }); console.log(speed.format(88)); // 88 km/h 紧凑记数法 const compact = new Intl.NumberFormat("en", { notation: "compact", compactDisplay: "short", }); console.log(compact.format(12345)); // 12K formatToParts​ formatToParts(number) 将格式化结果拆分为各部分的对象数组,包括以下属性: type:类型 value:值字符串 const cny = new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY", }); console.log(cny.formatToParts(1234.5)); [ { "type": "currency", "value": "¥" }, { "type": "integer", "value": "1" }, { "type": "group", "value": "," }, { "type": "integer", "value": "234" }, { "type": "decimal", "value": "." }, { "type": "fraction", "value": "50" } ] formatRange​ formatRange(start, end) 用于格式化数字区间(如 3–5 个单位),返回格式化的字符串。 const nf = new Intl.NumberFormat("en-US", { style: "currency", currency: "CNY", }); console.log(nf.formatRange(3, 5)); // CN¥3.00–5.00 formatRangeToParts​ formatRangeToParts(start, end) 返回数字区间的分段信息,其中 type 和 value 同 formatToParts,source 属性用来去分当前分段的来源是属于传入的 start 还是 end 参数的部分,值可以是以下三种: startRange:当前分段属于参数 start 的; endRange:当前分段属于参数 end; shared:共享部分;例如,货币符号、“-” 分隔符等 const nf = new Intl.NumberFormat("en-US", { style: "currency", currency: "CNY", }); console.log(nf.formatRange(3, 5)); [ { "type": "currency", "value": "CN¥", "source": "shared" }, { "type": "integer", "value": "3", "source": "startRange" }, { "type": "literal", "value": "–", "source": "shared" }, { "type": "integer", "value": "5", "source": "endRange" } ] resolvedOptions​ resolvedOptions() 返回初始化对象时解析得到的配置对象,其属性同初始化对象时传入的配置保持一致。 浏览器兼容性​ 现代主流浏览器与 Node.js 均已广泛支持 Intl.NumberFormat 的核心能力;部分较新的选项(如 roundingMode、trailingZeroDisplay 等)在旧版本环境中可能不受支持,建议参考 MDN 兼容性表并按需做回退与检测。 Intl.ListFormat​ Intl.ListFormat 是一个语言相关的列表格式化构造器,用于以给定语言环境的习惯将字符串数组格式化为自然语言列表。 构造函数​ 使用 new Intl.ListFormat(locales, options) 创建 Intl.ListFormat 对象: locales:Unicode BCP 47 语言标签字符串、Intl.Locale 对象,或它们各自组成的数组; options: localeMatcher:区域设置匹配算法,"lookup" 或 "best fit"; type:列表类型,默认 "conjunction" "conjunction":合并(和)列表,如 "A, B, and C"; "disjunction":析取(或)列表,如 "A, B, or C"; "unit":单位值的列表,如 "5 pounds, 12 ounces"; style:风格,默认 "long" "long":长格式; "short":短格式; "narrow":最窄格式(当 style 为 "narrow" 时,type 只能为 "unit")。 方法​ supportedLocalesOf​ Intl.ListFormat.supportedLocalesOf(locales, options) 返回受支持的区域设置列表。 format​ format(list) 将多个字符串组合在一起格式化为指定的语言列表。 参数: list:一个可迭代的对象,例如字符串数组。 返回组合后的字符串。 const list = ["Motorcycle", "Bus", "Car"]; console.log( new Intl.ListFormat("en-GB", { style: "long", type: "conjunction" }).format( list, ), ); // Motorcycle, Bus and Car console.log( new Intl.ListFormat("en-GB", { style: "short", type: "disjunction" }).format( list, ), ); // Motorcycle, Bus or Car console.log( new Intl.ListFormat("en-GB", { style: "narrow", type: "unit" }).format(list), ); // Motorcycle Bus Car formatToParts​ formatToParts(list: string[]) 返回组成该列表的分段对象数组,包含类型与值。 const fruits = ["Apple", "Orange", "Pineapple"]; const myListFormat = new Intl.ListFormat("en-GB", { style: "long", type: "conjunction", }); console.table(myListFormat.formatToParts(fruits)); [ { "type": "element", "value": "Apple" }, { "type": "literal", "value": ", " }, { "type": "element", "value": "Orange" }, { "type": "literal", "value": " and " }, { "type": "element", "value": "Pineapple" } ] resolvedOptions​ resolvedOptions() 返回初始化对象时解析得到的配置对象,其属性同初始化对象时传入的配置保持一致。 浏览器兼容性​ 现代浏览器已支持 Intl.ListFormat。如果需要在不支持的环境中使用,可采用 polyfill(例如 FormatJS 提供的 polyfill)。 Intl.PluralRules​ Plural 译为复数,Intl.PluralRules 对象用于按照特定语言的复数规则对数值进行分类(如 one、few、many 等)。 不同的语言使用不同的形式来表示事物的复数(基数)和事物的顺序(序数)。例如英语区分单数和复数的常用形式是在事物单名词后加一个字母 s(有些不可数名词不加);中文则只有一种,无论是一个还是多个都不会对名词进行修改;还有英语用四种表示顺序的形势:"th", "st", "nd", "rd", 而中文和阿拉伯语都只有一种表达序数的形式。 构造函数​ 使用 new Intl.PluralRules(locales, options) 创建 Intl.PluralRules 对象: locales:Unicode BCP 47 语言标签字符串、Intl.Locale 对象,或它们各自组成的数组; options: localeMatcher:区域设置匹配算法,"lookup" 或 "best fit"; type:复数类别类型,默认 "cardinal" "cardinal":基数复数规则(0, 1, 2, ...); "ordinal":序数复数规则(第一、第二、第三等,或者 "1st", "2nd", "3rd"); minimumIntegerDigits、minimumFractionDigits、maximumFractionDigits、minimumSignificantDigits、maximumSignificantDigits、roundingPriority、roundingIncrement、roundingMode:同 Intl.NumberFormat 对象的参数,用来控制数值格式化时的精度,影响分类结果(可选)。 方法​ supportedLocalesOf​ Intl.PluralRules.supportedLocalesOf(locales, options) 静态方法返回受支持的区域设置。 select​ select(number) 返回传入数字的指定语言环境下的复数类别字符串,返回值只会是这几种:zero、one、 two、 few、 many、或 other。如果一个语言的没有单复数区别形式,例如中文,那么只会返回 other。不同语言的返回值可以参考 LDML 语言负数规则定义表。 // 阿拉伯语有更丰富的复数类别 const prAr = new Intl.PluralRules("ar-EG"); console.log(prAr.select(0)); // 'zero' console.log(prAr.select(1)); // 'one' console.log(prAr.select(2)); // 'two' console.log(prAr.select(6)); // 'few' console.log(prAr.select(18)); // 'many' // 中文则只会返回 other const zh = new Intl.PluralRules("zh-CN"); console.log(prAr.select(0)); // 'other' console.log(prAr.select(1)); // 'other' console.log(prAr.select(2)); // 'other' console.log(prAr.select(6)); // 'other' console.log(prAr.select(18)); // 'other' 通用的格式化文本段落中复数格式的方法: // 在消息格式化中使用 function formatItems(count: number, lang = "en") { const pr = new Intl.PluralRules(lang); const rules: Record<string, string> = { one: `${count} item`, other: `${count} items`, }; return rules[pr.select(count)] ?? rules.other; } selectRange​ selectRange(start, end) 对数值范围进行分类,返回 zero、one、 two、 few、 many、或 other。 new Intl.PluralRules("sl").selectRange(102, 201); // 'few' new Intl.PluralRules("pt").selectRange(102, 102); // 'other' resolvedOptions​ resolvedOptions() 返回初始化对象时解析得到的选项。 浏览器兼容性​ 现代浏览器已支持 Intl.PluralRules 的核心功能。不同语言的可用类别由运行时的 CLDR 数据决定;如需在旧环境中使用,可参考社区 polyfill(如 FormatJS)。 Intl.Segmenter​ Intl.Segmenter 对象用于将指定语言环境下字符串拆分为词、字或句级别的有意义片段。 注意 Intl.Segmenter 对象是 ES 2024 新加入的 API,目前仅在较新版本浏览器中支持,例如 chrome > 87 构造函数​ 使用 new Intl.Segmenter(locales, options) 创建 Intl.Segmenter 对象: locales:Unicode BCP 47 语言标签字符串、Intl.Locale 对象,或它们各自组成的数组; options: localeMatcher:区域设置匹配算法,"lookup" 或 "best fit"; granularity:拆分粒度,必填,取值: "grapheme":按照 grapheme 集群(在计算机和语言学里,grapheme 指的是人类直观上认为的一个字符,而不是底层存储的码点或字节)边界将输入分割成段 "word":根据语言环境的决定,在单词边界处将输入分割成段。 "sentence":根据语言环境的决定,在句子边界处将输入分割成段。 方法​ supportedLocalesOf​ Intl.Segmenter.supportedLocalesOf(locales, options) 返回受支持的区域设置。 segment​ segment(input: string) 根据当前设置对文本进行分段,返回 Segments 可迭代对象(可以使用 for…of 遍历或者使用 …转换成数组)。 Segments 迭代器的对象属性如下: segment:该片段的字符串值; index:该片段在原始输入中的起始索引; input:原始输入字符串; isWordLike:当 granularity 为 "word" 时,指示该分段是否是类词的标记(如字母数字词)。 const segmenter = new Intl.Segmenter("zh-CN", { granularity: "word" }); const text = "今天天气很好。我打算去公园散步,然后喝杯咖啡。"; // 使用 segment 方法 const segments = segmenter.segment(text); // segment 返回一个可迭代对象,可以遍历 for (const { segment, index, isWordLike } of segments) { console.log({ segment, index, isWordLike }); } {segment: '今天', index: 0, isWordLike: true} {segment: '天气', index: 2, isWordLike: true} {segment: '很好', index: 4, isWordLike: true} {segment: '。', index: 6, isWordLike: false} {segment: '我', index: 7, isWordLike: true} {segment: '打算', index: 8, isWordLike: true} {segment: '去', index: 10, isWordLike: true} {segment: '公园', index: 11, isWordLike: true} {segment: '散步', index: 13, isWordLike: true} {segment: ',', index: 15, isWordLike: false} {segment: '然后', index: 16, isWordLike: true} {segment: '喝杯', index: 18, isWordLike: true} {segment: '咖啡', index: 20, isWordLike: true} {segment: '。', index: 22, isWordLike: false} resolvedOptions​ resolvedOptions() 返回初始化时解析得到的选项。 总结​ Intl 对象为 JavaScript 提供了强大的国际化能力,使得开发多语言应用变得更加简单和规范。通过合理使用 Intl 提供的各种格式化器,可以确保应用在不同语言环境下的正确显示和用户体验。

2025/9/4
articleCard.readMore

POML 一种管理 Prompt 的工具

POML 是微软团队开源的一款管理 prompt 的工具,下面一起来探索下使用方式。 POML​ 什么是 POML​ POML(Prompt Orchestration Markup Language),提示词编排标记语言,使用 .poml 扩展名的文件来定义。主要特点如下: 结构化提示标记:POML 采用 XML 标签语法来约束提示词的结构,例如 <role>、<task>、<output-format> 等; <poml> <role>You are a patient teacher explaining concepts to a 10-year-old.</role> <task>Explain the concept of photosynthesis using the provided image as a reference.</task> <output-format> Keep the explanation simple, engaging, and under 100 words. Start with "Hey there, future scientist!". </output-format> </poml> 超文本标记语法:类似于 HTML,POML 能包含超越文本的数据格式,例如表格、图片,视频,音频等数据;甚至能通过 <stylesheet> 定义或内联属性修改样式; 集成模板引擎:POML 内置模板引擎,支持变量( {{ }} )、循环( for )、条件( if )和变量定义( <let> ),用于动态生成复杂的数据驱动提示; 软件开发工具包:提供 Nodejs 使用的 TypeScript SDK 和 Python SDK,也提供了 VS Code 扩展。 POML 的语法​ 对于前端开发来说,POML 的语法看起来相当简单,毕竟熟悉了 HTML、React 这类标签语法,所以使用 POML 只需要学习内部定义的一些标签的名称即可。 在 POML 的 Components 这篇文档中定义了所有 POML 支持的标签名称,常见的有以下这些: poml​ poml 主要用于提示符的根元素,也可以在其他位置使用。如果第一个元素是纯文本内容,则将添加<poml syntax="text">;如果第一个元素是POML 组件,则自动在顶层添加 <poml syntax="markdown">。<poml>标签可以设置以下属性: syntax:值可以是 markdown, html, json, yaml, xml, text; speaker:当前内容的角色,可以是 human, ai, system; className:定义 markdown 渲染的样式; tokenLimit:限制 prompt 的 token 数量,有些 LLM 有输入限制,超出限制则会被截断; charLimit:内容字符长度限制; priority:token 限制是从父级向下应用的——先处理子节点的限制/优先级,再按 priority(优先级)排序、删除低优先级内容,最后再对剩下的内容进行截断。也就是说,你可以在 <poml tokenLimit="N"> 上控制整个块的 token 预算,并用 priority 控制哪些子节点先保留。 role​ <role>标签定义 AI 角色,可以使用caption定义role标签渲染的 markdown title 的文本,默认显示为 Role,示例如下: <role>You are a Senior Front end Engineer</role> task​ <task> 表示希望语言模型执行的操作。<task> 通常是简洁明了的陈述,还可以包括完成任务的步骤或说明列表。 <task>Cook a recipe on how to prepare a beef dish.</task> output-format​ <output-format>标签表示输出格式,可以是特定格式,例如 JSON,XML 或 CSV,或者是 story,图表或指令的步骤等通用格式,但是不要指定 PDF 或者视频。示例: <output-format>Respond with a JSON without additional characters or punctuations.</output-format> h​ h 或者 <header> 用来定义标题。 <h syntax="markdown">Section Title</h> p​ <p>标签用于定义段落。 <p>Contents of the paragraph.</p> b​ <b>标签用于定义加粗文本。 <p><b>Task:</b> Do something.</p> cp​ <cp> 表示 CaptionedParagraph,也就是标题段落,用来定义一个带标题的段落,这样就可以把其他元素包含进去。示例: <cp caption="USER REQUEST"> <div whiteSpace="pre"> {{ prompt }} </div> </cp> list​ <list> 和 <item> 标签在一起配合使用,用来定义列表,比如程序的功能模块,AI 处理步骤等;可以使用 listStyle 属性来设置列表在 markdown 渲染的样式,可以设置为 star, dash, plus, decimal, latin。示例: <list listStyle="decimal"> <item>Item 1</item> <item>Item 2</item> </list> <list> 和 <item> 可以嵌套使用,并且通常放在 <cp> 内部使用,例如: <cp caption="PROCEDURE (MUST follow strictly)"> <list listStyle="latin"> <item> <b>Clarify Scope</b> <list listStyle="dash"> <item>Restate the objective in your own words; if unclear, ask one concise question.</item> <item>Sketch a high-level plan of how to meet the objective.</item> </list> </item> <item> <b>Locate exact code edit point</b> <list listStyle="dash"> <item>Work <b>only</b> inside the given code files.</item> <item>Quote the <b>smallest</b> relevant snippet (line numbers) you need to change.</item> </list> </item> </list> </cp> code​ <code>用于定义行内或者代码块。示例: 行内代码 <code inline="true">const x = 42;</code> 代码块 <code lang="javascript"> const x = 42; </code> object​ <obj> 表示对象内容或者 JSON。 <Object syntax="json" data="{ key: 'value' }" /> table​ <table> 用来定义表格数据或者 excel。 <table records="{{[{ name: 'Alice', age: 20 }, { name: 'Bob', age: 30 }]}}" /> excel <table src="data.xlsx" parser="excel" maxRecords="10" syntax="csv" /> document​ <document> 表示 PDF,TXT 或 DOCX 之类的外部文档,使用 multimedia 属性表示是否为多媒体。 <Document src="sample.docx" multimedia="false"/> webpage​ <webpage> 表示展示网页内容,可以使用 selector 属性来定义一个 CSS 选择器,用于从页面中提取特定内容显示。 <webpage url="https://example.com" selector="main article" /> img​ <img> 表示图像。可以使用 maxWidth、maxHeight、resize 来定义图片的尺寸。 <img src="path/to/image.jpg" alt="Image description" position="bottom" /> audio​ audio标签用于输入音频,可使用src或者base64属性来定义文件链接,如果定义src,那么文件会被转换成base64。示例: <Audio src="path/to/audio.mp3" /> 或 <Audio src="data:audio/mpeg;base64,[data]" /> example​ <example>标签用于提供示例上下文,帮助模型了解预期的输入和输出。一般和 <input> 和 <output> 一起使用,<input> 用来定义输入内容,<output> 定义示例问题的输出。示例: <example> <input>What is the capital of France?</input> <output>Paris</output> </example> vscode 插件​ VS Code POML 扩展 能够帮助开发者在 VSCode 内使用 POML 语法编辑 prompt,该扩展主要包含以下功能: 在 .poml 后缀文件内编辑时提供 POML 语法提示; 提供 vscode 侧边栏面板用来管理 POML 文件; 对 POML 进行可视化渲染。 TypeScript SDK​ pomljs 是 POML 提供的 TypeScript SDK,其内部包含: 基于 React 的 JSX 组件:所有 POML 支持的标签都有对应的 JSX 组件可以使用; 提供 read 和 write 方法来读取和渲染 JSX 编写的 Prompt。 备注 0.0.8 版本及以下的 pomljs 存在组件和类型定义导出声明的问题,目前不建议使用。

2025/9/2
articleCard.readMore

如何使用 Translator Web API

LanguageDetector 和 Translator 是分别基于 AI 来检测和翻译 Web 文本的 API,由 W3C 的机器学习社区组(Web Machine Learning Community Group)于 2025/02/17 日发布的草案定义。该草案由 Chrome 内置 AI 团队的开发者 Domenic Denicola 提出,目前也仅在 Chrome >= 138 版本才稳定支持( Chrome 131~137 版本实验性支持)。下面就来探索下该 API 的使用。 API 介绍​ LanguageDetector​ LanguageDetector 是用于语言文本检测的 API,对检测的文本提供符合 BCP 47 language tag 规范的结果,例如en、zh、jp等。 注意 LanguageDetector 仅在使用了 HTTPS 的网页中支持。 availability()​ 使用availability()方法来检测 LanguageDetector API 的可用性。 async LanguageDetector.availability(options) options包含一个属性: expectedInputLanguages: string[]:必填,希望支持的多个语言标签,默认为["en"]参考 BCP 47 language tag。 availability()方法返回Promise字符串枚举值: available:支持; downloadable:支持,但是需要下载 AI 模型或者模型需要的数据; downloading:支持,但必须完成正在进行的下载才能继续; unavailable:不支持。 create()​ 使用create方法来创建一个 Language Detector 的实例, async LanguageDetector.create(options) 参数options包含三个属性: expectedInputLanguages: string[]:必填,预期检测文本符合的语言标签范围,有助于提高语言检测的准确性。默认为["en"]; monitor: CreateMonitor:CreateMonitor实例,用于监测下载 AI 模型或者数据的进度; signal: AbortSignal:AbortSignal实例,用于中断检测。 create()方法返回 LanguageDetector 的实例对象,其包含两个属性和三个方法: expectedInputLanguages: string[]:使用 LanguageDetector 预期检测的语言标签范围数组,同传递给create方法的参数值; inputQuota:一个数字,表示LanguageDetector一次最大能检测的文本的配额,可能是受限于内存硬件限制或者 JS 最大可处理的字符串长度限制的数值;也可能是 AI 模型限制的 token 数量等。因为翻译或语言检测的输入可能过大,以至于底层 AI 模型无法处理,inputQuota 则有助于检测是否输入文本过长。 detect(input, options):异步方法,用于对文本进行检测,可传递AbortSignal参数来中断;返回一个对象数组,每个对象包含两个属性: detectedLanguage:检测到的 BCP 语言标签; confidence:匹配准确度,从0~1,数组元素的顺序会按照confidence从大到小排列,也就是最匹配的语言是返回的第一个元素,例如下面样啊 measureInputUsage(input, options):异步方法,检测给定文本输入的语言检测操作将使用多少输入配额;返回数值。只有当measureInputUsage返回的值小于inputQuota时,才能使用detect方法进行检测,否则会抛出QuotaExceededError异常; destroy():销毁 LanguageDetector 实例。 Translator​ Translator API 用于语言翻译。 注意 Translator 仅在使用了 HTTPS 的网页中支持。 availability()​ 使用availability()方法来检测 Translator API 的可用性。 async Translator.availability(options) options包含两个属性: sourceLanguage: string:必填,输入文本的预期语言标签,例如en; targetLanguage: string:必填,目标翻译语言标签,例如zh。 availability()方法返回Promise字符串枚举值: available:支持; downloadable:支持,但是需要下载 AI 模型或者模型需要的数据; downloading:支持,但必须完成正在进行的下载才能继续; unavailable:不支持。 create()​ 使用create方法来创建一个 Translator 的实例。 async Translator.create(options) 参数options包含两个属性: sourceLanguage: string:必填,输入文本的预期语言标签,例如en; targetLanguage: string:必填,目标翻译语言标签,例如zh。 monitor: CreateMonitor:CreateMonitor实例,用于监测下载 AI 模型或者数据的进度; signal: AbortSignal:AbortSignal实例,用于中断翻译。 create()方法返回 Translator 的实例对象,其包含三个属性和三个方法: sourceLanguage: string:输入文本的预期语言标签; targetLanguage: string:目标翻译语言标签; inputQuota:一个数字,表示Translator一次最大能翻译的文本的配额。 translate(input, options):异步方法,用于对文本进行翻译,可传递AbortSignal参数来中断;返回翻译后的字符串。 translateStreaming(input, options):异步方法,生成输入字符串的翻译流,返回一个ReadableStream对象。 measureInputUsage(input, options):异步方法,检测给定文本输入的翻译操作将使用多少输入配额;返回数值。只有当measureInputUsage返回的值小于inputQuota时,才能使用translate方法进行翻译,否则会抛出QuotaExceededError异常; destroy():销毁 Translator 实例。 为什么需要 LanguageDetector 和 Translator​ LanguageDetector 和 Translator 由 Chrome 内置 AI 开发团队推动,Chrome 内置 AI 的能力是一种客户端 AI。传统稳定的翻译方式一般将事先完成的网站翻译内容存储于云端,或者使用在线 API 进行实时翻译,而客户端 AI 驱动的翻译能从以下几个方面带来收益: 敏感数据的本地处理:客户端 AI 可以提升您的隐私保护能力。例如,如果您处理的是敏感数据,可以向用户提供采用端到端加密的 AI 功能。 流畅的用户体验:在某些情况下,无需往返服务器即可提供近乎即时的结果。客户端 AI 可以决定功能是否可行,以及用户体验是否理想。 更好地利用 AI:用户的设备可以承担部分处理负荷,从而更好地利用各项功能。例如,如果您提供高级 AI 功能,则可以使用客户端 AI 预览这些功能,以便潜在客户了解您产品的优势,而您无需支付额外费用。这种混合方法还可以帮助您管理推理费用,尤其是在经常使用的用户流上。 离线使用 AI:即使没有网络连接,用户也可以使用 AI 功能。这意味着您的网站和 Web 应用可以在离线状态下或在连接状况不稳定的情况下正常运行。 当然另一方面,Chrome 开发团队为了扩大 Chrome 的影响力和推广 Google 的云业务,也是努力将 Chrome 内置 AI 的这些 API 进行 W3C 标准化。 使用​ TypeScript 类型​ LanguageDetector 和 Translator 目前尚未在 TypeScript 类型中支持,因此需要本地项目中自定义: interface Window { LanguageDetector: LanguageDetectorConstructor; Translator: TranslatorConstructor; } interface LanguageDetectorConstructor { availability(options: { expectedInputLanguages: string[]; }): Promise<"available" | "downloadable" | "downloading" | "unavailable">; create(options?: LanguageDetectorOptions): Promise<LanguageDetectorInstance>; } interface TranslatorConstructor { availability(options: { sourceLanguage: string; targetLanguage: string; }): Promise<"available" | "downloadable" | "downloading" | "unavailable">; create(options: TranslatorOptions): Promise<TranslatorInstance>; } interface LanguageDetectorOptions { /** * 预期检测文本符合的语言标签范围 * 提高语言检测的准确性 */ expectedInputLanguages: string[]; /** * 用于监测下载 AI 模型或者数据的进度 */ monitor?: CreateMonitor; /** * 用于中断检测 */ signal?: AbortSignal; } interface LanguageDetectionResult { /** * 检测到的 BCP 语言标签 */ detectedLanguage: string; /** * 匹配准确度,从 0 到 1 */ confidence: number; } interface TranslatorOptions { /** * 输入文本的预期语言标签 */ sourceLanguage: string; /** * 目标翻译语言标签 */ targetLanguage: string; /** * 用于监测下载 AI 模型或者数据的进度 */ monitor?: CreateMonitor; /** * 用于中断翻译 */ signal?: AbortSignal; } interface TranslatorInstance { /** * 输入文本的预期语言标签 */ sourceLanguage: string; /** * 目标翻译语言标签 */ targetLanguage: string; /** * 一次最大能翻译的文本配额 */ inputQuota: number; /** * 翻译输入文本 * @param input 要翻译的文本 * @param options 可选参数,包括中断信号 * @returns 翻译后的字符串 */ translate(input: string, options?: { signal?: AbortSignal }): Promise<string>; /** * 生成输入字符串的翻译流 * @param input 要翻译的文本 * @param options 可选参数,包括中断信号 * @returns 翻译流的 ReadableStream 对象 */ translateStreaming(input: string, options?: { signal?: AbortSignal }): ReadableStream<string>; /** * 检测翻译操作将使用多少输入配额 * @param input 要检测的文本 * @param options 可选参数,包括中断信号 * @returns 使用的配额数值 */ measureInputUsage(input: string, options?: { signal?: AbortSignal }): Promise<number>; /** * 销毁 Translator 实例 */ destroy(): void; } interface LanguageDetectorInstance { /** * 预期检测文本符合的语言标签范围 */ expectedInputLanguages: string[]; /** * 一次最大能检测的文本配额 */ inputQuota: number; /** * 检测输入文本的语言 * @param input 要检测的文本 * @param options 可选参数,包括中断信号 * @returns 检测结果数组 */ detect(input: string, options?: { signal?: AbortSignal }): Promise<LanguageDetectionResult[]>; /** * 检测语言检测操作将使用多少输入配额 * @param input 要检测的文本 * @param options 可选参数,包括中断信号 * @returns 使用的配额数值 */ measureInputUsage(input: string, options?: { signal?: AbortSignal }): Promise<number>; /** * 销毁 LanguageDetector 实例 */ destroy(): void; } 使用示例​ 请参考 demo 仓库 async function translateUnknownCustomerInput(textToTranslate, targetLanguage) { const detectorAvailability = await LanguageDetector.availability(); // 获取网页原始语言 let sourceLanguage = document.documentElement.lang; // 使用 LanguageDetector 检测语言 if (detectorAvailability !== "unavailable") { if (detectorAvailability !== "available") { console.log("Language detection is available, but something will have to be downloaded. Hold tight!"); } const detector = await LanguageDetector.create(); const [bestResult] = await detector.detect(textToTranslate); if (bestResult.detectedLanguage === "und" || bestResult.confidence < 0.4) { // 无法检测语言,则返回原文本 return textToTranslate; } sourceLanguage = bestResult.detectedLanguage; } // 使用 Translator 翻译文本 const translatorAvailability = await Translator.availability({ sourceLanguage, targetLanguage }); if (translatorAvailability === "unavailable") { console.warn("Translation is not available. Falling back to cloud API."); return await useSomeCloudAPIToTranslate(textToTranslate, { sourceLanguage, targetLanguage }); } if (translatorAvailability !== "available") { console.log("Translation is available, but something will have to be downloaded. Hold tight!"); } const translator = await Translator.create({ sourceLanguage, targetLanguage }); return await translator.translate(textToTranslate); } 翻译效果​ 以罗伯特·弗罗斯特(Robert Frost)的经典诗《未选择的路》(The Road Not Taken)的中文翻译来对比效果,说实话这个翻译的结果,基本是基于一个单词一个单词翻译完拼接在一起的结果😂,完全没考虑语境。

2025/8/25
articleCard.readMore

eslint 支持多线程并发 Lint

ESLint 于 2025/08/15 日合并了一个 PR:feat: multithread linting,这个 PR 解决了一个长达十年之久的 issue,是一个非常大的优化项。下面一起来看下这个 PR 改动了什么。 PR 改动​ 在 feat: multithread linting 这个 PR 改动的文件中,主要改动如下: ESLint nodejs api 新增 concurrency 属性; ESLint CLI 新增 concurrency 参数; concurrency 可以设置为 off、auto 和一个数字;默认值为 off,也就是默认不启动多线程。 根据这个 PR 开发者的描述,启动多线程可以让 ESLint 在大型项目多个文件的速度表现上提升 30% 以上,虽然比不上用 Rust 或者 Go 编写的下一代 Lint 工具,但是相较于 ESLint 本身是非常大的提升了。 何时可用​ 截止到这篇文章的时间,ESLint 目前最新的版本 9.33.0 尚未包含这个 PR 改动的内容,我估计等下个版本 ESLint 就会包含这项改动了,也就是 9.34.0 版本。 ESLint 是如何计算 worker 线程数的​ 根据 PR 改动的代码细节,找到了 lib/eslint/eslint.js 这个文件中根据设置的 concurrency 对 worker 线程计算的方法,在 287 行。 当配置 concurrency: auto 时,会使用 Node 的 os.availableParallelism() 方法获取进程真正可用的最大并行度,会考虑容器限制(Docker、K8s 限制 CPU quota 的情况); 当配置 concurrency 为一个数字时,会对比 concurrency 和 lint 文件数量,去最小值; 当计算出使用多少个 worker 时,就会使用 node:worker_threads 模块的 Worker 创建多个 worker 实例。 /** * 计算使用多少个 worker * @param {number | "auto" | "off"} concurrency The normalized concurrency setting. * @param {number} fileCount The number of files to be linted. * @param {{ availableParallelism: () => number }} [os] Node.js `os` module, or a mock for testing. * @returns {number} The effective number of worker threads to be started. A value of zero disables multithread linting. */ function calculateWorkerCount( concurrency, fileCount, { availableParallelism } = os, ) { let workerCount; switch (concurrency) { case "off": return 0; case "auto": { workerCount = Math.min( availableParallelism() >> 1, Math.ceil(fileCount / AUTO_FILES_PER_WORKER), ); break; } default: workerCount = Math.min(concurrency, fileCount); break; } return workerCount > 1 ? workerCount : 0; } /** * The smallest net linting ratio that doesn't trigger a poor concurrency warning. * The net linting ratio is defined as the net linting duration divided by the thread's total runtime, * where the net linting duration is the total linting time minus the time spent on I/O-intensive operations: * **Net Linting Ratio** = (**Linting Time** – **I/O Time**) / **Thread Runtime**. * - **Linting Time**: Total time spent linting files * - **I/O Time**: Portion of linting time spent loading configs and reading files * - **Thread Runtime**: End-to-end execution time of the thread * * This value is a heuristic estimation that can be adjusted if required. */ const LOW_NET_LINTING_RATIO = 0.7; /** * 根据计算的 workerCount 创建多个 Worker 实例来实现多线程 Lint */ async function runWorkers( filePaths, workerCount, eslintOptionsOrURL, warnOnLowNetLintingRatio, ) { const fileCount = filePaths.length; const results = Array(fileCount); const workerURL = pathToFileURL(path.join(__dirname, "./worker.js")); const filePathIndexArray = new Uint32Array( new SharedArrayBuffer(Uint32Array.BYTES_PER_ELEMENT), ); const abortController = new AbortController(); const abortSignal = abortController.signal; const workerOptions = { env: SHARE_ENV, workerData: { eslintOptionsOrURL, filePathIndexArray, filePaths, }, }; const hrtimeBigint = process.hrtime.bigint; let worstNetLintingRatio = 1; /** * A promise executor function that starts a worker thread on each invocation. * @param {() => void} resolve_ Called when the worker thread terminates successfully. * @param {(error: Error) => void} reject Called when the worker thread terminates with an error. * @returns {void} */ function workerExecutor(resolve_, reject) { const workerStartTime = hrtimeBigint(); const worker = new Worker(workerURL, workerOptions); worker.once( "message", (/** @type {WorkerLintResults} */ indexedResults) => { const workerDuration = hrtimeBigint() - workerStartTime; // The net linting ratio provides an approximate measure of worker thread efficiency, defined as the net linting duration divided by the thread's total runtime. const netLintingRatio = Number(indexedResults.netLintingDuration) / Number(workerDuration); worstNetLintingRatio = Math.min( worstNetLintingRatio, netLintingRatio, ); for (const result of indexedResults) { const { index } = result; delete result.index; results[index] = result; } resolve_(); }, ); worker.once("error", error => { abortController.abort(error); reject(error); }); abortSignal.addEventListener("abort", () => worker.terminate()); } // 使用 workerCount 创建 worker const promises = Array(workerCount); for (let index = 0; index < workerCount; ++index) { promises[index] = new Promise(workerExecutor); } await Promise.all(promises); if (worstNetLintingRatio < LOW_NET_LINTING_RATIO) { warnOnLowNetLintingRatio(); } return results; } 是否无脑开启多线程​ concurrency 默认关闭,也就是你不应该无脑开启多线程,因为多线程的开销(创建/销毁 worker + 进程间通信)等也会增加 Lint 时长。 在上面创建 worker 中我们也可以看到 ESLint 会定义一个叫 netLintingRatio 的数值,用来计算 worker 的执行效率: $$ Net Linting Ratio = (Linting Time – I/O Time) / Thread Runtime. $$ 当 netLintingRatio 小于 LOW_NET_LINTING_RATIO: 0.7,则表示当前多线程执行效率不高,会输出警告提示信息,提示应该禁用或者降低concurrency 的值。

2025/8/19
articleCard.readMore

下一代前端工具链对比

在目前火热的 AI Coding 概念的背后,前端工具链生态也在蓬勃发展。目前新一代工具如Vite、Bun、 Rspack等已经不局限于只做Webpack那样的打包工具,而是围绕性能、开发体验和可扩展性构建完整的前端工具解决方案,各自形成了独特的技术路线与社区氛围。在这篇文章中,我将浅显得对比 Rspack、Vite 与 Bun 的生态布局,并在后续持续关注并更新这篇文章的内容。 Vite 生态​ Vite 于 2020 年发布,当时主要是为了解决 Webpack 在大型项目中启动和热更新慢的问题。Vite 过去 5 年发展迅速,到现在已经攀升为了 No.1 的前端构建工具。 Vite 目前主要是由 VoidZero 公司成员维护,这家公司是尤雨溪在 2024 年创立,目前主要工作就是维护Vite、Vitest、Rolldown、OXC这些项目。VoidZero 的愿景是使用 Rust 开发语言重塑前端工具链。下面总结一下 VoidZero 基于 Rust 的生态工具以及目前的进展。 OXC​ OXC,全称 JavaScript Oxidation Compiler,它是一个使用 Rust 语言开发的 JS 工具集合,包含 Parser(JS 代码解析), Linter(代码检查工具), Formatter(格式化工具), Transpiler(转译器), Minifier(代码压缩)等。 OXC 的工具全部使用 Rust 语言编写,旨在替代 Babel,Acorn 这一类传统的 JS 代码解析工具,极大地提升了解析和编译 JS 代码的速度。此类工具还有目前相对更加成熟的 SWC 。 oxc-parser​ oxc-parser是一个代码解析器,它能够将.js、.jsx代码解析成符合estree规范的 AST,或者将.ts、.tsx代码解析成符合typescript-estree规范的 AST,可以在该官方提供的Playground中测试生成的 AST。 在oxc-parser提供的 benchmark 测试中显示出了卓越的性能,速度为目前使用更多的swc的 3 倍以上,具体请查看该仓库:bench-javascript-parser-written-in-rust. oxc-walker​ oxc-walker可以解析代码,并提供了一些方法用来遍历 AST 节点,节点类型可以从@oxc-project/types获取。如果你需要对 AST 进行遍历然后修改节点,可以使用这个工具。 import { parseAndWalk } from 'oxc-walker' const nodes = [] parseAndWalk('console.log("hello world")', 'test.js', { enter(node) { nodes.push(node) }, }) console.log(nodes) oxc-transform​ oxc-transform类似于@babel/core的transform方法,将.jsx,.ts以及.tsx代码转换为标准的 JS 代码,或者将最新 ECMAScript 语法(如 ES2024)编写的代码转换成旧版本(如 ES6),目前最低支持转换成 ES6 的版本。同时,oxc-transform还支持生成 TS 代码的.d.ts定义文件。 import { transform } from 'oxc-transform'; const result = transform('test.ts', 'const foo: any = a ?? b;', { target: 'es2018', }); console.log(result); oxlint​ oxlint是一个代码格式检查工具,旨在替换性能堪忧的 ESLint。 根据oxlint的 benchmark 显示,oxlint的速度是 ESLint 的 50~100 倍。 oxlint于 2025-06-10 发布了第一个稳定版本 1.0,目前可以在中小型项目中完全替换 ESLint,像 Shopify、Airbnb 等一些企业项目已经着手接入了。如果你想替换 ESLint,可以使用 @oxlint/migrate 这个工具来快速迁移。 oxlint 使用 .oxlintrc.json 文件配置,其 API 基本和 ESLint v9 的配置一致,例如ignorePatterns、globals、settings、plugins以及rules等。对于常见的 typescript、react、import 等插件也在开发支持中,具体进度可以查看 oxlint plan. 最新进展 oxlint 于 2025/8/17 日宣布支持已集成 typed linting 功能。 使用 Rust 重写 Lint 工具的一大挑战就是,基于 TS 类型信息来进行更复杂的代码检查—— typed-linting,例如 typescript-eslint 中具有的以下规则: @typescript-eslint/await-thenable:不允许对非Thenable函数使用await; @typescript-eslint/no-floating-promises:安全处理Promise的错误等; @typescript-eslint/no-for-in-array:不允许使用for...in循环迭代数组; oxlint 在 typescript-eslint 开发的 tsgolint 基础上 fork 并维护 oxlint 版本的类型检查工具 oxlint-tsgolint(tsgolint 后续可能会不再维护了)。 oxlint-tsgolint 目前在大型 monorepo 上仍然存在性能瓶颈,OXC 团队正着手解决。 oxc formatter​ Formatter 方面进展相对缓慢,目前 OXC 仅实现了一个 Prettier 插件:@prettier/plugin-oxc,其内部使用了 oxc-parser 提升格式化代码的速度。未来 OXC 目标是完全移植 Prettier 这样的工具,来进一步提升性能。 oxc-minify​ oxc-minify 是一个代码压缩工具,旨在替换 Terser,目前能够和 esbuild 相媲美。 根据 benchmark 显示 oxc-minify 与性能排名第一的 @swc/core 只在某些显示库(d3,echarts等)的压缩表现中存在非常微小的差距,具体请查看 benchmark 仓库中详细的对比。 oxc-resolver​ oxc-resolver 是一个模块路径解析工具,旨在替换 Webpack 使用的 enhanced-resolve。 根据 benchmark 显示其速度为 enhanced-resolve 的 17 倍以上。 并且 oxc-resolver 内置 tsconfig-paths-webpack-plugin,能够自动对 tsconfig 中配置的 paths 进行解析。 Rolldown​ Rolldown,基于 OXC 项目,用于打包前端应用或者库,对标 Rollup。 Vite 目前使用 esbuild 进行依赖预打包,使用 Rollup 进行生产构建。Rolldown 的目标是将这两个过程统一到一个高性能的打包工具中,以降低复杂性。 Rolldown 提供了与 Rollup 高度兼容的 API(尤其是插件接口),并且具有类似的 treeshaking 功能以优化 bundle 大小。但是,Rolldown 的功能范围与 esbuild 更相似,并内置了以下附加功能: Platform presets ts、.tsx代码转换 Node.js 兼容模块解析 ESM / CJS 模块互操作 define inject CSS 打包(实验性) 代码压缩(开发中) Rolldown 还有一些在 esbuild 中有相近概念但在 Rollup 中不存在的功能: 模块类型(实验) 插件钩子过滤器 最后,Rolldown 提供了一些 esbuild 和 Rollup 没有实现的功能: Chunk Splitting(实验) 支持 HMR(开发中) Module Federation,模块联邦(计划中) Tsdown​ tsdown 是基于 Rolldown 开发的一个 library 打包工具,旨在替换 tsup。tsup 目前已不再积极维护,其仓库页面介绍也推荐迁移到 tsdown。 tsdown 在 Rolldown 基础上简化了一些配置,专用于打包库。具有以下特性: 支持生成.d.ts定义文件;并且支持 tsconfig 的 isolatedDeclarations 属性,为每个 TS 文件独立地生成 .d.ts 文件。 支持生成库的多种格式,esm、cjs,iife以及umd等; 支持处理非代码资产,如 .json 或 .wasm 文件等; 支持 unbundle mode,也就是 bundleless,仅转换模块的代码而不打包。 Vitest​ Vitest 是基于 Vite 的代码测试工具,旨在替换 Jest。相信大多数人在使用 Jest 测试 TS+React 项目时都会遇到各种各样复杂的坑,尤其是Jest在处理 ES Module 以及 TypeScript 模块时让人头疼的配置。Vitest 可能就是你的迁移选择。 Vitest 相比 Jest 存在以下优势: 内置 ES Module 和 TS、TSX 的支持,无需像 Jest 那样频繁折腾 Babel 或 SWC 配置来让 TS 测试顺利跑起来; 无缝衔接基于 Vite 构建的项目,测试环境能重用同一套解析/插件规则等,不需要再折腾配置测试工具; 速度更快,基于 Vite 的按需编译与缓存,watch 模式下改一行代码看到测试结果的等待更短;同时还具有多进程/worker 并行执行测试。 从使用量来看,Vitest 正稳步增长,快速追赶 Jest。 VitePress​ VitePress 是基于 Vite 的文档开发工具,使用 VUE 作为渲染框架。 Vite+​ Vite+ 是以 Vite 为核心的一系列生态工具,目的是解决 JS 工具散落冗余的问题。也就是你在开发项目的时候,使用 Vite 不需要再去关心如何选择构建工具,测试工具,Linter,Formatter等问题。 Rstack​ Rstack 是由 Bytedance Web Infra 团队维护的围绕Rspack打造的 JS 统一工具链。Rstack生态的工具品牌风格比较统一,一般使用英文字母rs前缀命名,图标都是一个卡通的小螃蟹拿着不同的工具。 SWC​ Rstack 生态的基石就是 SWC。SWC 是基于 Rust 语言开发的一个编译器项目,包含一系列编译和转换 JS 代码的工具。 @swc/core​ @swc/core提供 Parser、Tranformer 以及代码压缩 Minifier 的功能,支持 JS 和 TS 以及 TSX 代码。 支持同步或者异步解析 JS 代码生成 AST; 支持同步或者异步转换高版本的 JS 代码到最低 ES3 版本; 支持同步或者异步压缩 JS 代码。 @swc/wasm-web​ @swc/wasm-web使用 WebAssembly 在浏览器内同步转换代码。 @swc/html​ @swc/html负责对 HTML 代码进行压缩。 Rspack​ Rspack 基于 Rust 语言开发,底层使用 SWC 来转换和压缩 JS 代码,API 基本对齐 Webpack,目标就是无缝替换 Webpack,提升在大型项目上的构建打包速度。 Rspack 于 2024/8/28 就发布了第一个稳定版本1.0,到目前差不多正好一年的时间。相比 Webpack,Rspack 提供 CLI 命令(例如pnpm create rspack@latest)用于直接创建项目,开箱即用,非常方便。 module解析​ 相比 Webpack,Rspack 内置了对.js、.css、.json、以及静态资源(图片、视频、字体)等模块类型的支持,对于jsx、less等其他模块类型可以自行配置。 plugins​ Rspack 目前支持了大部分常用的 Webpack 插件,具体查看Rspack-plugins的描述。 其他功能​ 对于 Webpack 的一些特性,例如 Module Federation、Lazy compilation、Chunk Splitting 以及 Tree shaking 等也都已经支持。 Rsbuild​ Rsbuild 是基于 Rspack 开发,专门构建 Web 应用的工具。Rsbuild 根据不同的 UI 框架(React、VUE等)预设了部分 Rspack 的配置项,通过使用 Rsbuild CLI 能一行命令创建项目并开箱即用,省去了配置构建工具的繁琐步骤,这点和 Vite 一样。 Rslib​ Rslib 是基于 Rspack 的库构建工具。提供以下特性: CLI 命令直接创建库项目,开箱即用; 内置对几乎所有模块类型的支持,常见的jsx,css,less以及图片,视频,字体等全都默认支持; 支持生成库的多种格式,esm、cjs,iife以及umd等; 支持bundleless模式; 支持isolatedDeclarations; 支持模块联邦开发,也就是打包成一个独立的模块; 支持 Storybook。 Rslint​ Rslint 是使用 go 语言编写的代码检查工具,它是从 tsgolint fork 出来维护的版本,上文说过,tsgolint已经没有继续开发的计划了。 Rslint 相比 ESLint 有以下优化目标: TS 优先,基于 go 和 typescript-go 重写 ESLint 和 typescript-eslint 的规则; 提升速度,预测 20~40 倍于 ESLint; 简化 ESLint 繁琐复杂的配置; 支持 Monorepo,以及 TypeScript project references。 Rslint 目前仍处于开发阶段,尚无稳定版本,可以在官方文档中查看其进度。 Rspress​ Rspress 是一个基于 Rsbuild 的静态站点生成器,基于 React 框架进行渲染,内置了一套默认的文档主题。 相比 React 生态的 Docusaurus 来说,Rspress 使用了基于 Rust 开发了 Markdown 编译器mdx-rs,编译文档的速度更快。 Rspress 还提供以下特性: 根据文档目录自动生成布局; 多版本文档展示; 内置对全部文档内容的搜索; 提供组件的预览和 Playground 功能,并且支持移动端组件的预览。 Rstest​ Rstest 是一个基于 Rspack 的测试框架,提供兼容 Jest 的 API,内置对 TypeScript、ESM 的支持,目前处于开发阶段,暂未发布稳定版本。 Rsdoctor​ Rsdoctor是一个项目构建分析工具,对编译、构建过程以及依赖等信息提供可视化的图表和信息展示。简单来说,Rsdoctor 就是一个 super webpack bundle analyzer。之前用 Webpack 需要安装webpack bundle analyzer、speed-measure-webpack-plugin等等插件才能监测到项目内部构建的细节,现在只需要一个插件就能可视化展示更详细的项目构建信息。 Rsdoctor 支持所有基于 Rspack 或 Webpack 的工具和框架,如果是 Webpack 项目,可以使用 @rsdoctor/webpack-plugin 接入,基于 Rsdoctor 的 CLI 命令启动项目,就会自动打开一个本地页面展示项目构建的信息。 Rsdoctor 能够分析模块解析 Loader 以及 Plugins 的执行时间: Rsdoctor 能够显示打包产物的体积,有webpack bundle analyzer那样的 treemap 功能: Rsdoctor 还能检测项目的重复依赖,这在 monorepo 项目中十分关键。具体可查看官方文档对于重复包优化的介绍。 Bun​ Bun 使用 Zig 语言开发的 JS 运行时,类似于 Nodejs,但是它本身内置了一套开发、测试、运行和打包 JS、TS 项目的工具。Bun 兼容 Nodejs 生态,旨在取代 Node.js、NPM、Webpack 等多个工具,成为一个完整、高效的 JavaScript 运行时。 Bun 目前具有以下优势: 启动速度快,Bun 使用 JavaScriptCore 引擎,官方文档显示启动速度目前比 Node.js 快 4 倍; 安装包的速度快,Bun 的包管理器是原生实现的,提供类似于 NPM 一样的依赖管理命令,如 bun add、bun install 等,但是其速度远远超过 NPM、PNPM 这些包管理工具, bun install 的速度为 npm install 的 25 倍以上; Bun 实现了标准的 Web API,例如fetch、WebSocket和ReadableStream,并且致力于完全兼容内置的 Node.js 全局变量(process、Buffer)和模块(path、fs、http等); Bun 内置对大量模块类型的解析,如 ES Module,JSX,TS,HTML,CSS,WASM 等; Bun 内置打包器,使用bun build命令能直接构建打包项目,官方文档显示其性能非常优秀: Bun 内置测试工具,使用bun test能直接测试 JSX,TS 等模块代码,同时还支持快照测试,DOM 测试以及 Watch 模式等,非常方便。Bun 的目的是兼容 Jest,但是目前仍有一些功能尚未实现。 Bun 内置对 monorepo 的支持,使用bun install或者执行scripts时可以使用--filter选择 monorepo 下的项目。 总而言之,Bun 将多个工具整合到了它的运行时中,使用 Bun 则不需要单独安装和配置不同的构建、测试工具,下面是 Bun 和 Nodejs、deno 这些运行时的详细对比。 总结​ 总体来说,Rstack 生态发展的路线更加清晰和成熟,所有工具基本都可以用于生产环境了;而 Vite 底层的 OXC 目前还处于早期快速迭代状态,不够稳定,工具之间看起来也比较散落,毕竟 OXC 是完全使用 Rust 重写编译器,所以进展会缓慢一点。 最后就以一张简单的表格总结一下上面介绍的各种工具: 生态ViteRstackBun 编译器OXCSWC内置 构建工具RolldownRspack内置bun build 库打包工具TsdownRslib内置bun build 测试工具VitestRstest内置bun test LinterOxlintRslint无 Formatter@prettier/plugin-oxc无无 文档开发VitePressRspress无 其他Rsdoctor

2025/8/11
articleCard.readMore

如何开发自己的一个 shadcn 组件

距离上一篇介绍shadcn文章已过去一年之久啦😅。在这一年多的时间内,随着AI Generate Web技术的快速发展,shadcn凭借其 AI 友好的组件开发模式,生态发展得极为庞大。shadcn本身也经过了一些架构调整,新增了一些特性,比如add命令支持第三方registry以及支持build命名等。本篇在解析shadcn CLI 的基础上详细介绍一下如何加入shadcn组件生态。 shadcn add​ 上一篇讲到shadcn add命令会默认从shadcn文档同域的地址获取registry.json文件,以解析shadcn组件库的组件目录结构和组件代码。当时shadcn add命令只支持通过定义process.env.COMPONENTS_REGISTRY_URL来自定义registry.json的域,这种方式最大的局限性就是在使用的时候只能指定一个第三方域的地址,当你要使用shadcn add添加不同域的组件时,就要不断修改process.env.COMPONENTS_REGISTRY_URL。 所以shadcn add命令最大的改进就是支持通过components参数来指定要添加的组件的地址,这样第三方组件就能通过本地编写registry.json自由地将自己的某个单一的组件或者整个组件库分享出去。 支持这一特性的代码也很简单,如下所示,在add命令执行的第一步(源码)会判断components是否为一个URL,如果是则请求该json内容。 const options = addOptionsSchema.parse({ components, cwd: path.resolve(opts.cwd), ...opts, }) let itemType: z.infer<typeof registryItemTypeSchema> | undefined let registryItem: any = null if ( components.length > 0 && // 判断 add 命令的第一个参数是否为 url (isUrl(components[0]) || isLocalFile(components[0])) ) { registryItem = await getRegistryItem(components[0], "") itemType = registryItem?.type } isUrl判断是否为远端地址 function isUrl(path: string) { try { new URL(path) return true } catch (error) { return false } } 如果是远端地址,则fetch请求json,获取json内容定义的组件源码等内容,后面就跟上一篇的介绍的流程大体一致,就不多赘述了。 function getRegistryItem(name: string, style: string) { try { ... // Handle URLs and component names const [result] = await fetchRegistry([ isUrl(name) ? name : `styles/${style}/${name}.json`, ]) return registryItemSchema.parse(result) } catch (error) { logger.break() handleError(error) return null } } shadcn build​ shadcn build是shadcn在2.3.0中新增的命名,用来构建registry.json文件,也就是让你的组件库支持使用shadcn add命令添加到其他项目内部。 shadcn build命令的逻辑非常简单(源码位置)总结来说就分为 4 步: 查找并解析 registry.json​ 该命令默认会从项目根目录获取registry.json文件,也可以通过registry和cwd参数来指定registry.json文件的路径(一般用于monorepo项目)。 const options = buildOptionsSchema.parse({ cwd: path.resolve(opts.cwd), registryFile: registry, outputDir: opts.output, }) // 检查指定的目录是否存在,并返回解析后的本地 registry.json 和输出文件目录绝对路径 const { resolvePaths } = await preFlightBuild(options) // 读取 registry.json const content = await fs.readFile(resolvePaths.registryFile, "utf-8") // 使用 zod 校验 registry.json 的结构是否符合 registry schema 结构 // https://ui.shadcn.com/schema/registry-item.json const result = registrySchema.safeParse(JSON.parse(content)) if (!result.success) { logger.error( `Invalid registry file found at ${highlighter.info( resolvePaths.registryFile )}.` ) process.exit(1) } 该命名使用zod进行registry.json结构的校验,判断其是否符合shadcn约束的定义结构。shadcn官方对于registry.json的约束,可以在shadcn的文档中查看——registry.json,这里就不一一介绍了。 遍历文件路径​ 根据registry.json中注册的items字段,可以找到定义项目内部组件的文件路径字段files,这些在shadcn的文档中都有详细介绍,参考这里registry-item.json for (const registryItem of registry.items) { ... } 读取组件代码并写入到registry.json中​ shadcn build这个命令主要就是为了将组件源码写入到registry.json中,从而使得第三方开发者在为自己的组件库编写registry.json时无需将组件源码写入registry.json,避免繁琐的流程。 for (const registryItem of result.data.items) { if (!registryItem.files) { continue } for (const file of registryItem.files) { file["content"] = await fs.readFile( path.resolve(resolvePaths.cwd, file.path), "utf-8" ) } } 将registry.json写入输出目录​ build命令最后默认将registry.json内容写入到项目根目录的public目录下,这样做的目的主要是因为现在大部分的组件开发框架都会将public目录做为默认的静态不编译文件目录,在项目打包的时候支持拷贝目录内部的文件到打包目录下。如果一个组件库会发布文档网站,那么registry.json就可以直接在文档网站的域内访问到,也不用为registry.json再单独折叠其他域名地址了。 当然build命令也支持使用output参数修改registry.json写入的目录路径。 await fs.writeFile( path.resolve(resolvePaths.outputDir, `${registry.name}.json`), JSON.stringify(result.data, null, 2) ) 加入shadcn生态​ 如果你想编写一个组件或者组件库,让其支持shadcn的生态,能够使用shadcn add命令在各个项目之间共享,最简单的方式是基于shadcn提供的nextjs模板项目直接开发。 你可以直接在 GitHub 上基于这个模板项目创建仓库并克隆到本地直接开始开发你的组件,这个项目的结构如下所示: ├── 📄 README.md └── 📂 app/ // nextjs 的路由文件,用来编写组件开发文档 │ ├── 📄 favicon.ico │ ├── 📄 globals.css │ ├── 📄 layout.tsx │ ├── 📄 page.tsx └── 📂 components/ // 第三方通用组件,使用 shadcn add 添加其他第三方组件辅助开发 ├── 📄 components.json │ ├── 📄 open-in-v0-button.tsx └── 📂 lib/ │ ├── 📄 utils.ts └── 📂 public/ // 输出组件注册的 registry.json 文件,构建文档的时候会直接拷贝 │ └── 📂 r/ │ ├── 📄 hello-world.json └── 📂 registry/ // 组件存放目录 │ └── 📂 new-york/ │ └── 📂 blocks/ // 块级复杂组件 │ └── 📂 hello-world/ │ ├── 📄 hello-world.tsx │ └── 📂 ui/ // 单个组件 │ ├── 📄 button.tsx └── 📄 tsconfig.json ├── 📄 registry.json // 注册组件,必须自己编写 当你在编写完组件push到 GitHub 后,可以直接在 Vercel 上绑定你的仓库并构建发布。 然后别人就能通过 Vercel 给你分配的域名地址或者你自定义的域名地址访问到你开发的组件的registry.json,并且使用shadcn add命令添加你开发的组件到本地。 基于shadcn这套流程最大的便捷之处就是你无需去选择工具打包你的组件库,你只需要编写好组件的registry.json即可。 参考项目​ 歌词组件仓库 使用 Vercel 导入项目并一键部署,现在就可以使用访问我的歌词组件的registry.json文件了,并且支持在项目中使用shadcn add https://shadcn-lyrics.vercel.app/r/lyrics.json来添加组件。

2025/7/27
articleCard.readMore

shadcn-ui实现原理

Shadcn ui 是最近比较火的一个 React 组件库,官方介绍其并非组件库,因为它并非基于 npm package 的形式来维护,而是通过 nodejs 命令支持将组件源码直接复制到你的项目中。 本篇文章从使用到分析 shadcn cli 命令源码的实现,来深入了解这个组件库内部的一些原理。 使用​ 初始化​ shadcn ui 依赖于 tailwindcss,所以使用 shadcn ui 的组件需要安装和配置 tailwindcss,这里就不细说了。 tailwindcss 安装完就可以使用 shadcn ui 提供的 cli 工具来初始化一些配置了: npx shadcn-ui@latest init 执行init命令会询问下面一堆问题,然后可以看到项目内部多了components目录(如果已存在则不变),还有一个components.json文件。 // components.json { "$schema": "https://ui.shadcn.com/schema.json", // 组件样式 "style": "default", // 是否 rsc 组件 "rsc": false, // 是否使用 typescript "tsx": true, // tailwind 的一些配置 "tailwind": { "config": "tailwind.config.js", "css": "app/globals.css", "baseColor": "slate", "cssVariables": false, "prefix": "" }, // shadcn 组件使用到的 utils 函数的引用路径,以及相互之间引用的相对路径 "aliases": { "components": "@/components", "utils": "@/lib/utils" } } 添加组件​ 使用 cli 命令添加 shadcn ui 具有的组件,比如添加一个button组件 npx shadcn-ui@latest add button 执行完上面这行命令,就会往components目录下写入button组件代码,这个组件实现比较简单,源码就是下面这样。可以看到其内部还依赖了一个组件库@radix-ui/react-slot,(这不是套娃吗😅) 更新组件​ shadcn 的 cli 还有一个diff命令用于显示组件更新,执行diff button可以看到其会在终端显示远端和本地存在不同的代码行,通过蓝绿色区分开,说实话,这功能感觉比较鸡肋。 monorepo 支持​ shadcn ui提供的三个 cli 命令都支持通过--cwd来指定项目的根目录,也算是支持monorepo吧。 cli解析​ shadcn 的 cli 代码基于 pnpm workspace 的结构,虽然是 monorepo 项目,但 workspace 下只有一个 cli 项目,代码目录结构还是很简洁清晰的。 src/commands下对应三个命令的执行文件,在index.ts内部引用,并通过Command注册。 src ┣ commands ┃ ┣ add.ts ┃ ┣ diff.ts ┃ ┗ init.ts ┣ utils ┗ index.ts import { add } from "@/src/commands/add" import { diff } from "@/src/commands/diff" import { init } from "@/src/commands/init" import { Command } from "commander" import { getPackageInfo } from "./utils/get-package-info" process.on("SIGINT", () => process.exit(0)) process.on("SIGTERM", () => process.exit(0)) async function main() { const packageInfo = await getPackageInfo() const program = new Command() .name("shadcn-ui") .description("add components and dependencies to your project") .version( packageInfo.version || "1.0.0", "-v, --version", "display the version number" ) program.addCommand(init).addCommand(add).addCommand(diff) program.parse() } main() init​ init方法如下,正如init命令定义的描述那样initialize your project and install dependencies,其主要工作其实就两步: 使用promptForConfig方法,通过命令行交互获取用户输入的配置信息 使用runInit方法生成配置文件components.json和存放utils,components的目录 import { z } from "zod" const initOptionsSchema = z.object({ cwd: z.string(), yes: z.boolean(), defaults: z.boolean(), }) export const init = new Command() .action(async (opts) => { try { // 使用 zod 校验并获取命令行输入 const options = initOptionsSchema.parse(opts) const cwd = path.resolve(options.cwd) // 检测是否具有 tailwind 配置文件 tailwind.config.(js|ts),没有则报错 preFlight(cwd) // 获取目录下components.json配置文件内容 const projectConfig = await getProjectConfig(cwd) if (projectConfig) { // 如果是 nextjs+typescript 项目且存在 tailwind base 配置和 tsconfig paths 配置,则执行简单化问题交互 const config = await promptForMinimalConfig( cwd, projectConfig, opts.defaults ) // 执行生成文件步骤 await runInit(cwd, config) } else { const existingConfig = await getConfig(cwd) // 使用 prompts 询问全部问题 const config = await promptForConfig(cwd, existingConfig, options.yes) await runInit(cwd, config) } logger.info("") logger.info( `${chalk.green( "Success!" )} Project initialization completed. You may now add components.` ) logger.info("") } catch (error) { handleError(error) } }) promptForConfig​ promptForConfig方法源码如下,其主要工作就是三步: 从远端加载 shadcn 支持的主题和基础色配置信息,这部分信息需要在命令行交互内使用,提示用户选择 使用命令行交互获取用户偏好配置; 生成 components.json 配置文件; 最后返回解析配置的结果 export async function promptForConfig( cwd: string, defaultConfig: Config | null = null, skip = false ) { // 获取远端主题配置,https://ui.shadcn.com/registry/styles/index.json const styles = await getRegistryStyles() // 获取组件基础色配置 const baseColors = await getRegistryBaseColors() // 命令行输入交互 const options = await prompts([...]) // 校验输入 const config = rawConfigSchema.parse({...}) // 生成 components.json 配置文件 logger.info("") const spinner = ora(`Writing components.json...`).start() const targetPath = path.resolve(cwd, "components.json") await fs.writeFile(targetPath, JSON.stringify(config, null, 2), "utf8") spinner.succeed() // 返会配置解析信息,包括解析得到的 components 存放目录绝对路径等信息 return await resolveConfigPaths(cwd, config) } // 从远端获取主题配置文件 export async function getRegistryStyles() { try { const [result] = await fetchRegistry(["styles/index.json"]) return stylesSchema.parse(result) } catch (error) { throw new Error(`Failed to fetch styles from registry.`) } } // 获取主题色配置 export async function getRegistryBaseColors() { return [ { name: "slate", label: "Slate", }, { name: "gray", label: "Gray", }, { name: "zinc", label: "Zinc", }, { name: "neutral", label: "Neutral", }, { name: "stone", label: "Stone", }, ] } fetchRegistry​ promptForConfig内部使用的fetchRegistry方法,默认从https://ui.shadcn.com域名拉取配置,这个域名也就是shadcn ui 官方文档的域名地址。https://ui.shadcn.com/registry/styles/index.json获取一份主题配置,对应components.json的style const baseUrl = process.env.COMPONENTS_REGISTRY_URL ?? "https://ui.shadcn.com" async function fetchRegistry(paths: string[]) { try { const results = await Promise.all( paths.map(async (path) => { const response = await fetch(`${baseUrl}/registry/${path}`, { agent, }) return await response.json() }) ) return results } catch (error) { console.log(error) throw new Error(`Failed to fetch registry from ${baseUrl}.`) } } resolveConfigPaths​ resolveConfigPaths内部使用tsconfig-paths的createMatchPath方法来对tconfig.json中定义的paths字段进行解析,可以得到一个绝对路径。 import { loadConfig, createMatchPath, type ConfigLoaderSuccessResult } from "tsconfig-paths" export async function resolveConfigPaths(cwd: string, config: RawConfig) { // 读取项目的tsconfig.json文件 const tsConfig = await loadConfig(cwd) return configSchema.parse({ ...config, resolvedPaths: { ..., // 解析相对路径得到绝对路径 utils: await resolveImport(config.aliases["utils"], tsConfig), components: await resolveImport(config.aliases["components"], tsConfig), ui: config.aliases["ui"] ? await resolveImport(config.aliases["ui"], tsConfig) : await resolveImport(config.aliases["components"], tsConfig), }, }) } // 从 components.json 中定义的 aliases.utils,aliases.components 相对路径解析得到绝对路径 export async function resolveImport( importPath: string, config: Pick<ConfigLoaderSuccessResult, "absoluteBaseUrl" | "paths"> ) { return createMatchPath(config.absoluteBaseUrl, config.paths)( importPath, undefined, () => true, [".ts", ".tsx"] ) } createMatchPath一般配合loadConfig使用,首先使用loadConfig读取项目的tsconfig.json文件,其返回项目根目录的绝对路径absoluteBaseUrl。loadConfig函数签名如下: export interface ConfigLoaderSuccessResult { resultType: "success"; // tsconfig.json 的绝对路径 configFileAbsolutePath: string; // tsconfig.json 定义的 baseUrl baseUrl?: string; // 基于 baseUrl 的项目绝对路径 absoluteBaseUrl: string; // tsconfig.json 定义的 paths paths: { [key: string]: Array<string>; }; mainFields?: (string | string[])[]; addMatchAll?: boolean; } export interface ConfigLoaderFailResult { resultType: "failed"; message: string; } export declare type ConfigLoaderResult = ConfigLoaderSuccessResult | ConfigLoaderFailResult; export declare function loadConfig(cwd?: string): ConfigLoaderResult; 例如对于下面一个项目结构返回的absoluteBaseUrl如下,注意absoluteBaseUrl会根据baseUrl变化,一般是项目根目录后面串接baseUrl部分路径,所以不推荐项目使用baseUrl,影响的范围不可控。 tsconfig-path-test ├─ src │ └─ index.ts ├─ package.json └─ tsconfig.json { resultType: 'success', configFileAbsolutePath: 'D:\\code\\tsconfig-path-test\\tsconfig.json', baseUrl: undefined, absoluteBaseUrl: 'D:\\code\\tsconfig-path-test', paths: { '@/*': [ './src/*' ] }, addMatchAll: false } 从loadConfig获取到项目根目录的绝对路径以后,就可以传入createMatchPath方法,createMatchPath函数签名如下: /** * 匹配路径的方法 */ export interface MatchPath { (requestedModule: string, readJson?: Filesystem.ReadJsonSync, fileExists?: (name: string) => boolean, extensions?: ReadonlyArray<string>): string | undefined; } /** * 创建一个解析 tsconfig.json 中 paths 配置的路径的函数 * * @param absoluteBaseUrl 根据 baseUrl 定义的项目根目录 * @param paths tsconfig.json 定义的 paths * @param mainFields A list of package.json field names to try when resolving module files. Select a nested field using an array of field names. * @param addMatchAll Add a match-all "*" rule if none is present */ export declare function createMatchPath(absoluteBaseUrl: string, paths: { [key: string]: Array<string>; }, mainFields?: (string | string[])[], addMatchAll?: boolean): MatchPath; 所以上面的resolveImport方法对@/components,@/lib/utils这样的相对路径,解析可以得到其绝对路径如D:\code\tsconfig-path-test\src\components。 runInit​ runInit方法负责生成components,utils等目录,以及替换tailwind.config.ts配置,并安装 shadcn ui 需要的第三方组件库依赖tailwindcss-animate,class-variance-authority,clsx等。 export async function runInit(cwd: string, config: Config) { // 生成 components,utils 目录 for (const [key, resolvedPath] of Object.entries(config.resolvedPaths)) { let dirname = path.extname(resolvedPath) ? path.dirname(resolvedPath) : resolvedPath if (!existsSync(dirname)) { await fs.mkdir(dirname, { recursive: true }) } } const extension = config.tsx ? "ts" : "js" const tailwindConfigExtension = path.extname( config.resolvedPaths.tailwindConfig ) let tailwindConfigTemplate: string if (tailwindConfigExtension === ".ts") { tailwindConfigTemplate = config.tailwind.cssVariables ? templates.TAILWIND_CONFIG_TS_WITH_VARIABLES : templates.TAILWIND_CONFIG_TS } else { tailwindConfigTemplate = config.tailwind.cssVariables ? templates.TAILWIND_CONFIG_WITH_VARIABLES : templates.TAILWIND_CONFIG } // 使用lodash的template方法往tailwind.config里写入一些配置项 await fs.writeFile( config.resolvedPaths.tailwindConfig, template(tailwindConfigTemplate)({ extension, prefix: config.tailwind.prefix, }), "utf8" ) // 从https://ui.shadcn.com/registry/colors/[baseColor].json获取主题色配置文件,写入到tailwind 全局 css 文件里 const baseColor = await getRegistryBaseColor(config.tailwind.baseColor) if (baseColor) { await fs.writeFile( config.resolvedPaths.tailwindCss, config.tailwind.cssVariables ? config.tailwind.prefix ? applyPrefixesCss(baseColor.cssVarsTemplate, config.tailwind.prefix) : baseColor.cssVarsTemplate : baseColor.inlineColorsTemplate, "utf8" ) } // 生成 utils.cn 文件 await fs.writeFile( `${config.resolvedPaths.utils}.${extension}`, extension === "ts" ? templates.UTILS : templates.UTILS_JS, "utf8" ) // 安装依赖 const dependenciesSpinner = ora(`Installing dependencies...`)?.start() const packageManager = await getPackageManager(cwd) const deps = [ ...[ "tailwindcss-animate", "class-variance-authority", "clsx", "tailwind-merge" ], config.style === "new-york" ? "@radix-ui/react-icons" : "lucide-react", ] await execa( packageManager, [packageManager === "npm" ? "install" : "add", ...deps], { cwd, } ) dependenciesSpinner?.succeed() } getPackageManager​ getPackageManager方法获取项目使用的依赖管理工具,这个方法非常实用,万能! import { execa } from "execa"; import { detect } from "@antfu/ni" const packageManager = await getPackageManager(cwd); // 执行 install 安装依赖 await execa( packageManager, [packageManager === "npm" ? "install" : "add", ...[ "tailwindcss-animate", "class-variance-authority", "clsx", "tailwind-merge", config.style === "new-york" ? "@radix-ui/react-icons" : "lucide-react", ]], { cwd, } ) // 检测当前使用的 npm package 管理工具 export async function getPackageManager( targetDir: string ): Promise<"yarn" | "pnpm" | "bun" | "npm"> { const packageManager = await detect({ programmatic: true, cwd: targetDir }) if (packageManager === "yarn@berry") return "yarn" if (packageManager === "pnpm@6") return "pnpm" if (packageManager === "bun") return "bun" return packageManager ?? "npm" } add​ add命令的实现也比较简单,其主要实现部分的源码如下,可拆解为以下四步: 使用getRegistryIndex获取 shadcn 注册支持的所有组件及其依赖; 使用resolveTree解析用户添加的组件,及其依赖的其他内部组件; 通过fetchTree拉取组件源码; 使用transform转换组件源码并生成文件 const add = new Command() .name("add") .argument("[components...]", "the components to add") .option("-y, --yes", "skip confirmation prompt.", true) .option("-o, --overwrite", "overwrite existing files.", false) .option( "-c, --cwd <cwd>", "the working directory. defaults to the current directory.", process.cwd() ) .option("-a, --all", "add all available components", false) .option("-p, --path <path>", "the path to add the component to.") .action(async (components, opts) => { try { // 校验命令行参数 const options = addOptionsSchema.parse({ components, ...opts, }) // 获取项目根目录绝对路径 const cwd = path.resolve(options.cwd) // 读取项目根目录的配置文件 components.json const config = await getConfig(cwd) // 默认从 https://ui.shadcn.com/registry/index.json 获取注册组件列表 const registryIndex = await getRegistryIndex() let selectedComponents = options.all ? registryIndex.map((entry) => entry.name) : options.components // 获取所有要添加的组件信息 const tree = await resolveTree(registryIndex, selectedComponents) // 获取组件源码 const payload = await fetchTree(config.style, tree) const baseColor = await getRegistryBaseColor(config.tailwind.baseColor) // 生成组件代码文件 const spinner = ora(`Installing components...`).start() for (const item of payload) { const targetDir = await getItemTargetPath( config, item, options.path ? path.resolve(cwd, options.path) : undefined ) for (const file of item.files) { let filePath = path.resolve(targetDir, file.name) // 对组件源码内部 import 路径等进行AST解析并替换 const content = await transform({ filename: file.name, raw: file.content, config, baseColor, }) await fs.writeFile(filePath, content) } // 安装组件依赖 const packageManager = await getPackageManager(cwd) // 安装依赖 if (item.dependencies?.length) { await execa( packageManager, [ packageManager === "npm" ? "install" : "add", ...item.dependencies, ], { cwd, } ) } } spinner.succeed(`Done.`) } catch (error) { handleError(error) } }) getRegistryIndex​ getRegistryIndex会默认从一个远端地址——index.json加载一个组件json,这个json包含了 shadcn ui 所有支持的组件信息。 const registryIndex = await getRegistryIndex() export async function getRegistryIndex() { try { const [result] = await fetchRegistry(["index.json"]) return registryIndexSchema.parse(result) } catch (error) { throw new Error(`Failed to fetch components from registry.`) } } 例如我们添加的button组件的信息如下: [ { "name": "button", "dependencies": [ "@radix-ui/react-slot" ], "files": [ "ui/button.tsx" ], "type": "components:ui" } ] json的schema描述如下,从代码的 TODO 注释可以看出 shadcn 的维护者有意将该部分注册组件的 schema 共享出去,应该是为了更方便组件库的维护者使用。 // TODO: 提取为一个 package export const registryItemSchema = z.object({ name: z.string(), // 组件名称 dependencies: z.array(z.string()).optional(), // 组件依赖的第三方 package devDependencies: z.array(z.string()).optional(), // 组件开发依赖 registryDependencies: z.array(z.string()).optional(), // 组件依赖的其他 shadcn ui 内部的组件 files: z.array(z.string()), // 组件包含的文件列表,相对路径 type: z.enum(["components:ui", "components:component", "components:example"]), // 组件类型 }) export const registryIndexSchema = z.array(registryItemSchema) resolveTree​ 在获取到远端注册的所有 shadcn ui 内部组件的信息后,resolveTree会对用户选择添加的组件进行依赖解析,得到所有需要添加到项目内部的组件信息。 const tree = await resolveTree(registryIndex, selectedComponents) /** * 解析组件依赖关系,得到所有需要添加到项目内部的组件信息 * @param index 所有注册的组件信息 * @param names 用户选择添加的组件 */ export async function resolveTree( index: z.infer<typeof registryIndexSchema>, names: string[] ) { const tree: z.infer<typeof registryIndexSchema> = [] for (const name of names) { const entry = index.find((entry) => entry.name === name) if (!entry) { continue } // 用户添加的组件本身 tree.push(entry) // 添加的组件所依赖的 shadcn ui 内部的组件也需要被添加 if (entry.registryDependencies) { const dependencies = await resolveTree(index, entry.registryDependencies) tree.push(...dependencies) } } // 过滤不同组件之间依赖的相同的组件 return tree.filter( (component, index, self) => self.findIndex((c) => c.name === component.name) === index ) } fetchTree​ 在获取所有要添加的组件后,使用fetchTree从远端加载其源码。 const payload = await fetchTree(config.style, tree) /** * 加载组件源码 * @param style components.json 指定的 style * @param tree 用户选择添加的组件 */ export async function fetchTree( style: string, tree: z.infer<typeof registryIndexSchema> ) { try { const paths = tree.map((item) => `styles/${style}/${item.name}.json`) const result = await fetchRegistry(paths) return registryWithContentSchema.parse(result) } catch (error) { throw new Error(`Failed to fetch tree from registry.`) } } 例如button组件,从https://ui.shadcn.com/registry/styles/default/button.json获取到其源码信息为: { "name": "button", "dependencies": [ "@radix-ui/react-slot" ], "files": [ { "name": "button.tsx", "content": "import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst buttonVariants = cva(\n \"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50\",\n {\n variants: {\n variant: {\n default: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n destructive:\n \"bg-destructive text-destructive-foreground hover:bg-destructive/90\",\n outline:\n \"border border-input bg-background hover:bg-accent hover:text-accent-foreground\",\n secondary:\n \"bg-secondary text-secondary-foreground hover:bg-secondary/80\",\n ghost: \"hover:bg-accent hover:text-accent-foreground\",\n link: \"text-primary underline-offset-4 hover:underline\",\n },\n size: {\n default: \"h-10 px-4 py-2\",\n sm: \"h-9 rounded-md px-3\",\n lg: \"h-11 rounded-md px-8\",\n icon: \"h-10 w-10\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"default\",\n },\n }\n)\n\nexport interface ButtonProps\n extends React.ButtonHTMLAttributes<HTMLButtonElement>,\n VariantProps<typeof buttonVariants> {\n asChild?: boolean\n}\n\nconst Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n ({ className, variant, size, asChild = false, ...props }, ref) => {\n const Comp = asChild ? Slot : \"button\"\n return (\n <Comp\n className={cn(buttonVariants({ variant, size, className }))}\n ref={ref}\n {...props}\n />\n )\n }\n)\nButton.displayName = \"Button\"\n\nexport { Button, buttonVariants }\n" } ], "type": "components:ui" } transform​ 在得到组件源码以后,从components.json中解析配置项aliases.components,得到项目存放组件的目录路径,然后使用transform方法对组件源码进行解析,并最终生成组件文件。 for (const item of payload) { for (const file of item.files) { let filePath = path.resolve(targetDir, file.name) // Run transformers. const content = await transform({ filename: file.name, raw: file.content, config, baseColor, }) if (!config.tsx) { filePath = filePath.replace(/\.tsx$/, ".jsx") filePath = filePath.replace(/\.ts$/, ".js") } await fs.writeFile(filePath, content) } } transform方法内部使用了ts-morph对组件源码进行AST解析,并根据components指定的配置对组件源码中import路径,CSS 变量,tailwind prefix 进行替换,以匹配当前项目。 import { promises as fs } from "fs" import { tmpdir } from "os" import { Project, ScriptKind, type SourceFile } from "ts-morph" // 创建 project 实例 const project = new Project({ compilerOptions: {}, }) // 使用 ts-morph 定义的 AST 转换方法 const transformers: Transformer[] = [ transformImport, transformRsc, transformCssVars, transformTwPrefixes, ] // 创建临时文件 async function createTempSourceFile(filename: string) { // tmpdir 使用系统的临时文件目录 const dir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-")) return path.join(dir, filename) } export async function transform(opts: TransformOpts) { const tempFile = await createTempSourceFile(opts.filename) // 使用 project.createSourceFile 根据源码创建 tsx 文件 const sourceFile = project.createSourceFile(tempFile, opts.raw, { scriptKind: ScriptKind.TSX, }) // 对import路径,CSS 变量,tailwind prefix for (const transformer of transformers) { transformer({ sourceFile, ...opts }) } return await transformJsx({ sourceFile, ...opts, }) } diff​ diff命令内部主要使用了diff对项目的组件代码和远端组件代码进行对比,然后输出到 shell 内,具体实现就不细说了,比较简单。 组件的注册​ 最后讲一下 shadcn ui 组件的注册方法,在上文对add和init命令的解析中,可以看到经常使用fetchRegistry这个方法获取远端json文件,而其默认地址就是 shadcn ui 官网的地址https://ui.shadcn.com/,所以这里就可以得出一个结论,组件注册的index.json,以及主题信息style.json等信息维护在网站目录下,也就是 shadcn 项目 monorepo 内部的apps\www目录。 在apps/www目录下的package.json中可以找到以下命令: "scripts": { "build": "contentlayer build && pnpm build:registry && next build", "build:registry": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/build-registry.mts && prettier --loglevel silent --write \"registry/**/*.{ts,tsx,mdx}\" --cache", }, 其中build:registry会使用tsx执行./scripts/build-registry.mts这个文件,其内部执行以下这段代码: import { blocks } from "@/registry/blocks" import { examples } from "@/registry/examples" import { Registry } from "@/registry/schema" import { ui } from "@/registry/ui" const registry: Registry = [...ui, ...examples, ...blocks] try { // safeParse 方法相比 parse 方法在验证失败时不会抛出错误,其返回值是一个对象,它包含两个属性:success 和 data 或 error const result = registrySchema.safeParse(registry) if (!result.success) { console.error(result.error) process.exit(1) } await buildRegistry(result.data) await buildStyles(result.data) await buildThemes() console.log("✅ Done!") } catch (error) { console.error(error) process.exit(1) } 其中@/registry/ui维护的就是所有组件的信息: import { Registry } from "@/registry/schema" export const ui: Registry = [ { name: "accordion", type: "components:ui", dependencies: ["@radix-ui/react-accordion"], files: ["ui/accordion.tsx"], }, ... ] 执行buildRegistry会根据@/registry/ui生成registry/index.json文件。 const REGISTRY_PATH = path.join(process.cwd(), "public/registry") async function buildRegistry(registry: Registry) { const project = new Project({ compilerOptions: {}, }) async function createTempSourceFile(filename: string) { const dir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-")) return path.join(dir, filename) } // ---------------------------------------------------------------------------- // Build registry/index.json. // ---------------------------------------------------------------------------- const names = registry.filter((item) => item.type === "components:ui") const registryJson = JSON.stringify(names, null, 2) rimraf.sync(path.join(REGISTRY_PATH, "index.json")) await fs.writeFile( path.join(REGISTRY_PATH, "index.json"), registryJson, "utf8" ) } 执行buildStyles会生成包含组件源码的registry/styles/[style]/[name].json文件,这些生成的 json 文件放在public目录下,在网站部署的时候public目录的文件都会拷贝到网站部署的根目录下,所以后续就能通过域名+[json文件路径]的地址加载这些 json 文件。 // ---------------------------------------------------------------------------- // Build registry/styles/[style]/[name].json. // ---------------------------------------------------------------------------- async function buildStyles(registry: Registry) { for (const style of styles) { const targetPath = path.join(REGISTRY_PATH, "styles", style.name) for (const item of registry) { if (item.type !== "components:ui") { continue } const files = item.files?.map((file) => { // // 对应 apps\www\registry 下组件代码路径 const content = readFileSync( path.join(process.cwd(), "registry", style.name, file), "utf8" ) return { name: basename(file), content, } }) const payload = { ...item, files, } await fs.writeFile( path.join(targetPath, `${item.name}.json`), JSON.stringify(payload, null, 2), "utf8" ) } } } shadcn 用到的一些 nodejs 工具​ commander​ 注册 nodejs cli 命令以及 cli 参数,以及获取 cli 输入,执行操作等。 prompts​ cli 输入交互 zod​ zod 是一个用于 TypeScript 和 JavaScript 的数据验证库。它允许开发者定义一个模式(schema),这个模式描述了数据应该如何结构化,以及每个字段的类型是什么。可以在 node 和 web 环境下使用。 cosmiconfig​ 查找或读取json,yml等配置文件 tsconfig-paths​ 解析tsconfig.json中定义的paths的模块路径。 chalk​ cli 打印颜色文字 ora​ cli loading 交互效果 node-fetch​ nodejs 中使用 fetch tsx​ 支持直接在 node 命令行中执行 typescript 文件。

2024/4/28
articleCard.readMore

TypeScript全局类型定义的方式

TypeScript 全局类型定义或者覆盖在日常开发中经常使用,本文主要介绍几种常见的方式。 使用declare global命名空间​ 在包含在 TypeScript 类型检测文件目录内的任意位置新建xxx.d.ts文件,并使用declare global全局命名空间语法来定义覆盖类型,例如: declare global { /*~ *~ 定义 String 类型实例上的方法 */ interface String { fancyFormat(): string; } } /* 文件内必须包含一个 export 语句 */ export {}; 也可以使用declare global定义一些全局变量,全局类型,全局方法等: declare global { let timeout: number; const version: string; function checkCat(c: Cat, s?: VetID); class Cat { constructor(n: number); readonly age: number; purr(): void; } interface CatSettings { weight: number; name: string; tailLength?: number; } /*~ *~ 定义 Window 实例上的属性或者方法 */ interface Window { a: string; myFn: VoidFunction; } } export {} 注意 使用declare global定义全局类型时,该文件内部必须包含至少一个export语句! 使用declare module命名空间​ 如果要对一个第三方的包覆盖其类型定义,可以使用import <module>和declare module语法,例如覆盖axios的类型定义。 axios在其实例方法上定义的类型存在一个无用的泛型参数,这个参数在使用get、post等方法时必须要传,给开发带来了一些不便;同时项目自身可能会对axios进行封装,添加一些额外的config参数,因此我们可以在项目中通过以下方式来全局覆盖axios自身的类型定义: /* */ import axios from 'axios'; /** * 覆盖 AxiosRequestConfig */ declare module 'axios' { /** * 自定义配置参数 */ export interface AxiosRequestConfig { /** * 即时更新 */ useTimeStamp?: boolean; } // https://github.com/axios/axios/issues/1510#issuecomment-525382535 export interface AxiosInstance { request<T = any> (config: AxiosRequestConfig): Promise<T>; get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>; delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>; head<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>; post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T>; put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T>; patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T>; } } 使用全局模块类型声明​ 在包含在 TypeScript 类型检测文件目录内的任意位置新建xxx.d.ts文件,内部可以随意定义任何类型,但是不能包含任何export和import语句,例如: /*~ *~ 全局函数 */ declare function myLib(a: string): string; declare function myLib(a: number): number; /*~ *~ 全局类型 */ interface Person { name: string; age: number; } /*~ *~ 全局对象 */ declare namespace myLib { /*~ *~ myLib.version */ const version: string; /*~ *~ new myLib.Cat(); */ class Cat { constructor(n: number); readonly age: number; purr(): void; } /*~ *~ const a: myLib.CatSettings = { weight: 5, name: "Maru" }; */ interface CatSettings { weight: number; name: string; tailLength?: number; } /*~ *~ myLib.checkCat(c, v); */ function checkCat(c: Cat, s?: VetID); } 也可以直接定义Window上的变量、方法等。 interface Window { a: string; myFn: VoidFunction; }

2024/4/1
articleCard.readMore

IntersectionObserver API 用法

当我们说到图片懒加载、页面数据的滚动加载这些体验设计时,一般能够想到基于scroll事件,通过getBoundingClientRect方法获取元素相对于视口偏移量top,来判断元素是否可见,demo 如下 这种实现方式较为繁琐,但是现在我们有了IntersectionObserver API,可以大大简化这些通过计算元素偏移量来判断可视性的逻辑。 W3C 在 2017-09-14 正式发布了IntersectionObserver API 草案,其功能上提供了检测目标元素在祖先元素或 viewport 内可视情况变化的方式,这种观测是异步的,保证了开发上的便捷和性能上的高效。 IntersectionObserver类型定义​ IntersectionObserver本身是一个构造函数,需要通过new来初始化调用,基本使用方式如下: // 获取目标元素 DOM 对象 const observeTarget = document.querySelector("#targetId"); // 初始化IntersectionObserver实例 let observer = new IntersectionObserver((entries) => { entries.forEach(e => { // e.isIntersecting 为 true,则目标元素变得可见 if (e.isIntersecting) { } }) }, { threshold: [0, 1] }); // 开始监测目标元素 observer.observe(observeTarget); // 取消监测 observer.unobserve(observeTarget); observer.disconnect(); 下面是详细的 TypeScript 类型定义: /* * 构造函数 */ declare var IntersectionObserver: { prototype: IntersectionObserver; new(callback: IntersectionObserverCallback, options?: IntersectionObserverInit): IntersectionObserver; }; /* * 构造函数初始参数1:回调函数 */ interface IntersectionObserverCallback { (entries: IntersectionObserverEntry[], observer: IntersectionObserver): void; } /* * 构造函数初始参数2:回调函数执行条件设定 */ interface IntersectionObserverInit { /* * 目标元素的包含元素,如果不指定则默认为 viewport */ root?: Element | Document | null; /* * 目标元素相对于 viewport 或者包含元素的偏移量 * 注意:负值表示目标元素在包含元素内部偏右或者偏下的位置 */ rootMargin?: string; /* * 目标元素触发回调函数的 */ threshold?: number | number[]; } /* * IntersectionObserver对象属性和方法 */ interface IntersectionObserver { readonly root: Element | Document | null; readonly rootMargin: string; readonly thresholds: ReadonlyArray<number>; /* * 停止监测所有元素 */ disconnect(): void; /* * 开始监测目标元素 */ observe(target: Element): void; /* * 获取所有目标元素的监测对象 */ takeRecords(): IntersectionObserverEntry[]; /* * 停止监测目标元素 */ unobserve(target: Element): void; } /* * 回调函数获取的目标元素的监测变量 */ interface IntersectionObserverEntry { /* * 目标元素在 viewport 内部的坐标 */ readonly boundingClientRect: DOMRectReadOnly; /* * 目标元素可见比例 */ readonly intersectionRatio: number; /* * 目标元素可见区域在 viewport 内部的坐标 */ readonly intersectionRect: DOMRectReadOnly; /* * 目标元素是否可见 */ readonly isIntersecting: boolean; /* * 目标元素的root元素可见区域的坐标 */ readonly rootBounds: DOMRectReadOnly | null; /* * 目标元素 DOM 对象 */ readonly target: Element; /* * 相对于创建文档的时间,当元素可见性发生改变的时间对象 */ readonly time: DOMHighResTimeStamp; } 在初始化IntersectionObserver对象的时候,需要重点理解root和threshold的概念。 root​ root可以在初始化IntersectionObserver时指定,表示目标元素相对的容器元素,如果未指定则为html根元素。 root和目标元素都被看成是矩形盒子,而rootMargin则是目标元素相对于root的位置偏移量,因为root内部还可能排布的有其他元素或者root本身具有不为0的margin属性。 当rootMargin取正值时,元素在viewport或者祖先元素内部向上或者向左偏移 当rootMargin取负值时,元素在viewport或者祖先元素内部向下或者向右偏移 (下图引自Building A Dynamic Header With Intersection Observer — Smashing Magazine) threshold​ threshold用于指定一个或者多个在0 ~ 1范围内的数字,数字代表的是一个比例,表示元素在root内部可见面积相对于root的百分比。 当目标元素可见面积比例发生改变且在指定的阈值时,就会触发IntersectionObserver初始化时的回调函数,如果不指定,则默认为0,即元素在完全不可见和可见时触发回调函数。 警告 threshold并不能保证回调函数一定在这些指定的阈值到达时执行。 IntersectionObserver用例​ 判断元素是否可见​ 通过IntersectionObserver我们可以判断元素是否可见的两种情况: 在document的viewport内部是否可见,此时元素相对于浏览器窗口或者iframe定位 在祖先元素内部是否可见 Image Lazyload​ Sticky Header BoxShadow​ Infinite Scroll​

2022/10/29
articleCard.readMore

web图像格式对比(三)

无损压缩​ 无损压缩目前使用最广泛的图像格式是 PNG,但是近几年也出现了许多新的压缩算法标准,以期望替代 PNG,提供更好的图像压缩效果。 BMP​ BMP,BMP 是编码原始图像的一种文件格式,一般采取不压缩的方式存储图像数据,因此文件体积非常大,不推荐在 web 使用。 文件格式开发商 BMP微软 开发商微软 发布时间1987 年跟随 Windows2 发布 文件名后缀.bmp,.dib MIMEimage/bmp``image/x-bmp 色深最高支持 32 bpp,使用压缩算法后只支持 4bpp 或 8bpp 最大图像尺寸2 ^ 31 - 1 兼容性全平台 GIF​ GIF,Graphics Interchange Format,图形交换格式,使用 LZW 无损压缩算法来压缩图像数据,可以将多个图像存储在一个文件中,因此可以用 GIF 来制作动画帧。 GIF 的历史​ GIF 最初版本为 87a,由美国 CompuServe 公司在 1987 年发布,由于其支持彩色图像和使用高速的编解码算法,所以很快流行起来,但是此时的 GIF 还不支持动画。理论上,GIF 是最早在互联网流行的图像格式,比 JPEG 还早了三四年。 1989 年,CompuServe 公司又发布了一个增强版本 89a,此版本为 GIF 增加了图形控制拓展,允许 GIF 文件中的每一幅图像可以在等待一定的时间延迟后进行绘制,从第一幅图像开始绘制到最后一幅结束,不支持循环。 为了让 GIF 能够自动循环播放图像,Netscape 公司开发者在 1995 年在 GIF 动画帧序列前添加了一个表示帧播放次数的数据块,指定帧应该被播放的次数,从 0~ 65535 次,0 就表示永远循环,从此便有了强大的动画表情包。后续 Netscape 也将对 GIF 的支持添加到了浏览器中,后续随着网络发展,各大浏览器也都支持了,直到今天仍然使用的是 1989 年发布的 89a 标准。 开发者美国 CompuServe 公司 发布时间1987 色深8 bpp 是否支持 alpha 通道不支持,但是 GIF 支持为每个颜色分量指定一个索引位作为透明背景颜色 支持动画支持 支持隔行扫描支持,允许部分下载渐进式解析 文件格式GIF 文件名后缀.gif MIMEimage/gif 最大图像尺寸65536×65536 兼容性全平台 缺陷GIF 的缺陷主要由以下方面:GIF 无损压缩图像的压缩比不如视频压缩,导致多帧画面编码得到的文件体积过大;目前,越来越多的图像压缩标准支持动画,例如 webp,apng,heif 等,它们都具有更高压缩比和更出色的显示效果,谷歌开发者也推荐将 GIF 转换成 webm, mp4 等视频格式来减小体积;GIF 最多支持 256 种颜色,制作不了全彩图像,无法满足设计群体的要求 GIF 更多的还是应用于 1MiB 下的动画图像,其余场景都有更好的图像格式选择。 开源工具imagemin-gifsicle:nodejs 使用 gifsicle 优化 GIF 图像 gif2webp:将 GIF 转换成 webpezgif:图像格式转换网站 PNG/APNG​ PNG,Portable Network Graphics,便携式网络图形,最初由一群计算机图形开发专家(PNG Development Group)组织开发,初衷是应对 GIF 使用的压缩算法 LZW 的专利争端。 PNG 提供的文件格式本身只支持编码单个图像,直到 2001 年,开发者发布了 MNG(多图像网络图形) 图像格式,让 PNG 也能像 GIF 那样支持动画,但是 MNG 格式并未得到主流浏览器的支持,早期只是 mozilla 和 netscape 支持,IE,chrome,safari 这些浏览器则从未支持 MNG。 此后,mozilla 开发者基于 PNG 开发出了 APNG(动画便捷式网络图形)格式并于 2008 年发布,兼容 PNG 格式的同时,支持动画显示,比 GIF 支持更高色深和更高的压缩比,动图质量更高且体积更小。但是 PNG 开发小组一直拒绝将 APNG 作为拓展纳入自己的标准,也就 mozilla 在背后推广,这就导致 APNG 一直没普及使用,再加上 mozilla 近年来相比 chrome 越来越势弱,APNG 的未来前景渺茫。 PNGAPNG 开发者PNG Development Group(PNG 开发小组)mozilla 发布时间1996,2003 年纳入 w3c 标准2008 色深32 bpp32bpp 是否支持 alpha 通道支持支持 支持动画不支持支持 支持隔行扫描支持,允许部分下载渐进式解析,但会增加图像体积支持 文件格式PNG,APNG 文件名后缀.png.png MIMEimage/pngimage/png,image/apng 最大图像尺寸2 ^ 31 - 12 ^ 31 - 1 兼容性PNG 全平台兼容APNG 只有 IE 不支持 开源库libpng:编解码 pnglibpng-apng:编辑码 APNG gif2apng:使用 c++ 开发转换 gif 到 apng 性能相比 GIF 可以减少 20% 的体积,同时可编码的颜色更多,图像质量更高。 使用率PNG 本身基本没有缺陷,在 web 上的使用率也一直是最高的普及率不高,使用者较少 示例 Webp​ webp 实际本身主打的是无损压缩静态图像的格式,其无损压缩格式比 PNG 可以减少 26% 的体积。 开发者google 发布时间2010 文件格式RIFF,RIFF 是微软和 IBM 于 1991 推出的一种容器格式,可用来编码位图,音频,视频等数据形式,在此文件格式基础上还衍生出了 AVI,WAV 等文件格式。 文件名后缀.webp MIMEimage/webp 色深32 bpp Alpha 通道支持 8 bit 最大图像尺寸16383 x 16383 支持动画支持 兼容性除了 IE 基本都支持 开源库libwebp:包含在 JPEG、PNG 和 webp 之间互相转换的 CLI 工具,cwebp 可以将 PNG、JPEG 转换成 Webp,dwebp 可以将 webp 转换成 PNG img2webp:从一系列输入图像创建动画 WebP 文件的 CLI 工具 gif2webp: 将 GIF 图像转换为 WebP 的 CLI 工具 libwebp2:第二版 libwebp imagemin-webp:nodejs 工具,转换 JPEG/PNG 到 webp 格式 sharp:nodejs 工具,转换其他图像格式到 webp 普及率就目前来看,使用 webp 网站还是相对较低,75% <-> 4.8%,有趣的是,google 目前自己也很少使用 webp,之前在 webstore 用过一段时间后又替换回了 jpeg 和 png HEIC​ HEIC(High Efficiency Image Container,高效图像容器格式),是编码经过 HEVC(H.265,High Efficiency Video Coding)压缩过的图像的文件格式,是 IOS 11+ 系统内部默认图像格式,目前仅在 IOS 和 macOS 内部默认支持。 HEICHigh Efficiency Image Container 开发者MPEG(Moving Pictures Experts Group) 发布时间2013 文件格式HEIF(High Efficiency Image File Format),由 MPEG 开发并于 2015 年推出的一种容器格式,支持以不同的文件编码格式存储单个或多个图像,例如用来编码 HEVC 压缩过的图像使用 HEIC 文件格式,编码 AV1 压缩过的图像使用 AVIF 文件格式。 文件名后缀.heic,.heics MIMEimage/heic,image/heic-sequence 最大色深16 bpp 最大图像尺寸8192×4352(8k) 是否支持透明支持 alpha 分量 是否支持动画支持 是否支持隔行扫描不支持 兼容性 开源库libheif:支持编解码 HEIF 和 HEIC 的 c++ 库 sharp:nodejs 转换图像格式的工具,支持 HEIC AVIF​ AVIF(AOMedia Video Image Format,AOM 视频图像格式),是编码经过 AV1(AOMedia Video 1)算法压缩过的图像的文件格式,由 AOMedia(开放媒体联盟)开发维护,最初规范 1.0.0 于 2019 年 2 月发布,现今仍然沿用这个版本的规范。 AOMedia 由目前一些最具影响力的互联网公司组成,例如亚马逊、谷歌、苹果,Netflix,国内的有腾讯、阿里等等,这些公司通过共享其各自掌握的专利技术开发出了免版税的视频流数据压缩算法 AV1,以替代由 MPEG 推出的视频压缩算法 HEVC,避免向 HEVC 背后的专利持有者支付日益高昂的专利费。 AVIFAOMedia Video Image Format 开发者AOMedia 发布时间2019 文件格式HEIF(High Efficiency Image File Format),由 MPEG 开发并于 2015 年推出的一种容器格式,支持以不同的文件编码格式存储单个或多个图像,例如用来编码 HEVC 压缩过的图像使用 HEIC 文件格式,编码 AV1 压缩过的图像使用 AVIF 文件格式。 文件名后缀.avif MIMEimage/avif 最大色深12 bpp 最大图像尺寸8192×4352(8k) 是否支持透明支持 alpha 分量 是否支持动画支持 是否支持隔行扫描不支持渐进式解析,必须完整下载图片才开始解析 兼容性Edge 虽然用了 chromium 内核,但是不支持 avif 开源库libavif:官方使用 C 编写的编解码 avif 的库 avif.js:avif polyfill,为不支持 avif 的浏览器提供支持 sharp:nodejs 转换图像格式的工具,支持 avif avif.io:支持转换其他图像格式到 avif 的网站 JPEG XL​ JPEG XL 是 JPEG(联合图像专家组)开发的新一代图像压缩算法和文件格式,建立在 google 开发的 PIK 和 cloudinary 开发的 FUIF(Free Universal Image Format)基础上,其中 FUIF 又是建立在 FLIF(Free Lossless Image Format) 项目基础上,目前 PIK、FLIF、FUIF 都被废弃了,这些项目的开发者都已经转头全力支持 JPEG XL 编解码库 libjxl 的开发。 JPEG XL 旨在替代当前的 JPEG 标准,其支持无损/有损压缩,也支持动画,并且免版税,其标准化的编码算法在 2022 年 5 月份才发布,标准号 ISO/IEC 18181。 JPEG XLJPEG extended longterm 开发者JPEG 发布时间2022 文件名后缀.jxl MIMEimage/jxl 最大色深32 bpp 最大图像尺寸(2^30 - 1) * (2^30 - 1) 是否支持透明支持 alpha 分量 是否支持动画支持 是否支持隔行扫描支持,可以渐进式解析 兼容性暂无平台支持,但是 chrome 和 firefox 从 90 版本进入测试阶段,可以通过 flags 开启,例如 chrome://flags#enable-jxl 开源库libjxl:使用 c++ 实现的编解码 JPEG XL 库 squoosh:在线转换图像格式,支持 JPEG XL libSquoosh:nodejs 转换图像格式的工具,支持 JPEG XL

2022/8/31
articleCard.readMore

web图像格式对比(四)

nodejs 图像处理库​ 说完了有损压缩和无损压缩的图像格式,来整理下 nodejs 方面图像处理相关的库。 sharp​ 基于 libvips 实现,提供 JPEG、PNG、GIF、Webp、AVIF 以及 SVG 图像格式的处理,包括图像格式转换,图像尺寸缩放,图像组合,图像旋转,等等多种操作,也支持将图像转换成 base64 字符串。 例如将 png 转换成 avif import sharp from 'sharp'; sharp('./images/rgb.png') .toFormat('avif', { quality: 50 }) .toFile('build/images/rgb.avif') .then(info => console.log(info)); 例如将 jpeg 转成 base64 const resizedImageBuf = await require('sharp')(pathToMyImage) .toBuffer(); console.log(`data:image/png;base64,${resizedImageBuf.toString('base64')}`); imagemin(停止维护)​ imagemin 是插件式的压缩图像的工具,可通过引入自定义的插件来支持不同图像格式的转换和图像尺寸的优化,文档相对简陋,并且本身已停止维护,常用的插件有以下这些: imagemin-jpegtran:优化 jpeg 体积 imagemin-optipng:优化 png 体积 imagemin-gifsicle:优化 gif 体积 imagemin-webp:转换到 webp imagemin-svgo:基于 svgo 优化 svg import imagemin from 'imagemin'; import imageminWebp from 'imagemin-webp'; await imagemin(['images/*.{jpg,png}'], { destination: 'build/images', plugins: [ imageminWebp({ quality: 100 }) ] }); libsquoosh​ Google 开发的 libSquoosh,支持使用 nodejs cli 转换图像格式的工具,支持 JPEG XL,具体的可以看这篇介绍 —— Introducing libSquoosh 其他构建工具​ Webpack​ 版本loaderplugin <= 4url-loader:支持读取图像并转换成 base64file-loader:支持读取图像imagemin-webpack-plugin(停止维护) image-minimizer-webpack-plugin:支持使用 imagemin 和libsquoosh 优化图像尺寸 5内置支持 - assets module Vite​ vite-plugin-imagemin:使用 imagemin 压缩图像 Rollup​ @rollup/plugin-image:支持import jpeg,png 等图像文件的语法并打包

2022/8/31
articleCard.readMore

web图像格式对比(一)

背景​ 根据 httpachive 的统计数据,在统计的 5,431,533 个 PC 页面中: 图像流量平均占页面的 44.5%(1032.0 KB -> 2317.8 KB) 请求数量平均占页面的 33%(25 -> 76) 所以图像数据在网站内容部分占比巨大,使用合适的图像格式能有效降低网站的流量带宽,同时降低网络传输延时,提升用户体验。 数字图像分类​ 原始图​ Raw Image,原始文件,一般是通过单反相机拍摄得到的原始文件,保留大量图像细节,但是所需的文件存储空间非常大,可以通过 ps 等图像编辑软件处理,并且转换压缩后得到光栅图,然后才能在其他图像查看软件正常显示。 常见的原始文件格式一般有 BMP,adobe 的 DNG,佳能相机的 CR2 等。 光栅图​ Raster Image,光栅图,或者叫位图,由像素组成。 像素​ pixel,像素,每个像素都是原始图像的一个采样样本,也是图像的最小可编辑单元。在计算机显示设备上,每个像素的颜色有三个分量,也就是三原色(RGB-红绿蓝)。 通过改变 RGB 每个分量的权重来达到组合成其他颜色的目的,这就需要提到色深的概念。 色深​ 色深是编码单个像素点所使用的 bit 数,使用 bpp(bit per pixel)单位。色深主要用来表示单个像素所能展示的颜色数量,呈 2 的指数级关系: 1 bpp,2^1 = 2 种颜色,也就是黑色和白色 2 bpp,2^2 = 4 种颜色 8 bpp,2^8 = 256 种颜色 24 bpp,2^24 = 16777216 种颜色,能达到人眼识别的颜色范围 32 bpp,在 24 位色深的基础上增加 8 bit 单独的 alpha 分量,然后通过每个颜色分量和 alpha 分量叠加运算改变其透明度;支持 alpha 分量的图像可以单独显示出图像内部透明区域,例如实现透明背景 矢量图​ Vector Image,矢量图,基于笛卡尔坐标系内的点组成的几何图形,常见的也就是 SVG 格式的图像。 位图和矢量图的对比​ 矢量图的呈现不会受到图像尺寸的影响,这是相对于位图的优势; 矢量图支持编程化动态修改其图像本身的尺寸,颜色等细节; 位图比矢量图具有更丰富的颜色细节,更适合表现真实世界的物品和艺术创作,而矢量图往往应用于工程制图方面; 从位图到矢量图的转换叫图像追踪或光栅矢量化,相反,从矢量图转换成位图叫光栅化。 图像压缩概念​ 为什么需要压缩​ 从色深和图像的像素数可以计算出一张原始图像所需的存储空间大小: $$ width * height * n (bpp) / 8 = [size] byte $$ 例如一张 1920 * 1080 像素宽高的 24 bpp 图像,所需约 7.91 MB,那么对于标称 500G 的硬盘,完全占用的情况只能存储 63254 张该图像;不仅如此,考虑到图像需要在不同设备或网络上传输,这个体积也是无法接受的,所以就需要图像压缩算法来减小图像数据的体积,节约在计算机设备上存储成本以及网络传输的带宽成本。 图像压缩分为有损压缩和无损压缩: 无损压缩指的是计算机根据逆向的解压缩算法能完全恢复原始图像, 有损压缩则无法从解压缩算法恢复得到原始图像,所以有损压缩后的图像显示效果可能在人眼观察上有所降低。 经过图像压缩算法得到的数据还需要指定文件编码格式,以便于图像数据在计算机系统上存储、读取和共享,文件格式通常和文件名后缀是对应的,但是具体的文件格式还是要根据文件编码的开头数据来确定。 压缩比率​ 压缩软件一般都会提供压缩比选择范围,压缩比是 原始数据/压缩后数据 计算得到的比率,同时,也会提供图像质量参数供选择,开发者需要权衡质量和图像体积选择的权重。 $$ compression ratio = uncompressed size / compressed size $$ 解析模式​ 图像编解码算法有两种解析模式,基线解析和渐进式解析: 基线解析:如果是从 web 加载,需要完全下载图像后从上往下解析图像并显示; 渐进式解析:渐进式相比基线编码式采用更复杂的隔行扫描式编码方式,同时解码也是隔行扫描解码,所以相比基线解码从上往下解析逐渐得到整张图像的方式,隔行扫描会更快看到整张图像的大致影像;应用于 web 方面,浏览器在下载到图像的部分数据后就能解析呈现整张图像的模糊缩影,后续继续下载数据再慢慢绘制整张完整的图像,从而优化慢速网络环境下的用户体验。 参见基线编码模式下的 JPEG 和渐进式编码模式的 JPEG 解析对比:https://nerds.sh/snippets/baseline-vs-progressive.html 图像兼容性处理​ 如果网站在使用一些较新的图像压缩格式(avif,webp等)时,可能在不同的浏览器上会出现兼容性问题,这时候可以通过 HTML5 的picture元素来解决。 picture元素内部可以包含多个source元素和一个img元素,浏览器会首先根据source的media或type等属性来检测是否支持加载改图像,如果不匹配或者浏览器本身不支持picture元素,则直接采用img指定的图像。 <picture> <source srcset="photo.avif" type="image/avif"> <source srcset="photo.webp" type="image/webp"> <img src="photo.jpg" alt="photo"> </picture> source 元素​ source元素包含以下属性: media:指定媒体查询条件 type:指定 MIME type,浏览器不支持就会跳过 srcset:指定一个或多个图像的 URL,多个图像的 URL 使用逗号分隔 <source srcset="mdn-logo-wide.avif" media="(min-width: 600px)" type="image/avif"> picture 元素的兼容性​ 除了 IE 都支持(IE 于 2022-05-26 停止维护)

2022/8/30
articleCard.readMore

web图像格式对比(二)

有损压缩​ 有损压缩目前使用最广泛的是 JPEG 压缩算法和文件格式,下面来整理一下三种有损压缩格式。 JPEG​ JPEG 是 Joint Photographic Experts Group(联合图像专家组) 的首字母缩写,其前身是 —— Joint Bi-level Image Experts Group(联合二进制图像专家组),JBIG 负责制定二进制图像(二进制图像的每个像素只能使用一个 bit 表示黑色或者白色,也就是黑白图像)的压缩编码格式,例如 JBIG1 和 JBIG2;联合图像专家组负责维护 JBIG 标准以及制定彩色图像压缩新的算法标准。 实际上,联合图像专家组于 1992 年发布 JPEG 标准后还制定了一系列其他压缩标准,以下是 JPEG 家族的图像压缩标准: JPEG JPEG 2000 JPEG LS(lossless) JPEG XR(extended range)JPEG XT(extended tone still image) JPEG XS(extended extra small) JPEG XL(extended longterm) 发布时间1992199719982011201520192022 文件格式JFIF,Exif,TIFFJP2,JPXJXRJFIFJXSJXL 文件后缀名.jpeg,.jpg,.jfif,.jif,.jpe,.tifjp2, .j2k, .jpf, .jpm, .jpg2, .j2c, .jpc, .jpx, .mj2.jls.jxr,.hdp,.wdp .jpeg,.jpg,.jfif,.jif,.jpe.jxs.jxl 有损/无损有损,也支持无损但不常用有损,也支持无损但不常用无损有损/无损无损有损有损/无损 最大图像分辨率(2^16 - 1) * (2^16 - 1)(2^32 - 1) * (2^32 - 1)(2^30 - 1) * (2^30 - 1) 色深8 bpp32 bpp16 bpp8 bpp16 bpp24 bpp,32 bpp 是否支持 alpha 通道不支持支持支持支持支持支持 MIME typeimage/jpegimage/jp2, image/jpx, image/jpm,image/jxr,image/vnd.ms-photoimage/jpegimage/jxsc, video/jxsvimage/jxl 兼容性全平台只有 safari 支持已知 Chrome 有查看医学成像的插件 —— medical-image,其他平台未知只有 IE 支持暂无平台使用暂无平台使用暂无平台支持,但是 chrome 和 firefox 从 90 版本进入测试阶段,可以开启实验性特性 对比相比 JPEG 标准,JPEG-2000 着重提升码流的灵活性组织,使得图片传输可以渐进式加载:也就是用户在收到整个文件的一小部分后,查看者可以看到最终图片的质量较低的版本,然后通过从源下载更多数据位,质量会逐渐提高。JPEG LS 主要优化了 JPEG 无损压缩部分的算法,可以实现 2:1 的压缩比,并且提高了编解码速度,主要应用于医学成像领域,例如 X 光片。与 JPEG 相比,JPEG XR 文件格式支持更高的压缩比,以对具有同等质量的图像进行编码。JPEG XS 被称为视觉无损低延迟轻量级图像,主要适用于网络码流传输场景下的图像压缩,因为其具有超高速的编解码压缩算法和高达 10:1 的压缩比,同时其编码器可以根据带宽动态调整压缩比来完美匹配可用带宽,从而减少图像传输延迟时间。目前被图像专家组纳入标准,旨在替代当前的 JPEG 压缩标准,有损压缩相比目前的 JPEG 可以减小约 60% 的体积,同时优化了高压缩比下的图像质量,无损压缩相比 PNG 减少 35% 的体积。 开源库libjpeg:官方于 1992 发布的编解码 jpeg 库 libjpeg-turbo:社区基于 libjpeg 实现的增强版本,于 2010 发布,相比 libjpeg ,编码速度提升 26 倍,因此使用更广泛,例如 chromium mozjpeg:mozilla 开发者于 2014 推出的版本,基于 libjpeg-turbo,相比 libjpeg-turbo 压缩的文件体积减小 10% 左右 Guetzli:google 于 2016 年推出的基于 libjpeg 实现的版本,相比 libjpeg 压缩得到的文件体积减小 2030% 备注TIFF(Tag Image File Format),标记图像文件格式,可以存储一张或者多张位图,可以不压缩,或者无损压缩、有损压缩; 但是通常文件体积都比较大,因此很少用于 web,浏览器也只有 Safari 支持读取这种文件格式。 Exif(Exchange Image File Format) 是拓展于 TIFF 的一种文件格式,可用于编码压缩后的 JFIF 文件, 同时也可以用于音频文件的编码。 ICO​ ICO,icon,最初由微软公司开发用于 windows 系统应用程序显示在桌面、任务栏以及文件管理器内部的图标,在 web 上可以用于 favicon(favorite icon)。 属性解释 发布时间1999,2010 年通过 html5 规范支持link的rel=icon属性 MIMEimage/vnd.microsoft.icon,image/x-icon 文件名后缀.ico 色深8 bpp,24 bpp,32 bpp 最大尺寸256×256 兼容性全平台 favicon 通常用于显示在浏览器标签页 title、收藏夹等位置的图标,通过 HTML 规范定义的link标签和rel=icon或者rel=apple-touch-icon来嵌入到页面代码中,并且可以通过sizes属性来提供跨浏览器支持。 浏览器默认支持的尺寸要求如下: Chrome desktop:32×32(大部分浏览器都默认支持 32×32) Safari desktop:32×32 IOS Safari:180×180 Android Chrome:196×196 IE:16×16, 32×32, 48×48 <link rel="icon" sizes="32x32" href="icon.png"> <link rel="icon" sizes="180x180" href="icon-180x180.png"> <link rel="apple-touch-icon" sizes="180x180" href="icon.png"> <link rel="icon" href="/favicon.svg" type="image/svg+xml"> 目前 favicon 使用 PNG 的更多,但是未来的趋势是用 SVG 格式,因为 SVG 没有尺寸限制,不受设备分辨率影响且通常体积小于 PNG,而且支持运行时动态修改其样式,不足点是 svg favicon 的兼容性非常不好,目前仅在使用 chromium 内核的浏览器支持。 Webp​ Webp(读作weppy),webp 支持有损压缩,但是有损压缩效果在某些时候要弱于 JPEG,这里不多赘述,见后面文章无损压缩的分析。

2022/8/30
articleCard.readMore

混合内容请求限制

背景​ 这两天又遇到一个图片问题,具体就是在 HTTPS 协议网页请求完的 HTTP 图片会被限制下载,这...,web 静态资源的问题还挺多坑。 什么是混合内容​ Mixed Content 来自于 W3C 规范的定义 —— 混合内容 (以下简称规范),简单来说,当使用 HTTPS 请求的网页中包含其他通过 HTTP 请求的资源时,这些通过 HTTP 请求的内容就属于 Mixed Content。 W3C 的这篇规范不建议浏览器一律阻止所有混合内容的请求,视情况而定,将混合内容分为两种: 可升级的混合内容请求,一般也被称为被动/显示混合内容 无法升级的混合内容请求,一般也被称为主动型混合内容 被动型混合内容​ 被动混合内容主要是img,video,audio这些元素通过src属性指定的 URL 发起的请求,其本身既不受同源策略的限制,也不会受到这里混合内容的访问限制,并且浏览器还会自动帮助完成 HTTPS 访问的升级,然后在浏览器控制台给出一条如下提示内容,具体的可以移步这个网页观察细节 —— mixed content example (mixed-content.vercel.app) 如果资源本身无法通过 HTTPS 访问,那么资源不会被加载并且报错。 W3C 也有个响应头来限制所有 HTTPS 站点内的 HTTP 请求 —— Content-Security-Policy: block-all-mixed-content,可以通过meta标签指定,不过这个请求头支持的浏览器比较少,目前这个属性值是被弃用了。 <meta http-equiv="Content-Security-Policy" content="block-all-mixed-content" > 主动型混合内容​ 主动性混合内容是指可以直接获取并修改页面数据的请求,也就是XMLHttpRequest和fetch发起的 HTTP 请求,这些请求会被浏览器直接拦截,并不会发起跨域请求,然后在控制台提示报错信息。 警告 主动型混合内容无法通过前端解决,只能后端统一升级 HTTPS,或者对于页面能够通过 HTTPS 请求的内容,手动修改请求 URL 的协议。

2022/7/12
articleCard.readMore

私有网络访问限制

背景​ 上一篇 blog 提到了跨域访问图像资源引起的 canvas 污染的问题,其实当时我在查看跨域请求到的图像资源时,还无意中发现了一个新的 HTTP 响应头Access-Control-Allow-Private-Network,这篇文章就来简单探讨一下这个非常新的跨域请求的特性。 Access-Control-Allow-Private-Network​ Access-Control-Allow-Private-Network是 W3C 社区草案 Private Network Access 草案(以下简称PNA)制定的新的 HTTP 响应头字段,要求浏览器在判定从公共网络请求私有网络或者本地网络的跨域请求场景时,必须先发送预检请求,同时携带Access-Control-Request-Private-Network: true请求头,只有当服务端携带了Access-Control-Allow-Private-Network: true响应头字段时,才能继续发送原始请求,否则提示跨域错误。 什么是私有网络请求​ PNA草案将域名ip分为以下三种类型,从public ip请求private ip或者localhost;以及从private ip请求localhost这两种情况都属于私有网络请求的范围,浏览器还可以拓展这个ip的限定范围。 local:本地ip private:私有ip public:公共ip 私有地址段​ 一般来说private ip和local ip也就是以下这些,包括了一些ipv6的地址段。 ipip 类型 127.0.0.0/8local 10.0.0.0/8private 172.16.0.0/12private 192.168.0.0/16private 169.254.0.0/16private ::1/128local fc00::/7private fe80::/10private 信息 ipv4 采用点分十进制表示,使用.分为 4 段,每段都由 1 个字节 8 位二进制数组成,转换成 10 进制表示范围就是 0~255 ipv6 采用冒分十六进制表示,使用:分为 8 段,每段由 2 个字节 16 位二进制数组成,但是表示的时候采用 16 进制的形式取四合一得到 4 个 16 进制的数;如果一个段内都是0,那么该段可以简写成一个0表示;同时如果连续两段以上都是0,可以简写成::来表示所有为0的段。 chrome 进展​ 从 Chrome 规划的时间线来看: Chrome 94 推出测试版,开始在控制台对私有网络请求显示警告提示; Chrome 102 版本测试结束,需要手动开启试验特性; Chrome 105 版本正式推出 测试效果​ 默认情况下,Chrome 102 稳定版后默认不会支持PNA,我们先来看下不开启的效果。 我这里使用了 Chrome 103 稳定版测试了PNA特性,测试步骤如下: 在 GitHub 新建一个只包含以下网页的仓库,也就是点击fetch就会请求本地网络的index.html文件,然后通过 vercel 发布到公共网络; <!DOCTYPE html> <html lang="en"> <head> <title>Home</title> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width" /> </head> <body> <div> <a href="javascript:;" onclick="fetchLocalhost()" aria-current="page" >fetch</a > </div> <script> function fetchLocalhost() { fetch('http://127.0.0.1:8080/index.js').then(async (res) => { console.log(await res.text()); }); } </script> </body> </html> 在本地电脑另建一个新的文件夹,添加index.js文件,通过http-server启动一个本地服务器,并开启CORS,这样index.html就会暴露在127.0.0.1:8080下,并且允许跨域访问; 然后打开 vercel 部署的公共网页,点击 fetch 发起请求,可以看到这里公共网络的请求顺利拿到了本地服务的index.js文件 开启 PNA​ 现在我通过chrome://flags手动开启下面的试验特性,可观察到以下结果: private-network-access-send-preflights:发送预检请求,并在响应头不包含Access-Control-Allow-Private-Network: true时只在控制台显示警告,而不阻止后续请求的发送和结果的获取; private-network-access-respect-preflight-results:仅在预检请求获取的响应包含Access-Control-Allow-Private-Network: true时才会发送后续请求,即使本地服务开启了CORS响应头Access-Control-Allow-Origin:*等也无济于事。 总结​ 总体来说,PNA这个跨域请求限制对于开发者来说非常安全,前端开发场景经常需要在本地启动开发环境的本地服务代理本地资源,如果开发期间不小心点了某个网页,其具有对本地服务攻击性的请求,那么数据泄露将是毁灭性的。其实这也是 CSRF 攻击的一种,具体的 CSRF 攻击探讨下篇继续。

2022/7/2
articleCard.readMore

canvas 污染问题

背景​ 前端时间实现了一个用canvas往模板图片绘制数据的功能点,遇到了一个跨域引起canvas污染的问题,仔细发掘下去发现不少的技术点。 tainted canvas​ 对于canvas污染的问题,这应该是非常常见的在处理跨域资源时会遇到的问题。 一般来说,利用canvas绘制图像的时候需要执行以下步骤: 创建canvas元素; 获取canvas元素的二维渲染上下文对象; 创建Image对象并加载; 等待图像加载完成的时候绘制在canvas上 const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); const img = new Image(); img.src = 'xxxx'; img.onload = () => { canvas.width = img.width; canvas.height = img.height; canvas.drawImage(img, 0, 0); } 当使用drawImage方法绘制一个不同源的图像时,此时并不会报错,但是canvas会变成tainted (被污染),之后如果在当前被污染的canvas上调用以下方法时就会抛出SecurityError的错误。 警告 HTMLCanvasElement.toDataURL() HTMLCanvasElement.toBlob() CanvasRenderingContext2D.getImageData() Uncaught SecurityError: Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported. Uncaught SecurityError: Failed to execute 'getImageData' on 'CanvasRenderingContext2D': The canvas has been tainted by cross-origin data. 为什么会出现 tainted canvas​ tainted canvas 其实还是牵扯到浏览器同源策略的限制问题,一般来说这个问题在使用XMLHttpRequest或者Fetch发起网络请求的情况比较多见,而在这里的目的是禁止使用canvas随意从另一个网站加载图片再转换成数据的强盗行为。 这看起来合情合理,但是现在大型网站一般都会走 CDN 服务器来缓存并代理资源访问,这就导致在动态加载图像的时候实际可能走的是 CDN 服务器域名,这就导致网页域名和资源域名不同源了。本来防别人的,这下连自己人也堵在外面了。 如何解决 tainted canvas 问题​ 要解决 canvas 污染的问题,需要 CORS + crossOrign 两步配置: 配置服务端响应头支持跨域请求的域名,请求方法等; 设置crossorigin属性 cors header 配置​ 关于服务端 CORS 的配置就不细说了,具体的可以看我的这篇文章: 提示 跨域直通车 —— 跨域与解决方案 | icodex crossorigin​ HTML 规范给crossorigin制定了三个允许值: anonymous或""(空字符串):匿名请求访问资源,不会在跨域请求的时候携带任何身份凭据; use-credentials:在跨域请求的时候携带身份凭据,仅当服务端响应头返回Access-Control-Allow-Credentials: true的时候才允许使用跨域资源 crossorigin无论设置成哪一个值都会指定浏览器以跨域的模式请求资源,去发送相关CORS相关的请求头,并通过检查服务端是否返回Access-Control-Allow-Orgin等响应头来判断用户是否有权限完全访问响应内容。但是为了客户端安全考虑,一般设置成anonymous更为合适,避免向陌生的服务端发送网页的cookie等身份数据。 放在canvas绘制image的代码里可以这样修改: const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); const img = new Image(); img.src = 'xxxx'; img.crossOrigin = 'anonymous'; img.onload = () => { canvas.width = img.width; canvas.height = img.height; canvas.drawImage(img, 0, 0); } 如果是img标签可以增加crossorigin属性: <img src="xxx" crossorigin="anonymous" /> 以下资源可能也需要crossorigin来取得数据访问权: 元素限制 img, audio, video当它们被放在canvas元素内部使用时 script使用 window.onerror link加载webmanifest时必须添加crossorigin属性

2022/6/29
articleCard.readMore

饥荒云服务器搭建

​ 玩饥荒的小伙伴都知道,饥荒这个游戏在创建多人房间的时候,会将房主的电脑作为本地服务器来中转数据,然后就经常遇到卡顿的问题。这篇文章尝试使用腾讯云轻量服务器来搭建饥荒的云服务器。 配置 镜像系统: CentOS7.6-Docker20 实例规格 CPU: 2核 内存: 4GB 系统盘 60GB SSD云硬盘管理快照 流量包 1000GB/月(带宽:6Mbps) SSH 远程连接 什么是 SSH​ SSH 全程Secure Shell,是一种加密的网络传输协议,比较常用于远程登录系统,例如传输命令行界面或者执行服务端命令等。 SSH 采用非对称加密方式来保护数据传输,这种加密方式也是 web 应用协议 HTTPS 的加密手段。这种加密方式需要两个密钥:公钥和私钥。在实际运用的时候,假如有通信双方 A 和 B,A 先使用密钥算法计算产生一个公钥 Pkey 和私钥 Skey,并将其告知 B,后续 B 在发送数据的时候先使用公钥 Pkey 加密然后传输,经过公钥 Pkey 加密的数据,只能通过 A 保留的 Skey 来解密,以此来保证数据的安全性。 SSH 身份验证有多种途径,其中一种方法是使用自动生成的公钥-私钥对来简单地加密网络连接,随后使用密码认证进行登录;另一种方法是人工生成一对公钥和私钥,公钥需要放在待访问的电脑之中,然后就可通过生成的密钥进行认证,这样就可以在不输入密码的情况下登录。 powershell 连接​ windows10 2018 年秋季更新(也就是 1809 版本)后内置 OpenSSH,可以直接通过 powershell 或者 cmd 执行 SSH 连接。 ssh root@<ip> // 然后提示输入密码 // 断开连接 logout 以往的 powershell 等也可以通过 Get started with OpenSSH | Microsoft Docs 这篇文档一步一步往下配置 openssh,然后通过ssh命令连接云服务器,不过需要注意两点: 新购买的云服务器需要先通过重置密码操作来配置 SSH 远程登录密码; powershell 需要以管理员身份运行。 windows terminal​ windows terminal 本身支持 SSH 连接,通过生成密钥对,并添加新配置的方式来完成自动登录和认证的过程,以此来提高效率。 windows terminal 支持可视化配置,只需要注意命令行这里为: ssh <username>@<ip> -p 22 -i ~/.ssh/id_rsa 更新系统 yum -y update 安装远程桌面 我个人对 linux 命令一窍不通,所以这里通过安装远程桌面的方式来安排! yum -y install epel-release && yum groupinstall Xfce yum -y install xrdp systemctl start xrdp echo "xfce4-session" > ~/.Xclients chmod +x .Xclients 然后通过 windows 的远程桌面客户端就可以连接服务器了 安装steamcmd 安装运行环境​ yum -y install glibc.i686 libstdc++.i686 screen libcurl.i686 安装 steamcmd​ 先创建steam/steamcmd目录,然后在该目录下执行安装steamcmd的命令并解压 curl -sqL "https://steamcdn-a.akamaihd.net/client/installer/steamcmd_osx.tar.gz" | tar zxvf - 安装饥荒服务器程序 安装完steamcmd后,就可以使用steamcmd安装饥荒服务器程序Don’t Starve Together Dedicated Server # 运行 steamcmd ./steamcmd.sh # 匿名身份登录 login anonymous # 安装 Don’t Starve Together Dedicated Server app_update 343050 validate +quit 安装完Don’t Starve Together Dedicated Server以后,就可以在steam/steamapps/common下找到该程序,为了方便以后命令操作,将该文件夹名称修改为dst 初次运行 server 还是刚才的dst目录下,找到bin目录,里面有个dontstarve_dedicated_server_nullrenderer程序,使用命令行运行它 cd steam/steamcmd/common/dst/bin ./dontstarve_dedicated_server_nullrenderer 可能会报error while loading shared libraries: libcurl-gnutls.so.4: cannot open shared object file的错误,运行以下命令解决,注意将末尾的目录替换成你安装的 server 的路径。 ln -s /usr/lib/libcurl.so.4 ~/steam/steamcmd/common/dst/bin/lib32/libcurl-gnutls.so.4 然后看到your server will not start就表示可以成功执行。 配置饥荒世界 生成地图配置​ 配置饥荒服务器这里最简单的方式是借助饥荒客户端来解决,也就是首先我们在本地运行饥荒服务器创建一个多人房间,把森林、洞穴以及mod什么的都配置好,并选择本地存档,然后生成世界。 然后我们到电脑的本地存档目录,一般在电脑文档目录下,例如我这里在Documents\Klei\DoNotStarveTogether\347061565下,然后就可以看到多个生成的存档的目录,找到最近时间创建的就是刚才生成的世界。 世界存档包含以下文件和目录: 生成并替换 token​ 然后登录到Klei官网 —— Login(支持 steam 登录),然后找到饥荒游戏服务器并进入。 然后输入名称选择添加新服务器 然后新添加的服务器会显示在当前页面上方列表里,找到它并把下方的一串token复制下来 然后回到刚才本地电脑创建的世界的存档目录,打开cluster_token.txt文件,将复制的服务器token替换进去即可。 配置 mod​ 打开刚才饥荒世界的Master或者Caves目录,找到modoverrides.lua文件,使用文件编辑器打开,可以看到创建世界时配置启用的所有mod的 id。 回到服务器上饥荒服务器的dst/mods目录,找到dedicated_server_mods_setup.lua文件,该文件是服务器创建世界时安装mod的配置文件。 将modoverrides.lua内部的mod的id复制下来,并用ServerModSetup("xxxx")包裹,写入dedicated_server_mods_setup.lua文件,这就完成mod安装配置了。 上传饥荒世界配置文件 配置完饥荒世界和mod以后,我们把刚才本地电脑文件目录下的存档目录直接上传到服务器的.klei/DoNotStarveTogether目录下,这里我使用了 ftp 客户端FileZilla来上传文件,很方便很快! 运行地上和洞穴服务 上传完饥荒世界的配置文件以后,就可以再次使用dontstarve_dedicated_server_nullrenderer运行地上和洞穴服务了。 这里需要开两个shell窗口来运行两个命令。 # 启动地上世界 ./dontstarve_dedicated_server_nullrenderer -console -cluster MyDediServer -shard Caves # 启动洞穴 ./dontstarve_dedicated_server_nullrenderer -console -cluster MyDediServer -shard Master

2022/6/25
articleCard.readMore

如何给类库打包

背景​ 在业务场景开发过程中,经常会需要我们手动编写一些方法来解决一些业务场景问题,例如防抖、节流、正则表达式表单校验方法等。一般我们会把这些方法统一放在项目的某个目录,例如utils下维护。 但是当涉及到跨团队使用的时候,这些方法通过npm包的形式来维护会减少团队开发成本。这篇文章主要探索使用rollup和api-extractor打包基于 TypeScript 开发的类库的使用过程。 rollup 是干嘛的​ rollup(汇总)是一个支持 ES Modules 模块语法的 JS 应用打包工具,可以将 ES 模块语法编译成 CommonJS,AMD,IIFE 等形式的代码。同时,rollup支持Tree-Shaking。 Tree-Shaking​ rollup提出Tree-Shaking这个词,用来删减无用的代码段。原理上简单来说从编译代码得到的AST(Abstract Syntax Tree),首先标记相关联的代码,然后移除没被标记的代码,类似于标记-清除的内存回收机制。 通过 ES Modules 模块语法支持静态分析的特性,可以让Tree-Shaking更好地发挥作用,但是许多第三方包并不是以 ES Modules 形式对外界暴露 API,例如lodash,要想在代码层面上配合Tree-Shaking,可以通过引入子模块来解决问题。例如使用import map from 'lodash-es/map'而不是import { map } from 'lodash-es'。 不支持第三方包​ rollup本身不支持处理从node_modules引入的第三方模块,需要通过额外的plugin —— @rollup/plugin-node-resolve来处理。 // rollup.config.js import resolve from '@rollup/plugin-node-resolve'; export default { input: 'src/main.js', output: { file: 'bundle.js', format: 'cjs' }, plugins: [resolve()] }; 配置项​ rollup支持 CLI 命令rollup,默认查找项目根目录下的rollup.config.js配置文件,默认配置文件使用 CJS 模块语法,但是也支持以.cjs和.mjs两种后缀来区分在配置文件中使用的模块语法。 // rollup.config.js interface Configuration { // 打包入口 input?: string | string[] | { [entryAlias: string]: string }; // 插件数组 plugins?: (Plugin | null | false | undefined)[]; // 忽略打包的模块 external?: (string | RegExp)[] | string | RegExp | (( source: string, importer: string | undefined, isResolved: boolean ) => boolean | null | undefined); // 缓存之前的构建产物,在 watch 模式下提高后续构建速度,开启的时候 rollup 会只分析改变的模块 // 可以指定缓存 plugin 和 module cache?: false | RollupCache; // 警示信息自定义处理函数 onwarn?: Function; preserveEntrySignatures?: false | 'strict' | 'allow-extension' | 'exports-only'; // 是否对使用了 rollup 过时的特性直接报错 strictDeprecations?: boolean; // acorn 编译器的配置项 acorn?: AcornOptions; // acorn 使用的插件 acornInjectPlugins?: (() => unknown)[] | (() => unknown); // 全局上下文对象,例如 window context?: string; // 单独配置每个模块的上下文对象 moduleContext?: ((id: string) => string | null | undefined) | { [id: string]: string }; preserveSymlinks?: boolean; // 是否对使用未定义的模块提示报错 shimMissingExports?: boolean; // 自定义 tree-shaking 的过程 treeshake?: boolean | TreeshakingPreset | TreeshakingOptions;, // 确定在运行了多少次之后,插件不再使用的缓存资源应该被删除,默认 10 experimentalCacheExpiry?: number, // 是否计算构建性能,例如构建时间等 perf?: boolean, // 构建输出配置,可以是一个数组 output: { // 输出文件目录 dir?: string; // 输出文件名 file?: string; // 指定输出产物的模块语法,默认 es,也就是 ES Modules 语法 format?: 'amd' | 'cjs' | 'es' | 'iife' | 'system' | 'umd'| 'commonjs' | 'esm' | 'module' | 'systemjs'; // 将外部模块 ID 转换为全局变量名,例如 jquery => $, 在所有模块内部不需要引用 jquery 就可以直接使用 $ globals: { [name: string]: string } | ((name: string) => string); // 打包产物对外暴露的全局变量名 name?: string; // 只作用于当前输出的插件 plugins?: (OutputPlugin | null | false | undefined)[]; // 其他静态资源的输出文件名 assetFileNames?: string | ((chunkInfo: PreRenderedAsset) => string); // 放置在打包产物顶部的字符串内容,例如作者信息,版本声明等,和下面的 footer 一样 banner?: string | (() => string | Promise<string>); footer?: string | (() => string | Promise<string>); // 指定 code-splitting 拆分出来的 chunk 的文件名 chunkFileNames?: string | ((chunkInfo: PreRenderedChunk) => string); // 减少 rollup 生成的包装器的代码,可以用于优化生成产物体积,默认 false compact?: boolean; // 指定从 entry 指定的 chunk 的文件名 entryFileNames?: string | ((chunkInfo: PreRenderedChunk) => string); // 是否拓展 name 指定的全局变量的名称 extend?: boolean, // 当拆分多个 chunk 的时候,rollup 会把后续关联的模块添加到入口模块 import 进来,这样JS 引擎在从 // 入口开始加载 chunk 的时候就会提前发现其他关联的模块并加载它们,从而提升多个 chunk 的加载速度 hoistTransitiveImports?: boolean, // 当指定一个入口模块的时候,将其内部使用动态导入 import() 的模块直接打包进来而不是创建单独的 chunk inlineDynamicImports?: boolean, interop?: boolean | 'auto' | 'esModule' | 'default' | 'defaultOnly'; // 放置在打包产物内部的介绍信息 intro?: string | (() => string | Promise<string>); outro?: string | (() => string | Promise<string>); // code-splitting 的方式,可以将第三方包拆分出来成单独的 chunk manualChunks?: { [chunkAlias: string]: string[] } | GetManualChunk; // 对于 es 模块或者 compact 设置为 true,会将模块名混淆成单个字母来表示,更好的压缩代码体积 minifyInternalExports?: boolean; // 配置 external 指定的模块名和模块路径的映射关系,可以是 CDN URL 地址 paths?: Record<string, string> | ((id: string) => string); // 将每个模块都拆分成单独的 chunk 打包输出,可用于将文件结构转换为不同的模块格式来输出不同打包产物 preserveModules?: boolean; // 保证指定目录的模块从入口进入可以输出到 output 指定的输出文件夹,从而和 preserveModules 隔离开 preserveModulesRoot?: string; // 输出 sourcemap 的形式,默认 false sourcemap?: boolean | 'inline' | 'hidden'; // 指定源代码的实际代码是否被添加到 sourcemap 中,可减小 sourcemap 的体积 sourcemapExcludeSources?: boolean; sourcemapFile?: string; sourcemapPathTransform, // 校验生成的 JS 代码是否有效 validate?: boolean; // 以下属于模块语法相关的配置项 amd; esModule?: boolean; // 决定模块导出的语法形式,默认情况下 rollup 会根据入口模块来决定 exports?: 'default' | 'named' | 'none' | 'auto'; // 是否不允许修改导出的模块 externalLiveBindings?: boolean; // 是否使用 Object.freeze 冻结 import as 形式导出的模块 freeze?: boolean; // 是否使用缩进字符 indent?: boolean | string; namespaceToStringTag?: boolean; // 为 UMD 模块添加额外的不会导致冲突的导出 noConflict?: boolean; // 使用 const 定义导出的模块而不是 var preferConst?: boolean; // 移除 chunk 名称中的 \0, ? and * 字符 sanitizeFileName?: boolean | (string) => string; // 是否在非 ES Modules 模块顶部添加 use strict,默认添加 strict?: boolean; // 当打包模块输出为 SystemJS 的时候,是否用 null 替换空的 setter 方法 systemNullSetters?: boolean; }, // 热更新监听配置 watch: { // 配置 Rollup 在触发重新构建之前等待进一步更改的时间 buildDelay?: number; chokidar?: ChokidarOptions; // 是否在重新构建的时候清空控制台 clearScreen?: boolean; // 排除监听的文件 exclude?: string | RegExp | (string | RegExp)[]; include?: string | RegExp | (string | RegExp)[]; // 触发热更新的时候是否跳过 bundle.write() skipWrite?: boolean; } }; 常用plugin​ rollup目前支持的plugin都在这个列表里 —— rollup/awesome: ⚡️ Delightful Rollup Plugins。常用的有以下这些: @rollup/plugin-node-resolve:解析node_modules中第三方模块 @rollup/plugin-typescript:解析 TypeScript 模块,注意配置tsconfig路径 @rollup/plugin-alias:alias模块名 rollup-plugin-visualizer:rollup打包产物可视化图分析依赖项 rollup-plugin-progress:构建进度条 api-extractor 是什么​ api-extractor是辅助打包 TypeScript 类型系统的工具,属于 TypeScript 语言领域的rollup。一般来说,我们使用rollup只为打包生成.js文件,借助@rollup/plugin-typescript这样的插件也可以生成.d.ts后缀的类型定义文件,但是类型定义往往分散在项目不同的文件下,导致构建产物有很多.d.ts文件,看起来很乱,那么别人用的时候往往还需要从node_modules下引用不同的.d.ts模块,这就很不方便了。 api-extractor主要就是解决上面这个问题的,它可以将所有类型定义从一个入口获取到,最后汇总到一个.d.ts文件内部。此外,还有以下功能: 将所有 TS 类型定义导出到一个.d.ts文件,这一项功能最有用; 从项目入口遍历所有export的类型,并生成一个 markdown 报告,一般没用; 根据类型生成包含类型签名和注释的 JSON 文件,然后内置的api-documenter可以根据这些 JSON 文件生成 API 文档,一般没用; 使用​ api-extractor使用比较简单: 首先全局安装@microsoft/api-extractor pnpm add @microsoft/api-extractor -g 然后在项目根目录执行api-extractor init会生成一个api-extractor的 JSON 配置文件api-extractor.json 配置api-extractor.json,关键需要为其指定两个路径:类型入口文件路径mainEntryPointFilePath和汇总输出的文件路径untrimmedFilePath { // 类型入口文件路径,必须为 .d.ts 后缀 "mainEntryPointFilePath": "<projectFolder>/lib/index.d.ts", "compiler": { "tsconfigFilePath": "<projectFolder>/tsconfig.json" }, "apiReport": { // 是否生成 API 报告,一般用不到,关掉 "enabled": false }, "docModel": { // 是否生成 doc model 文档,一般用不到,关掉 "enabled": false }, "dtsRollup": { "enabled": true, // 汇总 *.d.ts 文件后输出的目录,有用而且是关键配置 "untrimmedFilePath": "<projectFolder>/lib/index.d.ts" } } 接着我们需要保证项目生成了api-extractor需要的类型入口文件,因此可以先执行tsc得到构建输出的*.d.ts文件; 最后执行api-extractor run即可,会在dtsRollup.untrimmedFilePath配置的路径下重新生成.d.ts文件,内部汇总项目所有的类型定义。 vite 是怎么用 api-extractor 的​ 从api-extractor官网的介绍里,我根本看不出api-extractor的使用收益在哪,这里只能先学习vite是怎么用的了。 从vite配置的api-extractor.json来看,其指定的类型文件入口为./temp/node/index.d.ts,汇总生成的.d.ts文件位于./dist/node/index.d.ts。 { "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", // 项目目录 "projectFolder": "./src/node", // 类型定义入口 "mainEntryPointFilePath": "./temp/node/index.d.ts", "dtsRollup": { "enabled": true, "untrimmedFilePath": "", // 整理汇总后的 .d.ts 输出目录 "publicTrimmedFilePath": "./dist/node/index.d.ts" }, "apiReport": { "enabled": false }, "docModel": { "enabled": false }, "tsdocMetadata": { "enabled": false } } 但是在实际项目中并未发现该文件,猜测是经过tsc编译生成的目录,于是查找package.json下scripts的配置,果然找到和tsc相关的命令。 这里一共四个相关的命令: build-temp-types:使用tsc编译 TypeScript,但是指定了emitDeclarationOnly,只生成.d.ts文件到temp/node目录 patch-types:使用ts-node运行scripts目录下的patchTypes.ts程序 roll-types:运行api-extractor程序,完事后移除temp文件夹 build-type:使用npm-run-all的run-s按顺序执行命令 { "scripts": { "build-types": "run-s build-temp-types patch-types roll-types", "build-temp-types": "tsc --emitDeclarationOnly --outDir temp/node -p src/node", "patch-types": "ts-node scripts/patchTypes.ts", "roll-types": "api-extractor run && rimraf temp", } } 下面我进入packages/vite分别执行以上命令看看效果。 cd packages/vite pnpm run build-temp-types 执行build-temp-types得到所有文件的.d.ts文件 然后继续执行patch-types,patch-types这个程序会把src/node目录下代码中从types/*引入的类型转换成相对路径,这样api-extractor才能查找得到。 例如import type { Alias } from "types/alias"转换后得到import type { Alias } from "../../../types/alias"。 最后执行api-extractor run来汇总所有的类型定义,并输出到./dist/node/index.d.ts目录下,这个目录同时也是rollup打包输出的 JS 文件目录。 其中关键的地方就在于api-extractor的入口文件,在vite内部的src/node/index.ts只用作类型定义入口,内部负责从其他目录export类型或者方法等成员。 export { resolvePackageEntry } from './plugins/resolve' export { splitVendorChunkPlugin, splitVendorChunk } from './plugins/splitVendorChunk' export { resolvePackageData } from './packages' export { normalizePath } from './utils' // ... 最佳实践​ 介绍完以上两种工具的使用,下面开搞,项目结构如下: -scripts -build.js // rollup 打包程序 -src -debounce.ts -throttle.ts -index.ts // 导出其他方法 安装rollup到项目依赖后,使用rollup的 JavaScript API 来编写打包程序,放在scripts/build.js下,目的是从项目的多个入口进行打包,从而得到多个分离的.js文件。 const bundle = await rollup({ input, plugins: [ pluginTS({ // 指定生成 *.d.ts 类型文件 tsconfig: path.resolve(process.cwd(), 'tsconfig.json'), // 这里我们指定 rollup 打包不需要生成 .d.ts 文件 declaration: false, }), nodeResolve(), analyze && visualizer({ filename: path.resolve(process.cwd(), 'analyze/stats.html'), open: true, gzipSize: true, }), ].filter(Boolean), }); 然后使用tsc --emitDeclarationOnly --declaration --outDir dts生成.d.ts文件,index.d.ts如下: export { default as debounce } from './debounce'; export { default as throttle } from './throttle'; 最后执行api-extractor run输出汇总的.d.ts文件,命令配置如下: { "scripts": { "build": "run-s bundle build-types rollup-types", "bundle": "node scripts/build.js", "build-types": "tsc --emitDeclarationOnly --declaration --outDir dts", "rollup-types": "api-extractor run && rimraf dts", } } QA​ ae-missing-release-tag​ 使用api-extractor的时候,可能会报下面的错误 *“___ is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal).”* 主要原因是api-extractor使用的注释比较严格,可以通过@public, @alpha(内部测试) 或@beta(公开测试)这些注释来区分一个成员的版本。 两种方法解决上述问题: 方法注释上添加@public; 在api-extractor.json中配置关掉提示 "messages": { "extractorMessageReporting": { "ae-missing-release-tag": { "logLevel": "none" } } }

2022/3/26
articleCard.readMore

实现 Promise.all 有哪些要点

Promise.all​ Promise.all是日常使用频率非常高的异步方法,这两天回顾了一下Promise的几个静态方法,对Promise.all又有了一些新的认识。 Promise.all 的定义​ Promise.all(iterable) 理解Promise.allAPI 需要注意以下几点: 接收一个可迭代对象作为参数,否则throw TypeError的错误; 当迭代器内部所有Promise对象fulfilled的时候,Promise.all返回fulfilled状态的Promise对象,resolve参数为一个数组,包含所有非Promise对象以及所有Promise对象resolve的值; 当迭代器内部存在一个Promise对象状态变为rejected或者非Promise元素执行出错时,Promise.all将立即返回rejected状态的Promise对象,reject参数为rejected的Promise对象抛出的信息,或者非Promise元素执行报错的信息; Promise.all变成fulfilled状态时,resolve的数组元素按照迭代器元素的顺序,非Promise对象会直接放在对应位置返回。 从Promise.all API 定义来看,首先需要理解什么是可迭代对象。 什么是可迭代​ 可迭代是 ES6 提出的语法协议,是指满足以下两个要求的对象: iterable:可迭代。可迭代指的是一个对象内部需要实现Symbol.iterator方法,该方法不接收参数,同时返回一个对象,这个对象必须是迭代器。当使用for...of遍历的时候,会调用Symbol.iterator方法。 iterator:迭代器。迭代器是一个对象,包含一个next方法,该方法可接收一个参数,同时返回一个包含以下两个属性的对象。 done: boolean:表示当前迭代器是否迭代完毕,结束迭代则设为true,此时value可以省略,因为没东西要输出了嘛。 value:any:迭代器每次调用返回的值 基于class实现的可迭代对象示例如下: class SimpleClass { constructor(data) { this.data = data } // 首先实现 Symbol.iterator 方法 [Symbol.iterator]() { let index = 0; // 返回一个迭代器 return { // 迭代器内部必须包含 next 方法 next: () => { if (index < this.data.length) { return { value: this.data[index++], done: false } } else { return { done: true } } } } } } 目前内置的可迭代对象有以下这些: Array String Map Set TypedArray 这些类型的值都可以使用for...of遍历。 警告 有个需要注意的点是,无法准确判断一个对象是否可迭代,因为要同时满足上述说的两个条件有个 hack 的方法。 const myIterator = { next: function() { // ... }, [Symbol.iterator]: function() { return this; } }; 但是对于日常来说,谁会写这么 hack 的代码呢?所以要判断一个对象是否可迭代仍然可以使用: typeof sth[Symbol.iterator] === 'function' Promise.all 的实现​ Promise._all = function (iterable) { // 判断迭代器 if (typeof iterable?.[Symbol.iterator] !== "function") { throw new TypeError("parameter must be iterable"); } return new Promise((resolve, reject) => { let count = [...iterable].length; // 如果是空的迭代器,则直接 resolve 空数组 if (count === 0) { resolve([]); } else { const resultArr = []; for (let it of iterable) { Promise.resolve(it) .then((v) => { count--; resultArr.push(v); if (count === 0) { resolve(resultArr); } }) .catch((err) => { reject(err); }); } } }); }; 测试 const promise1 = Promise.resolve(3); const promise2 = 42; const promise3 = new Promise((resolve, reject) => { setTimeout(resolve, 100, 'foo'); }); Promise._all([promise1, promise2, promise3]).then((values) => { console.log(values); }); // [ 3, 42, 'foo' ]

2022/3/23
articleCard.readMore

PureEsm

Node 从v12.17(LTS)版本开始正式支持 ESM 模块语法,但是 node 本身又支持 CJS 语法,直接迁移的话还是有些成本的。 模块判定标准​ ESM 模块​ 使用的时候 Node 根据以下条件判断传入import或者import()的模块为 ESM: .mjs模块名后缀 离模块最近的 package.json内定义的type="module"字段 在 CLI 内部执行一行代码时传递给node参数--input-type=module node --input-type=module --eval "import { sep } from 'path'; console.log(sep);" CJS模块​ 但是 Node 同时又需要支持 CommonJS 语法,对于使用import或者import()传入的模块根据以下条件判定其为 CJS 模块: .cjs模块名后缀 离模块最近的 package.json内定义的type="commonjs"字段 在 CLI 内部执行一行代码时传递给node参数--input-type=commonjs node --input-type=commonjs --eval "import { sep } from 'path'; console.log(sep);" 什么是 Pure ESM​ Pure ESM 是 sindresorhus 提出来的一个概念 —— Pure ESM package (github.com),指的是一个使用 Node 开发的模块只能使用 ESM 模块语法导入。 作者建议 Node 开发应该迁移到 ESM,原因就是上文说的import语法既支持 ESM 也支持 CJS,但是 CJS 的语法require只支持 CJS 模块。 作者还介绍了 CommonJS 迁移到 ESM 的情况: 从 CommonJS 迁移到 ESM​ 在package.json添加"type": "module" 使用exports替换package.json的main字段来指定入口文件,因为main只适用于 CJS 语法 在package.json指定 Node 版本必须大于12.20 "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } 如果是 TypeScript 项目,还需要在tsconfig.json指定module: "es2020" 最后一步,改造 CJS 语法require()/module.export到 ESM 语法import/export,对于异步模块需要使用import()或者top level await。 升级报错的问题​ 如果一个 node 开发的 package 是使用的 ESM 模块语法导出,那么只能在 ESM 模块中使用,否则就会报错ERR_REQUIRE_ESM,例如使用chalk5.0+ // index.js const chalk = require('chalk'); 这时候最简单的方法是直接把当前模块改成.mjs后缀,然后使用 ESM 的import语法引入。 // index.mjs import chalk from 'chalk'; console.log(chalk.blue('Hello world!')) 这样是能在当前模块处理第三方 ESM 模块了,但是使用该.mjs模块的模块也需要是 ESM 模块,同样需要import(xxx.mjs)。可想而知,不断改造下去整个项目都迁移到了 ESM 模块,所以要在 CJS 项目中使用 ESM 模块,整个项目都必须遵循 ESM 模块语法,这时候直接使用type: "module"最好。 // package.json { "type": "module" }

2022/2/28
articleCard.readMore