Repository: ascoders/weekly Branch: master Commit: 9357b24e8b5d Files: 303 Total size: 1.8 MB Directory structure: gitextract_q9gvtcfs/ ├── .gitignore ├── .lintmdrc ├── .travis.yml ├── SQL/ │ ├── 231.SQL 入门.md │ ├── 232.SQL 聚合查询.md │ ├── 233.SQL 复杂查询.md │ ├── 234.SQL CASE 表达式.md │ ├── 235.SQL 窗口函数.md │ └── 236.SQL grouping.md ├── TS 类型体操/ │ ├── 243.精读《Pick, Awaited, If...》.md │ ├── 244.精读《Get return type, Omit, ReadOnly...》.md │ ├── 245.精读《Promise.all, Replace, Type Lookup...》.md │ ├── 246.精读《Permutation, Flatten, Absolute...》.md │ ├── 247.精读《Diff, AnyOf, IsUnion...》.md │ ├── 248.精读《MinusOne, PickByType, StartsWith...》.md │ ├── 249.精读《ObjectEntries, Shift, Reverse...》.md │ ├── 250.精读《Flip, Fibonacci, AllCombinations...》.md │ ├── 251.精读《Trim Right, Without, Trunc...》.md │ └── 252.精读《Unique, MapTypes, Construct Tuple...》.md ├── helper.js ├── package.json ├── readme.md ├── 前沿技术/ │ ├── 1.精读《js 模块化发展》.md │ ├── 10.精读《Web Components 的困境》.md │ ├── 100.精读《V8 引擎 Lazy Parsing》.md │ ├── 101.精读《持续集成 vs 持续交付 vs 持续部署》.md │ ├── 102.精读《Monorepo 的优势》.md │ ├── 104.精读《Function Component 入门》.md │ ├── 105.精读《What's new in javascript》.md │ ├── 107.精读《Optional chaining》.md │ ├── 109.精读《Vue3.0 Function API》.md │ ├── 11.精读《前端调试技巧》.md │ ├── 111.精读《前端未来展望》.md │ ├── 112.精读《源码学习》.md │ ├── 113.精读《Nodejs V12》.md │ ├── 117.精读《Tableau 探索式模型》.md │ ├── 118.精读《使用 css 变量生成颜色主题》.md │ ├── 119.精读《前端深水区》.md │ ├── 12.精读《React 高阶组件》.md │ ├── 120.精读《React Hooks 最佳实践》.md │ ├── 121.精读《前端与 BI》.md │ ├── 123.精读《用 Babel 创造自定义 JS 语法》.md │ ├── 124.精读《用 css grid 重新思考布局》.md │ ├── 125.精读《深度学习 - 函数式之美》.md │ ├── 126.精读《Nuxtjs》.md │ ├── 127.精读《React Conf 2019 - Day1》.md │ ├── 129.精读《React Conf 2019 - Day2》.md │ ├── 13.精读《This 带来的困惑》.md │ ├── 132.精读《正交的 React 组件》.md │ ├── 133.精读《寻找框架设计的平衡点》.md │ ├── 134.精读《我在阿里数据中台大前端》.md │ ├── 138.精读《精通 console.log》.md │ ├── 139.精读《手写 JSON Parser》.md │ ├── 14.精读《架构设计之 DCI》.md │ ├── 140.精读《结合 React 使用原生 Drag Drop API》.md │ ├── 141.精读《useRef 与 createRef 的区别》.md │ ├── 142.精读《如何做好 CodeReview》.md │ ├── 143.精读《Suspense 改变开发方式》.md │ ├── 144.精读《Webpack5 新特性 - 模块联邦》.md │ ├── 145.精读《React Router v6》.md │ ├── 146.精读《React Hooks 数据流》.md │ ├── 147. 精读《@types react 值得注意的 TS 技巧》.md │ ├── 148. 精读《React Error Boundaries》.md │ ├── 149. 精读《React 性能调试》.md │ ├── 15.精读《TC39 与 ECMAScript 提案》.md │ ├── 150. 精读《Deno 1.0 你需要了解的》.md │ ├── 152. 精读《recoil》.md │ ├── 153. 精读《snowpack》.md │ ├── 154. 精读《用 React 做按需渲染》.md │ ├── 157. 精读《如何比较 Object 对象》.md │ ├── 158. 精读《Typescript 4》.md │ ├── 159. 精读《对低代码搭建的理解》.md │ ├── 16.精读《CSS Animations vs Web Animations API》.md │ ├── 160. 精读《函数缓存》.md │ ├── 161.精读《可视化搭建思考 - 富文本搭建》.md │ ├── 162.精读《Tasks, microtasks, queues and schedules》.md │ ├── 163.精读《Spring 概念》.md │ ├── 164.精读《数据搭建引擎 bi-designer API-设计器》.md │ ├── 165.精读《数据搭建引擎 bi-designer API-组件》.md │ ├── 166.精读《BI 搭建 - 筛选条件》.md │ ├── 17.精读《如何安全地使用 React context》.md │ ├── 18.精读《设计完美的日期选择器》.md │ ├── 19.精读《最佳前端面试题》及面试官技巧.md │ ├── 190.精读《DOM diff 原理详解》.md │ ├── 191.精读《高性能表格》.md │ ├── 192.精读《DOM diff 最长上升子序列》.md │ ├── 193.精读《React Server Component》.md │ ├── 194.精读《算法基础数据结构》.md │ ├── 195.精读《新一代前端构建工具对比》.md │ ├── 196.精读《前端职业规划 - 2021 年》.md │ ├── 197.精读《低代码逻辑编排》.md │ ├── 2.精读《模态框的最佳实践》.md │ ├── 20.精读《Nestjs》文档.md │ ├── 202.精读《React 18》.md │ ├── 204.精读《默认、命名导出的区别》.md │ ├── 205.精读《JS with 语法》.md │ ├── 206.精读《一种 Hooks 数据流管理方案》.md │ ├── 207.精读《Typescript infer 关键字》.md │ ├── 208.精读《Typescript 4.4》.md │ ├── 209.精读《捕获所有异步 error》.md │ ├── 21.精读《Web fonts: when you need them, when you don’t》.md │ ├── 210.精读《class static block》.md │ ├── 211.精读《Microsoft Power Fx》.md │ ├── 212.精读《可维护性思考》.md │ ├── 213.精读《Prisma 的使用》.md │ ├── 214.精读《web streams》.md │ ├── 215.精读《什么是 LOD 表达式》.md │ ├── 216.精读《15 大 LOD 表达式 - 上》.md │ ├── 217.精读《15 大 LOD 表达式 - 下》.md │ ├── 218.精读《Rust 是 JS 基建的未来》.md │ ├── 219.精读《深入了解现代浏览器一》.md │ ├── 22.精读《V8 引擎特性带来的的 JS 性能变化》.md │ ├── 220.精读《深入了解现代浏览器二》.md │ ├── 221.精读《深入了解现代浏览器三》.md │ ├── 222.精读《深入了解现代浏览器四》.md │ ├── 223.精读《Records & Tuples 提案》.md │ ├── 224.精读《Records & Tuples for React》.md │ ├── 225.精读《Excel JS API》.md │ ├── 226.精读《2021 前端新秀回顾》.md │ ├── 228.精读《pipe operator for JavaScript》.md │ ├── 23.精读《API 设计原则》.md │ ├── 230.精读《对 Markdown 的思考》.md │ ├── 237.精读《Typescript 4.5-4.6 新特性》.md │ ├── 238.精读《不再需要 JS 做的 5 件事》.md │ ├── 239.精读《JS 数组的内部实现》.md │ ├── 24.精读《现代 JavaScript 概览》.md │ ├── 240.精读《React useEvent RFC》.md │ ├── 242.精读《web reflow》.md │ ├── 25.精读《null >= 0?》.md │ ├── 253.精读《pnpm》.md │ ├── 254.精读《对前端架构的理解 - 分层与抽象》.md │ ├── 255.精读《SolidJS》.md │ ├── 256.精读《依赖注入简介》.md │ ├── 257.精读《State of CSS 2022》.md │ ├── 258.精读《proposal-extractors》.md │ ├── 259.精读《Headless 组件用法与原理》.md │ ├── 26.精读《加密媒体扩展》.md │ ├── 260.精读《如何为 TS 类型写单测》.md │ ├── 261.精读《Rest vs Spread 语法》.md │ ├── 262.精读《迭代器 Iterable》.md │ ├── 263.精读《我们为何弃用 css-in-js》.md │ ├── 264.精读《维护好一个复杂项目》.md │ ├── 265.精读《磁贴布局 - 功能分析》.md │ ├── 266.精读《磁贴布局 - 功能实现》.md │ ├── 267.精读《磁贴布局 - 性能优化》.md │ ├── 27.精读《css-in-js 杀鸡用牛刀》.md │ ├── 277.精读《利用 GPT 解读 PDF》.md │ ├── 28.精读《2017 前端性能优化备忘录》.md │ ├── 281.精读《自由 + 磁贴混合布局》.md │ ├── 282.精读《自由布局吸附线的实现》.md │ ├── 287.精读《VisActor 数据可视化工具》.md │ ├── 29.精读《JS 中的内存管理》.md │ ├── 3.精读《前后端渲染之争》.md │ ├── 30.精读《Javascript 事件循环与异步》.md │ ├── 31.精读《我不再使用高阶组件》.md │ ├── 32.精读《React Router4.0 进阶概念》.md │ ├── 33.精读《30 行 js 代码创建神经网络》.md │ ├── 34.精读《React 代码整洁之道》.md │ ├── 35.精读《dob - 框架实现》.md │ ├── 36.精读《When You “Git” in Trouble- a Version Control Story》.md │ ├── 37.精读《how we position and what we compare》.md │ ├── 38.精读《dob - 框架使用》.md │ ├── 39.精读《全链路体验浏览器挖矿》.md │ ├── 4.精读《AsyncAwait 优越之处》.md │ ├── 40.精读《初探 Reason 与 GraphQL》.md │ ├── 41.精读《Ant Design 3.0 背后的故事》.md │ ├── 42.精读《前端数据流哲学》.md │ ├── 43.精读《增强现实与可视化》.md │ ├── 44.精读《Rekit Studio》.md │ ├── 45.精读《React's new Context API》.md │ ├── 46.精读《react-rxjs》.md │ ├── 47.精读《webpack4.0 升级指南》.md │ ├── 49.精读《Compilers are the New Frameworks》.md │ ├── 5.精读《民工叔单页数据流方案》.md │ ├── 50.精读《快速上手构建 ARKit 应用》.md │ ├── 51.精读《Elements of Web Dev》.md │ ├── 52.精读《图解 ES 模块》.md │ ├── 53.精读《插件化思维》.md │ ├── 54.精读《在浏览器运行 serverRender》.md │ ├── 55.精读《async await 是把双刃剑》.md │ ├── 56.精读《重新思考 Redux》.md │ ├── 57.精读《现代 js 框架存在的根本原因》.md │ ├── 58.精读《Typescript2.0 - 2.9》.md │ ├── 59.精读《如何利用 Nodejs 监听文件夹》.md │ ├── 6.精读《JavaScript 错误堆栈处理》.md │ ├── 60.精读《如何在 nodejs 使用环境变量》.md │ ├── 61.精读《React 八种条件渲染》.md │ ├── 62.精读《JS 引擎基础之 Shapes and Inline Caches》.md │ ├── 63.精读《React 的多态性》.md │ ├── 68.精读《衡量用户体验》.md │ ├── 69.精读《SQL vs Flux》.md │ ├── 7.精读《请停止 css-in-js 的行为》.md │ ├── 72.精读《REST, GraphQL, Webhooks, & gRPC 如何选型》.md │ ├── 74.精读《12 个评估 JS 库你需要关心的事》.md │ ├── 76.精读《谈谈 Web Workers》.md │ ├── 77.精读《用 Reduce 实现 Promise 串行执行》.md │ ├── 79.精读《React Hooks》.md │ ├── 8.精读《入坑 React 前没有人会告诉你的事》.md │ ├── 80.精读《怎么用 React Hooks 造轮子》.md │ ├── 81.精读《使用 CSS 属性选择器》.md │ ├── 83.精读《React16 新特性》.md │ ├── 84.精读《Typescript 3.2 新特性》.md │ ├── 86.精读《国际化布局 - Logical Properties》.md │ ├── 87.精读《setState 做了什么》.md │ ├── 88.精读《Caches API》.md │ ├── 89.精读《如何编译前端项目与组件》.md │ ├── 9.精读《Immutable 结构共享》.md │ ├── 91.精读《正则 ES2018》.md │ ├── 94.精读《Serverless 给前端带来了什么》.md │ ├── 95.精读《Function VS Class 组件》.md │ ├── 96.精读《useEffect 完全指南》.md │ ├── 97.精读《编写有弹性的组件》.md │ └── 99.精读《Scheduling in React》.md ├── 可视化搭建/ │ ├── 268.如何抽象可视化搭建.md │ ├── 269.组件注册与画布渲染.md │ ├── 270.画布与组件元信息数据流.md │ ├── 271.可视化搭建内置 API.md │ ├── 272.容器组件设计.md │ ├── 273.组件值与联动.md │ ├── 274.定义联动协议.md │ ├── 275.组件值校验.md │ ├── 276.keepAlive 模式.md │ ├── 278.ComponentLoader 与动态组件.md │ ├── 279.自动批处理与冻结.md │ └── 280.场景实战.md ├── 商业思考/ │ ├── 103.精读《为什么专家不再关心技术细节》.md │ ├── 106.精读《数据之上·智慧之光 - 2018》.md │ ├── 108.精读《智能商业》.md │ ├── 114.精读《谁在世界中心》.md │ ├── 115.精读《Tableau 入门》.md │ ├── 116.精读《刷新》.md │ ├── 131.精读《从 0 到 1》.md │ ├── 135.精读《极客公园 IFX - 上》.md │ ├── 136.精读《极客公园 IFX - 下》.md │ ├── 137.精读《当我在分享的时候,我在做什么?》.md │ └── 90.精读《极客公园 2019》.md ├── 数学之美/ │ └── 296.手动算根号.md ├── 数据技术专家能力模型.md ├── 机器学习/ │ ├── 291.机器学习简介: 寻找函数的艺术.md │ ├── 292.万能近似定理: 逼近任何函数的理论.md │ ├── 293.实现万能近似函数: 神经网络的架构设计.md │ ├── 294.反向传播: 揭秘神经网络的学习机制.md │ └── 295.完整实现神经网络: 实战演练.md ├── 源码解读/ │ ├── 110.精读《Inject Instance 源码》.md │ ├── 122.精读《robot 源码 - 有限状态机》.md │ ├── 128.精读《Hooks 取数 - swr 源码》.md │ ├── 130.精读《unstated 与 unstated-next 源码》.md │ ├── 151. 精读《@umijs use-request》源码.md │ ├── 155. 精读《use-what-changed 源码》.md │ ├── 156. 精读《react-intersection-observer 源码》.md │ ├── 227. 精读《zustand 源码》.md │ ├── 229.精读《vue-lit 源码》.md │ ├── 241.精读《react-snippets - Router 源码》.md │ ├── 48.精读《Immer.js》源码.md │ ├── 73.精读《sqorn 源码》.md │ ├── 75.精读《Epitath 源码 - renderProps 新用法》.md │ ├── 82.精读《Htm - Hyperscript 源码》.md │ ├── 92.精读《React PowerPlug 源码》.md │ ├── 93.精读《syntax-parser 源码》.md │ └── 98.精读《react-easy-state 源码》.md ├── 生活/ │ └── 290.个人养老金利与弊.md ├── 算法/ │ ├── 198.精读《算法 - 动态规划》.md │ ├── 199.精读《算法 - 滑动窗口》.md │ ├── 200.精读《算法 - 回溯》.md │ ├── 201.精读《算法 - 二叉树》.md │ ├── 203.精读《算法 - 二叉搜索树》.md │ ├── 283.精读《算法题 - 通配符匹配》.md │ ├── 284.精读《算法题 - 统计可以被 K 整除的下标对数目》.md │ ├── 285.精读《算法题 - 最小覆盖子串》.md │ ├── 286.精读《算法题 - 地下城游戏》.md │ ├── 288.精读《算法题 - 编辑距离》.md │ └── 289.精读《算法题 - 二叉树中的最大路径和》.md ├── 编译原理/ │ ├── 64.精读《手写 SQL 编译器 - 词法分析》.md │ ├── 65.精读《手写 SQL 编译器 - 文法介绍》.md │ ├── 66.精读《手写 SQL 编译器 - 语法分析》.md │ ├── 67.精读《手写 SQL 编译器 - 回溯》.md │ ├── 70.精读《手写 SQL 编译器 - 语法树》.md │ ├── 71.精读《手写 SQL 编译器 - 错误提示》.md │ ├── 78.精读《手写 SQL 编译器 - 性能优化之缓存》.md │ └── 85.精读《手写 SQL 编译器 - 智能提示》.md └── 设计模式/ ├── 167.精读《设计模式 - Abstract Factory 抽象工厂》.md ├── 168.精读《设计模式 - Builder 生成器》.md ├── 169.精读《设计模式 - Factory Method 工厂方法》.md ├── 170.精读《设计模式 - Prototype 原型模式》.md ├── 171.精读《设计模式 - Singleton 单例模式》.md ├── 172.精读《设计模式 - Adapter 适配器模式》.md ├── 173.精读《设计模式 - Bridge 桥接模式》.md ├── 174.精读《设计模式 - Composite 组合模式》.md ├── 175.精读《设计模式 - Decorator 装饰器模式》.md ├── 176.精读《设计模式 - Facade 外观模式》.md ├── 177.精读《设计模式 - Flyweight 享元模式》.md ├── 178.精读《设计模式 - Proxy 代理模式》.md ├── 179.精读《设计模式 - Chain of Responsibility 职责链模式》.md ├── 180.精读《设计模式 - Command 命令模式》.md ├── 181.精读《设计模式 - Interpreter 解释器模式》.md ├── 182.精读《设计模式 - Iterator 迭代器模式》.md ├── 183.精读《设计模式 - Mediator 中介者模式》.md ├── 184.精读《设计模式 - Memoto 备忘录模式》.md ├── 185.精读《设计模式 - Observer 观察者模式》.md ├── 186.精读《设计模式 - State 状态模式》.md ├── 187.精读《设计模式 - Strategy 策略模式》.md ├── 188.精读《设计模式 - Template Method 模版模式》.md └── 189.精读《设计模式 - Visitor 访问者模式》.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ /node_modules /yarn.lock ================================================ FILE: .lintmdrc ================================================ { "excludeFiles": [], "rules": { "no-long-code": 0, "no-trailing-punctuation": 0 } } ================================================ FILE: .travis.yml ================================================ language: node_js node_js: - "10" before_install: - npm i -g lint-md-cli script: lint-md ./ ================================================ FILE: SQL/231.SQL 入门.md ================================================ 本系列是 SQL 系列的开篇,介绍一些宏观与基础的内容。 ## SQL 是什么? SQL 是一种结构化查询语言,用于管理关系型数据库,我们 90% 接触的都是查询语法,但其实它包含完整的增删改查和事物处理功能。 ## 声明式特性 SQL 属于声明式编程语言,而现代通用编程语言一般都是命令式的。但是不要盲目崇拜声明式语言,比如说它未来会代替低级的命令式语言,因为声明式本身也有它的缺点,它与命令式语言也有相通的地方。 为什么我们觉得声明式编程语言更高级?因为声明式语言抽象程度更高,比如 `select * from table1` 仅描述了要从 table1 查询数据,但查询的具体步骤的完全没提,这背后可能存在复杂的索引优化与锁机制,但我们都无需关心,这简直是编程的最高境界。 那为什么现在所有通用业务代码都是命令式呢?因为 **命令式给了我们描述具体实现的机会** ,而通用领域的编程正需要建立在严谨的实现细节上。比如校验用户权限这件事,即便 AI 编程提供了将 “登陆用户仅能访问有权限的资源” 转化为代码的能力,我们也不清楚资源具体指哪些,以及在权限转移过程中的资源所有权属于谁。 SQL 之所以能保留声明式特性,完全因为锁定了关系型数据管理这个特定领域,而恰恰对这个领域的需求是标准化且可枚举的,才使声明式成为可能。 基于命令式语言也完全可拓展出声明式能力,比如许多 ORM 提供了类似 `select({}).from({}).where({})` 之类的语法,甚至一个 `login()` 函数也是声明式编程的体现,因为调用者无需关心是如何登陆的,总之调用一下就完成了登陆,这不就是声明式的全部精髓吗? ## 语法分类 作为关系型数据库管理工具,SQL 需要定义、操纵与控制数据。 数据定义即修改数据库与表级别结构,这些是数据结构,或者是数据元信息,它不代表具体数据,但描述数据的属性。 数据操纵即修改一行行具体数据,增删改查。 数据控制即对事务、用户权限的管理与控制。 ### 数据定义 DDL(Data Definition Language)数据定义,包括 `CREATE` `DROP` `ALTER` 方法。 ### 数据操纵 DML(Data Manipulation Language)数据操纵,包括 `SELECT` `INSERT` `UPDATE` `DELETE` 方法。 ### 数据控制 DCL(Data Control Language)数据控制,包括 `COMMIT`、`ROLLBACK` 等。 所有 SQL 操作都围绕这三种类型,其中数据操纵几乎占了 90% 的代码量,毕竟数据查询的诉求远大于写,数据写入对应数据采集,而数据查询对应数据分析,数据分析领域能玩出的花样远比数据采集要多。 PS:有些情况下,会把最重要的 `SELECT` 提到 DQL(Data Query Language)分类下,这样分类就变成了四个。 ## 集合运算 SQL 世界的第一公民是集合,就像 JAVA 世界第一公民是对象。我们只有以集合的视角看待 SQL,才能更好的理解它。 何为集合视角,即所有的查询、操作都是二维数据结构中进行的,而非小学算术里的单个数字间加减乘除关系。 集合的运算一般有 `UNION` 并集、`EXCEPT` 差集、`INTERSECT` 交集,这些都是以行为单位的操作,而各种 JOIN 语句则是以列为单位的集合运算,也是后面提到的连接查询。 只要站在二维数据结构中进行思考,运算无非是横向或纵向的操作。 ## 数据范式 数据范式分为五层,每层要求都比上一层更严苛,因此是一个可以逐步遵循的范式。数据范式要求数据越来越解耦,减少冗余。 比如第一范式要求每列都具有原子性,即都是不可分割的最小数据单元。如果数据采集时,某一列作为字符串存储,并且以 "|" 分割表示省市区,那么它就不具有原子性。 当然实际生产过程往往不都遵循这种标准,因为表不是孤立的,在数据处理流中,可能在某个环节再把列原子化,而原始数据为了压缩体积,进行列合并处理。 希望违反范式的还不仅是底层表,现在大数据处理场景下,越来越多的业务采用大宽表结构,甚至故意进行数据冗余以提升查询效率,列存储引擎就是针对这种场景设计的,所以数据范式在大数据场景下是可以变通的,但依然值得学习。 ## 聚合 当采用 GROUP BY 分组聚合数据时,如希望针对聚合值筛选,就不能用 WHERE 限定条件了,因为 WHERE 是基于行的筛选,而不是针对组合的。(GROUP BY 对数据进行分组,我们称这些组为 “组合”),所以需要使用针对组合的筛选语句 HAVING: ```sql SELECT SUM(pv) FROM table GROUP BY city HAVING AVG(uv) > 100 ``` 这个例子中,如果 HAVING 换成 WHERE 就没有意义,因为 WHERE 加聚合条件时,需要对所有数据进行合并,不符合当前视图的详细级别。(关于视图详细级别,在我之前写的 [精读《什么是 LOD 表达式》](https://github.com/ascoders/weekly/blob/master/%E5%89%8D%E6%B2%BF%E6%8A%80%E6%9C%AF/215.%E7%B2%BE%E8%AF%BB%E3%80%8A%E4%BB%80%E4%B9%88%E6%98%AF%20LOD%20%E8%A1%A8%E8%BE%BE%E5%BC%8F%E3%80%8B.md) 有详细说明)。 聚合如此重要,是因为我们分析数据必须在高 LEVEL 视角看,明细数据是看不出趋势的。而复杂的需求往往伴随着带有聚合的筛选条件,明白 SQL 是如何支持的非常重要。 ## CASE 表达式 CASE 表达式分为简单与搜索 CASE 表达式,简单表达式: ```sql SELECT CASE pv WHEN 1 THEN 'low' ELSE 'high' END AS quality ``` 上面的例子利用 CASE 简单表达式形成了一个新字段,这种模式等于生成了业务自定义临时字段,在对当前表进行数据加工时非常有用。搜索 CASE 表达式能力完全覆盖简单 CASE 表达式: ```sql SELECT CASE WHEN pv < 100 THEN 'low' ELSE 'high' END AS quality ``` 可以看到,搜索 CASE 表达式可以用 “表达式” 描述条件,可以轻松完成更复杂的任务,甚至可以在表达式里使用子查询、聚合等手段,这些都是高手写 SQL 的惯用技巧,所以 CASE 表达式非常值得深入学习。 ## 复杂查询 SELECT 是 SQL 最复杂的部分,其中就包含三种复杂查询模式,分别是连接查询与子查询。 ### 连接查询 指 JOIN 查询,比如 LEFT JOIN、RIGHT JOIN、INNER JOIN。 在介绍聚合时我们提到了,连接查询本质上就是对列进行拓展,而两个表之间不会无缘无故合成一个,所以必须有一个外键作为关系纽带: ```sql SELECT A.pv, B.uv FROM table1 as t1 LEFT JOIN table2 AS P t2 ON t1.productId = t2.productId ``` 连接查询不仅拓展了列,还会随之拓展行,而拓展方式与连接的查询的类型有关。除了连接查询别的表,还可以连接查询自己,比如: ```sql SELECT t1.pv AS pv1, P2.pv AS pv2 FROM tt t1, tt t2 ``` 这种子连接查询结果就是自己对自己的笛卡尔积,可通过 WHERE 筛选去重,后面会有文章专门介绍。 ### 子查询与视图 子查询就是 SELECT 里套 SELECT,一般来说 SELECT 会从内到外执行,只有在关联子查询模式下,才会从外到内执行。 而如果把子查询保存下来,就是一个视图,这个视图并不是实体表,所以很灵活,且数据会随着原始表数据而变化: ```sql CREATE VIEW countryGDP (country, gdp) AS SELECT country, SUM(gdp) FROM tt GROUP BY country ``` 之后 `countryGDP` 这个视图就可以作为临时表来用了。 这种模式其实有点违背 SQL 声明式的特点,因为定义视图类似于定义变量,如果继续写下去,势必会形成一定命令式思维逻辑,但这是无法避免的。 ## 事务 当 SQL 执行一连串操作时,难免遇到不执行完就会出现脏数据的问题,所以事务可以保证操作的原子性。一般来说每个 DML 操作都是一个内置事务,而 SQL 提供的 START TRANSACTION 就是让我们可以自定义事务范围,使一连串业务操作都可以包装在一起,成为一个原子性操作。 对 SQL 来说,原子性操作是非常安全的,即失败了不会留下任何痕迹,成功了会全部成功,不会存在中间态。 ## OLAP OLAP(OnLine Analytical Processing)即实时数据分析,是 BI 工具背后计算引擎实现的基础。 现在越来越多的 SQL 数据库支持了窗口函数实现,用于实现业务上的 runningSum 或 runningAvg 等功能,这些都是数据分析中很常见的。 以 runningSum 为例,比如双十一实时表的数据是以分钟为单位的实时 GMV,而我们要做一张累计到当前时间的 GMV 汇总折线图,Y 轴就需要支持 `running_sum(GMV)` 这样的表达式,而这背后可能就是通过窗口函数实现的。 当然也不是所有业务函数都由 SQL 直接提供,业务层仍需实现大量内存函数,在 JAVA 层计算,这其中一部分是需要下推到 SQL 执行的,只有内存函数与下推函数结合在一起,才能形成我们在 BI 工具看到的复杂计算字段效果。 ## 总结 SQL 是一种声明式语言,一个看似简单的查询语句,在引擎层往往对应着复杂的实现,这就是 SQL 为何如此重要却又如此普及的原因。 虽然 SQL 容易上手,但要系统的理解它,还得从结构化数据与集合的概念开始进行思想转变。 不要小看 CASE 语法,它不仅与容易与编程语言的 CASE 语法产生混淆,本身结合表达式进行条件分支判断,是许多数据分析师在日常工作中最长用的套路。 现在使用简单 SQL 创建应用的场景越来越少了,但 BI 场景下,基于 SQL 的增强表达式场景越来越多了,本系列我就是以理解 BI 场景下查询表达式为目标创建的,希望能够学以致用。 > 讨论地址是:[精读《SQL 入门》· Issue #398 · ascoders/weekly](https://github.com/ascoders/weekly/issues/398) **如果你想参与讨论,请 [点击这里](https://github.com/ascoders/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: SQL/232.SQL 聚合查询.md ================================================ SQL 为什么要支持聚合查询呢? 这看上去是个幼稚的问题,但我们还是一步步思考一下。数据以行为粒度存储,最简单的 SQL 语句是 `select * from test`,拿到的是整个二维表明细,但仅做到这一点远远不够,出于以下两个目的,需要 SQL 提供聚合函数: 1. 明细数据没有统计意义,比如我想知道今天的营业额一共有多少,而不太关心某桌客人消费了多少。 2. 虽然可以先把数据查到内存中再聚合,但在数据量非常大的情况下很容易把内存撑爆,可能一张表一天的数据量就有 10TB,而 10TB 数据就算能读到内存里,聚合计算可能也会慢到难以接受。 另外聚合本身也有一定逻辑复杂度,而 SQL 提供了聚合函数与分组聚合能力,可以方便快速的统计出有业务价值的聚合数据,这奠定了 SQL 语言的分析价值,因此大部分分析软件直接采用 SQL 作为直接面向用户的表达式。 ## 聚合函数 常见的聚合函数有: - COUNT:计数。 - SUM:求和。 - AVG:求平均值。 - MAX:求最大值。 - MIN:求最小值。 ### COUNT COUNT 用来计算有多少条数据,比如我们看 id 这一列有多少条: ```sql SELECT COUNT(id) FROM test ``` 但我们发现其实查任何一列的 COUNT 都是一样的,那传入 id 有什么意义呢?没必要特殊找一个具体列指代呀,所以也可以写成: ```sql SELECT COUNT(*) FROM test ``` 但这两者存在微妙差异。SQL 存在一种很特殊的值类型 `NULL`,如果 COUNT 指定了具体列,则统计时会跳过此列值为 `NULL` 的行,而 `COUNT(*)` 由于未指定具体列,所以就算包含了 `NULL`,甚至某一行所有列都为 `NULL`,也都会包含进来。所以 `COUNT(*)` 查出的结果一定大于等于 `COUNT(c1)`。 当然任何聚合函数都可以跟随查询条件 WHERE,比如: ```sql SELECT COUNT(*) FROM test WHERE is_gray = 1 ``` ### SUM SUM 求和所有项,因此必须作用于数值字段,而不能用于字符串。 ```sql SELECT SUM(cost) FROM test ``` SUM 遇到 NULL 值时当 0 处理,因为这等价于忽略。 ### AVG AVG 求所有项均值,因此必须作用于数值字段,而不能用于字符串。 ```sql SELECT AVG(cost) FROM test ``` AVG 遇到 NULL 值时采用了最彻底的忽略方式,即 NULL 完全不参与分子与分母的计算,就像这一行数据不存在一样。 ### MAX、MIN MAX、MIN 分别求最大与最小值,与上面不同的是,也可以作用于字符串上,因此可以根据字母判断大小,从大到小依次对应 `a-z`,但即便能算,也没有实际意义且不好理解,因此不建议对字符串求极值。 ```sql SELECT MAX(cost) FROM test ``` ### 多个聚合字段 虽然都是聚合函数,但 MAX、MIN 严格意义上不算是聚合函数,因为它们只是寻找了满足条件的行。可以看看下面两段查询结果的对比: ```sql SELECT MAX(cost), id FROM test -- id: 100 SELECT SUM(cost), id FROM test -- id: 1 ``` 第一条查询可以找到最大值那一行的 id,而第二条查询的 id 是无意义的,因为不知道归属在哪一行,所以只返回了第一条数据的 id。 当然,如果同时计算 MAX、MIN,那么此时 id 也只返回第一条数据的值,因为这个查询结果对应了复数行: ```sql SELECT MAX(cost), MIN(cost), id FROM test -- id: 1 ``` 基于这些特性,最好不要混用聚合与非聚合,也就是一条查询一旦有一个字段是聚合的,那么所有字段都要聚合。 现在很多 BI 引擎的自定义字段都有这条限制,因为混用聚合与非聚合在自定义内存计算时处理起来边界情况很多,虽然 SQL 能支持,但业务自定义的函数可能不支持。 ## 分组聚合 分组聚合就是 GROUP BY,其实可以把它当作一种高级的条件语句。 举个例子,查询每个国家的 GDP 总量: ```sql SELECT SUM(GDP) FROM amazing_table GROUP BY country ``` 返回的结果就会按照国家进行分组,这时,聚合函数就变成了在组内聚合。 其实如果我们只想看中、美的 GDP,用非分组也可以查,只是要分成两条 SQL: ```sql SELECT SUM(GDP) FROM amazing_table WHERE country = '中国' SELECT SUM(GDP) FROM amazing_table WHERE country = '美国' ``` 所以 GROUP BY 也可理解为,将某个字段的所有可枚举的情况都查了出来,并整合成一张表,每一行代表了一种枚举情况,不需要分解为一个个 WHERE 查询了。 ### 多字段分组聚合 GROUP BY 可以对多个维度使用,含义等价于表格查询时行/列拖入多个维度。 上面是 BI 查询工具视角,如果没有上下文,可以看下面这个递进描述: - 按照多个字段进行分组聚合。 - 多字段组合起来成为唯一 Key,即 `GROUP BY a,b` 表示 a,b 合在一起描述一个组。 - `GROUP BY a,b,c` 查询结果第一列可能看到许多重复的 a 行,第二列看到重复 b 行,但在同一个 a 值内不会重复,c 在 b 行中同理。 下面是一个例子: ```sql SELECT SUM(GDP) FROM amazing_table GROUP BY province, city, area ``` 查询结果为: ```text 浙江 杭州 余杭区 浙江 杭州 西湖区 浙江 宁波 海曙区 浙江 宁波 江北区 北京 ......... ``` ### GROUP BY + WHERE WHERE 是根据行进行条件筛选的。因此 GROUP BY + WHERE 并不是在组内做筛选,而是对整体做筛选。 但由于按行筛选,其实组内或非组内结果都完全一样,所以我们几乎无法感知这种差异: ```sql SELECT SUM(GDP) FROM amazing_table GROUP BY province, city, area WHERE industry = 'internet' ``` 然而,忽略这个差异会导致我们在聚合筛选时碰壁。 比如要筛选出平均分大于 60 学生的成绩总和,如果不使用子查询,是无法在普通查询中在 WHERE 加聚合函数实现的,比如下面就是一个语法错误的例子: ```sql SELECT SUM(score) FROM amazing_table WHERE AVG(score) > 60 ``` 不要幻想上面的 SQL 可以执行成功,不要在 WHERE 里使用聚合函数。 ### GROUP BY + HAVING HAVING 是根据组进行条件筛选的。因此可以在 HAVING 使用聚合函数: ```sql SELECT SUM(score) FROM amazing_table GROUP BY class_name HAVING AVG(score) > 60 ``` 上面的例子中可以正常查询,表示按照班级分组看总分,且仅筛选出平均分大于 60 的班级。 所以为什么 HAVING 可以使用聚合条件呢?因为 HAVING 筛选的是组,所以可以对组聚合后过滤掉不满足条件的组,这样是有意义的。而 WHERE 是针对行粒度的,聚合后全表就只有一条数据,无论过滤与否都没有意义。 但要注意的是,GROUP BY 生成派生表是无法利用索引筛选的,所以 WHERE 可以利用给字段建立索引优化性能,而 HAVING 针对索引字段不起作用。 ## 总结 聚合函数 + 分组可以实现大部分简单 SQL 需求,在写 SQL 表达式时,需要思考这样的表达式是如何计算的,比如 `MAX(c1), c2` 是合理的,而 `SUM(c1), c2` 这个 `c2` 就是无意义的。 最后记住 WHERE 是 GROUP BY 之前执行的,HAVING 针对组进行筛选。 > 讨论地址是:[精读《SQL 聚合查询》· Issue #401 · ascoders/weekly](https://github.com/ascoders/weekly/issues/401) **如果你想参与讨论,请 [点击这里](https://github.com/ascoders/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: SQL/233.SQL 复杂查询.md ================================================ SQL 复杂查询指的就是子查询。 为什么子查询叫做复杂查询呢?因为子查询相当于查询嵌套查询,因为嵌套导致复杂度几乎可以被无限放大(无限嵌套),因此叫复杂查询。下面是一个最简单的子查询例子: ```sql SELECT pv FROM ( SELECT pv FROM test ) ``` 上面的例子等价于 `SELECT pv FROM test`,但因为把表的位置替换成了一个新查询,所以摇身一变成为了复杂查询!所以复杂查询不一定真的复杂,甚至可能写出和普通查询等价的复杂查询,要避免这种无意义的行为。 我们也要借此机会了解为什么子查询可以这么做。 ### 理解查询的本质 当我们查一张表时,数据库认为我们在查什么? 这点很重要,因为下面两个语句都是合法的: ```sql SELECT pv FROM test SELECT pv FROM ( SELECT pv FROM test ) ``` 为什么数据库可以把子查询当作表呢?为了统一理解这些概念,我们有必要对查询内容进行抽象理解:**任意查询位置都是一条或多条记录**。 比如 `test` 这张表,显然是多条记录(当然只有一行就是一条记录),而 `SELECT pv FROM test` 也是多条记录,然而因为 `FROM` 后面可以查询任意条数的记录,所以这两种语法都支持。 不仅是 `FROM` 可以跟单条或多条记录,甚至 `SELECT`、`GROUP BY`、`WHERE`、`HAVING` 后都可以跟多条记录,这个后面再说。 说到这,也就很好理解子查询的变种了,比如我们可以在子查询内使用 `WHERE` 或 `GROUP BY` 等等,因为无论如何,只要查询结果是多条记录就行了: ```sql SELECT sum(people) as allPeople, sum(gdp), city FROM ( SELECT people, gdp, city FROM test GROUP BY city HAVING sum(gdp) > 10000 ) ``` 这个例子就有点业务含义了。子查询是从内而外执行的,因此我们先看内部的逻辑:按照城市分组,筛选出总 GDP 超过一万的所有地区的人口数量明细。外层查询再把人口数加总,这样就能对比每个 GDP 超过一万的地区,总人口和总 GDP 分别是多少,方便对这些重点城市做对比。 不过这个例子看起来还是不太自然,因为我们没必要写成复杂查询,其实简单查询也是等价的: ```sql SELECT sum(people) as allPeople, sum(gdp), city FROM test GROUP BY city HAVING sum(gdp) > 10000 ``` 那为什么要多此一举呢?因为复杂查询的真正用法并不在这里。 ### 视图 正因为子查询的存在,我们才可能以类似抽取变量的方式,抽取子查询,这个抽取出来的抽象就是视图: ```sql CREATE VIEW my_table(people, gdp, city) AS SELECT sum(people) as allPeople, sum(gdp), city FROM test GROUP BY city HAVING sum(gdp) > 10000 SELECT sum(people) as allPeople, sum(gdp), city FROM my_table ``` 这样的好处是,这个视图可以被多条 SQL 语句复用,不仅可维护性变好了,执行时也仅需查询一次。 要注意的是,SELECT 可以使用任何视图,但 INSERT、DELETE、UPDATE 用于视图时,需要视图满足一下条件: 1. 未使用 DISTINCT 去重。 2. FROM 单表。 3. 未使用 GROUP BY 和 HAVING。 因为上面几种模式都会导致视图成为聚合后的数据,不方便做除了查以外的操作。 另外一个知识点就是物化视图,即使用 MATERIALIZED 描述视图: ```sql CREATE MATERIALIZED VIEW my_table(people, gdp, city) AS ... ``` 这种视图会落盘,为什么要支持这个特性呢?因为普通视图作为临时表,无法利用索引等优化手段,查询性能较低,所以物化视图是较为常见的性能优化手段。 说到性能优化手段,还有一些比较常见的理念,即把读的复杂度分摊到写的时候,比如提前聚合新表落盘或者对 CASE 语句固化为字段等,这里先不展开。 ### 标量子查询 上面说了,WHERE 也可以跟子查询,比如: ```sql SELECT city FROM test WHERE gdp > ( SELECT avg(gdp) from test ) ``` 这样可以查询出 gdp 大于平均值的城市。 那为什么不能直接这么写呢? ```sql SELECT city FROM test WHERE gdp > avg(gdp) -- 报错,WHERE 无法使用聚合函数 ``` 看上去很美好,但其实第一篇我们就介绍了,WHERE 不能跟聚合查询,因为这样会把整个父查询都聚合起来。那为什么子查询可以?因为子查询聚合的是子查询啊,父查询并没有被聚合,所以这才符合我们的意图。 所以上面例子不合适的地方在于,直接在当前查询使用 `avg(gdp)` 会导致聚合,而我们并不想聚合当前查询,但又要通过聚合拿到平均 GDP,所以就要使用子查询了! 回过头来看,为什么这一节叫标量子查询?标量即单一值,因为 `avg(gdp)` 聚合出来的只有一个值,所以 WHERE 可以把它当做一个单一数值使用。反之,如果子查询没有使用聚合函数,或 GROUP BY 分组,那么就不能使用 `WHERE >` 这种语法,但可以使用 `WHERE IN`,这涉及到单条与多条记录的思考,我们接着看下一节。 ### 单条和多条记录 介绍标量子查询时说到了,`WHERE >` 的值必须时单一值。但其实 WHERE 也可以跟返回多条记录的子查询结果,只要使用合理的条件语句,比如 IN: ```sql SELECT area FROM test WHERE gdp IN ( SELECT max(gdp) from test GROUP BY city ) ``` 上面的例子,子查询按照城市分组,并找到每一组 GDP 最大的那条记录,所以如果数据粒度是区域,那么我们就查到了每个城市 GDP 最大的那些记录,然后父查询通过 WHERE IN 找到 gdp 符合的复数结果,所以最后就把每个城市最大 gdp 的区域列了出来。 但实际上 `WHERE >` 语句跟复数查询结果也不会报错,但没有任何意义,所以我们要理解查询结果是单条还是多条,在 WHERE 判断时选择合适的条件。WHERE 适合跟复数查询结果的语法有:`WHERE IN`、`WHERE SOME`、`WHERE ANY`。 ### 关联子查询 所谓关联子查询,即父子查询间存在关联,既然如此,子查询肯定不能单独优先执行,毕竟和父查询存在关联嘛,所以关联子查询是先执行外层查询,再执行内层查询的。要注意的是,对每一行父查询,子查询都会执行一次,因此性能不高(当然 SQL 会对相同参数的子查询结果做缓存)。 那这个关联是什么呢?关联的是每一行父查询时,对子查询执行的条件。这么说可能有点绕,举个例子: ```sql SELECT * FROM test where gdp > ( select avg(gdp) from test group by city ) ``` 对这个例子来说,想要查找 gdp 大于按城市分组的平均 gdp,比如北京地区按北京比较,上海地区按上海比较。但很可惜这样做是不行的,因为父子查询没有关联,SQL 并不知道要按照相同城市比较,因此只要加一个 WHERE 条件,就变成关联子查询了: ```sql SELECT * FROM test as t1 where gdp > ( select avg(gdp) from test as t2 where t1.city = t2.city group by city ) ``` 就是在每次判断 `WHERE gdp >` 条件时,重新计算子查询结果,将平均值限定在相同的城市,这样就符合需求了。 ## 总结 学会灵活运用父子查询,就掌握了复杂查询了。 SQL 第一公民是集合,所以所谓父子查询就是父子集合的灵活组合,这些集合可以出现在几乎任何位置,根据集合的数量、是否聚合、关联条件,就派生出了标量查询、关联子查询。 更深入的了解就需要大量实战案例了,但万变不离其宗,掌握了复杂查询后,就可以理解大部分 SQL 案例了。 > 讨论地址是:[精读《SQL 复杂查询》· Issue #403 · ascoders/weekly](https://github.com/ascoders/weekly/issues/403) **如果你想参与讨论,请 [点击这里](https://github.com/ascoders/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: SQL/234.SQL CASE 表达式.md ================================================ CASE 表达式分为简单表达式与搜索表达式,其中搜索表达式可以覆盖简单表达式的全部能力,我也建议只写搜索表达式,而不要写简单表达式。 简单表达式: ```sql SELECT CASE city WHEN '北京' THEN 1 WHEN '天津' THEN 2 ELSE 0 END AS abc FROM test ``` 搜索表达式: ```sql SELECT CASE WHEN city = '北京' THEN 1 WHEN city = '天津' THEN 2 ELSE 0 END AS abc FROM test ``` 明显可以看出,简单表达式只是搜索表达式 `a = b` 的特例,因为无法书写任何符号,只要条件换成 `a > b` 就无法胜任了,而搜索表达式不但可以轻松胜任,甚至可以写聚合函数。 ## CASE 表达式里的聚合函数 为什么 CASE 表达式里可以写聚合函数? 因为本身表达式就支持聚合函数,比如下面的语法,我们不会觉得奇怪: ```sql SELECT sum(pv), avg(uv) from test ``` 本身 SQL 就支持多种不同的聚合方式同时计算,所以将其用在 CASE 表达式里,也是顺其自然的: ```sql SELECT CASE WHEN count(city) = 100 THEN 1 WHEN sum(dau) > 200 THEN 2 ELSE 0 END AS abc FROM test ``` 只要 SQL 表达式中存在聚合函数,那么整个表达式都聚合了,此时访问非聚合变量没有任何意义。所以上面的例子,即便在 CASE 表达式中使用了聚合,其实也不过是聚合了一次后,按照条件进行判断罢了。 这个特性可以解决很多实际问题,比如将一些复杂聚合判断条件的结果用 SQL 结构输出,那么很可能是下面这种写法: ```sql SELECT CASE WHEN 聚合函数(字段) 符合什么条件 THEN xxx ... 可能有 N 个 ELSE NULL END AS abc FROM test ``` 这也可以认为是一种行转列的过程,即 **把行聚合后的结果通过一条条 CASE 表达式形成一个个新的列**。 ## 聚合与非聚合不能混用 我们希望利用 CASE 表达式找出那些 pv 大于平均值的行,以下这种想当然的写法是错误的: ```sql SELECT CASE WHEN pv > avg(pv) THEN 'yes' ELSE 'no' END AS abc FROM test ``` 原因是,只要 SQL 中存在聚合表达式,那么整条 SQL 就都是聚合的,所以返回的结果只有一条,而我们期望查询结果不聚合,只是判断条件用到了聚合结果,那么就要使用子查询。 为什么子查询可以解决问题?因为子查询的聚合发生在子查询,而不影响当前父查询,理解了这一点,就知道为什么下面的写法才是正确的了: ```sql SELECT CASE WHEN pv > ( SELECT avg(pv) from test ) THEN 'yes' ELSE 'no' END AS abc FROM test ``` 这个例子也说明了 CASE 表达式里可以使用子查询,因为子查询是先计算的,所以查询结果在哪儿都能用,CASE 表达式也不例外。 ## WHERE 中的 CASE WHERE 后面也可以跟 CASE 表达式的,用来做一些需要特殊枚举处理的筛选。 比如下面的例子: ```sql SELECT * FROM demo WHERE CASE WHEN city = '北京' THEN true ELSE ID > 5 END ``` 本来我们要查询 ID 大于 5 的数据,但我想对北京这个城市特别对待,那么就可以在判断条件中再进行 CASE 分支判断。 这个场景在 BI 工具里等价于,创建一个 CASE 表达式字段,可以拖入筛选条件生效。 ## GROUP BY 中的 CASE 想不到吧,GROUP BY 里都可以写 CASE 表达式: ```sql SELECT isPower, sum(gdp) FROM test GROUP BY CASE WHEN isPower = 1 THEN city, area ELSE city END ``` 上面例子表示,计算 GDP 时,对于非常发达的城市,按照每个区粒度查看聚合结果,也就是看的粒度更细一些,而对于欠发达地区,本身 gdp 也不高,直接按照城市粒度看聚合结果。 这样,就按照不同的条件对数据进行了分组聚合。由于返回行结果是混在一起的,像这个例子,可以根据 isPower 字段是否为 1 判断,是否按照城市、区域进行了聚合,如果没有其他更显著的标识,可能导致无法区分不同行的聚合粒度,因此谨慎使用。 ## ORDER BY 中的 CASE 同样,ORDER BY 使用 CASE 表达式,会将排序结果按照 CASE 分类进行分组,每组按照自己的规则排序,比如: ```sql SELECT * FROM test ORDER BY CASE WHEN isPower = 1 THEN gdp ELSE people END ``` 上面的例子,对发达地区采用 gdp 排序,否则采用人口数量排序。 ## 总结 CASE 表达式总结一下有如下特点: 1. 支持简单与搜索两种写法,推荐搜索写法。 2. 支持聚合与子查询,需要注意不同情况的特点。 3. 可以写在 SQL 查询的几乎任何地方,只要是可以写字段的地方,基本上就可以替换为 CASE 表达式。 4. 除了 SELECT 外,CASE 表达式还广泛应用在 INSERT 与 UPDATE,其中 UPDATE 的妙用是不用将 SQL 拆分为多条,所以不用担心数据变更后对判断条件的二次影响。 > 讨论地址是:[精读《SQL CASE 表达式》· Issue #404 · ascoders/weekly](https://github.com/ascoders/weekly/issues/404) **如果你想参与讨论,请 [点击这里](https://github.com/ascoders/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: SQL/235.SQL 窗口函数.md ================================================ 窗口函数形如: ```sql 表达式 OVER (PARTITION BY 分组字段 ORDER BY 排序字段) ``` 有两个能力: 1. 当表达式为 `rank()` `dense_rank()` `row_number()` 时,拥有分组排序能力。 2. 当表达式为 `sum()` 等聚合函数时,拥有累计聚合能力。 无论何种能力,**窗口函数都不会影响数据行数,而是将计算平摊在每一行**。 这两种能力需要区分理解。 ## 底表 以上是示例底表,共有 8 条数据,城市1、城市2 两个城市,下面各有地区1~4,每条数据都有该数据的人口数。 ## 分组排序 如果按照人口排序,`ORDER BY people` 就行了,但如果我们想在城市内排序怎么办? 此时就要用到窗口函数的分组排序能力: ```sql SELECT *, rank() over (PARTITION BY city ORDER BY people) FROM test ``` 该 SQL 表示在 city 组内按照 people 进行排序。 其实 PARTITION BY 也是可选的,如果我们忽略它: ```sql SELECT *, rank() over (ORDER BY people) FROM test ``` 也是生效的,但该语句与普通 ORDER BY 等价,因此利用窗口函数进行分组排序时,一般都会使用 PARTITION BY。 ### 各分组排序函数的差异 我们将 `rank()` `dense_rank()` `row_number()` 的结果都打印出来: ```sql SELECT *, rank() over (PARTITION BY city ORDER BY people), dense_rank() over (PARTITION BY city ORDER BY people), row_number() over (PARTITION BY city ORDER BY people) FROM test ``` 其实从结果就可以猜到,这三个函数在处理排序遇到相同值时,对排名统计逻辑有如下差异: 1. `rank()`: 值相同时排名相同,但占用排名数字。 2. `dense_rank()`: 值相同时排名相同,但不占用排名数字,整体排名更加紧凑。 3. `row_number()`: 无论值是否相同,都强制按照行号展示排名。 上面的例子可以优化一下,因为所有窗口逻辑都是相同的,我们可以利用 WINDOW AS 提取为一个变量: ```sql SELECT *, rank() over wd, dense_rank() over wd, row_number() over wd FROM test WINDOW wd as (PARTITION BY city ORDER BY people) ``` ## 累计聚合 我们之前说过,凡事使用了聚合函数,都会让查询变成聚合模式。如果不用 GROUP BY,聚合后返回行数会压缩为一行,即使用了 GROUP BY,返回的行数一般也会大大减少,因为分组聚合了。 然而使用窗口函数的聚合却不会导致返回行数减少,那么这种聚合是怎么计算的呢?我们不如直接看下面的例子: ```sql SELECT *, sum(people) over (PARTITION BY city ORDER BY people) FROM test ``` 可以看到,在每个 city 分组内,按照 people 排序后进行了 **累加**(相同的值会合并在一起),这就是 BI 工具一般说的 RUNNGIN_SUM 的实现思路,当然一般我们排序规则使用绝对不会重复的日期,所以不会遇到第一个红框中合并计算的问题。 累计函数还有 `avg()` `min()` 等等,这些都一样可以作用于窗口函数,其逻辑可以按照下图理解: 你可能有疑问,直接 `sum(上一行结果,下一行)` 不是更方便吗?为了验证猜想,我们试试 `avg()` 的结果: 可见,如果直接利用上一行结果的缓存,那么 avg 结果必然是不准确的,所以窗口累计聚合是每行重新计算的。当然也不排除对于 sum、max、min 做额外性能优化的可能性,但 avg 只能每行重头计算。 ### 与 GROUP BY 组合使用 窗口函数是可以与 GROUP BY 组合使用的,遵循的规则是,窗口范围对后面的查询结果生效,所以其实并不关心是否进行了 GROUP BY。我们看下面的例子: 按照地区分组后进行累加聚合,是对 GROUP BY 后的数据行粒度进行的,而不是之前的明细行。 ## 总结 窗口函数在计算组内排序或累计 GVM 等场景非常有用,我们只要牢记两个知识点就行了: 1. 分组排序要结合 PARTITION BY 才有意义。 2. 累计聚合作用于查询结果行粒度,支持所有聚合函数。 > 讨论地址是:[精读《SQL 窗口函数》· Issue #405 · ascoders/weekly](https://github.com/ascoders/weekly/issues/405) **如果你想参与讨论,请 [点击这里](https://github.com/ascoders/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: SQL/236.SQL grouping.md ================================================ SQL grouping 解决 OLAP 场景总计与小计问题,其语法分为几类,但要解决的是同一个问题: ROLLUP 与 CUBE 是封装了规则的 GROUPING SETS,而 GROUPING SETS 则是最原始的规则。 为了方便理解,让我们从一个问题入手,层层递进吧。 ## 底表 以上是示例底表,共有 8 条数据,城市1、城市2 两个城市,下面各有地区1~4,每条数据都有该数据的人口数。 现在想计算人口总计,以及各城市人口小计。在没有掌握 grouping 语法前,我们只能通过两个 select 语句 union 后得到: ```sql SELECT city, sum(people) FROM test GROUP BY city union SELECT '合计' as city, sum(people) FROM test ``` 但两条 select 语句聚合了两次,性能是一个不小的开销,因此 SQL 提供了 GROUPING SETS 语法解决这个问题。 ## GROUPING SETS GROUP BY GROUPING SETS 可以指定任意聚合项,比如我们要同时计算总计与分组合计,就要按照空内容进行 GROUP BY 进行一次 sum,再按照 city 进行 GROUP BY 再进行一次 sum,换成 GROUPING SETS 描述就是: ```sql SELECT city, area, sum(people) FROM test GROUP BY GROUPING SETS((), (city, area)) ``` 其中 `GROUPING SETS((), (city, area))` 表示分别按照 `()`、`(city, area)` 聚合计算总计。返回结果是: 可以看到,值为 NULL 的行就是我们要的总计,其值是没有任何 GROUP BY 限制算出来的。 类似的,我们还可以写 `GROUPING SETS((), (city), (city, area), (area))` 等任意数量、任意组合的 GROUP BY 条件。 通过这种规则计算的数据我们称为 “超级分组记录”。我们发现 “超级分组记录” 产生的 NULL 值很容易和真正的 NULL 值弄混,所以 SQL 提供了 GROUPING 函数解决这个问题。 ## 函数 GROUPING 对于超级分组记录产生的 NULL,是可以被 `GROUPING()` 函数识别为 1 的: ```sql SELECT GROUPING(city), GROUPING(area), sum(people) FROM test GROUP BY GROUPING SETS((), (city, area)) ``` 具体效果见下图: 可以看到,但凡是超级分组计算出来的字段都会识别为 1,我们利用之前学习的 [SQL CASE 表达式](https://github.com/ascoders/weekly/blob/master/SQL/234.SQL%20CASE%20%E8%A1%A8%E8%BE%BE%E5%BC%8F.md) 将其转换为总计、小计字样,就可以得出一张数据分析表了: ```sql SELECT CASE WHEN GROUPING(city) = 1 THEN '总计' ELSE city END, CASE WHEN GROUPING(area) = 1 THEN '小计' ELSE area END, sum(people) FROM test GROUP BY GROUPING SETS((), (city, area)) ``` 然后前端表格展示时,将第一行 “总计”、“小计” 单元格合并为 “总计”,就完成了总计这个 BI 可视化分析功能。 ## ROLLUP ROLLUP 是卷起的意思,是一种特定规则的 GROUPING SETS,以下两种写法是等价的: ```sql SELECT sum(people) FROM test GROUP BY ROLLUP(city) -- 等价于 SELECT sum(people) FROM test GROUP BY GROUPING SETS((), (city)) ``` 再看一组等价描述: ```sql SELECT sum(people) FROM test GROUP BY ROLLUP(city, area) -- 等价于 SELECT sum(people) FROM test GROUP BY GROUPING SETS((), (city), (city, area)) ``` 发现规律了吗?ROLLUP 会按顺序把 GROUP BY 内容 “一个个卷起来”。用 GROUPING 函数判断超级分组记录对 ROLLUP 同样适用。 ## CUBE CUBE 又有所不同,它对内容进行了所有可能性展开(所以叫 CUBE)。 类比上面的例子,我们再写两组等价的展开: ```sql SELECT sum(people) FROM test GROUP BY CUBE(city) -- 等价于 SELECT sum(people) FROM test GROUP BY GROUPING SETS((), (city)) ``` 上面的例子因为只有一项还看不出来,下面两项分组就能看出来了: ```sql SELECT sum(people) FROM test GROUP BY CUBE(city, area) -- 等价于 SELECT sum(people) FROM test GROUP BY GROUPING SETS((), (city), (area), (city, area)) ``` 所谓 CUBE,是一种多维形状的描述,二维时有 2^1 种展开,三维时有 2^2 种展开,四维、五维依此类推。可以想象,如果用 CUBE 描述了很多组合,复杂度会爆炸。 ## 总结 学习了 GROUPING 语法,以后前端同学的你不会再纠结这个问题了吧: > 产品开启了总计、小计,我们是额外取一次数还是放到一起获取啊? 这个问题的标准答案和原理都在这篇文章里了。PS:对于不支持 GROUPING 语法数据库,要想办法屏蔽,就像前端 polyfill 一样,是一种降级方案。至于如何屏蔽,参考文章开头提到的两个 SELECT + UNION。 > 讨论地址是:[精读《SQL grouping》· Issue #406 · ascoders/weekly](https://github.com/ascoders/weekly/issues/406) **如果你想参与讨论,请 [点击这里](https://github.com/ascoders/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: TS 类型体操/243.精读《Pick, Awaited, If...》.md ================================================ TS 强类型非常好用,但在实际运用中,免不了遇到一些难以描述,反复看官方文档也解决不了的问题,至今为止也没有任何一篇文档,或者一套教材可以解决所有犄角旮旯的类型问题。为什么会这样呢?因为 TS 并不是简单的注释器,而是一门图灵完备的语言,所以很多问题的解决方法藏在基础能力里,但你学会了基础能力又不一定能想到这么用。 解决该问题的最好办法就是多练,通过实际案例不断刺激你的大脑,让你养成 TS 思维习惯。所以话不多说,我们今天从 [type-challenges](https://github.com/type-challenges/type-challenges) 的 Easy 难度题目开始吧。 ## 精读 ### [Pick](https://github.com/type-challenges/type-challenges/blob/main/questions/00004-easy-pick/README.md) 手动实现内置 `Pick` 函数,返回一个新的类型,从对象 T 中抽取类型 K: ```ts interface Todo { title: string description: string completed: boolean } type TodoPreview = MyPick const todo: TodoPreview = { title: 'Clean room', completed: false, } ``` 结合例子更容易看明白,也就是 `K` 是一个字符串,我们需要返回一个新类型,仅保留 `K` 定义的 Key。 第一个难点在如何限制 `K` 的取值,比如传入 `T` 中不存在的值就要报错。这个考察的是硬知识,只要你知道 `A extends keyof B` 这个语法就能联想到。 第二个难点在于如何生成一个仅包含 `K` 定义 Key 的类型,你首先要知道有 `{ [A in keyof B]: B[A] }` 这个硬知识,这样可以重新组合一个对象: ```ts // 代码 1 type Foo = { [P in keyof T]: T[P] } ``` 只懂这个语法不一定能想出思路,原因是你要打破对 TS 的刻板理解,`[K in keyof T]` 不是一个固定模板,其中 `keyof T` 只是一个指代变量,它可以被换掉,如果你换掉成另一个范围的变量,那么这个对象的 Key 值范围就变了,这正好契合本题的 `K`: ```ts // 代码 2(本题答案) type MyPick = { [P in K]: T[P] } ``` 这个题目别看知道答案后简单,回顾下还是有收获的。对比上面两个代码例子,你会发现,只不过是把代码 1 的 `keyof T` 从对象描述中提到了泛型定义里而已,所以功能上没有任何变化,但因为泛型可以由用户传入,所以代码 1 的 `P in keyof T` 因为没有泛型支撑,这里推导出来的就是 `T` 的所有 Keys,而代码 2 虽然把代码挪到了泛型,但因为用的是 `extends` 描述,所以表示 `P` 的类型被约束到了 `T` 的 Keys,至于具体是什么,得看用户代码怎么传。 所以其实放到泛型里的 `K` 是没有默认值的,而写到对象里作为推导值就有了默认值。泛型里给默认值的方式如下: ```ts // 代码 3 type MyPick = { [P in K]: T[P] } ``` 也就是说,这样 `MyPick` 就也可以正确工作并原封不动返回 `Todo` 类型,也就是说,代码 3 在不传第二个参数时,与代码 1 的功能完全一样。仔细琢磨一下共同点与区别,为什么代码 3 可以做到和代码 1 功能一样,又有更强的拓展性,你对 TS 泛型的实战理解就上了一个台阶。 ### [Readonly](https://github.com/type-challenges/type-challenges/blob/main/questions/00007-easy-readonly/README.md) 手动实现内置 `Readonly` 函数,将对象所有属性设置为只读: ```ts interface Todo { title: string description: string } const todo: MyReadonly = { title: "Hey", description: "foobar" } todo.title = "Hello" // Error: cannot reassign a readonly property todo.description = "barFoo" // Error: cannot reassign a readonly property ``` 这道题反而比第一题简单,只要我们用 `{ [A in keyof B]: B[A] }` 重新声明对象,并在每个 Key 前面加上 `readonly` 修饰即可: ```ts // 本题答案 type MyReadonly = { readonly [K in keyof T]: T[K] } ``` 根据这个特性我们可以做很多延伸改造,比如将对象所有 Key 都设定为可选: ```ts type Optional = { [K in keyof T]?: T[K] } ``` `{ [A in keyof B]: B[A] }` 给了我们描述每一个 Key 属性细节的机会,限制我们发挥的只有想象力。 ### [First Of Array](https://github.com/type-challenges/type-challenges/blob/main/questions/00014-easy-first/README.md) 实现类型 `First`,取到数组第一项的类型: ```ts type arr1 = ['a', 'b', 'c'] type arr2 = [3, 2, 1] type head1 = First // expected to be 'a' type head2 = First // expected to be 3 ``` 这题比较简单,很容易想到的答案: ```ts // 本题答案 type First = T[0] ``` 但在写这个答案时,有 10% 脑细胞提醒我没有判断边界情况,果然看了下答案,有空数组的情况要考虑,空数组时返回类型 `never` 而不是 `undefined` 会更好,下面几种写法都是答案: ```ts type First = T extends [] ? never : T[0] type First = T['length'] extends 0 ? never : T[0] type First = T extends [infer P, ...infer Rest] ? P : never ``` 第一种写法通过 `extends []` 判断 `T` 是否为空数组,是的话返回 `never`。 第二种写法通过长度为 0 判断空数组,此时需要理解两点:1. 可以通过 `T['length']` 让 TS 访问到值长度(类型的),2. `extends 0` 表示是否匹配 0,即 `extends` 除了匹配类型,还能直接匹配值。 第三种写法是最省心的,但也使用了 `infer` 关键字,即使你充分知道 `infer` 怎么用([精读《Typescript infer 关键字》](https://github.com/ascoders/weekly/blob/master/%E5%89%8D%E6%B2%BF%E6%8A%80%E6%9C%AF/207.%E7%B2%BE%E8%AF%BB%E3%80%8ATypescript%20infer%20%E5%85%B3%E9%94%AE%E5%AD%97%E3%80%8B.md)),也很难想到它。用 `infer` 的理由是:该场景存在边界情况,最便于理解的写法是 “如果 T 形如 ``” 那我就返回类型 `P`,否则返回 `never`”,这句话用 TS 描述就是:`T extends [infer P, ...infer Rest] ? P : never`。 ### [Length of Tuple](https://github.com/type-challenges/type-challenges/blob/main/questions/00018-easy-tuple-length/README.md) 实现类型 `Length` 获取元组长度: ```ts type tesla = ['tesla', 'model 3', 'model X', 'model Y'] type spaceX = ['FALCON 9', 'FALCON HEAVY', 'DRAGON', 'STARSHIP', 'HUMAN SPACEFLIGHT'] type teslaLength = Length // expected 4 type spaceXLength = Length // expected 5 ``` 经过上一题的学习,很容易想到这个答案: ```ts type Length = T['length'] ``` 对 TS 来说,元组和数组都是数组,但元组对 TS 来说可以观测其长度,`T['length']` 对元组来说返回的是具体值,而对数组来说返回的是 `number`。 ### [Exclude](https://github.com/type-challenges/type-challenges/blob/main/questions/00043-easy-exclude/README.md) 实现类型 `Exclude`,返回 `T` 中不存在于 `U` 的部分。该功能主要用在联合类型场景,所以我们直接用 `extends` 判断就行了: ```ts // 本题答案 type Exclude = T extends U ? never : T ``` 实际运行效果: ```ts type C = Exclude<'a' | 'b', 'a' | 'c'> // 'b' ``` 看上去有点不那么好理解,这是因为 TS 对联合类型的执行是分配律的,即: ```ts Exclude<'a' | 'b', 'a' | 'c'> // 等价于 Exclude<'a', 'a' | 'c'> | Exclude<'b', 'a' | 'c'> ``` ### [Awaited](https://github.com/type-challenges/type-challenges/blob/main/questions/00189-easy-awaited/README.md) 实现类型 `Awaited`,比如从 `Promise` 拿到 `ExampleType`。 首先 TS 永远不会执行代码,所以脑子里不要有 “await 得等一下才知道结果” 的念头。该题关键就是从 `Promise` 中抽取类型 `T`,很适合用 `infer` 做: ```ts type MyAwaited = T extends Promise ? U : never ``` 然而这个答案还不够标准,标准答案考虑了嵌套 `Promise` 的场景: ```ts // 该题答案 type MyAwaited> = T extends Promise ? P extends Promise ? MyAwaited

: P : never ``` 如果 `Promise

` 取到的 `P` 还形如 `Promise`,就递归调用自己 `MyAwaited

`。这里提到了递归,也就是 TS 类型处理可以是递归的,所以才有了后面版本做尾递归优化。 ### [If](https://github.com/type-challenges/type-challenges/blob/main/questions/00268-easy-if/README.md) 实现类型 `If`,当 `C` 为 `true` 时返回 `T`,否则返回 `F`: ```ts type A = If // expected to be 'a' type B = If // expected to be 'b' ``` 之前有提过,`extends` 还可以用来判定值,所以果断用 `extends true` 判断是否命中了 `true` 即可: ```ts // 本题答案 type If = C extends true ? T : F ``` ### [Concat](https://github.com/type-challenges/type-challenges/blob/main/questions/00533-easy-concat/README.md) 用类型系统实现 `Concat`,将两个数组类型连起来: ```ts type Result = Concat<[1], [2]> // expected to be [1, 2] ``` 由于 TS 支持数组解构语法,所以可以大胆的尝试这么写: ```ts type Concat

= [...P, ...Q] ``` 考虑到 `Concat` 函数应该也能接收非数组类型,所以做一个判断,为了方便书写,把 `extends` 从泛型定义位置挪到 TS 类型推断的运行时: ```ts // 本题答案 type Concat = [ ...P extends any[] ? P : [P], ...Q extends any[] ? Q : [Q], ] ``` 解决这题需要信念,相信 TS 可以像 JS 一样写逻辑。这些能力都是版本升级时渐进式提供的,所以需要不断阅读最新 TS 特性,快速将其理解为固化知识,其实还是有一定难度的。 ### [Includes](https://github.com/type-challenges/type-challenges/blob/main/questions/00898-easy-includes/README.md) 用类型系统实现 `Includes` 函数: ```ts type isPillarMen = Includes<['Kars', 'Esidisi', 'Wamuu', 'Santana'], 'Dio'> // expected to be `false` ``` 由于之前的经验,很容易做下面的联想: ```ts // 如果题目要求是这样 type isPillarMen = Includes<'Kars' | 'Esidisi' | 'Wamuu' | 'Santana', 'Dio'> // 那我就能用 extends 轻松解决了 type Includes = K extends T ? true : false ``` 可惜第一个输入是数组类型,`extends` 可不支持判定 “数组包含” 逻辑,此时要了解一个新知识点,即 TS 判断中的 `[number]` 下标。不仅这道题,以后很多困难题都需要它作为基础知识。 `[number]` 下标表示任意一项,而 `extends T[number]` 就可以实现数组包含的判定,因此下面的解法是有效的: ```ts type Includes = K extends T[number] ? true : false ``` 但翻答案后发现这并不是标准答案,还真找到一个反例: ```ts type Includes = K extends T[number] ? true : false type isPillarMen = Includes<[boolean], false> // true ``` 原因很简单,`true`、`false` 都继承自 `boolean`,所以 `extends` 判断的界限太宽了,题目要求的是精确值匹配,故上面的答案理论上是错的。 标准答案是每次判断数组第一项,并递归(讲真觉得这不是 easy 题),分别有两个难点。 第一如何写 Equal 函数?比较流行的方案是这个: ```ts type Equal = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? true : false ``` 关于如何写 Equal 函数还引发了一次 [小讨论](https://github.com/microsoft/TypeScript/issues/27024#issuecomment-421529650),上面的代码构造了两个函数,这两个函数内的 `T` 属于 deferred(延迟)判断的类型,该类型判断依赖于内部 `isTypeIdenticalTo` 函数完成判断。 有了 `Equal` 后就简单了,我们用解构 + `infer` + 递归的方式做就可以了: ```ts // 本题答案 type Includes = T extends [infer F, ...infer Rest] ? Equal extends true ? true : Includes : false ``` 每次取数组第一个值判断 `Equal`,如果不匹配则拿剩余项递归判断。这个函数组合了不少 TS 知识,比如: - 递归 - 解构 - `infer` - `extends true` 可以发现,就为了解决 `true extends boolean` 为 `true` 的问题,我们绕了一大圈使用了更复杂的方式来实现,这在 TS 体操中也算是常态,解决问题需要耐心。 ### [Push](https://github.com/type-challenges/type-challenges/blob/main/questions/03057-easy-push/README.md) 实现 `Push` 函数: ```ts type Result = Push<[1, 2], '3'> // [1, 2, '3'] ``` 这道题真的很简单,用解构就行了: ```ts // 本题答案 type Push = [...T, K] ``` 可见,想要轻松解决一个 TS 简单问题,首先你需要能解决一些困难问题 😁。 ### [Unshift](https://github.com/type-challenges/type-challenges/blob/main/questions/03060-easy-unshift/README.md) 实现 `Unshift` 函数: ```ts type Result = Unshift<[1, 2], 0> // [0, 1, 2,] ``` 在 `Push` 基础上改下顺序就行了: ```ts // 本题答案 type Unshift = [K, ...T] ``` ### [Parameters](https://github.com/type-challenges/type-challenges/blob/main/questions/03312-easy-parameters/README.md) 实现内置函数 `Parameters`: `Parameters` 可以拿到函数的参数类型,直接用 `infer` 实现即可,也比较简单: ```ts type Parameters = T extends (...args: infer P) => any ? P : [] ``` `infer` 可以很方便从任何具体的位置取值,属于典型难懂易用的语法。 ## 总结 学会 TS 基础语法后,活用才是关键。 > 讨论地址是:[精读《Pick, Awaited, If...》· Issue #422 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/422) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: TS 类型体操/244.精读《Get return type, Omit, ReadOnly...》.md ================================================ 解决 TS 问题的最好办法就是多练,这次解读 [type-challenges](https://github.com/type-challenges/type-challenges) Medium 难度 1~8 题。 ## 精读 ### [Get Return Type](https://github.com/type-challenges/type-challenges/blob/main/questions/00002-medium-return-type/README.md) 实现非常经典的 `ReturnType`: ```ts const fn = (v: boolean) => { if (v) return 1 else return 2 } type a = MyReturnType // should be "1 | 2" ``` 首先不要被例子吓到了,觉得必须执行完代码才知道返回类型,其实 TS 已经帮我们推导好了返回类型,所以上面的函数 `fn` 的类型已经是这样了: ```ts const fn = (v: boolean): 1 | 2 => { ... } ``` 我们要做的就是把函数返回值从内部抽出来,这非常适合用 `infer` 实现: ```ts // 本题答案 type MyReturnType = T extends (...args: any[]) => infer P ? P : never ``` `infer` 配合 `extends` 是解构复杂类型的神器,如果对上面代码不能一眼理解,说明对 `infer` 熟悉度还是不够,需要多看。 ### [Omit](https://github.com/type-challenges/type-challenges/blob/main/questions/00003-medium-omit/README.md) 实现 `Omit`,作用恰好与 `Pick` 相反,排除对象 `T` 中的 `K` key: ```ts interface Todo { title: string description: string completed: boolean } type TodoPreview = MyOmit const todo: TodoPreview = { completed: false, } ``` 这道题比较容易尝试的方案是: ```ts type MyOmit = { [P in keyof T]: P extends K ? never : T[P] } ``` 其实仍然包含了 `description`、`title` 这两个 Key,只是这两个 Key 类型为 `never`,不符合要求。 所以只要 `P in keyof T` 写出来了,后面怎么写都无法将这个 Key 抹去,我们应该从 Key 下手: ```ts type MyOmit = { [P in (keyof T extends K ? never : keyof T)]: T[P] } ``` 但这样写仍然不对,我们思路正确,即把 `keyof T` 中归属于 `K` 的排除,但因为前后 `keyof T` 并没有关联,所以需要借助 `Exclude` 告诉 TS,前后 `keyof T` 是同一个指代(上一讲实现过 `Exclude`): ```ts // 本题答案 type MyOmit = { [P in Exclude]: T[P] } type Exclude = T extends U ? never : T ``` 这样就正确了,掌握该题的核心是: 1. 三元判断还可以写在 Key 位置。 2. JS 抽不抽函数效果都一样,但 TS 需要推断,很多时候抽一个函数出来就是为了告诉 TS “是同一指代”。 当然既然都用上了 `Exclude`,我们不如再结合 `Pick`,写出更优雅的 `Omit` 实现: ```ts // 本题优雅答案 type MyOmit = Pick> ``` ### [Readonly 2](https://github.com/type-challenges/type-challenges/blob/main/questions/00008-medium-readonly-2/README.md) 实现 `MyReadonly2`,让指定的 Key `K` 成为 ReadOnly: ```ts interface Todo { title: string description: string completed: boolean } const todo: MyReadonly2 = { title: "Hey", description: "foobar", completed: false, } todo.title = "Hello" // Error: cannot reassign a readonly property todo.description = "barFoo" // Error: cannot reassign a readonly property todo.completed = true // OK ``` 该题乍一看蛮难的,因为 `readonly` 必须定义在 Key 位置,但我们又没法在这个位置做三元判断。其实利用之前我们自己做的 `Pick`、`Omit` 以及内置的 `Readonly` 组合一下就出来了: ```ts // 本题答案 type MyReadonly2 = Readonly> & Omit ``` 即我们可以将对象一分为二,先 `Pick` 出 `K` Key 部分设置为 Readonly,再用 `&` 合并上剩下的 Key,正好用到上一题的函数 `Omit`,完美。 ### [Deep Readonly](https://github.com/type-challenges/type-challenges/blob/main/questions/00009-medium-deep-readonly/README.md) 实现 `DeepReadonly` 递归所有子元素: ```ts type X = { x: { a: 1 b: 'hi' } y: 'hey' } type Expected = { readonly x: { readonly a: 1 readonly b: 'hi' } readonly y: 'hey' } type Todo = DeepReadonly // should be same as `Expected` ``` 这肯定需要用类型递归实现了,既然要递归,肯定不能依赖内置 `Readonly` 函数,我们需要将函数展开手写: ```ts // 本题答案 type DeepReadonly = { readonly [K in keyof T]: T[K] extends Object> ? DeepReadonly : T[K] } ``` 这里 `Object` 也可以用 `Record` 代替。 ### [Tuple to Union](https://github.com/type-challenges/type-challenges/blob/main/questions/00010-medium-tuple-to-union/README.md) 实现 `TupleToUnion` 返回元组所有值的集合: ```ts type Arr = ['1', '2', '3'] type Test = TupleToUnion // expected to be '1' | '2' | '3' ``` 该题将元组类型转换为其所有值的可能集合,也就是我们希望用所有下标访问这个数组,在 TS 里用 `[number]` 作为下标即可: ```ts // 本题答案 type TupleToUnion = T[number] ``` ### [Chainable Options](https://github.com/type-challenges/type-challenges/blob/main/questions/00012-medium-chainable-options/README.md) 直接看例子比较好懂: ```ts declare const config: Chainable const result = config .option('foo', 123) .option('name', 'type-challenges') .option('bar', { value: 'Hello World' }) .get() // expect the type of result to be: interface Result { foo: number name: string bar: { value: string } } ``` 也就是我们实现一个相对复杂的 `Chainable` 类型,拥有该类型的对象可以 `.option(key, value)` 一直链式调用下去,直到使用 `get()` 后拿到聚合了所有 `option` 的对象。 如果我们用 JS 实现该函数,肯定需要在当前闭包存储 Object 的值,然后提供 `get` 直接返回,或 `option` 递归并传入新的值。我们不妨用 Class 来实现: ```ts class Chain { constructor(previous = {}) { this.obj = { ...previous } } obj: Object get () { return this.obj } option(key: string, value: any) { return new Chain({ ...this.obj, [key]: value }) } } const config = new Chain() ``` 而本地要求用 TS 实现,这就比较有趣了,正好对比一下 JS 与 TS 的思维。先打个岔,该题用上面 JS 方式写出来后,其实类型也就出来了,但用 TS 完整实现类型也另有其用,特别在一些复杂函数场景,需要用 TS 系统描述类型,JS 真正实现时拿到 any 类型做纯运行时处理,将类型与运行时分离开。 好我们回到题目,我们先把 `Chainable` 的框架写出来: ```ts type Chainable = { option: (key: string, value: any) => any get: () => any } ``` 问题来了,如何用类型描述 `option` 后还可以接 `option` 或 `get` 呢?还有更麻烦的,如何一步一步将类型传导下去,让 `get` 知道我此时拿的类型是什么呢? `Chainable` 必须接收一个泛型,这个泛型默认值是个空对象,所以 `config.get()` 返回一个空对象也是合理的: ```ts type Chainable = { option: (key: string, value: any) => any get: () => Result } ``` 上面的代码对于第一层是完全没问题的,直接调用 `get` 返回的就是空对象。 第二步解决递归问题: ```ts // 本题答案 type Chainable = { option: (key: K, value: V) => Chainable get: () => Result } ``` 递归思维大家都懂就不赘述了。这里有个看似不值得一提,但确实容易坑人的地方,就是如何描述一个对象仅包含一个 Key 值,这个值为泛型 `K` 呢? ```ts // 这是错的,因为描述了一大堆类型 { [K] : V } // 这也是错的,这个 K 就是字面量 K,而非你希望的类型指代 { K: V } ``` 所以必须使用 TS “习惯法” 的 `[K in keyof T]` 的套路描述,即便我们知道 `T` 只有一个固定的类型。可见 JS 与 TS 完全是两套思维方式,所以精通 JS 不必然精通 TS,TS 还是要大量刷题培养思维的。 ### [Last of Array](https://github.com/type-challenges/type-challenges/blob/main/questions/00015-medium-last/README.md) 实现 `Last` 获取元组最后一项的类型: ```ts type arr1 = ['a', 'b', 'c'] type arr2 = [3, 2, 1] type tail1 = Last // expected to be 'c' type tail2 = Last // expected to be 1 ``` 我们之前实现过 `First`,类似的,这里无非是解构时把最后一个描述成 `infer`: ```ts // 本题答案 type Last = T extends [...infer Q, infer P] ? P : never ``` 这里要注意,`infer Q` 有人第一次可能会写成: ```ts type Last = T extends [...Others, infer P] ? P : never ``` 发现报错,因为 TS 里不可能随便使用一个未定义的泛型,而如果把 Others 放在 `Last` 里,你又会面临一个 TS 大难题: ```ts type Last = T extends [...Others, infer P] ? P : never // 必然报错 Last ``` 因为 `Last` 仅传入了一个参数,必然报错,但第一个参数是用户给的,第二个参数是我们推导出来的,这里既不能用默认值,又不能不写,无解了。 如果真的硬着头皮要这么写,必须借助 TS 还未通过的一项特性:[部分类型参数推断](https://github.com/microsoft/TypeScript/issues/26242),举个例子,很可能以后的语法是: ```ts type Last = T extends [...Others, infer P] ? P : never ``` 这样首先传参只需要一个了,而且还申明了第二个参数是一个推断类型。不过该提案还未支持,而且本质上和把 `infer` 写到表达式里面含义和效果也都一样,所以对这道题来说就不用折腾了。 ### [Pop](https://github.com/type-challenges/type-challenges/blob/main/questions/00016-medium-pop/README.md) 实现 `Pop`,返回去掉元组最后一项之后的类型: ```ts type arr1 = ['a', 'b', 'c', 'd'] type arr2 = [3, 2, 1] type re1 = Pop // expected to be ['a', 'b', 'c'] type re2 = Pop // expected to be [3, 2] ``` 这道题和 `Last` 几乎完全一样,返回第一个解构值就行了: ```ts // 本题答案 type Pop = T extends [...infer Q, infer P] ? Q : never ``` ## 总结 从题目中很明显能看出 TS 思维与 JS 思维有很大差异,想要真正掌握 TS,大量刷题是必须的。 > 讨论地址是:[精读《Get return type, Omit, ReadOnly...》· Issue #422 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/422) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: TS 类型体操/245.精读《Promise.all, Replace, Type Lookup...》.md ================================================ 解决 TS 问题的最好办法就是多练,这次解读 [type-challenges](https://github.com/type-challenges/type-challenges) Medium 难度 9~16 题。 ## 精读 ### [Promise.all](https://github.com/type-challenges/type-challenges/blob/main/questions/00020-medium-promise-all/README.md) 实现函数 `PromiseAll`,输入 PromiseLike,输出 `Promise`,其中 `T` 是输入的解析结果: ```ts const promiseAllTest1 = PromiseAll([1, 2, 3] as const) const promiseAllTest2 = PromiseAll([1, 2, Promise.resolve(3)] as const) const promiseAllTest3 = PromiseAll([1, 2, Promise.resolve(3)]) ``` 该题难点不在 `Promise` 如何处理,而是在于 `{ [K in keyof T]: T[K] }` 在 TS 同样适用于描述数组,这是 JS 选手无论如何也想不到的: ```ts // 本题答案 declare function PromiseAll(values: T): Promise<{ [K in keyof T]: T[K] extends Promise ? U : T[K] }> ``` 不知道是 bug 还是 feature,TS 的 `{ [K in keyof T]: T[K] }` 能同时兼容元组、数组与对象类型。 ### [Type Lookup](https://github.com/type-challenges/type-challenges/blob/main/questions/00062-medium-type-lookup/README.md) 实现 `LookUp`,从联合类型 `T` 中查找 `type` 为 `P` 的项并返回: ```ts interface Cat { type: 'cat' breeds: 'Abyssinian' | 'Shorthair' | 'Curl' | 'Bengal' } interface Dog { type: 'dog' breeds: 'Hound' | 'Brittany' | 'Bulldog' | 'Boxer' color: 'brown' | 'white' | 'black' } type MyDog = LookUp // expected to be `Dog` ``` 该题比较简单,只要学会灵活使用 `infer` 与 `extends` 即可: ```ts // 本题答案 type LookUp = T extends { type: infer U } ? ( U extends P ? T : never ) : never ``` 联合类型的判断是一个个来的,所以我们只要针对每一个单独写判断就行了。上面的解法中,我们先利用 `extend` + `infer` 锁定 `T` 的类型是包含 `type` key 的对象,且将 `infer U` 指向了 `type`,所以在内部再利用三元运算符判断 `U extends P ?` 就能将 `type` 命中的类型挑出来。 笔者翻了下答案,发现还有一种更高级的解法: ```ts // 本题答案 type LookUp = U extends { type: T } ? U : never ``` 该解法更简洁,更完备: - 在泛型处利用 `extends { type: any }`、`extends U['type']` 直接锁定入参类型,让错误校验更早发生。 - `T extends U['type']` 精确缩小了参数 `T` 范围,可以学到的是,之前定义的泛型 `U` 可以直接被后面的新泛型使用。 - `U extends { type: T }` 是一种新的思考角度。在第一个答案中,我们的思维方式是 “找到对象中 `type` 值进行判断”,而第二个答案直接用整个对象结构 `{ type: T }` 判断,是更纯粹的 TS 思维。 ### [Trim Left](https://github.com/type-challenges/type-challenges/blob/main/questions/00106-medium-trimleft/README.md) 实现 `TrimLeft`,将字符串左侧空格清空: ```ts type trimed = TrimLeft<' Hello World '> // expected to be 'Hello World ' ``` 在 TS 处理这类问题只能用递归,不能用正则。比较容易想到的是下面的写法: ```ts // 本题答案 type TrimLeft = T extends ` ${infer R}` ? TrimLeft : T ``` 即如果字符串前面包含空格,就把空格去了继续递归,否则返回字符串本身。掌握该题的关键是 `infer` 也能用在字符串内进行推导。 ### [Trim](https://github.com/type-challenges/type-challenges/blob/main/questions/00108-medium-trim/README.md) 实现 `Trim`,将字符串左右两侧空格清空: ```ts type trimmed = Trim<' Hello World '> // expected to be 'Hello World' ``` 这个问题简单的解法是,左右都 Trim 一下: ```ts // 本题答案 type Trim = TrimLeft> type TrimLeft = T extends ` ${infer R}` ? TrimLeft : T type TrimRight = T extends `${infer R} ` ? TrimRight : T ``` 这个成本很低,性能也不差,因为单写 `TrimLeft` 与 `TrimRight` 都很简单。 如果不采用先 Left 后 Right 的做法,想要一次性完成,就要有一些 TS 思维了。比较笨的思路是 “如果左边有空格就切分左边,或者右边有空格就切分右边”,最后写出来一个复杂的三元表达式。比较优秀的思路是利用 TS 联合类型: ```ts // 本题答案 type Trim = T extends ` ${infer R}` | `${infer R} ` ? Trim : T ``` `extends` 后面还可以跟联合类型,这样任意一个匹配都会走到 `Trim` 递归里。这就是比较难说清楚的 TS 思维,如果没有它,你只能想到三元表达式,但一旦理解了联合类型还可以在 `extends` 里这么用,TS 帮你做了 N 元表达式的能力,那么写出来的代码就会非常清秀。 ### [Capitalize](https://github.com/type-challenges/type-challenges/blob/main/questions/00110-medium-capitalize/README.md) 实现 `Capitalize` 将字符串第一个字母大写: ```ts type capitalized = Capitalize<'hello world'> // expected to be 'Hello world' ``` 如果这是一道 JS 题那就简单到爆,可题目是 TS 的,我们需要再度切换为 TS 思维。 首先要知道利用基础函数 `Uppercase` 将单个字母转化为大写,然后配合 `infer` 就不用多说了: ```ts type MyCapitalize = T extends `${infer F}${infer U}` ? `${Uppercase}${U}` : T ``` ### [Replace](https://github.com/type-challenges/type-challenges/blob/main/questions/00116-medium-replace/README.md) 实现 TS 版函数 `Replace`,将字符串 `From` 替换为 `To`: ```ts type replaced = Replace<'types are fun!', 'fun', 'awesome'> // expected to be 'types are awesome!' ``` 把 `From` 夹在字符串中间,前后用两个 `infer` 推导,最后输出时前后不变,把 `From` 换成 `To` 就行了: ```ts // 本题答案 type Replace = S extends `${infer A}${From}${infer B}` ? `${A}${To}${B}` : S ``` ### [ReplaceAll](https://github.com/type-challenges/type-challenges/blob/main/questions/00119-medium-replaceall/README.md) 实现 `ReplaceAll`,将字符串 `From` 替换为 `To`: ```ts type replaced = ReplaceAll<'t y p e s', ' ', ''> // expected to be 'types' ``` 该题与上题不同之处在于替换全部,解法肯定是递归,关键是何时递归的判断条件是什么。经过一番思考,如果 `infer From` 能匹配到不就说明还可以递归吗?所以加一层三元判断 `From extends ''` 即可: ```ts // 本题答案 type ReplaceAll = From extends '' ? S : ( S extends `${infer A}${From}${infer B}` ? ( From extends '' ? `${A}${To}${B}` : `${A}${To}${ReplaceAll}` ) : S ) ``` 补充一些细节: 1. 如果替换文本为空字符串需要跳过,否则会匹配第二个任意字符。 2. 为了防止替换完后结果可以再度匹配,对递归形式做一下调整,下次递归直接从剩余部分开始。 ### [Append Argument](https://github.com/type-challenges/type-challenges/blob/main/questions/00191-medium-append-argument/README.md) 实现类型 `AppendArgument`,将函数参数拓展一个: ```ts type Fn = (a: number, b: string) => number type Result = AppendArgument // expected be (a: number, b: string, x: boolean) => number ``` 该题很简单,用 `infer` 就行了: ```ts // 本题答案 type AppendArgument = F extends (...args: infer T) => infer R ? (...args: [...T, E]) => R : F ``` ## 总结 这几道题都比较简单,主要考察对 `infer` 和递归的熟练使用。 > 讨论地址是:[精读《Promise.all, Replace, Type Lookup...》· Issue #425 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/425) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: TS 类型体操/246.精读《Permutation, Flatten, Absolute...》.md ================================================ 解决 TS 问题的最好办法就是多练,这次解读 [type-challenges](https://github.com/type-challenges/type-challenges) Medium 难度 17~24 题。 ## 精读 ### [Permutation](https://github.com/type-challenges/type-challenges/blob/main/questions/00296-medium-permutation/README.md) 实现 `Permutation` 类型,将联合类型替换为可能的全排列: ```ts type perm = Permutation<'A' | 'B' | 'C'>; // ['A', 'B', 'C'] | ['A', 'C', 'B'] | ['B', 'A', 'C'] | ['B', 'C', 'A'] | ['C', 'A', 'B'] | ['C', 'B', 'A'] ``` 看到这题立马联想到 TS 对多个联合类型泛型处理是采用分配律的,在第一次做到 `Exclude` 题目时遇到过: ```ts Exclude<'a' | 'b', 'a' | 'c'> // 等价于 Exclude<'a', 'a' | 'c'> | Exclude<'b', 'a' | 'c'> ``` 所以这题如果能 “递归触发联合类型分配率”,就有戏解决啊。但触发的条件必须存在两个泛型,而题目传入的只有一个,我们只好创造第二个泛型,使其默认值等于第一个: ```ts type Permutation ``` 这样对本题来说,会做如下展开: ```ts Permutation<'A' | 'B' | 'C'> // 等价于 Permutation<'A' | 'B' | 'C', 'A' | 'B' | 'C'> // 等价于 Permutation<'A', 'A' | 'B' | 'C'> | Permutation<'B', 'A' | 'B' | 'C'> | Permutation<'C', 'A' | 'B' | 'C'> ``` 对于 `Permutation<'A', 'A' | 'B' | 'C'>` 来说,排除掉对自身的组合,可形成 `'A', 'B'`,`'A', 'C'` 组合,之后只要再递归一次,再拼一次,把已有的排除掉,就形成了 `A` 的全排列,以此类推,形成所有字母的全排列。 这里要注意两点: 1. 如何排除掉自身?`Exclude` 正合适,该函数遇到 `T` 在联合类型 `P` 中时,会返回 `never`,否则返回 `T`。 2. 递归何时结束?每次递归时用 `Exclude` 留下没用过的组合,最后一次组合用完一定会剩下 `never`,此时终止递归。 ```ts // 本题答案 type Permutation = [T] extends [never] ? [] : T extends U ? [T, ...Permutation>] : [] ``` 验证一下答案,首先展开 `Permutation<'A', 'B', 'C'>`: ```ts 'A' extends 'A' | 'B' | 'C' ? ['A', ...Permutation<'B' | 'C'>] : [] 'B' extends 'A' | 'B' | 'C' ? ['B', ...Permutation<'A' | 'C'>] : [] 'C' extends 'A' | 'B' | 'C' ? ['C', ...Permutation<'A' | 'B'>] : [] ``` 我们再展开第一行 `Permutation<'B' | 'C'>`: ```ts 'B' extends 'B' | 'C' ? ['B', ...Permutation<'C'>] : [] 'C' extends 'B' | 'C' ? ['C', ...Permutation<'B'>] : [] ``` 再展开第一行的 `Permutation<'C'>`: ```ts 'C' extends 'C' ? ['C', ...Permutation] : [] ``` 此时已经完成全排列,但我们还要处理一下 `Permutation`,使其返回 `[]` 并终止递归。那为什么要用 `[T] extends [never]` 而不是 `T extends never` 呢? 如果我们用 `T extends never` 代替本题答案,输出结果是 `never`,原因如下: ```ts type X = never extends never ? 1 : 0 // 1 type Custom = T extends never ? 1 : 0 type Y = Custom // never ``` 理论上相同的代码,为什么用泛型后输出就变成 `never` 了呢?原因是 TS 在做 `T extends never ?` 时,会对联合类型进行分配,此时有一个特例,即当 `T = never` 时,会跳过分配直接返回 `T` 本身,所以三元判断代码实际上没有执行。 `[T] extends [never]` 这种写法可以避免 TS 对联合类型进行分配,继而绕过上面的问题。 ### [Length of String](https://github.com/type-challenges/type-challenges/blob/main/questions/00298-medium-length-of-string/README.md) 实现 `LengthOfString` 返回字符串 T 的长度: ```ts LengthOfString<'abc'> // 3 ``` 破解此题你需要知道一个前提,即 TS 访问数组类型的 `[length]` 属性可以拿到长度值: ```ts ['a','b','c']['length'] // 3 ``` 也就是说,我们需要把 `'abc'` 转化为 `['a', 'b', 'c']`。 第二个需要了解的前置知识是,用 `infer` 指代字符串时,第一个指代指向第一个字母,第二个指向其余所有字母: ```ts 'abc' extends `${infer S}${infer E}` ? S : never // 'a' ``` 那转换后的数组存在哪呢?类似 js,我们弄第二个默认值泛型存储即可: ```ts // 本题答案 type LengthOfString = S extends `${infer S}${infer E}` ? LengthOfString : N['length'] ``` 思路就是,每次把字符串第一个字母拿出来放到数组 `N` 的第一项,直到字符串被取完,直接拿此时的数组长度。 ### [Flatten](https://github.com/type-challenges/type-challenges/blob/main/questions/00459-medium-flatten/README.md) 实现类型 `Flatten`: ```ts type flatten = Flatten<[1, 2, [3, 4], [[[5]]]]> // [1, 2, 3, 4, 5] ``` 此题一看就需要递归: ```ts // 本题答案 type Flatten = T extends [infer Start, ...infer Rest] ? ( Start extends any[] ? Flatten]> : Flatten ) : Result ``` 这道题看似答案复杂,其实还是用到了上一题的套路:**递归时如果需要存储临时变量,用泛型默认值来存储**。 本题我们就用 `Result` 这个泛型存储打平后的结果,每次拿到数组第一个值,如果第一个值不是数组,则直接存进去继续递归,此时 `T` 自然是剩余的 `Rest`;如果第一个值是数组,则将其打平,此时有个精彩的地方,即 `...Start` 打平后依然可能是数组,比如 `[[5]]` 就套了两层,能不能想到 `...Flatten` 继续复用递归是解题关键。 ### [Append to object](https://github.com/type-challenges/type-challenges/blob/main/questions/00527-medium-append-to-object/README.md) 实现 `AppendToObject`: ```ts type Test = { id: '1' } type Result = AppendToObject // expected to be { id: '1', value: 4 } ``` 结合之前刷题的经验,该题解法很简单,注意 `K in Key` 可以给对象拓展某些指定 Key: ```ts // 本题答案 type AppendToObject = Obj & { [K in Key]: Value } ``` 当然也有不用 `Obj &` 的写法,即把原始对象和新 Key, Value 合在一起的描述方式: ```ts // 本题答案 type AppendToObject = { [key in (keyof T) | U]: key extends U ? V : T[Exclude] } ``` ### [Absolute](https://github.com/type-challenges/type-challenges/blob/main/questions/00529-medium-absolute/README.md) 实现 `Absolute` 将数字转成绝对值: ```ts type Test = -100; type Result = Absolute; // expected to be "100" ``` 该题重点是把数字转成绝对值字符串,所以我们可以用字符串的方式进行匹配: ```ts // 本题答案 type Absolute = `${T}` extends `-${infer R}` ? R : `${T}` ``` 为什么不用 `T extends` 来判断呢?因为 `T` 是数字,这样写无法匹配符号的字符串描述。 ### [String to Union](https://github.com/type-challenges/type-challenges/blob/main/questions/00531-medium-string-to-union/README.md) 实现 `StringToUnion` 将字符串转换为联合类型: ```ts type Test = '123'; type Result = StringToUnion; // expected to be "1" | "2" | "3" ``` 还是老套路,用一个新的泛型存储答案,递归即可: ```ts // 本题答案 type StringToUnion = T extends `${infer F}${infer R}` ? StringToUnion : P ``` 当然也可以不依托泛型存储答案,因为该题比较特殊,可以直接用 `|`: ```ts // 本题答案 type StringToUnion = T extends `${infer F}${infer R}` ? F | StringToUnion : never ``` ### [Merge](https://github.com/type-challenges/type-challenges/blob/main/questions/00599-medium-merge/README.md) 实现 `Merge` 合并两个对象,冲突时后者优先: ```ts type foo = { name: string; age: string; } type coo = { age: number; sex: string } type Result = Merge; // expected to be {name: string, age: number, sex: string} ``` 这道题答案甚至是之前题目的解题步骤,即用一个对象描述 + `keyof` 的思维: ```ts // 本题答案 type Merge = { [K in keyof A | keyof B] : K extends keyof B ? B[K] : ( K extends keyof A ? A[K] : never ) } ``` 只要知道 `in keyof` 支持元组,值部分用 `extends` 进行区分即可,很简单。 ### [KebabCase](https://github.com/type-challenges/type-challenges/blob/main/questions/00612-medium-kebabcase/README.md) 实现驼峰转横线的函数 `KebabCase`: ```ts KebabCase<'FooBarBaz'> // 'foo-bar-baz' ``` 还是老套路,用第二个参数存储结果,用递归的方式遍历字符串,遇到大写字母就转成小写并添加上 `-`,最后把开头的 `-` 干掉就行了: ```ts // 本题答案 type KebabCase = S extends `${infer F}${infer R}` ? ( Lowercase extends F ? KebabCase : KebabCase}`> ) : RemoveFirstHyphen type RemoveFirstHyphen = S extends `-${infer Rest}` ? Rest : S ``` 分开写就非常容易懂了,首先 `KebabCase` 每次递归取第一个字符,如何判断这个字符是大写呢?只要小写不等于原始值就是大写,所以判断条件就是 `Lowercase extends F` 的 false 分支。然后再写个函数 `RemoveFirstHyphen` 把字符串第一个 `-` 干掉即可。 ## 总结 TS 是一门编程语言,而不是一门简单的描述或者修饰符,很多复杂类型问题要动用逻辑思维来实现,而不是查查语法就能简单实现。 > 讨论地址是:[精读《Permutation, Flatten, Absolute...》· Issue #426 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/426) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: TS 类型体操/247.精读《Diff, AnyOf, IsUnion...》.md ================================================ 解决 TS 问题的最好办法就是多练,这次解读 [type-challenges](https://github.com/type-challenges/type-challenges) Medium 难度 25~32 题。 ## 精读 ### [Diff](https://github.com/type-challenges/type-challenges/blob/main/questions/00645-medium-diff/README.md) 实现 `Diff`,返回一个新对象,类型为两个对象类型的 Diff: ```ts type Foo = { name: string age: string } type Bar = { name: string age: string gender: number } Equal // { gender: number } ``` 首先要思考 Diff 的计算方式,A 与 B 的 Diff 是找到 A 存在 B 不存在,与 B 存在 A 不存在的值,那么正好可以利用 `Exclude` 函数,它可以得到存在于 `X` 不存在于 `Y` 的值,我们只要用 `keyof A`、`keyof B` 代替 `X` 与 `Y`,并交替 A、B 位置就能得到 Diff: ```ts // 本题答案 type Diff = { [K in Exclude | Exclude]: K extends keyof A ? A[K] : ( K extends keyof B ? B[K]: never ) } ``` Value 部分的小技巧我们之前也提到过,即需要用两套三元运算符保证访问的下标在对象中存在,即 `extends keyof` 的语法技巧。 ### [AnyOf](https://github.com/type-challenges/type-challenges/blob/main/questions/00949-medium-anyof/README.md) 实现 `AnyOf` 函数,任意项为真则返回 `true`,否则返回 `false`,空数组返回 `false`: ```ts type Sample1 = AnyOf<[1, '', false, [], {}]> // expected to be true. type Sample2 = AnyOf<[0, '', false, [], {}]> // expected to be false. ``` 本题有几个问题要思考: 第一是用何种判定思路?像这种判断数组内任意元素是否满足某个条件的题目,都可以用递归的方式解决,具体是先判断数组第一项,如果满足则继续递归判断剩余项,否则终止判断。这样能做但比较麻烦,还有种取巧的办法是利用 `extends Array<>` 的方式,让 TS 自动帮你遍历。 第二个是如何判断任意项为真?为真的情况很多,我们尝试枚举为假的 Case:`0` `undefined` `''` `undefined` `null` `never` `[]`。 结合上面两个思考,本题作如下解答不难想到: ```ts type Falsy = '' | never | undefined | null | 0 | false | [] type AnyOf = T extends Falsy[] ? false : true ``` 但会遇到这个测试用例没通过: ```ts AnyOf<[0, '', false, [], {}]> ``` 如果此时把 `{}` 补在 `Falsy` 里,会发现除了这个 case 外,其他判断都挂了,原因是 `{ a: 1 } extends {}` 结果为真,因为 `{}` 并不表示空对象,而是表示所有对象类型,所以我们要把它换成 `Record`,以锁定空对象: ```ts // 本题答案 type Falsy = '' | never | undefined | null | 0 | false | [] | Record type AnyOf = T extends Falsy[] ? false : true ``` ### [IsNever](https://github.com/type-challenges/type-challenges/blob/main/questions/01042-medium-isnever/README.md) 实现 `IsNever` 判断值类型是否为 `never`: ```ts type A = IsNever // expected to be true type B = IsNever // expected to be false type C = IsNever // expected to be false type D = IsNever<[]> // expected to be false type E = IsNever // expected to be false ``` 首先我们可以毫不犹豫的写下一个错误答案: ```ts type IsNever = T extends never ? true :false ``` 这个错误答案离正确答案肯定是比较近的,但错在无法判断 `never` 上。在 `Permutation` 全排列题中我们就认识到了 `never` 在泛型中的特殊性,它不会触发 `extends` 判断,而是直接终结,致使判断无效。 而解法也很简单,只要绕过 `never` 这个特性即可,包一个数组: ```ts // 本题答案 type IsNever = [T] extends [never] ? true :false ``` ### [IsUnion](https://github.com/type-challenges/type-challenges/blob/main/questions/01097-medium-isunion/README.md) 实现 `IsUnion` 判断是否为联合类型: ```ts type case1 = IsUnion // false type case2 = IsUnion // true type case3 = IsUnion<[string|number]> // false ``` 这道题完全是脑筋急转弯了,因为 TS 肯定知道传入类型是否为联合类型,并且会对联合类型进行特殊处理,但并没有暴露联合类型的判断语法,所以我们只能对传入类型进行测试,推断是否为联合类型。 我们到现在能想到联合类型的特征只有两个: 1. 在 TS 处理泛型为联合类型时进行分发处理,即将联合类型拆解为独立项一一进行判定,最后再用 `|` 连接。 2. 用 `[]` 包裹联合类型可以规避分发的特性。 所以怎么判定传入泛型是联合类型呢?如果泛型进行了分发,就可以反推出它是联合类型。 难点就转移到了:如何判断泛型被分发了?首先分析一下,分发的效果是什么样: ```ts A extends A // 如果 A 是 1 | 2,分发结果是: (1 extends 1 | 2) | (2 extends 1 | 2) ``` 也就是这个表达式会被执行两次,第一个 `A` 在两次值分别为 `1` 与 `2`,而第二个 `A` 在两次执行中每次都是 `1 | 2`,但这两个表达式都是 `true`,无法体现分发的特殊性。 此时要利用包裹 `[]` 不分发的特性,即在分发后,由于在每次执行过程中,第一个 `A` 都是联合类型的某一项,因此用 `[]` 包裹后必然与原始值不相等,所以我们在 `extends` 分发过程中,再用 `[]` 包裹 `extends` 一次,如果此时匹配不上,说明产生了分发: ```ts type IsUnion = A extends A ? ( [A] extends [A] ? false : true ) : false ``` 但这段代码依然不正确,因为在第一个三元表达式括号内,`A` 已经被分发,所以 `[A] extends [A]` 即便对联合类型也是判定为真的,此时需要用原始值代替 `extends` 后面的 `[A]`,骚操作出现了: ```ts type IsUnion = A extends A ? ( [B] extends [A] ? false : true ) : false ``` 虽然我们申明了 `B = A`,但过程中因为 `A` 被分发了,所以运行时 `B` 是不等于 `A` 的,才使得我们达成目的。`[B]` 放 `extends` 前面是因为,`B` 是未被分发的,不可能被分发后的结果包含,所以分发时此条件必定为假。 最后因为测试用例有一个 `never` 情况,我们用刚才的 `IsNever` 函数提前判否即可: ```ts // 本题答案 type IsUnion = IsNever extends true ? false : ( A extends A ? ( [B] extends [A] ? false : true ) : false ) ``` 从该题我们可以深刻体会到 TS 的怪异之处,即 `type X = T extends ...` 中 `extends` 前面的 `T` 不一定是你看到传入的 `T`,如果是联合类型的话,会分发为单个类型分别处理。 ### [ReplaceKeys](https://github.com/type-challenges/type-challenges/blob/main/questions/01130-medium-replacekeys/README.md) 实现 `ReplaceKeys` 将 `Obj` 中每个对象的 `Keys` Key 类型转化为符合 `Targets` 对象对应 Key 描述的类型,如果无法匹配到 `Targets` 则类型置为 `never`: ```ts type NodeA = { type: 'A' name: string flag: number } type NodeB = { type: 'B' id: number flag: number } type NodeC = { type: 'C' name: string flag: number } type Nodes = NodeA | NodeB | NodeC type ReplacedNodes = ReplaceKeys // {type: 'A', name: number, flag: string} | {type: 'B', id: number, flag: string} | {type: 'C', name: number, flag: string} // would replace name from string to number, replace flag from number to string. type ReplacedNotExistKeys = ReplaceKeys // {type: 'A', name: never, flag: number} | NodeB | {type: 'C', name: never, flag: number} // would replace name to never ``` 本题别看描述很吓人,其实非常简单,思路:用 `K in keyof Obj` 遍历原始对象所有 Key,如果这个 Key 在描述的 `Keys` 中,且又在 `Targets` 中存在,则返回类型 `Targets[K]` 否则返回 `never`,如果不在描述的 `Keys` 中则用在对象里本来的类型: ```ts // 本题答案 type ReplaceKeys = { [K in keyof Obj] : K extends Keys ? ( K extends keyof Targets ? Targets[K] : never ) : Obj[K] } ``` ### [Remove Index Signature](https://github.com/type-challenges/type-challenges/blob/main/questions/01367-medium-remove-index-signature/README.md) 实现 `RemoveIndexSignature` 把对象 `` 中 Index 下标移除: ```ts type Foo = { [key: string]: any; foo(): void; } type A = RemoveIndexSignature // expected { foo(): void } ``` 该题思考的重点是如何将对象字符串 Key 识别出来,可以用 \`${infer P}\` 是否能识别到 `P` 来判断当前是否命中了字符串 Key: ```ts // 本题答案 type RemoveIndexSignature = { [K in keyof T as K extends `${infer P}` ? P : never]: T[K] } ``` ### [Percentage Parser](https://github.com/type-challenges/type-challenges/blob/main/questions/01978-medium-percentage-parser/README.md) 实现 `PercentageParser`,解析出百分比字符串的符号位与数字: ```ts type PString1 = '' type PString2 = '+85%' type PString3 = '-85%' type PString4 = '85%' type PString5 = '85' type R1 = PercentageParser // expected ['', '', ''] type R2 = PercentageParser // expected ["+", "85", "%"] type R3 = PercentageParser // expected ["-", "85", "%"] type R4 = PercentageParser // expected ["", "85", "%"] type R5 = PercentageParser // expected ["", "85", ""] ``` 这道题充分说明了 TS 没有正则能力,尽量还是不要做正则的事情 ^_^。 回到正题,如果非要用 TS 实现,我们只能枚举各种场景: ```ts // 本题答案 type PercentageParser = // +/-xxx% A extends `${infer X extends '+' | '-'}${infer Y}%`? [X, Y, '%'] : ( // +/-xxx A extends `${infer X extends '+' | '-'}${infer Y}` ? [X, Y, ''] : ( // xxx% A extends `${infer X}%` ? ['', X, '%'] : ( // xxx 包括 ['100', '%', ''] 这三种情况 A extends `${infer X}` ? ['', X, '']: never ) ) ) ``` 这道题运用了 `infer` 可以无限进行分支判断的知识。 ### [Drop Char](https://github.com/type-challenges/type-challenges/blob/main/questions/02070-medium-drop-char/README.md) 实现 `DropChar` 从字符串中移除指定字符: ```ts type Butterfly = DropChar<' b u t t e r f l y ! ', ' '> // 'butterfly!' ``` 这道题和 `Replace` 很像,只要用递归不断把 `C` 排除掉即可: ```ts // 本题答案 type DropChar = S extends `${infer A}${C}${infer B}` ? `${A}${DropChar}` : S ``` ## 总结 写到这,越发觉得 TS 虽然具备图灵完备性,但在逻辑处理上还是不如 JS 方便,很多设计计算逻辑的题目的解法都不是很优雅。 但是解决这类题目有助于强化对 TS 基础能力组合的理解与综合运用,在解决实际类型问题时又是必不可少的。 > 讨论地址是:[精读《Diff, AnyOf, IsUnion...》· Issue #429 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/429) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: TS 类型体操/248.精读《MinusOne, PickByType, StartsWith...》.md ================================================ 解决 TS 问题的最好办法就是多练,这次解读 [type-challenges](https://github.com/type-challenges/type-challenges) Medium 难度 33~40 题。 ## 精读 ### [MinusOne](https://github.com/type-challenges/type-challenges/blob/main/questions/02257-medium-minusone/README.md) 用 TS 实现 `MinusOne` 将一个数字减一: ```ts type Zero = MinusOne<1> // 0 type FiftyFour = MinusOne<55> // 54 ``` TS 没有 “普通” 的运算能力,但涉及数字却有一条生路,即 TS 可通过 `['length']` 访问数组长度,几乎所有数字计算都是通过它推导出来的。 这道题,我们只要构造一个长度为泛型长度 -1 的数组,获取其 `['length']` 属性即可,但该方案有一个硬伤,无法计算负值,因为数组长度不可能小于 0: ```ts // 本题答案 type MinusOne = [ ...arr, '' ]['length'] extends T ? arr['length'] : MinusOne ``` 该方案的原理不是原数字 -1,而是从 0 开始不断加 1,一直加到目标数字减一。但该方案没有通过 `MinusOne<1101>` 测试,因为递归 1000 次就是上限了。 还有一种能打破递归的思路,即: ```ts type Count = ['1', '1', '1'] extends [...infer T, '1'] ? T['length'] : 0 // 2 ``` 也就是把减一转化为 `extends [...infer T, '1']`,这样数组 `T` 的长度刚好等于答案。那么难点就变成了如何根据传入的数字构造一个等长的数组?即问题变成了如何实现 `CountTo` 生成一个长度为 `N`,每项均为 `1` 的数组,而且生成数组的递归效率也要高,否则还会遇到递归上限的问题。 网上有一个神仙解法,笔者自己想不到,但是可以拿出来给大家分析下: ```ts type CountTo< T extends string, Count extends 1[] = [] > = T extends `${infer First}${infer Rest}` ? CountTo[keyof N & First]> : Count type N = { '0': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T] '1': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1] '2': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1] '3': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1, 1] '4': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1, 1, 1] '5': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1, 1, 1, 1 ] '6': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1, 1, 1, 1, 1 ] '7': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1, 1, 1, 1, 1, 1 ] '8': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1, 1, 1, 1, 1, 1, 1 ] '9': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1, 1, 1, 1, 1, 1, 1, 1 ] } ``` 也就是该方法可以高效的实现 `CountTo<'1000'>` 产生长度为 1000,每项为 `1` 的数组,更具体一点,只需要遍历 `` 字符串长度次数,比如 `1000` 只要递归 4 次,而 `10000` 也只需要递归 5 次。 `CountTo` 函数体的逻辑是,如果字符串 `T` 非空,就拆为第一个字符 `First` 与剩余字符 `Rest`,然后拿剩余字符递归,但是把 `First` 一次性生成到了正确的长度。最核心的逻辑就是函数 `N` 了,它做的其实是把 `T` 的数组长度放大 10 倍再追加上当前数量的 1 在数组末尾。 而 `keyof N & First` 也是神来之笔,此处本意就是访问 `First` 下标,但 TS 不知道它是一个安全可访问的下标,而 `keyof N & First` 最终值还是 `First`,也可以被 TS 安全识别为下标。 拿 `CountTo<'123'>` 举例: 第一次执行 `First='1'`、`Rest='23'`: ```ts CountTo<'23', N<[]>['1']> // 展开时,...[] 还是 [],所以最终结果为 ['1'] ``` 第二次执行 `First='2'`、`Rest='3'` ```ts CountTo<'3', N<['1']>['2']> // 展开时,...[] 有 10 个,所以 ['1'] 变成了 10 个 1,追加上 N 映射表里的 2 个 1,现在一共有 12 个 1 ``` 第三次执行 `First='3'`、`Rest=''` ```ts CountTo<'', N<['1', ...共 12 个]>['3']> // 展开时,...[] 有 10 个,所以 12 个 1 变成 120 个,加上映射表中 3,一共有 123 个 1 ``` 总结一下,就是将数字 `T` 变成字符串,从最左侧开始获取,每次都把已经积累的数组数量乘以 10 再追加上当前值数量的 1,实现递归次数极大降低。 ### [PickByType](https://github.com/type-challenges/type-challenges/blob/main/questions/02595-medium-pickbytype/README.md) 实现 `PickByType`,将对象 `P` 中类型为 `Q` 的 key 保留: ```ts type OnlyBoolean = PickByType< { name: string count: number isReadonly: boolean isEnable: boolean }, boolean > // { isReadonly: boolean; isEnable: boolean; } ``` 本题很简单,因为之前碰到 Remove Index Signature 题目时,我们用了 `K in keyof P as xxx` 来对 Key 位置进行进一步判断,所以只要 `P[K] extends Q` 就保留,否则返回 `never` 即可: ```ts // 本题答案 type PickByType = { [K in keyof P as P[K] extends Q ? K : never]: P[K] } ``` ### [StartsWith](https://github.com/type-challenges/type-challenges/blob/main/questions/02688-medium-startswith/README.md) 实现 `StartsWith` 判断字符串 `T` 是否以 `U` 开头: ```ts type a = StartsWith<'abc', 'ac'> // expected to be false type b = StartsWith<'abc', 'ab'> // expected to be true type c = StartsWith<'abc', 'abcd'> // expected to be false ``` 本题也比较简单,用递归 + 首字符判等即可破解: ```ts // 本题答案 type StartsWith< T extends string, U extends string > = U extends `${infer US}${infer UE}` ? T extends `${infer TS}${infer TE}` ? TS extends US ? StartsWith : false : false : true ``` 思路是: 1. `U` 如果为空字符串则匹配一切场景,直接返回 `true`;否则 `U` 可以拆为以 `US`(U Start) 开头、`UE`(U End) 的字符串进行后续判定。 2. 接着上面的判定,如果 `T` 为空字符串则不可能被 `U` 匹配,直接返回 `false`;否则 `T` 可以拆为以 `TS`(T Start) 开头、`TE`(T End) 的字符串进行后续判定。 3. 接着上面的判定,如果 `TS extends US` 说明此次首字符匹配了,则递归匹配剩余字符 `StartsWith`,如果首字符不匹配提前返回 `false`。 笔者看了一些答案后发现还有一种降维打击方案: ```ts // 本题答案 type StartsWith = T extends `${U}${string}` ? true : false ``` 没想到还可以用 `${string}` 匹配任意字符串进行 `extends` 判定,有点正则的意思了。当然 `${string}` 也可以被 `${infer X}` 代替,只是拿到的 `X` 不需要再用到了: ```ts // 本题答案 type StartsWith = T extends `${U}${infer X}` ? true : false ``` 笔者还试了下面的答案在后缀 Diff 部分为 string like number 时也正确: ```ts // 本题答案 type StartsWith = T extends `${U}${number}` ? true : false ``` 说明字符串模板最通用的指代是 `${infer X}` 或 `${string}`,如果要匹配特定的数字类字符串也可以混用 `${number}`。 ### EndsWith 实现 `EndsWith` 判断字符串 `T` 是否以 `U` 结尾: ```ts type a = EndsWith<'abc', 'bc'> // expected to be true type b = EndsWith<'abc', 'abc'> // expected to be true type c = EndsWith<'abc', 'd'> // expected to be false ``` 有了上题的经验,这道题不要太简单: ```ts // 本题答案 type EndsWith = T extends `${string}${U}` ? true : false ``` 这可以看出 TS 的技巧掌握了就非常简单,但不知道就几乎无解,或者用很笨的递归来解决。 ### [PartialByKeys](https://github.com/type-challenges/type-challenges/blob/main/questions/02757-medium-partialbykeys/README.md) 实现 `PartialByKeys`,使 `K` 匹配的 Key 变成可选的定义,如果不传 `K` 效果与 `Partial` 一样: ```ts interface User { name: string age: number address: string } type UserPartialName = PartialByKeys // { name?:string; age:number; address:string } ``` 看到题目要求是不传参数时和 `Partial` 行为一直,就应该能想到应该这么起头写个默认值: ```ts type PartialByKeys = {} ``` 我们得用可选与不可选分别描述两个对象拼起来,因为 TS 不支持同一个对象下用两个 `keyof` 描述,所以只能写成两个对象: ```ts type PartialByKeys = { [Q in keyof T as Q extends K ? Q : never]?: T[Q] } & { [Q in keyof T as Q extends K ? never : Q]: T[Q] } ``` 但不匹配测试用例,原因是最终类型正确,但因为分成了两个对象合并无法匹配成一个对象,所以需要用一点点 Magic 行为合并: ```ts // 本题答案 type PartialByKeys = { [Q in keyof T as Q extends K ? Q : never]?: T[Q] } & { [Q in keyof T as Q extends K ? never : Q]: T[Q] } extends infer R ? { [Q in keyof R]: R[Q] } : never ``` 将一个对象 `extends infer R` 再重新展开一遍看似无意义,但确实让类型上合并成了一个对象,很有意思。我们也可以将其抽成一个函数 `Merge` 来使用。 本题还有一个函数组合的答案: ```ts // 本题答案 type Merge = { [K in keyof T]: T[K] } type PartialByKeys = Merge< Partial & Omit > ``` - 利用 `Partial & Omit` 来合并对象。 - 因为 `Omit` 中 `K` 有来自于 `keyof T` 的限制,而测试用例又包含 `unknown` 这种不存在的 Key 值,此时可以用 `extends PropertyKey` 处理此场景。 ### [RequiredByKeys](https://github.com/type-challenges/type-challenges/blob/main/questions/02759-medium-requiredbykeys/README.md) 实现 `RequiredByKeys`,使 `K` 匹配的 Key 变成必选的定义,如果不传 `K` 效果与 `Required` 一样: ```ts interface User { name?: string age?: number address?: string } type UserRequiredName = RequiredByKeys // { name: string; age?: number; address?: string } ``` 和上题正好相反,答案也呼之欲出了: ```ts type Merge = { [K in keyof T]: T[K] } type RequiredByKeys = Merge< Required & Omit > ``` 等等,一个测试用例都没过,为啥呢?仔细想想发现确实暗藏玄机: ```ts Merge<{ a: number } & { a?: number }> // 结果是 { a: number } ``` 也就是同一个 Key 可选与必选同时存在时,合并结果是必选。上一题因为将必选 `Omit` 掉了,所以可选不会被必选覆盖,但本题 `Merge & Omit>`,前面的 `Required` 必选优先级最高,后面的 `Omit` 虽然本身逻辑没错,但无法把必选覆盖为可选,因此测试用例都挂了。 解法就是破解这一特征,用原始对象 & 仅包含 `K` 的必选对象,使必选覆盖前面的可选 Key。后者可以 `Pick` 出来: ```ts type Merge = { [K in keyof T]: T[K] } type RequiredByKeys = Merge< T & Required> > ``` 这样就剩一个单测没通过了: ```ts Expect, UserRequiredName>> ``` 我们还要兼容 `Pick` 访问不存在的 Key,用 `extends` 躲避一下即可: ```ts // 本题答案 type Merge = { [K in keyof T]: T[K] } type RequiredByKeys = Merge< T & Required> > ``` ### [Mutable](https://github.com/type-challenges/type-challenges/blob/main/questions/02793-medium-mutable/README.md) 实现 `Mutable`,将对象 `T` 的所有 Key 变得可写: ```ts interface Todo { readonly title: string readonly description: string readonly completed: boolean } type MutableTodo = Mutable // { title: string; description: string; completed: boolean; } ``` 把对象从可写变成不可写: ```ts type Readonly = { readonly [K in keyof T]: T[K] } ``` 从不可写改成可写也简单,主要看你是否记住了这个语法:`-readonly`: ```ts // 本题答案 type Mutable = { -readonly [K in keyof T]: T[K] } ``` ### [OmitByType](https://github.com/type-challenges/type-challenges/blob/main/questions/02852-medium-omitbytype/README.md) 实现 `OmitByType` 根据类型 U 排除 T 中的 Key: ```ts type OmitBoolean = OmitByType< { name: string count: number isReadonly: boolean isEnable: boolean }, boolean > // { name: string; count: number } ``` 本题和 `PickByType` 正好反过来,只要把 `extends` 后内容对调一下即可: ```ts // 本题答案 type OmitByType = { [K in keyof T as T[K] extends U ? never : K]: T[K] } ``` ## 总结 本周的题目除了 `MinusOne` 那道神仙解法比较难以外,其他的都比较常见,其中 `Merge` 函数的妙用需要领悟一下。 > 讨论地址是:[精读《MinusOne, PickByType, StartsWith...》· Issue #430 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/430) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: TS 类型体操/249.精读《ObjectEntries, Shift, Reverse...》.md ================================================ 解决 TS 问题的最好办法就是多练,这次解读 [type-challenges](https://github.com/type-challenges/type-challenges) Medium 难度 41~48 题。 ## 精读 ### [ObjectEntries](https://github.com/type-challenges/type-challenges/blob/main/questions/02946-medium-objectentries/README.md) 实现 TS 版本的 `Object.entries`: ```ts interface Model { name: string; age: number; locations: string[] | null; } type modelEntries = ObjectEntries // ['name', string] | ['age', number] | ['locations', string[] | null]; ``` 经过前面的铺垫,大家应该熟悉了 TS 思维思考问题,这道题看到后第一个念头应该是:如何先把对象转换为联合类型?这个问题不解决,就无从下手。 对象或数组转联合类型的思路都是类似的,一个数组转联合类型用 `[number]` 作为下标: ```ts ['1', '2', '3']['number'] // '1' | '2' | '3' ``` 对象的方式则是 `[keyof T]` 作为下标: ```ts type ObjectToUnion = T[keyof T] ``` 再观察这道题,联合类型每一项都是数组,分别是 Key 与 Value,这样就比较好写了,我们只要构造一个 Value 是符合结构的对象即可: ```ts type ObjectEntries = { [K in keyof T]: [K, T[K]] }[keyof T] ``` 为了通过单测 `ObjectEntries<{ key?: undefined }>`,让 Key 位置不出现 `undefined`,需要强制把对象描述为非可选 Key: ```TS type ObjectEntries = { [K in keyof T]-?: [K, T[K]] }[keyof T] ``` 为了通过单测 `ObjectEntries>`,得将 Value 中 `undefined` 移除: ```ts // 本题答案 type RemoveUndefined = [T] extends [undefined] ? T : Exclude type ObjectEntries = { [K in keyof T]-?: [K, RemoveUndefined] }[keyof T] ``` ### [Shift](https://github.com/type-challenges/type-challenges/blob/main/questions/03062-medium-shift/README.md) 实现 TS 版 `Array.shift`: ```ts type Result = Shift<[3, 2, 1]> // [2, 1] ``` 这道题应该是简单难度的,只要把第一项抛弃即可,利用 `infer` 轻松实现: ```ts // 本题答案 type Shift = T extends [infer First, ...infer Rest] ? Rest : never ``` ### [Tuple to Nested Object](https://github.com/type-challenges/type-challenges/blob/main/questions/03188-medium-tuple-to-nested-object/README.md) 实现 `TupleToNestedObject`,其中 `T` 仅接收字符串数组,`P` 是任意类型,生成一个递归对象结构,满足如下结果: ```ts type a = TupleToNestedObject<['a'], string> // {a: string} type b = TupleToNestedObject<['a', 'b'], number> // {a: {b: number}} type c = TupleToNestedObject<[], boolean> // boolean. if the tuple is empty, just return the U type ``` 这道题用到了 5 个知识点:递归、辅助类型、`infer`、如何指定对象 Key、`PropertyKey`,你得全部知道并组合起来才能解决该题。 首先因为返回值是个递归对象,递归过程中必定不断修改它,因此给泛型添加第三个参数 `R` 存储这个对象,并且在递归数组时从最后一个开始,这样从最内层对象开始一点点把它 “包起来”: ```ts type TupleToNestedObject = /** 伪代码 T extends [...infer Rest, infer Last] */ ``` 下一步是如何描述一个对象 Key?之前 `Chainable Options` 例子我们学到的 `K in Q`,但需要注意直接这么写会报错,因为必须申明 `Q extends PropertyKey`。最后再处理一下递归结束条件,即 `T` 变成空数组时直接返回 `R`: ```ts // 本题答案 type TupleToNestedObject = T extends [] ? R : ( T extends [...infer Rest, infer Last extends PropertyKey] ? ( TupleToNestedObject ) : never ) ``` ### [Reverse](https://github.com/type-challenges/type-challenges/blob/main/questions/03192-medium-reverse/README.md) 实现 TS 版 `Array.reverse`: ```ts type a = Reverse<['a', 'b']> // ['b', 'a'] type b = Reverse<['a', 'b', 'c']> // ['c', 'b', 'a'] ``` 这道题比上一题简单,只需要用一个递归即可: ```ts // 本题答案 type Reverse = T extends [...infer Rest, infer End] ? [End, ...Reverse] : T ``` ### [Flip Arguments](https://github.com/type-challenges/type-challenges/blob/main/questions/03196-medium-flip-arguments/README.md) 实现 `FlipArguments` 将函数 `T` 的参数反转: ```ts type Flipped = FlipArguments<(arg0: string, arg1: number, arg2: boolean) => void> // (arg0: boolean, arg1: number, arg2: string) => void ``` 本题与上题类似,只是反转内容从数组变成了函数的参数,只要用 `infer` 定义出函数的参数,利用 `Reverse` 函数反转一下即可: ```ts // 本题答案 type Reverse = T extends [...infer Rest, infer End] ? [End, ...Reverse] : T type FlipArguments = T extends (...args: infer Args) => infer Result ? (...args: Reverse) => Result : never ``` ### [FlattenDepth](https://github.com/type-challenges/type-challenges/blob/main/questions/03243-medium-flattendepth/README.md) 实现指定深度的 Flatten: ```ts type a = FlattenDepth<[1, 2, [3, 4], [[[5]]]], 2> // [1, 2, 3, 4, [5]]. flattern 2 times type b = FlattenDepth<[1, 2, [3, 4], [[[5]]]]> // [1, 2, 3, 4, [[5]]]. Depth defaults to be 1 ``` 这道题比之前的 `Flatten` 更棘手一些,因为需要控制打平的次数。 基本想法就是,打平 `Deep` 次,所以需要实现打平一次的函数,再根据 `Deep` 值递归对应次: ```ts type FlattenOnce = T extends [infer X, ...infer Y] ? ( X extends any[] ? FlattenOnce : FlattenOnce ) : U ``` 然后再实现主函数 `FlattenDepth`,因为 TS 无法实现 +、- 号运算,我们必须用数组长度判断与操作数组来辅助实现: ```ts // FlattenOnce type FlattenDepth< T extends any[], U extends number = 1, P extends any[] = [] > = P['length'] extends U ? T : ( FlattenDepth, U, [...P, any]> ) ``` 当递归没有达到深度 `U` 时,就用 `[...P, any]` 的方式给数组塞一个元素,下次如果能匹配上 `P['length'] extends U` 说明递归深度已达到。 但考虑到测试用例 `FlattenDepth<[1, [2, [3, [4, [5]]]]], 19260817>` 会引发超长次数递归,需要提前终止,即如果打平后已经是平的,就不用再继续递归了,此时可以用 `FlattenOnce extends T` 判断: ```ts // 本题答案 // FlattenOnce type FlattenDepth< T extends any[], U extends number = 1, P extends any[] = [] > = P['length'] extends U ? T : ( FlattenOnce extends T ? T : ( FlattenDepth, U, [...P, any]> ) ) ``` ### [BEM style string](https://github.com/type-challenges/type-challenges/blob/main/questions/03326-medium-bem-style-string/README.md) 实现 `BEM` 函数完成其规则拼接: ```ts Expect, 'btn--small' | 'btn--medium' | 'btn--large' >>, ``` 之前我们了解了通过下标将数组或对象转成联合类型,这里还有一个特殊情况,即字符串中通过这种方式申明每一项,会自动笛卡尔积为新的联合类型: ```ts type BEM = `${B}__${E[number]}--${M[number]}` ``` 这是最简单的写法,但没有考虑项不存在的情况。不如创建一个 `SafeUnion` 函数,当传入值不存在时返回空字符串,保证安全的跳过: ```ts type IsNever = TValue[] extends never[] ? true : false; type SafeUnion = IsNever extends true ? "" : TUnion; ``` 最终代码: ```ts // 本题答案 // IsNever, SafeUnion type BEM = `${B}${SafeUnion<`__${E[number]}`>}${SafeUnion<`--${M[number]}`>}` ``` ### [InorderTraversal](https://github.com/type-challenges/type-challenges/blob/main/questions/03376-medium-inordertraversal/README.md) 实现 TS 版二叉树中序遍历: ```ts const tree1 = { val: 1, left: null, right: { val: 2, left: { val: 3, left: null, right: null, }, right: null, }, } as const type A = InorderTraversal // [1, 3, 2] ``` 首先回忆一下二叉树中序遍历 JS 版的实现: ```js function inorderTraversal(tree) { if (!tree) return [] return [ ...inorderTraversal(tree.left), res.push(val), ...inorderTraversal(tree.right) ] } ``` 对 TS 来说,实现递归的方式有一点点不同,即通过 `extends TreeNode` 来判定它不是 Null 从而递归: ```ts // 本题答案 interface TreeNode { val: number left: TreeNode | null right: TreeNode | null } type InorderTraversal = [T] extends [TreeNode] ? ( [ ...InorderTraversal, T['val'], ...InorderTraversal ] ): [] ``` 你可能会问,问什么不能像 JS 一样,用 `null` 做判断呢? ```ts type InorderTraversal = [T] extends [null] ? [] : ( [ // error ...InorderTraversal, T['val'], ...InorderTraversal ] ) ``` 如果这么写会发现 TS 抛出了异常,因为 TS 不能确定 `T` 此时符合 `TreeNode` 类型,所以要执行操作时一般采用正向判断。 ## 总结 这些类型挑战题目需要灵活组合 TS 的基础知识点才能破解,常用的包括: - 如何操作对象,增减 Key、只读、合并为一个对象等。 - 递归,以及辅助类型。 - `infer` 知识点。 - 联合类型,如何从对象或数组生成联合类型,字符串模板与联合类型的关系。 > 讨论地址是:[精读《ObjectEntries, Shift, Reverse...》· Issue #431 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/431) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: TS 类型体操/250.精读《Flip, Fibonacci, AllCombinations...》.md ================================================ 解决 TS 问题的最好办法就是多练,这次解读 [type-challenges](https://github.com/type-challenges/type-challenges) Medium 难度 49~56 题。 ## 精读 ### [Flip](https://github.com/type-challenges/type-challenges/blob/main/questions/04179-medium-flip/README.md) 实现 `Flip`,将对象 `T` 中 Key 与 Value 对调: ```ts Flip<{ a: "x", b: "y", c: "z" }>; // {x: 'a', y: 'b', z: 'c'} Flip<{ a: 1, b: 2, c: 3 }>; // {1: 'a', 2: 'b', 3: 'c'} Flip<{ a: false, b: true }>; // {false: 'a', true: 'b'} ``` 在 `keyof` 描述对象时可以通过 `as` 追加变形,所以这道题应该这样处理: ```ts type Flip = { [K in keyof T as T[K]]: K } ``` 由于 Key 位置只能是 String or Number,所以 `T[K]` 描述 Key 会显示错误,我们需要限定 Value 的类型: ```ts type Flip> = { [K in keyof T as T[K]]: K } ``` 但这个答案无法通过测试用例 `Flip<{ pi: 3.14; bool: true }>`,原因是 `true` 不能作为 Key。只能用字符串 `'true'` 作为 Key,所以我们得强行把 Key 位置转化为字符串: ```ts // 本题答案 type Flip> = { [K in keyof T as `${T[K]}`]: K } ``` ### [Fibonacci Sequence](https://github.com/type-challenges/type-challenges/blob/main/questions/04182-medium-fibonacci-sequence/README.md) 用 TS 实现斐波那契数列计算: ```ts type Result1 = Fibonacci<3> // 2 type Result2 = Fibonacci<8> // 21 ``` 由于测试用例没有特别大的 Case,我们可以放心用递归实现。JS 版的斐波那契非常自然,但 TS 版我们只能用数组长度模拟计算,代码写起来自然会比较扭曲。 首先需要一个额外变量标记递归了多少次,递归到第 N 次结束: ```ts type Fibonacci = N['length'] extends T ? ( // xxx ) : Fibonacci ``` 上面代码每次执行都判断是否递归完成,否则继续递归并把计数器加一。我们还需要一个数组存储答案,一个数组存储上一个数: ```ts // 本题答案 type Fibonacci< T extends number, N extends number[] = [1], Prev extends number[] = [1], Cur extends number[] = [1] > = N['length'] extends T ? Prev['length'] : Fibonacci ``` 递归时拿 `Cur` 代替下次的 `Prev`,用 `[...Prev, ...Cur]` 代替下次的 `Cur`,也就是说,下次的 `Cur` 符合斐波那契定义。 ### [AllCombinations](https://github.com/type-challenges/type-challenges/blob/main/questions/04260-medium-nomiwase/README.md) 实现 `AllCombinations` 对字符串 `S` 全排列: ```ts type AllCombinations_ABC = AllCombinations<'ABC'> // should be '' | 'A' | 'B' | 'C' | 'AB' | 'AC' | 'BA' | 'BC' | 'CA' | 'CB' | 'ABC' | 'ACB' | 'BAC' | 'BCA' | 'CAB' | 'CBA' ``` 首先要把 `ABC` 字符串拆成一个个独立的联合类型,进行二次组合才可能完成全排列: ```ts type StrToUnion = S extends `${infer F}${infer R}` ? F | StrToUnion : never ``` `infer` 描述字符串时,第一个指向第一个字母,第二个指向剩余字母;对剩余字符串递归可以将其逐一拆解为单个字符并用 `|` 连接: ```ts StrToUnion<'ABC'> // 'A' | 'B' | 'C' ``` 将 `StrToUnion<'ABC'>` 的结果记为 `U`,则利用对象转联合类型特征,可以制造出 `ABC` 在三个字母时的全排列: ```ts { [K in U]: `${K}${AllCombinations>}` }[U] // `ABC${any}` | `ACB${any}` | `BAC${any}` | `BCA${any}` | `CAB${any}` | `CBA${any}` ``` 然而只要在每次递归时巧妙的加上 `'' |` 就可以直接得到答案了: ```ts type AllCombinations> = | '' | { [K in U]: `${K}${AllCombinations>}` }[U] // '' | 'A' | 'B' | 'C' | 'AB' | 'AC' | 'BA' | 'BC' | 'CA' | 'CB' | 'ABC' | 'ACB' | 'BAC' | 'BCA' | 'CAB' | 'CBA' ``` 为什么这么神奇呢?这是因为每次递归时都会经历 `''`、`'A'`、`'AB'`、`'ABC'` 这样逐渐累加字符的过程,而每次都会遇到 `'' |` 使其自然形成了联合类型,比如遇到 `'A'` 时,会自然形成 `'A'` 这项联合类型,同时继续用 `'A'` 与 `Exclude<'A' | 'B' | 'C', 'A'>` 进行组合。 更精妙的是,第一次执行时的 `''` 填补了全排列的第一个 Case。 最后注意到上面的结果产生了一个 Error:"Type instantiation is excessively deep and possibly infinite",即这样递归可能产生死循环,因为 `Exclude` 的结果可能是 `never`,所以最后在开头修补一下对 `never` 的判否,利用之前学习的知识,`never` 不会进行联合类型展开,所以我们用 `[never]` 判断来规避: ```ts // 本题答案 type AllCombinations> = [ U ] extends [never] ? '' : '' | { [K in U]: `${K}${AllCombinations>}` }[U] ``` ### [Greater Than](https://github.com/type-challenges/type-challenges/blob/main/questions/04425-medium-greater-than/README.md) 实现 `GreaterThan` 判断 `T > U`: ```ts GreaterThan<2, 1> //should be true GreaterThan<1, 1> //should be false GreaterThan<10, 100> //should be false GreaterThan<111, 11> //should be true ``` 因为 TS 不支持加减法与大小判断,看到这道题时就应该想到有两种做法,一种是递归,但会受限于入参数量限制,可能堆栈溢出,一种是参考 [MinusOne](https://github.com/ascoders/weekly/blob/master/TS%20%E7%B1%BB%E5%9E%8B%E4%BD%93%E6%93%8D/248.%E7%B2%BE%E8%AF%BB%E3%80%8AMinusOne%2C%20PickByType%2C%20StartsWith...%E3%80%8B.md) 的特殊方法,用巧妙的方式构造出长度符合预期的数组,用数组 `['length']` 进行比较。 先说第一种,递归肯定要有一个递增 Key,拿 `T` `U` 先后进行对比,谁先追上这个数,谁就是较小的那个: ```ts // 本题答案 type GreaterThan = T extends R['length'] ? false : U extends R['length'] ? true : GreaterThan ``` 另一种做法是快速构造两个长度分别等于 `T` `U` 的数组,用数组快速判断谁更长。构造方式不再展开,参考 `MinusOne` 那篇的方法即可,重点说下如何快速判断 `[1, 1]` 与 `[1, 1, 1]` 谁更大。 因为 TS 没有大小判断能力,所以拿到了 `['length']` 也没有用,我们得考虑 `arr1 extends arr2` 这种方式。可惜的是,长度不相等的数组,`extends` 永远等于 `false`: ```ts [1,1,1,1] extends [1,1,1] ? true : false // false [1,1,1] extends [1,1,1,1] ? true : false // false [1,1,1] extends [1,1,1] ? true : false // true ``` 但我们期望进行如下判断: ```ts ArrGreaterThan<[1,1,1,1],[1,1,1]> // true ArrGreaterThan<[1,1,1],[1,1,1,1]> // false ArrGreaterThan<[1,1,1],[1,1,1]> // false ``` 解决方法非常体现 TS 思维:既然俩数组相等才返回 `true`,那我们用 `[...T, ...any]` 进行补充判定,如果能判定为 `true`,就说明前者长度更短(因为后者补充几项后可以判等): ```ts type ArrGreaterThan = U extends [...T, ...any] ? false : true ``` 这样一来,第二种答案就是这样的: ```ts // 本题答案 type GreaterThan = ArrGreaterThan< NumberToArr, NumberToArr > ``` ### [Zip](https://github.com/type-challenges/type-challenges/blob/main/questions/04471-medium-zip/README.md) 实现 TS 版 `Zip` 函数: ```ts type exp = Zip<[1, 2], [true, false]> // expected to be [[1, true], [2, false]] ``` 此题同样配合辅助变量,进行计数递归,并额外用一个类型变量存储结果: ```ts // 本题答案 type Zip< T extends any[], U extends any[], I extends number[] = [], R extends any[] = [] > = I['length'] extends T['length'] ? R : U[I['length']] extends undefined ? Zip : Zip ``` `[...R, [T[I['length']], U[I['length']]]]` 在每次递归时按照 Zip 规则添加一条结果,其中 `I['length']` 起到的作用类似 for 循环的下标 i,只是在 TS 语法中,我们只能用数组的方式模拟这种计数。 ### [IsTuple](https://github.com/type-challenges/type-challenges/blob/main/questions/04484-medium-istuple/README.md) 实现 `IsTuple` 判断 `T` 是否为元组类型(Tuple): ```ts type case1 = IsTuple<[number]> // true type case2 = IsTuple // true type case3 = IsTuple // false ``` 不得不吐槽的是,无论是 TS 内部或者词法解析都是更有效的判断方式,但如果用 TS 来实现,就要换一种思路了。 Tuple 与 Array 在 TS 里的区别是前者长度有限,后者长度无限,从结果来看,如果访问其 `['length']` 属性,前者一定是一个固定数字,而后者返回 `number`,用这个特性判断即可: ```ts // 本题答案 type IsTuple = [T] extends [never] ? false : T extends readonly any[] ? number extends T['length'] ? false : true : false ``` 其实这个答案是根据单测一点点试出来的,因为存在 `IsTuple<{ length: 1 }>` 单测用例,它可以通过 `number extends T['length']` 的校验,但因为其本身不是数组类型,所以无法通过 `T extends readonly any[]` 的前置判断。 ### [Chunk](https://github.com/type-challenges/type-challenges/blob/main/questions/04499-medium-chunk/README.md) 实现 TS 版 `Chunk`: ```ts type exp1 = Chunk<[1, 2, 3], 2> // expected to be [[1, 2], [3]] type exp2 = Chunk<[1, 2, 3], 4> // expected to be [[1, 2, 3]] type exp3 = Chunk<[1, 2, 3], 1> // expected to be [[1], [2], [3]] ``` 老办法还是要递归,需要一个变量记录当前收集到 Chunk 里的内容,在 Chunk 达到上限时释放出来,同时也要注意未达到上限就结束时也要释放出来。 ```ts type Chunk< T extends any[], N extends number = 1, Chunked extends any[] = [] > = T extends [infer First, ...infer Last] ? Chunked['length'] extends N ? [Chunked, ...Chunk] : Chunk : [Chunked] ``` `Chunked['length'] extends N` 判断 `Chunked` 数组长度达到 `N` 后就释放出来,否则把当前数组第一项 `First` 继续塞到 `Chunked` 数组,数组项从 `Last` 开始继续递归。 我们发现 `Chunk<[], 1>` 这个单测没过,因为当 `Chunked` 没有项目时,就无需成组了,所以完整的答案是: ```ts // 本题答案 type Chunk< T extends any[], N extends number = 1, Chunked extends any[] = [] > = T extends [infer Head, ...infer Tail] ? Chunked['length'] extends N ? [Chunked, ...Chunk] : Chunk : Chunked extends [] ? Chunked : [Chunked] ``` ### [Fill](https://github.com/type-challenges/type-challenges/blob/main/questions/04518-medium-fill/README.md) 实现 `Fill`,将数组 `T` 的每一项替换为 `N`: ```ts type exp = Fill<[1, 2, 3], 0> // expected to be [0, 0, 0] ``` 这道题也需要用递归 + Flag 方式解决,即定义一个 `I` 表示当前递归的下标,一个 `Flag` 表示是否到了要替换的下标,只要到了这个下标,该 `Flag` 就永远为 `true`: ```ts type Fill< T extends unknown[], N, Start extends number = 0, End extends number = T['length'], I extends any[] = [], Flag extends boolean = I['length'] extends Start ? true : false > ``` 由于递归会不断生成完整答案,我们将 `T` 定义为可变的,即每次仅处理第一条,如果当前 `Flag` 为 `true` 就采用替换值 `N`,否则就拿原本的第一个字符: ```ts type Fill< T extends unknown[], N, Start extends number = 0, End extends number = T['length'], I extends any[] = [], Flag extends boolean = I['length'] extends Start ? true : false > = I['length'] extends End ? T : T extends [infer F, ...infer R] ? Flag extends false ? [F, ...Fill] : [N, ...Fill] : T ``` 但这个答案没有通过测试,仔细想想发现 `Flag` 在 `I` 长度超过 `Start` 后就判定失败了,为了让超过后维持 `true`,在 `Flag` 为 `true` 时将其传入覆盖后续值即可: ```ts // 本题答案 type Fill< T extends unknown[], N, Start extends number = 0, End extends number = T['length'], I extends any[] = [], Flag extends boolean = I['length'] extends Start ? true : false > = I['length'] extends End ? T : T extends [infer F, ...infer R] ? Flag extends false ? [F, ...Fill] : [N, ...Fill] : T ``` ## 总结 勤用递归、辅助变量可以解决大部分本周遇到的问题。 > 讨论地址是:[精读《Flip, Fibonacci, AllCombinations...》· Issue #432 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/432) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: TS 类型体操/251.精读《Trim Right, Without, Trunc...》.md ================================================ 解决 TS 问题的最好办法就是多练,这次解读 [type-challenges](https://github.com/type-challenges/type-challenges) Medium 难度 57~62 题。 ## 精读 ### [Trim Right](https://github.com/type-challenges/type-challenges/blob/main/questions/04803-medium-trim-right/README.md) 实现 `TrimRight` 删除右侧空格: ```ts type Trimed = TrimRight<' Hello World '> // expected to be ' Hello World' ``` 用 `infer` 找出空格前的字符串递归一下即可: ```ts type TrimRight = S extends `${infer R}${' '}` ? TrimRight : S ``` 再补上测试用例的边界情况,`\n` 与 `\t` 后就是完整答案了: ```ts // 本题答案 type TrimRight = S extends `${infer R}${' ' | '\n' | '\t'}` ? TrimRight : S ``` ### [Without](https://github.com/type-challenges/type-challenges/blob/main/questions/05117-medium-without/README.md) 实现 `Without`,从数组 `T` 中移除 `U` 中元素: ```ts type Res = Without<[1, 2], 1> // expected to be [2] type Res1 = Without<[1, 2, 4, 1, 5], [1, 2]> // expected to be [4, 5] type Res2 = Without<[2, 3, 2, 3, 2, 3, 2, 3], [2, 3]> // expected to be [] ``` 该题最难的点在于,参数 `U` 可能是字符串或字符串数组,我们要判断是否存在只能用 `extends`,这样就存在两个问题: 1. 既是字符串又是数组如何判断,合在一起判断还是分开判断? 2. `[1] extends [1, 2]` 为假,数组模式如何判断? 可以用数组转 Union 的方式解决该问题: ```ts type ToUnion = T extends any[] ? T[number] : T ``` 这样无论是数字还是数组,都会转成联合类型,而联合类型很方便判断 `extends` 包含关系: ```ts // 本题答案 type Without = T extends [infer H, ...infer R] ? H extends ToUnion ? Without : [H, ...Without] : [] ``` 每次取数组第一项,判断是否被 `U` 包含,是的话就丢弃(丢弃的动作是把 `H` 抛弃继续递归),否则包含(包含的动作是形成新的数组 `[H, ...]` 并把递归内容解构塞到后面)。 ### [Trunc](https://github.com/type-challenges/type-challenges/blob/main/questions/05140-medium-trunc/README.md) 实现 `Math.trunc` 相同功能的函数 `Trunc`: ```ts type A = Trunc<12.34> // 12 ``` 如果入参是字符串就很简单了: ```ts type Trunc = T extends `${infer H}.${infer R}` ? H : '' ``` 如果不是字符串,将其转换为字符串即可: ```ts // 本题答案 type Trunc = `${T}` extends `${infer H}.${infer R}` ? H : `${T}` ``` ### [IndexOf](https://github.com/type-challenges/type-challenges/blob/main/questions/05153-medium-indexof/README.md) 实现 `IndexOf` 寻找元素所在下标,找不到返回 `-1`: ```ts type Res = IndexOf<[1, 2, 3], 2>; // expected to be 1 type Res1 = IndexOf<[2,6, 3,8,4,1,7, 3,9], 3>; // expected to be 2 type Res2 = IndexOf<[0, 0, 0], 2>; // expected to be -1 ``` 需要用一个辅助变量存储命中下标,递归的方式一个个判断是否匹配: ```ts type IndexOf = T extends [infer F, ...infer R] ? F extends U ? Index['length'] : IndexOf : -1 ``` 但没有通过测试用例 `IndexOf<[string, 1, number, 'a'], number>`,原因是 `1 extends number` 结果为真,所以我们要换成 `Equal` 函数判断相等: ```ts // 本题答案 type IndexOf = T extends [infer F, ...infer R] ? Equal extends true ? Index['length'] : IndexOf : -1 ``` ### [Join](https://github.com/type-challenges/type-challenges/blob/main/questions/05310-medium-join/README.md) 实现 TS 版 `Join`: ```ts type Res = Join<["a", "p", "p", "l", "e"], "-">; // expected to be 'a-p-p-l-e' type Res1 = Join<["Hello", "World"], " ">; // expected to be 'Hello World' type Res2 = Join<["2", "2", "2"], 1>; // expected to be '21212' type Res3 = Join<["o"], "u">; // expected to be 'o' ``` 递归 `T` 每次拿第一个元素,再使用一个辅助字符串存储答案,拼接起来即可: ```ts // 本题答案 type Join = T extends [infer F extends string, ...infer R extends string[]] ? R['length'] extends 0 ? F : `${F}${U}${Join}` : '' ``` 唯一要注意的是处理到最后一项时,不要再追加 `U` 了,可以通过 `R['length'] extends 0` 来判断。 ### [LastIndexOf](https://github.com/type-challenges/type-challenges/blob/main/questions/05317-medium-lastindexof/README.md) 实现 `LastIndexOf` 寻找最后一个匹配的下标: ```ts type Res1 = LastIndexOf<[1, 2, 3, 2, 1], 2> // 3 type Res2 = LastIndexOf<[0, 0, 0], 2> // -1 ``` 和 `IndexOf` 类似,从最后一个下标往前判断即可。需要注意的是,我们无法用常规办法把 `Index` 下标减一,但好在 `R` 数组长度可以代替当前下标: ```ts // 本题答案 type LastIndexOf = T extends [...infer R, infer L] ? Equal extends true ? R['length'] : LastIndexOf : -1 ``` ## 总结 本周六道题都没有刷到新知识点,中等难题还剩 6 道,如果学到这里能有种索然无味的感觉,说明前面学习的很扎实。 > 讨论地址是:[精读《Trim Right, Without, Trunc...》· Issue #433 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/433) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: TS 类型体操/252.精读《Unique, MapTypes, Construct Tuple...》.md ================================================ 解决 TS 问题的最好办法就是多练,这次解读 [type-challenges](https://github.com/type-challenges/type-challenges) Medium 难度 63~68 题。 ## 精读 ### [Unique](https://github.com/type-challenges/type-challenges/blob/main/questions/05360-medium-unique/README.md) 实现 `Unique`,对 `T` 去重: ```ts type Res = Unique<[1, 1, 2, 2, 3, 3]> // expected to be [1, 2, 3] type Res1 = Unique<[1, 2, 3, 4, 4, 5, 6, 7]> // expected to be [1, 2, 3, 4, 5, 6, 7] type Res2 = Unique<[1, 'a', 2, 'b', 2, 'a']> // expected to be [1, "a", 2, "b"] type Res3 = Unique<[string, number, 1, 'a', 1, string, 2, 'b', 2, number]> // expected to be [string, number, 1, "a", 2, "b"] type Res4 = Unique<[unknown, unknown, any, any, never, never]> // expected to be [unknown, any, never] ``` 去重需要不断递归产生去重后结果,因此需要一个辅助变量 `R` 配合,并把 `T` 用 `infer` 逐一拆解,判断第一个字符是否在结果数组里,如果不在就塞进去: ```ts type Unique = T extends [infer F, ...infer Rest] ? Includes extends true ? Unique : Unique : R ``` 那么剩下的问题就是,如何判断一个对象是否出现在数组中,使用递归可以轻松完成: ```ts type Includes = Arr extends [infer F, ...infer Rest] ? Equal extends true ? true : Includes : false ``` 每次取首项,如果等于 `Value` 直接返回 `true`,否则继续递归,如果数组递归结束(不构成 `Arr extends [xxx]` 的形式)说明递归完了还没有找到相等值,直接返回 `false`。 把这两个函数组合一下就能轻松解决本题: ```ts // 本题答案 type Unique = T extends [infer F, ...infer Rest] ? Includes extends true ? Unique : Unique : R type Includes = Arr extends [infer F, ...infer Rest] ? Equal extends true ? true : Includes : false ``` ### [MapTypes](https://github.com/type-challenges/type-challenges/blob/main/questions/05821-medium-maptypes/README.md) 实现 `MapTypes`,根据对象 `R` 的描述来替换类型: ```ts type StringToNumber = { mapFrom: string; // value of key which value is string mapTo: number; // will be transformed for number } MapTypes<{iWillBeANumberOneDay: string}, StringToNumber> // gives { iWillBeANumberOneDay: number; } ``` 因为要返回一个新对象,所以我们使用 `{ [K in keyof T]: ... }` 的形式描述结果对象。然后就要对 Value 类型进行判断了,为了防止 `never` 的作用,我们包一层数组进行判断: ```ts type MapTypes = { [K in keyof T]: [T[K]] extends [R['mapFrom']] ? R['mapTo'] : T[K] } ``` 但这个解答还有一个 case 无法通过: ```ts MapTypes<{iWillBeNumberOrDate: string}, StringToDate | StringToNumber> // gives { iWillBeNumberOrDate: number | Date; } ``` 我们需要考虑到 Union 分发机制以及每次都要重新匹配一次是否命中 `mapFrom`,因此需要抽一个函数: ```ts type Transform = R extends any ? T extends R['mapFrom'] ? R['mapTo'] : never : never ``` 为什么要 `R extends any` 看似无意义的写法呢?原因是 `R` 是联合类型,这样可以触发分发机制,让每一个类型独立判断。所以最终答案就是: ```ts // 本题答案 type MapTypes = { [K in keyof T]: [T[K]] extends [R['mapFrom']] ? Transform : T[K] } type Transform = R extends any ? T extends R['mapFrom'] ? R['mapTo'] : never : never ``` ### [Construct Tuple](https://github.com/type-challenges/type-challenges/blob/main/questions/07544-medium-construct-tuple/README.md) 生成指定长度的 Tuple: ```ts type result = ConstructTuple<2> // expect to be [unknown, unkonwn] ``` 比较容易想到的办法是利用下标递归: ```ts type ConstructTuple< L extends number, I extends number[] = [] > = I['length'] extends L ? [] : [unknown, ...ConstructTuple] ``` 但在如下测试用例会遇到递归长度过深的问题: ```ts ConstructTuple<999> // Type instantiation is excessively deep and possibly infinite ``` 一种解法是利用 [minusOne](https://github.com/ascoders/weekly/blob/master/TS%20%E7%B1%BB%E5%9E%8B%E4%BD%93%E6%93%8D/248.%E7%B2%BE%E8%AF%BB%E3%80%8AMinusOne%2C%20PickByType%2C%20StartsWith...%E3%80%8B.md#minusone) 提到的 `CountTo` 方法快捷生成指定长度数组,把 `1` 替换为 `unknown` 即可: ```ts // 本题答案 type ConstructTuple = CountTo<`${L}`> type CountTo< T extends string, Count extends unknown[] = [] > = T extends `${infer First}${infer Rest}` ? CountTo[keyof N & First]> : Count type N = { '0': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T] '1': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown] '2': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown, unknown ] '3': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown, unknown, unknown ] '4': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown, unknown, unknown, unknown ] '5': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown, unknown, unknown, unknown, unknown ] '6': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown, unknown, unknown, unknown, unknown, unknown ] '7': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown, unknown, unknown, unknown, unknown, unknown, unknown ] '8': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown ] '9': [ ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown, unknown ] } ``` ### [Number Range](https://github.com/type-challenges/type-challenges/blob/main/questions/08640-medium-number-range/README.md) 实现 `NumberRange`,生成数字为从 `T` 到 `P` 的联合类型: ```ts type result = NumberRange<2, 9> // | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 ``` 以 `NumberRange<2, 9>` 为例,我们需要实现 `2` 到 `9` 的递增递归,因此需要一个数组长度从 `2` 递增到 `9` 的辅助变量 `U`,以及一个存储结果的辅助变量 `R`: ```ts type NumberRange ``` 所以我们先实现 `LengthTo` 函数,传入长度 `N`,返回一个长度为 `N` 的数组: ```ts type LengthTo = R['length'] extends N ? R : LengthTo ``` 然后就是递归了: ```ts // 本题答案 type NumberRange, R extends number = never> = U['length'] extends P ? ( R | U['length'] ) : ( NumberRange ) ``` `R` 的默认值为 `never` 非常重要,否则默认值为 `any`,最终类型就会被放大为 `any`。 ### [Combination](https://github.com/type-challenges/type-challenges/blob/main/questions/08767-medium-combination/README.md) 实现 `Combination`: ```ts // expected to be `"foo" | "bar" | "baz" | "foo bar" | "foo bar baz" | "foo baz" | "foo baz bar" | "bar foo" | "bar foo baz" | "bar baz" | "bar baz foo" | "baz foo" | "baz foo bar" | "baz bar" | "baz bar foo"` type Keys = Combination<['foo', 'bar', 'baz']> ``` 本题和 `AllCombination` 类似: ```ts type AllCombinations_ABC = AllCombinations<'ABC'> // should be '' | 'A' | 'B' | 'C' | 'AB' | 'AC' | 'BA' | 'BC' | 'CA' | 'CB' | 'ABC' | 'ACB' | 'BAC' | 'BCA' | 'CAB' | 'CBA' ``` 还记得这题吗?我们要将字符串变成联合类型: ```ts type StrToUnion = S extends `${infer F}${infer R}` ? F | StrToUnion : never ``` 而本题 `Combination` 更简单,把数组转换为联合类型只需要 `T[number]`。所以本题第一种组合解法是,将 `AllCombinations` 稍微改造下,再利用 `Exclude` 和 `TrimRight` 删除多余的空格: ```ts // 本题答案 type AllCombinations = [ U ] extends [never] ? '' : '' | { [K in U]: `${K} ${AllCombinations>}` }[U] type TrimRight = T extends `${infer R} ` ? TrimRight : T type Combination = TrimRight, ''>> ``` 还有一种非常精彩的答案在此分析一下: ```ts // 本题答案 type Combination = U extends infer U extends string ? `${U} ${Combination>}` | U : never; ``` 依然利用 `T[number]` 的特性将数组转成联合类型,再利用联合类型 `extends` 会分组的特性递归出结果。 之所以不会出现结尾出现多余的空格,是因为 `U extends infer U extends string` 这段判断已经杜绝了 `U` 消耗完的情况,如果消耗完会及时返回 `never`,所以无需用 `TrimRight` 处理右侧多余的空格。 至于为什么要定义 `A = U`,在前面章节已经介绍过了,因为联合类型 `extends` 过程中会进行分组,此时访问的 `U` 已经是具体类型了,但此时访问 `A` 还是原始的联合类型 `U`。 ### [Subsequence](https://github.com/type-challenges/type-challenges/blob/main/questions/08987-medium-subsequence/README.md) 实现 `Subsequence` 输出所有可能的子序列: ```ts type A = Subsequence<[1, 2]> // [] | [1] | [2] | [1, 2] ``` 因为是返回数组的全排列,只要每次取第一项,与剩余项的递归构造出结果,`|` 上剩余项本身递归的结果就可以了: ```ts // 本题答案 type Subsequence = T extends [infer F, ...infer R extends number[]] ? ( Subsequence | [F, ...Subsequence] ) : T ``` ## 总结 对全排列问题有两种经典解法: - 利用辅助变量方式递归,注意联合类型与字符串、数组之间转换的技巧。 - 直接递归,不借助辅助变量,一般在题目返回类型容易构造时选择。 > 讨论地址是:[精读《Unique, MapTypes, Construct Tuple...》· Issue #434 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/434) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: helper.js ================================================ /** * 发布辅助脚本 * @author 黄子毅 */ const fs = require("fs"); const dirs = [ "前沿技术", "TS 类型体操", "设计模式", "编译原理", "源码解读", "商业思考", "算法", "可视化搭建", "SQL", "机器学习", "数学之美", "生活", ]; dirs.forEach((dir) => { const readDir = fs.readdirSync(`./${dir}`); console.log(`### ${dir}\n`); readDir .sort((left, right) => left.split(".")[0] - right.split(".")[0]) .forEach((dirName) => { console.log( `- ${dirName.replace( ".md", "" )}` ); }); console.log(""); }); ================================================ FILE: package.json ================================================ { "name": "weekly", "version": "1.0.0", "description": "前端界的好文精读,每周更新!", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "repository": { "type": "git", "url": "git+https://github.com/dt-fe/weekly.git" }, "author": "", "license": "ISC", "bugs": { "url": "https://github.com/dt-fe/weekly/issues" }, "homepage": "https://github.com/dt-fe/weekly#readme", "dependencies": { "esm": "^3.2.25", "husky": "^3.0.4", "lint-md-cli": "^0.1.1" }, "husky": { "hooks": { "pre-commit": "npx lint-md ./" } } } ================================================ FILE: readme.md ================================================ # 前端精读 CircleCI Status 前端界的好文精读,每周更新! 最新精读:296.手动算根号 素材来源:[周刊参考池](https://github.com/ascoders/weekly/issues/2) 现已涵盖: - 结合大厂工作经验解读的 [前沿技术](./前沿技术),[源码解读](./源码解读)。 - 逐渐加入一些后端技术解读。 - 一些 [商业思考](./商业思考)。 - 已完成 [编译原理](./编译原理)、[设计模式](./设计模式) 两大基础模块。 ### 前沿技术 - 1.精读《js 模块化发展》 - 2.精读《模态框的最佳实践》 - 3.精读《前后端渲染之争》 - 4.精读《AsyncAwait 优越之处》 - 5.精读《民工叔单页数据流方案》 - 6.精读《JavaScript 错误堆栈处理》 - 7.精读《请停止 css-in-js 的行为》 - 8.精读《入坑 React 前没有人会告诉你的事》 - 9.精读《Immutable 结构共享》 - 10.精读《Web Components 的困境》 - 11.精读《前端调试技巧》 - 12.精读《React 高阶组件》 - 13.精读《This 带来的困惑》 - 14.精读《架构设计之 DCI》 - 15.精读《TC39 与 ECMAScript 提案》 - 16.精读《CSS Animations vs Web Animations API》 - 17.精读《如何安全地使用 React context》 - 18.精读《设计完美的日期选择器》 - 19.精读《最佳前端面试题》及面试官技巧 - 20.精读《Nestjs》文档 - 21.精读《Web fonts: when you need them, when you don’t》 - 22.精读《V8 引擎特性带来的的 JS 性能变化》 - 23.精读《API 设计原则》 - 24.精读《现代 JavaScript 概览》 - 25.精读《null >= 0?》 - 26.精读《加密媒体扩展》 - 27.精读《css-in-js 杀鸡用牛刀》 - 28.精读《2017 前端性能优化备忘录》 - 29.精读《JS 中的内存管理》 - 30.精读《Javascript 事件循环与异步》 - 31.精读《我不再使用高阶组件》 - 32.精读《React Router4.0 进阶概念》 - 33.精读《30 行 js 代码创建神经网络》 - 34.精读《React 代码整洁之道》 - 35.精读《dob - 框架实现》 - 36.精读《When You “Git” in Trouble- a Version Control Story》 - 37.精读《how we position and what we compare》 - 38.精读《dob - 框架使用》 - 39.精读《全链路体验浏览器挖矿》 - 40.精读《初探 Reason 与 GraphQL》 - 41.精读《Ant Design 3.0 背后的故事》 - 42.精读《前端数据流哲学》 - 43.精读《增强现实与可视化》 - 44.精读《Rekit Studio》 - 45.精读《React's new Context API》 - 46.精读《react-rxjs》 - 47.精读《webpack4.0 升级指南》 - 49.精读《Compilers are the New Frameworks》 - 50.精读《快速上手构建 ARKit 应用》 - 51.精读《Elements of Web Dev》 - 52.精读《图解 ES 模块》 - 53.精读《插件化思维》 - 54.精读《在浏览器运行 serverRender》 - 55.精读《async await 是把双刃剑》 - 56.精读《重新思考 Redux》 - 57.精读《现代 js 框架存在的根本原因》 - 58.精读《Typescript2.0 - 2.9》 - 59.精读《如何利用 Nodejs 监听文件夹》 - 60.精读《如何在 nodejs 使用环境变量》 - 61.精读《React 八种条件渲染》 - 62.精读《JS 引擎基础之 Shapes and Inline Caches》 - 63.精读《React 的多态性》 - 68.精读《衡量用户体验》 - 69.精读《SQL vs Flux》 - 72.精读《REST, GraphQL, Webhooks, & gRPC 如何选型》 - 74.精读《12 个评估 JS 库你需要关心的事》 - 76.精读《谈谈 Web Workers》 - 77.精读《用 Reduce 实现 Promise 串行执行》 - 79.精读《React Hooks》 - 80.精读《怎么用 React Hooks 造轮子》 - 81.精读《使用 CSS 属性选择器》 - 83.精读《React16 新特性》 - 84.精读《Typescript 3.2 新特性》 - 86.精读《国际化布局 - Logical Properties》 - 87.精读《setState 做了什么》 - 88.精读《Caches API》 - 89.精读《如何编译前端项目与组件》 - 91.精读《正则 ES2018》 - 94.精读《Serverless 给前端带来了什么》 - 95.精读《Function VS Class 组件》 - 96.精读《useEffect 完全指南》 - 97.精读《编写有弹性的组件》 - 99.精读《Scheduling in React》 - 100.精读《V8 引擎 Lazy Parsing》 - 101.精读《持续集成 vs 持续交付 vs 持续部署》 - 102.精读《Monorepo 的优势》 - 104.精读《Function Component 入门》 - 105.精读《What's new in javascript》 - 107.精读《Optional chaining》 - 109.精读《Vue3.0 Function API》 - 111.精读《前端未来展望》 - 112.精读《源码学习》 - 113.精读《Nodejs V12》 - 117.精读《Tableau 探索式模型》 - 118.精读《使用 css 变量生成颜色主题》 - 119.精读《前端深水区》 - 120.精读《React Hooks 最佳实践》 - 121.精读《前端与 BI》 - 123.精读《用 Babel 创造自定义 JS 语法》 - 124.精读《用 css grid 重新思考布局》 - 125.精读《深度学习 - 函数式之美》 - 126.精读《Nuxtjs》 - 127.精读《React Conf 2019 - Day1》 - 129.精读《React Conf 2019 - Day2》 - 132.精读《正交的 React 组件》 - 133.精读《寻找框架设计的平衡点》 - 134.精读《我在阿里数据中台大前端》 - 138.精读《精通 console.log》 - 139.精读《手写 JSON Parser》 - 140.精读《结合 React 使用原生 Drag Drop API》 - 141.精读《useRef 与 createRef 的区别》 - 142.精读《如何做好 CodeReview》 - 143.精读《Suspense 改变开发方式》 - 144.精读《Webpack5 新特性 - 模块联邦》 - 145.精读《React Router v6》 - 146.精读《React Hooks 数据流》 - 147. 精读《@types react 值得注意的 TS 技巧》 - 148. 精读《React Error Boundaries》 - 149. 精读《React 性能调试》 - 150. 精读《Deno 1.0 你需要了解的》 - 152. 精读《recoil》 - 153. 精读《snowpack》 - 154. 精读《用 React 做按需渲染》 - 157. 精读《如何比较 Object 对象》 - 158. 精读《Typescript 4》 - 159. 精读《对低代码搭建的理解》 - 160. 精读《函数缓存》 - 161.精读《可视化搭建思考 - 富文本搭建》 - 162.精读《Tasks, microtasks, queues and schedules》 - 163.精读《Spring 概念》 - 164.精读《数据搭建引擎 bi-designer API-设计器》 - 165.精读《数据搭建引擎 bi-designer API-组件》 - 166.精读《BI 搭建 - 筛选条件》 - 190.精读《DOM diff 原理详解》 - 191.精读《高性能表格》 - 192.精读《DOM diff 最长上升子序列》 - 193.精读《React Server Component》 - 194.精读《算法基础数据结构》 - 195.精读《新一代前端构建工具对比》 - 196.精读《前端职业规划 - 2021 年》 - 197.精读《低代码逻辑编排》 - 202.精读《React 18》 - 204.精读《默认、命名导出的区别》 - 205.精读《JS with 语法》 - 206.精读《一种 Hooks 数据流管理方案》 - 207.精读《Typescript infer 关键字》 - 208.精读《Typescript 4.4》 - 209.精读《捕获所有异步 error》 - 210.精读《class static block》 - 211.精读《Microsoft Power Fx》 - 212.精读《可维护性思考》 - 213.精读《Prisma 的使用》 - 214.精读《web streams》 - 215.精读《什么是 LOD 表达式》 - 216.精读《15 大 LOD 表达式 - 上》 - 217.精读《15 大 LOD 表达式 - 下》 - 218.精读《Rust 是 JS 基建的未来》 - 219.精读《深入了解现代浏览器一》 - 220.精读《深入了解现代浏览器二》 - 221.精读《深入了解现代浏览器三》 - 222.精读《深入了解现代浏览器四》 - 223.精读《Records & Tuples 提案》 - 224.精读《Records & Tuples for React》 - 225.精读《Excel JS API》 - 226.精读《2021 前端新秀回顾》 - 228.精读《pipe operator for JavaScript》 - 230.精读《对 Markdown 的思考》 - 237.精读《Typescript 4.5-4.6 新特性》 - 238.精读《不再需要 JS 做的 5 件事》 - 239.精读《JS 数组的内部实现》 - 240.精读《React useEvent RFC》 - 242.精读《web reflow》 - 253.精读《pnpm》 - 254.精读《对前端架构的理解 - 分层与抽象》 - 255.精读《SolidJS》 - 256.精读《依赖注入简介》 - 257.精读《State of CSS 2022》 - 258.精读《proposal-extractors》 - 259.精读《Headless 组件用法与原理》 - 260.精读《如何为 TS 类型写单测》 - 261.精读《Rest vs Spread 语法》 - 262.精读《迭代器 Iterable》 - 263.精读《我们为何弃用 css-in-js》 - 264.精读《维护好一个复杂项目》 - 265.精读《磁贴布局 - 功能分析》 - 266.精读《磁贴布局 - 功能实现》 - 267.精读《磁贴布局 - 性能优化》 - 277.精读《利用 GPT 解读 PDF》 - 281.精读《自由 + 磁贴混合布局》 - 282.精读《自由布局吸附线的实现》 - 287.精读《VisActor 数据可视化工具》 ### TS 类型体操 - 243.精读《Pick, Awaited, If...》 - 244.精读《Get return type, Omit, ReadOnly...》 - 245.精读《Promise.all, Replace, Type Lookup...》 - 246.精读《Permutation, Flatten, Absolute...》 - 247.精读《Diff, AnyOf, IsUnion...》 - 248.精读《MinusOne, PickByType, StartsWith...》 - 249.精读《ObjectEntries, Shift, Reverse...》 - 250.精读《Flip, Fibonacci, AllCombinations...》 - 251.精读《Trim Right, Without, Trunc...》 - 252.精读《Unique, MapTypes, Construct Tuple...》 ### 设计模式 - 167.精读《设计模式 - Abstract Factory 抽象工厂》 - 168.精读《设计模式 - Builder 生成器》 - 169.精读《设计模式 - Factory Method 工厂方法》 - 170.精读《设计模式 - Prototype 原型模式》 - 171.精读《设计模式 - Singleton 单例模式》 - 172.精读《设计模式 - Adapter 适配器模式》 - 173.精读《设计模式 - Bridge 桥接模式》 - 174.精读《设计模式 - Composite 组合模式》 - 175.精读《设计模式 - Decorator 装饰器模式》 - 176.精读《设计模式 - Facade 外观模式》 - 177.精读《设计模式 - Flyweight 享元模式》 - 178.精读《设计模式 - Proxy 代理模式》 - 179.精读《设计模式 - Chain of Responsibility 职责链模式》 - 180.精读《设计模式 - Command 命令模式》 - 181.精读《设计模式 - Interpreter 解释器模式》 - 182.精读《设计模式 - Iterator 迭代器模式》 - 183.精读《设计模式 - Mediator 中介者模式》 - 184.精读《设计模式 - Memoto 备忘录模式》 - 185.精读《设计模式 - Observer 观察者模式》 - 186.精读《设计模式 - State 状态模式》 - 187.精读《设计模式 - Strategy 策略模式》 - 188.精读《设计模式 - Template Method 模版模式》 - 189.精读《设计模式 - Visitor 访问者模式》 ### 编译原理 - 64.精读《手写 SQL 编译器 - 词法分析》 - 65.精读《手写 SQL 编译器 - 文法介绍》 - 66.精读《手写 SQL 编译器 - 语法分析》 - 67.精读《手写 SQL 编译器 - 回溯》 - 70.精读《手写 SQL 编译器 - 语法树》 - 71.精读《手写 SQL 编译器 - 错误提示》 - 78.精读《手写 SQL 编译器 - 性能优化之缓存》 - 85.精读《手写 SQL 编译器 - 智能提示》 ### 源码解读 - 48.精读《Immer.js》源码 - 73.精读《sqorn 源码》 - 75.精读《Epitath 源码 - renderProps 新用法》 - 82.精读《Htm - Hyperscript 源码》 - 92.精读《React PowerPlug 源码》 - 93.精读《syntax-parser 源码》 - 98.精读《react-easy-state 源码》 - 110.精读《Inject Instance 源码》 - 122.精读《robot 源码 - 有限状态机》 - 128.精读《Hooks 取数 - swr 源码》 - 130.精读《unstated 与 unstated-next 源码》 - 151. 精读《@umijs use-request》源码 - 155. 精读《use-what-changed 源码》 - 156. 精读《react-intersection-observer 源码》 - 227. 精读《zustand 源码》 - 229.精读《vue-lit 源码》 - 241.精读《react-snippets - Router 源码》 ### 商业思考 - 90.精读《极客公园 2019》 - 103.精读《为什么专家不再关心技术细节》 - 106.精读《数据之上·智慧之光 - 2018》 - 108.精读《智能商业》 - 114.精读《谁在世界中心》 - 115.精读《Tableau 入门》 - 116.精读《刷新》 - 131.精读《从 0 到 1》 - 135.精读《极客公园 IFX - 上》 - 136.精读《极客公园 IFX - 下》 - 137.精读《当我在分享的时候,我在做什么?》 ### 算法 - 198.精读《算法 - 动态规划》 - 199.精读《算法 - 滑动窗口》 - 200.精读《算法 - 回溯》 - 201.精读《算法 - 二叉树》 - 203.精读《算法 - 二叉搜索树》 - 283.精读《算法题 - 通配符匹配》 - 284.精读《算法题 - 统计可以被 K 整除的下标对数目》 - 285.精读《算法题 - 最小覆盖子串》 - 286.精读《算法题 - 地下城游戏》 - 288.精读《算法题 - 编辑距离》 - 289.精读《算法题 - 二叉树中的最大路径和》 ### 可视化搭建 - 268.如何抽象可视化搭建 - 269.组件注册与画布渲染 - 270.画布与组件元信息数据流 - 271.可视化搭建内置 API - 272.容器组件设计 - 273.组件值与联动 - 274.定义联动协议 - 275.组件值校验 - 276.keepAlive 模式 - 278.ComponentLoader 与动态组件 - 279.自动批处理与冻结 - 280.场景实战 ### SQL - 231.SQL 入门 - 232.SQL 聚合查询 - 233.SQL 复杂查询 - 234.SQL CASE 表达式 - 235.SQL 窗口函数 - 236.SQL grouping ### 机器学习 - 291.机器学习简介: 寻找函数的艺术 - 292.万能近似定理: 逼近任何函数的理论 - 293.实现万能近似函数: 神经网络的架构设计 - 294.反向传播: 揭秘神经网络的学习机制 - 295.完整实现神经网络: 实战演练 ### 数学之美 - 296.手动算根号 ### 生活 - 290.个人养老金利与弊 ## 关注前端精读微信公众号 ================================================ FILE: 前沿技术/1.精读《js 模块化发展》.md ================================================ 这次是前端精读期刊与大家第一次正式碰面,我们每周会精读并分析若干篇精品好文,试图讨论出结论性观点。没错,我们试图通过观点的碰撞,争做无主观精品好文的意见领袖。 我是这一期的主持人 —— [黄子毅](https://github.com/ascoders) 本期精读的文章是:[evolutionOfJsModularity](https://github.com/myshov/history-of-javascript/tree/master/4_evolution_of_js_modularity)。 懒得看文章?没关系,稍后会附上文章内容概述,同时,更希望能通过阅读这一期的精读,穿插着深入阅读原文。 # 1 引言 logo > 如今,Javascript 模块化规范非常方便、自然,但这个新规范仅执行了 2 年,就在 4 年前,js 的模块化还停留在运行时支持,10 年前,通过后端模版定义、注释定义模块依赖。对经历过来的人来说,历史的模块化方式还停留在脑海中,反而新上手的同学会更快接受现代的模块化规范。 但为什么要了解 Javascript 模块化发展的历史呢?因为凡事都有两面性,了解 Javascript 模块化规范,有利于我们思考出更好的模块化方案,纵观历史,从 1999 年开始,模块化方案最多维持两年,就出现了新的替代方案,比原有的模块化更清晰、强壮,我们不能被现代模块化方式限制住思维,因为现在的 ES2015 模块化方案距离发布也仅仅过了两年。 # 2 内容概要 **直接定义依赖 (1999)**: 由于当时 js 文件非常简单,模块化方式非常简单粗暴 —— 通过全局方法定义、引用模块。这种定义方式与现在的 commonjs 非常神似,区别是 commonjs 以文件作为模块,而这种方法可以在任何文件中定义模块,模块不与文件关联。 **闭包模块化模式 (2003)**: 用闭包方式解决了变量污染问题,闭包内返回模块对象,只需对外暴露一个全局变量。 **模版依赖定义 (2006)**: 这时候开始流行后端模版语法,通过后端语法聚合 js 文件,从而实现依赖加载,说实话,现在 go 语言等模版语法也很流行这种方式,写后端代码的时候不觉得,回头看看,还是挂在可维护性上。 **注释依赖定义 (2006)**: 几乎和模版依赖定义同时出现,与 1999 年方案不同的,不仅仅是模块定义方式,而是终于以文件为单位定义模块了,通过 [lazyjs](https://github.com/bevacqua/lazyjs) 加载文件,同时读取文件注释,继续递归加载剩下的文件。 **外部依赖定义 (2007)**: 这种定义方式在 cocos2d-js 开发中普遍使用,其核心思想是将依赖抽出单独文件定义,这种方式不利于项目管理,毕竟依赖抽到代码之外,我是不是得两头找呢?所以才有通过 webpack 打包为一个文件的方式暴力替换为 commonjs 的方式出现。 **Sandbox 模式 (2009)**: 这种模块化方式很简单,暴力,将所有模块塞到一个 `sandbox` 变量中,硬伤是无法解决命名冲突问题,毕竟都塞到一个 `sandbox` 对象里,而 `Sandbox` 对象也需要定义在全局,存在被覆盖的风险。模块化需要保证全局变量尽量干净,目前为止的模块化方案都没有很好的做到这一点。 **依赖注入 (2009)**: 就是大家熟知的 angular1.0,依赖注入的思想现在已广泛运用在 react、vue 等流行框架中。但依赖注入和解决模块化问题还差得远。 **CommonJS (2009)**: 真正解决模块化问题,从 node 端逐渐发力到前端,前端需要使用构建工具模拟。 **Amd (2009)**: 都是同一时期的产物,这个方案主要解决前端动态加载依赖,相比 commonJs,体积更小,按需加载。 **Umd (2011)**: 兼容了 CommonJS 与 Amd,其核心思想是,如果在 commonjs 环境(存在 `module.exports`,不存在 `define`),将函数执行结果交给 `module.exports` 实现 Commonjs,否则用 Amd 环境的 `define`,实现 Amd。 **Labeled Modules (2012)**: 和 Commonjs 很像了,没什么硬伤,但生不逢时,碰上 Commonjs 与 Amd,那只有被人遗忘的份了。 **YModules (2013)**: 既然都出了 Commonjs Amd,文章还列出了此方案,一定有其独到之处。其核心思想在于使用 `provide` 取代 `return`,可以控制模块结束时机,处理异步结果;拿到第二个参数 `module`,修改其他模块的定义(虽然很有拓展性,但用在项目里是个搅屎棍)。 **ES2015 Modules (2015)**: 就是我们现在的模块化方案,还没有被浏览器实现,大部分项目已通过 `babel` 或 `typescript` 提前体验。 # 3 精读 本次提出独到观点的同学有:[流形](https://github.com/arcthur),[黄子毅](https://github.com/ascoders),[苏里约](https://github.com/javie007),[camsong](https://github.com/camsong),[杨森](https://github.com/jasonslyvia),[淡苍](https://github.com/BlackGanglion),[留影](https://github.com/fanhc019),精读由此归纳。 ### 从语言层面到文件层面的模块化 > 从 1999 年开始,模块化探索都是基于语言层面的优化,真正的革命从 2009 年 CommonJS 的引入开始,前端开始大量使用预编译。 这篇文章所提供的模块化历史的方案都是逻辑模块化,**从 CommonJS 方案开始前端把服务端的解决方案搬过来之后,算是看到标准物理与逻辑统一的模块化**。但之后前端工程不得不引入模块化构建这一步。正是这一步给前端开发无疑带来了诸多的不便,尤其是现在我们开发过程中经常为了优化这个工具带了很多额外的成本。 从 CommonJS 之前其实都只是封装,并没有一套模块化规范,这个就有些像类与包的概念。我在 10 年左右用的最多的还是 YUI2,YUI2 是用 namespace 来做模块化的,但有很多问题没有解决,比如多版本共存,因此后来 YUI3 出来了。 ```javascript YUI().use('node', 'event', function (Y) { // The Node and Event modules are loaded and ready to use. // Your code goes here! }); ``` YUI3 的 sandbox 像极了差不多同时出现的 AMD 规范,但早期 yahoo 在前端圈的影响力还是很大的,而 requirejs 到 2011 年才诞生,因此圈子不是用着 YUI 要不就自己封装一套 sandbox,内部使用 jQuery。 为什么模块化方案这么晚才成型,可能早期应用的复杂度都在后端,前端都是非常简单逻辑。后来 Ajax 火了之后,web app 概念的开始流行,前端的复杂度也呈指数级上涨,到今天几乎和后端接近一个量级。**工程发展到一定阶段,要出现的必然会出现。**   ### 前端三剑客的模块化展望 > 从 js 模块化发展史,我们还看到了 css html 模块化方面的严重落后,如今依赖编译工具的模块化增强在未来会被标准所替代。 原生支持的模块化,**解决 html 与 css 模块化问题正是以后的方向。** 再回到 JS 模块化这个主题,开头也说到是为了构建 scope,实则提供了业务规范标准的输入输出的方式。但文章中的 JS 的模块化还不等于前端工程的模块化,Web 界面是由 HTML、CSS 和 JS 三种语言实现,不论是 CommonJS 还是 AMD 包括之后的方案都无法解决 CSS 与 HTML 模块化的问题。 对于 CSS 本身它就是 global scope,因此开发样式可以说是喜忧参半。近几年也涌现把 HTML、CSS 和 JS 合并作模块化的方案,其中 react/css-modules 和 vue 都为人熟知。当然,这一点还是非常依赖于 webpack/rollup 等构建工具,让我们意识到在 browser 端还有很多本质的问题需要推进。 对于 css 模块化,目前不依赖预编译的方式是 `styled-component`,通过 js 动态创建 class。而目前 css 也引入了[与 js 通信的机制 与 原生变量支持](https://developer.mozilla.org/zh-CN/docs/Web/CSS/Using_CSS_variables)。未来 css 模块化也很可能是运行时的,所以目前比较看好 `styled-component` 的方向。 对于 html 模块化,小尤最近爆出与 chrome 小组调研 html Modules,如果 html 得到了浏览器,编辑器的模块化支持,未来可能会取代 jsx 成为最强大的模块化、模板语言。 对于 js 模块化,最近出现的 ` ``` 函数式风格的入口是 `setup` 函数,采用了函数式风格后可以享受如下好处:类型自动推导、减少打包体积。 `setup` 函数返回值就是注入到页面模版的变量。我们也可以返回一个函数,通过使用 `value` 这个 API 产生属性并修改: ```jsx import { value } from 'vue' const MyComponent = { setup(props) { const msg = value('hello') const appendName = () => { msg.value = `hello ${props.name}` } return { msg, appendName } }, template: `

{{ msg }}
` } ``` 要注意的是,`value()` 返回的是一个对象,通过 `.value` 才能访问到其真实值。 为何 `value()` 返回的是 Wrappers 而非具体值呢?原因是 Vue 采用双向绑定,只有对象形式访问值才能保证访问到的是最终值,这一点类似 React 的 `useRef()` API 的 `.current` 规则。 那既然所有 `value()` 返回的值都是 Wrapper,那直接给模版使用时要不要调用 `.value` 呢?**答案是否定的,直接使用即可,模版会自动 `Unwrapping`:** ```jsx const MyComponent = { setup() { return { count: value(0) } }, template: `` } ``` 接下来是 **Hooks**,下面是一个使用 Hooks 实现获得鼠标实时位置的例子: ```jsx function useMouse() { const x = value(0) const y = value(0) const update = e => { x.value = e.pageX y.value = e.pageY } onMounted(() => { window.addEventListener('mousemove', update) }) onUnmounted(() => { window.removeEventListener('mousemove', update) }) return { x, y } } // in consuming component const Component = { setup() { const { x, y } = useMouse() const { z } = useOtherLogic() return { x, y, z } }, template: `
{{ x }} {{ y }} {{ z }}
` } ``` 可以看到,`useMouse` 将所有与 “处理鼠标位置” 相关的逻辑都封装了进去,乍一看与 React Hooks 很像,但是有两个区别: 1. `useMouse` 函数内改变 `x`、`y` 后,不会重新触发 `setup` 执行。 2. `x` `y` 拿到的都是 Wrapper 而不是原始值,且这个值会动态变化。 另一个重要 API 就是 **`watch`**,它的作用类似 React Hooks 的 **useEffect**,但实现原理和调用时机其实完全不一样。 `watch` 的目的是监听某些变量变化后执行逻辑,比如当 `id` 变化后重新取数: ```jsx const MyComponent = { props: { id: Number }, setup(props) { const data = value(null) watch(() => props.id, async (id) => { data.value = await fetchData(id) }) } } ``` 之所以要 `watch`,因为在 Vue 中,`setup` 函数仅执行一次,所以不像 React Function Component,每次组件 `props` 变化都会重新执行,因此无论是在变量、`props` 变化时如果想做一些事情,都需要包裹在 `watch` 中。 后面还有 `unwatching`、生命周期函数、依赖注入,都是一些语法定义,感兴趣可以继续[阅读原文](https://github.com/vuejs/rfcs/blob/function-apis/active-rfcs/0000-function-api.md#dependency-injection),笔者就不赘述了。 # 3. 精读 对于 Vue 3.0 的 Function API + Hooks 与 React Function Component + Hooks,笔者做一些对比。 ## Vue 与 React 逻辑结构 React Function Component 与 Hooks,虽然在实现原理上,与 Vue3.0 存在 Immutable 与 Mutable、JSX 与 Template 的区别,但逻辑理解上有着相通之处。 ```ts const MyComponent = { setup(props) { const x = value(0) const setXRandom = () => { x.value = Math.random() } return { x, setXRandom } }, template: ` ` } ``` 虽然在 Vue 中,`setup` 函数仅执行一次,看上去与 React 函数完全不一样(React 函数每次都执行),但其实 Vue 将渲染层(Template)与数据层(setup)分开了,而 React 合在了一起。 我们可以利用 React Hooks 将数据层与渲染层完全隔离: ```jsx // 类似 vue 的 setup 函数 function useMyComponentSetup(props) { const [x, setX] = useState(0) const setXRandom = useCallback(() => { setX(Math.random()) }, [setX]) return { x, setXRandom } } // 类似 vue 的 template 函数 function MyComponent(props: { name: String }) { const { x, setXRandom } = useMyComponentSetup(props) return ( ) } ``` 这源于 JSX 与 Template 的根本区别。JSX 使模版与 JS 可以写在一起,因此数据层与渲染层可以耦合在一起写(也可以拆分),但 Vue 采取的 Template 思路使数据层强制分离了,这也使代码分层更清晰了。 而实际上 Vue3.0 的 `setup` 函数也是可选的,再配合其支持的 TSX 功能,与 React 真的只有 Mutable 的区别了: ```jsx // 这是个 Vue 组件 const MyComponent = createComponent((props: { msg: string }) => { return () => h('div', props.msg) }) ``` 我们很难评价 Template 与 JSX 的好坏,但为了更透彻的理解 Vue 与 React,需要抛开 JSX&Template,Mutable&Immutable 去看,其实去掉这两个框架无关的技术选型,React@16 与 Vue@3 已经非常像了。 > Vue3.0 的精髓是学习了 React Hooks 概念,因此正好可以用 Hooks 在 React 中模拟 Vue 的 setup 函数。 关于这两套技术选型,已经是相对完美的组合,不建议在 JSX 中再实现类似 Mutable + JSX 的花样来(因为喜欢 Mutable 可以用 Vue 呀): - Vue:Mutable + Template - React:Immutable + JSX 真正影响编码习惯的就是 Mutable 与 Immutable,使用 Vue 就坚定使用 Mutable,使用 React 就坚定使用 Immutable,这样能最大程度发挥两套框架的价值。 ## Vue Hooks 与 React Hooks 的差异 先看 React Hooks 的简单语法: ```jsx const [ count, setCount ] = useState(0) const setToOne = () => setCount(1) ``` Vue Hooks 的简单语法: ```jsx const count = value(0) const setToOne = () => count.value = 1 ``` 之所以 React 返回的 `count` 是一个数字,是因为 Immutable 规则,而 Vue 返回的 `count` 是个对象,拥有 `count.value` 属性,也是因为 Vue Mutable 规则导致,这使得 Vue 定义的所有变量都类似 React 中 `useRef` 定义变量,因此不存 React `capture value` 的特性。 > 关于 capture value 更多信息,可以阅读 [精读《Function VS Class 组件》 Capute Value 介绍](https://github.com/dt-fe/weekly/blob/v2/095.%E7%B2%BE%E8%AF%BB%E3%80%8AFunction%20VS%20Class%20%E7%BB%84%E4%BB%B6%E3%80%8B.md#capture-props) 另外,对于 Hooks 的值变更机制也不同,我们看 Vue 的代码: ```jsx const Component = { setup() { const { x, y } = useMouse() const { z } = useOtherLogic() return { x, y, z } }, template: `
{{ x }} {{ y }} {{ z }}
` } ``` 由于 `setup` 函数仅执行一次,怎么做到当 `useMouse` 导致 `x`、`y` 值变化时,可以在 `setup` 中拿到最新的值? 在 React 中,`useMouse` 如果修改了 `x` 的值,那么使用 `useMouse` 的函数就会被重新执行,以此拿到最新的 `x`,而在 Vue 中,将 Hooks 与 Mutable 深度结合,通过包装 `x.value`,使得当 `x` 变更时,引用保持不变,仅值发生了变化。所以 Vue 利用 Proxy 监听机制,可以做到 `setup` 函数不重新执行,但 Template 重新渲染的效果。 这就是 Mutable 的好处,Vue Hooks 中,不需要 `useMemo` `useCallback` `useRef` 等机制,仅需一个 `value` 函数,直观的 Mutable 修改,就可以实现 React 中一套 Immutable 性能优化后的效果,这个是 Mutable 的魅力所在。 ## Vue Hooks 的优势 笔者对 RFC 中对 Vue、React Hooks 的对比做一个延展解释: 首先最大的不同:`setup` 仅执行一遍,而 React Function Component 每次渲染都会执行。 **Vue 的代码使用更符合 JS 直觉。** 这句话直截了当戳中了 JS 软肋,JS 并非是针对 Immutable 设计的语言,所以 Mutable 写法非常自然,而 Immutable 的写法就比较别扭。 当 Hooks 要更新值时,Vue 只要用等于号赋值即可,而 React Hooks 需要调用赋值函数,**当对象类型复杂时,还需借助第三方库才能保证进行了正确的 Immutable 更新。** **对 Hooks 使用顺序无要求,而且可以放在条件语句里。** 对 React Hooks 而言,调用必须放在最前面,而且不能被包含在条件语句里,这是因为 React Hooks 采用下标方式寻找状态,一旦位置不对或者 Hooks 放在了条件中,就无法正确找到对应位置的值。 而 Vue Function API 中的 Hooks 可以放在任意位置、任意命名、被条件语句任意包裹的,因为其并不会触发 `setup` 的更新,只在需要的时候更新自己的引用值即可,而 Template 的重渲染则完全继承 Vue 2.0 的依赖收集机制,它不管值来自哪里,只要用到的值变了,就可以重新渲染了。 **不会再每次渲染重复调用,减少 GC 压力。** 这确实是 React Hooks 的一个问题,所有 Hooks 都在渲染闭包中执行,每次重渲染都有一定性能压力,而且频繁的渲染会带来许多闭包,虽然可以依赖 GC 机制回收,但会给 GC 带来不小的压力。 而 Vue Hooks 只有一个引用,所以存储的内容就非常精简,也就是占用内存小,而且当值变化时,也不会重新触发 `setup` 的执行,所以确实不会造成 GC 压力。 **必须要总包裹 `useCallback` 函数确保不让子元素频繁重渲染。** React Hooks 有一个问题,就是完全依赖 Immutable 属性。**而在 Function Component 内部创建函数时,每次都会创建一个全新的对象,这个对象如果传给子组件,必然导致子组件无法做性能优化。** 因此 React 采取了 `useCallback` 作为优化方案: ```jsx const fn = useCallback(() => /* .. */, []) ``` 只有当第二个依赖参数变化时才返回新引用。但第二个依赖参数需要 lint 工具确保依赖总是正确的(关于为何要对依赖诚实,感兴趣可以移步 [精读《Function Component 入门》 - 永远对依赖诚实](https://github.com/dt-fe/weekly/blob/v2/104.%E7%B2%BE%E8%AF%BB%E3%80%8AFunction%20Component%20%E5%85%A5%E9%97%A8%E3%80%8B.md#%E6%B0%B8%E8%BF%9C%E5%AF%B9%E4%BE%9D%E8%B5%96%E9%A1%B9%E8%AF%9A%E5%AE%9E))。 回到 Vue 3.0,由于 `setup` 仅执行一次,因此函数本身只会创建一次,不存在多实例问题,不需要 `useCallback` 的概念,更不需要使用 [lint 插件](https://www.npmjs.com/package/eslint-plugin-react-hooks) 保证依赖书写正确,这对开发者是实实在在的友好。 **不需要使用 `useEffect` `useMemo` 等进行性能优化,所有性能优化都是自动的。** 这也是实在话,毕竟 Mutable + 依赖自动收集就可以做到最小粒度的精确更新,根本不会触发不必要的 Rerender,因此 `useMemo` 这个概念也不需要了。 而 `useEffect` 也需要传递第二个参数 “依赖项”,在 Vue 中根本不需要传递 “依赖项”,所以也不会存在用户不小心传错的问题,更不需要像 React 写一个 lint 插件保证依赖的正确性。(这也是笔者想对 React Hooks 吐槽的点,React 团队如何保障每个人都安装了 lint?就算装了 lint,如果 IDE 有 BUG,导致没有生效,随时可能写出依赖不正确的 “危险代码”,造成比如死循环等严重后果) # 4. 总结 通过对比 Vue Hooks 与 React Hooks 可以发现,Vue 3.0 将 Mutable 特性完美与 Hooks 结合,规避了一些 React Hooks 的硬伤。所以我们可以说 Vue 借鉴了 React Hooks 的思想,但创造出来的确实一个更精美的艺术品。 但 React Hooks 遵循的 Immutable 也有好的一面,就是每次渲染中状态被稳定的固化下来了,不用担心状态突然变更带来的影响(其实反而要注意状态用不变更带来的影响),对于数据记录、程序运行的稳定性都有较高的可预期性。 最后,对于喜欢 Mutable 的开发者,Vue 3.0 是你的最佳选择,基于 React + Mutable 搞的一些小轮子做到顶级可能还不如 Vue 3.0。对于 React 开发者来说,坚持你们的 Immutable 信仰吧,Vue 3.0 已经将 Mutable 发挥到极致,只有将 React Immutable 特性发挥到极致才能发挥 React 的最大价值。 > 讨论地址是:[精读《Vue3.0 Function API》 · Issue #173 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/173) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/11.精读《前端调试技巧》.md ================================================ 本期精读的文章是:[debugging-tips-tricks](https://css-tricks.com/debugging-tips-tricks/?utm_source=javascriptweekly&utm_medium=email) 编码只是开发过程中的一小部分,为了使我们工作更加高效,我们必须学会调试,并擅长调试。 # 1 引言 logo 梵高这幅画远景漆黑一片,近景的咖啡店色彩却反差很大,他只是望着黑夜中温暖的咖啡馆,交织着矛盾与孤独。代码不可能没有 BUG,调试与开发也始终交织在一起,我们在这两种矛盾中不断成长。 # 2 内容概要 文中列举了常用调试技巧,如下: ### Debugger 在代码中插入 `debugger` 可以在其位置触发断点调试。 ### Console.dir 使用 `console.dir` 命令,可以打印出对象的结构,而 `console.log` 仅能打印返回值,在打印 `document` 属性时尤为有用。 > ps: 大部分时候,对象返回值就是其结构 ### 使用辅助工具,语法高亮、linting 它可以帮助我们快速定位问题,其实 flow 与 typescript 也起到了很好的调试作用。 ### 浏览器拓展 使用类似 [ReactDTools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) [VueDTools](https://chrome.google.com/webstore/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd) 调试对应框架。 ### 借助 DevTools Chrome Dev Tools 非常强大,[dev-tips](https://umaar.com/dev-tips/) 列出了 100 多条它可以做的事。 ### 移动端调试工具 最靠谱的应该是 [eruda](http://eruda.liriliri.io/),可以内嵌在任何 h5 页面,充当 DevTools 控制台的作用。 ### 实时调试 不需要预先埋点,比如 `document.activeElement` 可以打印最近 focus 过的元素,因为打开控制台导致失去焦点,但我们可以通过此 api 获取它。 ### 结构化打印对象瞬时状态 `JSON.stringify(obj, null, 2)` 可以结构化打印出对象,因为是字符串,不用担心引用问题。 ### 数组调试 通过 `Array.prototype.find` 快速寻找某个元素。 # 3 精读 本精读由 [rccoder](https://github.com/rccoder) [ascoders](https://github.com/ascoders) [NE-SmallTown](https://github.com/NE-SmallTown) [BlackGanglion](https://github.com/BlackGanglion) [jasonslyvia](https://github.com/jasonslyvia) [alcat2008](https://github.com/alcat2008) [DanielWLam](https://github.com/DanielWLam) [HsuanXyz](https://github.com/HsuanXyz) [huxiaoyun](https://github.com/huxiaoyun) [vagusX](https://github.com/vagusX) 讨论而出。 ### 移动端真机测试 由于 webview 不一定支持连接 chrome 控制台调试,只有真机测试才能复现真实场景。 [browserstack](https://www.browserstack.com/) [dynatrace](https://www.dynatrace.com/platform/offerings/customer-experience-monitoring/) 都是真机测试平台,公司内部应该也会搭建这种平台。 ### 移动端控制台 - [Chrome 远程调试](https://developers.google.com/web/tools/chrome-devtools/remote-debugging/webviews) app 支持后,连接 usb 或者局域网,即可通过 Dev Tools 调试 webview 页面。 - [Weinre](http://people.apache.org/~pmuellr/weinre/docs/latest/Home.html) 通过页面加载脚本,与 pc 端调试器通信。 - 通过内嵌控制台解决,比如 [eruda](http://eruda.liriliri.io/) [VConsole](https://github.com/WechatFE/vConsole) - [Rosin](http://alloyteam.github.io/Rosin/) fiddler 的一个插件,协助移动页面调试。 - [jsconsole](https://jsconsole.com/) 在本地部署后,手机访问对应 ip,可以测试对应浏览器的控制台。 ### 请求代理 [charles](http://www.charlesproxy.com/) [Fiddler](http://www.telerik.com/fiddler) 可以抓包,更重要是可以代理请求。假数据、边界值测试、开发环境代码加载,每一项都非常有用。 ### 定制 Chrome 拓展 对于特定业务场景也可以通过开发 chrome 插件来做,比如分析自己网站的结构、版本、代码开发责任人、一键切换开发环境。 ### 在用户设备调试 把控制台输出信息打到服务器,本地通过与服务器建立 socket 链接实时查看控制台信息。要知道实时根据用户 id 开启调试信息,并看用户真是环境的控制台打印信息是非常有用的,能解决很多难以复现问题。 代码中可以使用封装过的 `console.log`,当服务端开启调试状态后,对应用户网页会源源不断打出 log。 ### DOM 断点、事件断点 - DOM 断点,在 dom 元素右键,选择 (Break on subtree modifications),可以在此 dom 被修改时触发断点,在不确定 dom 被哪段 js 脚本修改时可能有用。 - Event Listener Breakpoints,神器之一,对于任何事件都能进入断点,比如 click,touch,script 事件统统能监听。 ### 使用错误追踪平台 对错误信息采集、分析、报警是很必要的,这里有一些对外服务:[sentry](https://sentry.io/welcome/) [trackjs](https://trackjs.com/) ### 黑盒调试 SourceMap 可以精准定位到代码,但有时候报错是由某处代码统一抛出的,比如 [invariant](https://github.com/zertosh/invariant) 让人又爱又恨的库,所有定位全部跑到这个库里了(要你有何用),这时候,可以在 DevTools 源码中右键,选中 `BlackBox Script`,它就变成黑盒了,下次 log 的定位将会是准确的。 [FireFox](https://hacks.mozilla.org/2013/08/new-features-of-firefox-developer-tools-episode-25/)、[Chrome](https://umaar.com/dev-tips/128-blackboxing/)。 ### 删除无用的 css Css 不像 Js 一样方便分析规则是否存在冗余,Chrome 帮我们做了这件事:[CSS Tracker](https://umaar.com/dev-tips/126-css-tracker/)。 ### 在 Chrome 快速查找元素 Chrome 会记录最后插入的 5 个元素,分别以 `$0` ~ `$4` 的方式在控制台直接输出。 last-items ### Console.table 以表格形式打印,对于对象数组尤为合适。 ### 监听特定函数调用 `monitor` 有点像 `proxy`,用 `monitor` 包裹住的 function,在其调用后,会在控制台输出其调用信息。 ```javascript > function func(num){} > monitor(func) > func(3) // < function func called with arguments: 3 ``` ### 模拟发送请求利器 PostMan [PostMan](https://www.getpostman.com/products), FireFox 控制台 Network 也支持此功能。 ### 找到控制台最后一个对象 有了 `$_`,我们就不需要定义新的对象来打印值了,比如: ```javascript > [1, 2, 3, 4] < [1, 2, 3, 4] > $_.length // < 4 ``` 更多控制台相关技巧可以查看:[command-line-reference](https://developers.google.com/web/tools/chrome-devtools/console/command-line-reference?utm_source=dcc&utm_medium=redirect&utm_campaign=2016q3)。 # 3 总结 虽然在抛砖引玉,但整理完之后发现仍然是块砖头,调试技巧繁多,里面包含了通用的、不通用的,精读不可能一一列举。希望大家能根据自己的业务场景,掌握相关的调试技巧,让工作更加高效。 > 讨论地址是:[精读《前端调试技巧》 · Issue #17 · dt-fe/weekly](http://github.com/dt-fe/weekly/issues/17) > 如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,每周五发布。 ================================================ FILE: 前沿技术/111.精读《前端未来展望》.md ================================================ # 1. 引言 前端展望的文章越来越不好写了,随着前端发展的深入,需要拥有非常宽广的视野与格局才能看清前端的未来。 笔者根据自身经验,结合下面几篇文章发表一些总结与感悟: - [A Look at JavaScript’s Future](https://www.toptal.com/javascript/predicting-javascript-future) - [前端开发 20 年变迁史](https://mp.weixin.qq.com/s/yNg7Q0XNLJMnqffTIJhNUg) - [前端开发编程语言的过去、现在和未来](https://johnhax.net/2019/fe-lang/article1) - [绕过技术纷争,哪些技术决定前端开发者的未来?](https://mp.weixin.qq.com/s?__biz=MzUxMzcxMzE5Ng==&mid=2247491704&idx=1&sn=95ad66f7fe606801cdac74e296a41783) - [未来前端的机会在哪里?](https://mp.weixin.qq.com/s?__biz=MzIzOTU0NTQ0MA==&mid=2247490769&idx=1&sn=7ee6e01045a6fe7e15f16aa33afcc2ad&chksm=e92921dede5ea8c8e93489271e8877d2e8688bd511b32e22c287b6c468904c5466b40f6a2bec&xtrack=1&scene=90&subscene=93&sessionid=1562200039&clicktime=1562) 读完这几篇文章可以发现,即便是最资深的前端从业者,每个人看前端未来也有不同的侧重点。这倒不是因为视野的局限,而是现在前端领域太多了,专精其中某几个领域就足够了,适量比全面更好。 同时前端底层也在逐渐封闭,虽然目睹了前端几十年变迁的开发者仍会对一些底层知识津津乐道,但通往底层的大门已经一扇扇逐渐关闭了,将更多的开发者挤到上层区域建设,所以仅学会近几年的前端知识依然能找到不错的工作。 然而上层建设是不封顶的,有人看到了山,有人看到了星球,不同业务环境,不同视野的人看到的东西都不同。 有意思的是国内和国外看到前端未来的视角也不同:国内看到的是追求更多的参与感、影响力,国外看到的是对新特性的持续跟进。 # 2. 精读 前端可以从多个角度理解,比如规范、框架、语言、社区、场景以及整条研发链路。 看待前端未来的角度随着视野不同也会有变化,比如 Serverless 是未来,务实的思考是:前端在 Serverless 研发链路中仅处于使用方,并不会因为用了 Serverless 而提升了技术含量。更高格局的思考是:怎么推动 Serverless 的建设,不把自己局限在前端。 所以当我们读到不同的人对前端理解的时候,有人站在一线前端研发的角度,有人站在全栈的角度,也有人站在业务负责人的角度。其实国内前端发展也到了这个阶段,老一辈的前端开拓者们已经进入不同的业务领域,承担着更多不同的职能分工,甚至是整个大业务线的领导者,这说明两点: 1. 前辈已经用行动指出了前端突破天花板的各种方向。 2. 同是前端未来展望,不同的文章侧重的格局不同,两个标题相同的文章内容可能大相径庭。 笔者顺着这些文章分析角度,发表一些自己的看法。 ## 框架 在前端早期,也就是 1990 年浏览器诞生的时候,JS 没有良好的设计,浏览器也没有全面的实现,框架还没出来,浏览器之间就打起来了。 这也给前端发展定了一个基调:凭实力说话。 后面诞生的 Prototype、jquery 都是为了解决时代问题而诞生的,所以有种时代造就前端框架的感觉。 但到了最近几年,React、Angular、Vue 大有前端框架引领新时代的势头,前端要做的不再是填坑,而是模式创新。国内出现的小程序浪潮是个意料之外的现象,虽然群雄割据为开发者适配带来了一定成本,但本质上是中国在前端底层领域争取话语权的行为,而之所以各大公司不约而同的推出自己的小程序,则是商业、经济发展到了这个阶段的自然产物。 在原生开发领域,像 RN、Flutter 也是比较靠谱的移动端开发框架,RN 就长在 React 上,而 Flutter 的声明式 UI 也借鉴了前端框架的思路。每个框架都想往其他框架的领域渗透,所以标准总是很相近,各自的特色并没有宣传的那么明显,这个阶段只选用一种框架是明智的选择,未来这些框架之间会有更多使用场景争夺,但更多的是融合,推动新的开发方式提高生产力。 在数据驱动 UI 的方式上,具有代表性的是 React 的 Immutable 模式与 Vue 的 MVVM 观察者模式,前者模式虽然新颖,但是符合 JS 语言自然运行机制,Vue 的 MVVM 模式也相当好,特别是 Vue3.0 的 API 巧妙的解决了 React Hooks 无法解决的难题。如果 Vue 继续保持蓬勃的发展势头,未来前端 MVVM 模式甚至可能标准化,那么 Vue 是作为标准化的事实规范,还是和 JQuery 一样的命运,还需观察。 ## 语言 JS 语言本身有满多缺陷的,但通过 babel 前端工程师可以提前享受到大部分新特性,这在很大程度上抵消了早期语言设计带来的问题。 横向对比来看,我们还可以把编程语言分为:前端语言、后端语言、能编译到 JS 的语言。 之所以有 “能编译到 JS 的语言” 这一类,是因为 JS Runtime 几乎是前端跨平台的通用标准,能编译到 JS 就代表了可跨平台,然而现在 “能编译到 JS 的语言” 除了紧贴 JS 做类型增强的 TS 外,其他并没有火起来,有工具链生态不匹配的原因,也有各大公司之间利益争夺的原因。 后端语言越来越贴场景化,比如 Go 主打轻量级高并发方案,Python 以其易用性占领了大部分大数据、人工智能的运算场景。 与此对应的是前端语言的同质化,前端语言绑定在前端框架的趋势越来越明显,比如 IOS 平台只能用 OC 和 Swift,安卓只能用 JAVA 和 Kotlin,Flutter 只支持 Dart,与其说这些语言更适合这些平台特性,不如说背后是谷歌、苹果、微软等巨头对平台生态掌控权的争夺。Web 与移动端要解决的问题是类似的:如何高效管理 UI 状态,现在大部分都采用数据驱动的思路,通过 JSX 或 Template 的方式描述出 UI DSL(更多可参考 [前端开发编程语言的过去、现在和未来](https://johnhax.net/2019/fe-lang/article1) UI DSL 一节)、以及性能提升:渲染和计算分离(这里又分为并发与调度两种实现思路,目的和效果是类似的)。 所以编程语言的未来也没什么悬念,前端领域如果有的选就用 JS,没得选只能依附所在平台绑定的语言,而前端语言最近正在完成一轮升级大迁徙:JS -> TS,JAVA -> Kotlin,OC -> Swift,前端语言的特性、易用性正在逐步趋同。需要说明的是,如果仅了解这些语言的语法,对编程能力是毫无帮助的,了解平台特性,解决业务问题,提供更好的交互体验才是前端应该不断追求的目标,随着前端、Native 开发者之间的流动,前端领域语言层面差异会会来越小,大家越关注上层,越倾向抹平语言差异,甚至可能 All in JS,这不是因为 JS 有多大野心,而是因为在解决的问题趋同、业务优先的大背景下,大家都需要减少语言不通带来的障碍,最好的办法就是统一语言,从人类语言的演变就可以发现,要解决的问题趋同(人类交流)、与国家绑定的小众语言一直都有生存空间、语法大同小异,但不同语言都有一定自己的特色(比如法语表意更精确)、跨语言学习成本高,所以当国际化协作频繁时,一定会催生一套官方语言(英语),而使用基数大的语言可能会发展为通用国际语言(中文)。 将编程语言的割裂、统一比作人类语言来看,就能理解现状,和未来发展趋势了。 ## 可视化 前面也说过,前端的底层在逐渐封闭,而可视化就是前端的上层。 所以笔者很少提到工程化,原因就是未来前端开发者接触工程化的机会越来越少,工程化机制也越来越完善,前端会逐渐回归到自己的本质 - 人机交互,而交互的重要媒介就是图形,无论组件库还是智能化设计稿 To Code 都为了解放简单、模式化的交互工作,专业前端将更多聚集到图形化领域。 图形和数据是分不开的,所以图形化还要考虑性能问题与数据转换。 可视化是对性能要求最高的,因此像 web worker、GPU 加速都是常见处理手段,WASM 技术也会用到可视化中。具体到某个图表或大屏的性能优化,还会涉及数据抽样算法,分层渲染等,仅仅性能优化领域就有不少探索的空间。性能问题一般还伴随着数据量大,所以数据序列化方案也要一并考虑。 可视化图形学是非常学术的领域,从图形语法到交互语法,从一图一做的简单场景,到可视化分析场景的灵活拓展能力,再到探索式分析的图形语法完备性要求,可视化库想要一层层支持不同业务场景的需求,要有一个清晰的分层设计。 仅可视化的图形学领域,就足够将所有时间投入了,未来做可视化的前端会越来越专业,提供的工具库接口也越来越有一套最佳实践沉淀,对普通前端越来越友好。 BI 可视化分析就是前端深造的一个方向,跟随 BI 发展阶段,对前端的要求也在不断变化:工程化、组件化、搭建技术、渲染引擎、可视化、探索式、智能化,跟上产品对技术能力的要求,其实是相当有挑战性的。 ## 编辑器 编辑器方向主要有 IDE(Web IDE)、富文本编辑器。 **IDE 方向** 国产做的比较好的是 HBuilder,国际上做的比较好的是 VSCode,由于微软还同时推出了 Web 版 MonacoEditor,让 Web IDE 开发的门槛大大降低。 作为使用者,现在和未来的主流可能都是微软系,毕竟微软在操作系统、IDE 方面人才储备和经验积累很多。但随着云服务的变迁,引导着开发方式升级,IDE 游戏规则可能迎来重大改变 - 云化。云化使得作为开发者拥有更多竞争的机会,因为云上 IDE 市场现在还是蓝海,现在很多创业公司和大公司内部都在走这个方向,这标志着中国计算机技术往更底层的技术发展,未来会有更多的话语权。 从发展阶段来说,前端也发展到了 Web IDE 这个时代。对大公司来说,内部有许许多多割裂的工程化孤岛,不仅消耗大量优秀的前端同学去维护,也造成内部物料体系、工程体系难以打通,阻碍了内部技术流通,而云 IDE 天生的中心化环境管理可以解决这个问题,同时还能带来抹平计算机环境差异、统一编译环境、源码不落盘、甚至实现自动的多人协作也成为了可能,而云 IDE 因为在云上,也不止于 IDE,还可以很方便的集成流程,将研发全链路打通,因此在阿里内部也成为了今年四大方向之一。 所以今年可以明显看到的是,前端又在逐步替代低水平重复的 UI 设计,从设计稿生成代码,到研发链路上云,这种顶层设计正在进一步收窄前端底层建设,所以未来会有更多专业前端涌入可视化领域。 **富文本编辑器方向** 是一个重要且小众的领域,老牌做的较好的是 UEditor 系列,现在论体验和周边功能完善度,做得最好的是语雀编辑器。开源也有很多优秀的实现,比如 Quill、DraftJS、Slate 等等,但现在富文本编辑器核心能力是功能完备性(是否支持视频、脑图、嵌入)、性能、服务化功能打通了多少(是否支持在线解析 pdf、ppt 等文件)、交互自然程度(拷贝内容的智能识别)等等。如果将眼光放到全球,那国外有大量优秀富文本编辑器案例,比如 Google Docs、Word Online、iCloud Pages 等等。 最好用的富文本编辑器往往不开源,因为投入的技术研发成本是巨大的,本身这项技术就是一个产品,卖点就是源码。 富文本编辑器功能强度可以分为三个级别:L0~L2: - L0:利用浏览器自带的输入框,主要指 `contenteditable` 实现。 - L1:在 L0 的基础上通过 DOM API 自主实现增删改的功能,自定义能力非常强。 - L2:从输入框、光标开始自主研发,完全不依赖浏览器特性,如果研发团队能力强,可以实现任何功能,典型产品比如 Google Docs。 无论国内外都鲜有进入 L2 强度的产品,除了超级大公司或者主打编辑器的创业公司。 所以编辑器方向中,无论 IDE 方向,还是富文本编辑器方向,都值得深入探索,其中 IDE 方向更偏工程化一些,考验体系化思维,编辑器方向更偏经验与技术,考验基本功和架构设计能力。 ## 智能化 笔者认为智能化离前端这个工种是比较远的,智能化最终服务前后端,给前后端开发效率带来一个质的提升,而在此之前,作为前端从业者无非有两种选择:加入智能化开拓者队伍,或者准备好放弃可能被智能化替代的工作内容,积极投身于智能化解放开发者双手后,更具有挑战性的工作。这种挑战性的工作恰好包括了上面分析过的四个点:语言、框架、可视化、编辑器。 类比商业智能化,商业智能化包括网络协同和数据智能,也就是大量的网络协同产生海量数据,通过数据智能算法促进更好的算法模型、更高效的网络协同,形成一个反馈闭环。前端智能化也是类似,不管是自动切图、生成图片、页面,或者自动生成代码,都需要算法和前端工程师之间形成协同关系,并完成一个高效的反馈闭环,算法将是前端工程师手中的开发利器,且越规模化的使用功效越大。 另一种智能化方向是探索 BI 与可视化结合的智能化,通过功能完备的底层图表库,与后端通用 Cube 计算模型,形成一种探索式分析型 BI 产品,Tableau 就是典型的案例,在这个智能化场景中,需要对数据、产品、可视化全面理解的综合性人才,是前端职业生涯另一个突破点。 # 3. 总结 本文列举的五点显然不能代表前端的全貌,还遗漏了太多方面,比如工程化、组件化、Serverless 等,但 **语言、框架、可视化、编辑器、智能化** 这五个点是笔者认为前端,特别是国内前端值得持续发力,可以做深的点,成为任何一个领域的专家都足以突破前端工程师成长的天花板。 最后,前端是最贴近业务的技术之一,业务的未来决定了前端的未来,创造的业务价值决定了前端的价值,从现在开始锻炼自己的商业化思考能力与产品意识,看得懂业务,才能看到未来。 > 讨论地址是:[精读《前端未来展望》 · Issue #178 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/178) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/112.精读《源码学习》.md ================================================ # 1. 引言 [javascript-knowledge-reading-source-code](https://www.smashingmagazine.com/2019/07/javascript-knowledge-reading-source-code/) 这篇文章介绍了阅读源码的重要性,精读系列也已有八期源码系列文章,分别是: - [精读《Immer.js》源码](https://github.com/dt-fe/weekly/blob/v2/048.%E7%B2%BE%E8%AF%BB%E3%80%8AImmer.js%E3%80%8B%E6%BA%90%E7%A0%81.md) - [精读《sqorn 源码》](https://github.com/dt-fe/weekly/blob/v2/073.%E7%B2%BE%E8%AF%BB%E3%80%8Asqorn%20%E6%BA%90%E7%A0%81%E3%80%8B.md) - [精读《Epitath 源码 - renderProps 新用法》](https://github.com/dt-fe/weekly/blob/v2/075.%E7%B2%BE%E8%AF%BB%E3%80%8AEpitath%20%E6%BA%90%E7%A0%81%20-%20renderProps%20%E6%96%B0%E7%94%A8%E6%B3%95%E3%80%8B.md) - [精读《Htm - Hyperscript 源码》](https://github.com/dt-fe/weekly/blob/v2/082.%E7%B2%BE%E8%AF%BB%E3%80%8AHtm%20-%20Hyperscript%20%E6%BA%90%E7%A0%81%E3%80%8B.md) - [精读《React PowerPlug 源码》](https://github.com/dt-fe/weekly/blob/v2/092.%E7%B2%BE%E8%AF%BB%E3%80%8AReact%20PowerPlug%20%E6%BA%90%E7%A0%81%E3%80%8B.md) - [精读《syntax-parser 源码》](https://github.com/dt-fe/weekly/blob/v2/093.%E7%B2%BE%E8%AF%BB%E3%80%8Asyntax-parser%20%E6%BA%90%E7%A0%81%E3%80%8B.md) - [精读《react-easy-state 源码》](https://github.com/dt-fe/weekly/blob/v2/098.%E7%B2%BE%E8%AF%BB%E3%80%8Areact-easy-state%20%E6%BA%90%E7%A0%81%E3%80%8B.md) - [精读《Inject Instance 源码》](https://github.com/dt-fe/weekly/blob/v2/110.%E7%B2%BE%E8%AF%BB%E3%80%8AInject%20Instance%20%E6%BA%90%E7%A0%81%E3%80%8B.md) 笔者自己的感悟是,读过大量源码的程序员有以下几个特质: 1. 思考具有系统性,主要体现在改一处代码模块时,会将项目所有文件串联起来整体考虑,提前评估影响面。 2. 思考具有前瞻性,对已实现的方案可以快速评价所处阶段(临时 or 标准 or 可拓展),将边界情况提前解决,将框架 BUG 降低到最小程度。 3. 代码实现更优雅,有大量源码经验做支撑,解决同样问题时,这些程序员可以用更短的行数、更合适的三方库解决问题,代码可读性更好,模块拆分更合理,更利于维护。 既然阅读源码这么重要,那么怎么才能读好源码呢?本周精读的文章就是一篇方法论文章,告诉你如何更好的阅读源码。 # 2. 概述 原文分三个部分:阅读源码的好处、阅读源码的技巧、以及 Redux Connect 的案例研究。 ## 阅读源码的好处 阅读源码有助于理解抽象的概念,比如虚拟 DOM;有助于做方案调研,而不仅仅只看 Github star 数量;了解优秀框架目录结构的设计;看到一些陌生的工具函数,还可能激发你对 JS 规范的查阅,这种问题驱动的方式也是笔者推荐的 JS 规范学习方式。 ## 阅读源码的技巧 最好的阅读源码方式是看文章,如果源码的作者有写源码解读文章,这就是最省力的方式。虽然直接看代码可以了解到所有细节,但当你不清楚设计思路时,仅看源码可能会找不到方向,而读源码的最终目的是找到核心的设计理念,如果一个框架没有自己核心设计理念,这个框架也不值得诞生,更不值得被阅读。如果框架的作者已经将框架核心理念写成了文章,那读文章就是最佳方案。 还有一种方式是断点,写一个最小程序,在框架执行入口出打下断点,然后按照执行路径一步步理解。虽然执行路径中会存在大量无关的函数干扰精力,但如果你足够有耐心,当断点走完时一定会有所收获。 原文还提到了一种看源码方式,即没有目的的寻宝。在寻找框架主要思路的过程中,遇到一些有意思的函数,可以停下来仔细阅读,可能会发现一些对你有启发的代码片段。 ## Redux Connect 案例研究 原文以 Redux Connect 作为案例介绍研究思路。 首先看到 Connect 的功能 “包装组件” 后,就要问自己两个问题: 1. Connect 是如何实现包装组件后原样返回组件,但却增强组件功能的?(高阶组件知识) 2. 了解这个设计模式后,如何利用已有的文档实现它? 通过创建一个使用 Connect 的基本程序: ```js class MarketContainer extends Component { } const mapDispatchToProps = dispatch => { return { updateSummary: (summary, start, today) => dispatch(updateSummary(summary, start, today)) } } export default connect(null, mapDispatchToProps)(MarketContainer); ``` 比如从生成 connect 函数的 [createConnect](https://github.com/reduxjs/react-redux/blob/master/src/connect/connect.js#L46) 我们就可以学习到 [Facade Pattern](http://jargon.js.org/_glossary/FACADE_PATTERN.md) - 门面模式。 从 `createConnect` 函数调用处: ```js export function createConnect({ connectHOC = connectAdvanced, mapStateToPropsFactories = defaultMapStateToPropsFactories, mapDispatchToPropsFactories = defaultMapDispatchToPropsFactories, mergePropsFactories = defaultMergePropsFactories, selectorFactory = defaultSelectorFactory } = {}) ``` 我们可以学习到解构默认函数参数的知识点。 总之,在学习源码的过程中,可以了解到一些新的 JS 特性,一些设计模式,这些都是额外的宝藏,不断理解并学会运用到自己写的框架里,就实现了源码学习的目的。 # 3. 精读 原文介绍了学习源码的两个技巧,并利用 Redux Connect 实例说明了源码学习过程中可以学到许多周边知识,都让我们受益匪浅。 笔者结合之前写过的八篇源码分析文章,把最重要的设计思路提取出来,以实际的例子展示阅读源码能给我们思维带来哪些帮助。 ## Immerjs 源码的精华 Immer 可以让我们以 Mutable 的方式更新对象,最终得到一个 Immutable 对象: ```js this.setState(produce(state => (state.isShow = true))) ``` > 详细源码解读可以阅读 [这里](https://github.com/dt-fe/weekly/blob/v2/048.%E7%B2%BE%E8%AF%BB%E3%80%8AImmer.js%E3%80%8B%E6%BA%90%E7%A0%81.md#3-%E7%B2%BE%E8%AF%BB)。 核心思路是利用 Proxy 把脏活累活做掉。上面的例子中,`state` 已经是一个代理(Proxy)对象,通过自定义 `setting` 不断递归进行浅拷贝,最后返回一个新引用的顶层对象作为 `produce` 的返回值。 从 Immerjs 中,我们学到了 Proxy 可以化腐朽为神奇的用法,比看任何 Proxy 介绍文章都直观。 ## sqorn 源码的精华 sqorn 是一个 sql orm,举例来看: ```js const sq = require("sqorn-pg")(); const Person = sq`person`, Book = sq`book`; // SELECT const children = await Person`age < ${13}`; // "select * from person where age < 13" ``` > 详细源码解读可以阅读 [这里](https://github.com/dt-fe/weekly/blob/v2/073.%E7%B2%BE%E8%AF%BB%E3%80%8Asqorn%20%E6%BA%90%E7%A0%81%E3%80%8B.md#3-%E7%B2%BE%E8%AF%BB) 核心思路是在链式调用过程中创建 context 存储结构,并在链式调用的时候不断填充 context 信息,最终拿到的是一个结构化 context 对象,生成 sql 语句也就简单了。 从 sqorn 中,我们学到了如何实现链式调用 `init().a().b().c().print()` 最后拿到一个综合的结果,原理是内部维护了一个不断修改的对象。不论前端 React Vue 还是后端框架 Koa 等,一般都有内置的 context,一般实现这种优雅语法的框架内部都会维护 context。 ## Epitath 源码的精华 Epitath 在 React Hooks 之前出来,解决了高阶函数地狱的问题: ```js const App = epitath(function*() { const { count } = yield const { on } = yield return ( ) }) ``` > 详细源码解读可以阅读 [这里](https://github.com/dt-fe/weekly/blob/v2/075.%E7%B2%BE%E8%AF%BB%E3%80%8AEpitath%20%E6%BA%90%E7%A0%81%20-%20renderProps%20%E6%96%B0%E7%94%A8%E6%B3%95%E3%80%8B.md#3-%E7%B2%BE%E8%AF%BB) 其核心是利用 `generator` 的迭代,将 React 组件的平级结构还原成嵌套结构,将嵌套写法打平了: ```plain yield yield yield // 等价于 ``` 从 epitath 中,我们了解到 `generator` 原来可以这么用,正因为其执行是多次迭代的,因此我们可以利用这个特性,改变代码运行结构。 ## Htm - Hyperscript 源码的精华 Htm 将模版语法很自然的融入到了 html 中: ```js html`
<${Header} name="ToDo's (${page})" />
    ${todos.map( todo => html`
  • ${todo}
  • ` )}
<${Footer}>footer content here
`; ``` > 详细源码解读可以阅读 [这里](https://github.com/dt-fe/weekly/blob/v2/082.%E7%B2%BE%E8%AF%BB%E3%80%8AHtm%20-%20Hyperscript%20%E6%BA%90%E7%A0%81%E3%80%8B.md#3-%E7%B2%BE%E8%AF%BB) 其核心是怎么根据模版拿到 dom 元素的 AST?拿到 AST 后就方便生成后续内容了。 作者的办法是: ```js const TEMPLATE = document.createElement("template"); TEMPLATE.innerHTML = str; ``` 这样 TEMPLATE 就自带了 AST 解析,这是利用浏览器自带的 AST 解析拿到了 AST。从 Htm 中,我们学到了 `innerHTML` 可以生成标准 AST,所以只要有浏览器运行环境,需要拿 AST 的时候,不需要其他库,`innerHTML` 就是最好的方案。 ## React PowerPlug 源码的精华 React PowerPlug 是一个利用 render props 进行状态管理的工具库。 它可以在 JSX 中对任意粒度插入状态管理: ```js {({ value, set, reset }) => ( <> ) ``` 当然为了考虑个性化需求,Form Store 也向外暴露很多 API,可以直接获取和修改 value、error 的值。现在我们需要对一个表单的所有值提交到后端进行校验,根据后端返回,分别列出各项的校验错误信息,就需要借助相应项的 setError 去完成了。 这里主要参考了 [rc-form](https://github.com/react-component/form) 的实现方式,有兴趣的读者可以阅读其源码。 ```javascript import { createForm } from 'rc-form'; class Form extends React.Component { submit = () => { this.props.form.validateFields((error, value) => { console.log(error, value); }); } render() { const { getFieldError, getFieldDecorator } = this.props.form; const errors = getFieldError('required'); return (
{getFieldDecorator('required', { rules: [{ required: true }], })()} {errors ? errors.join(',') : null}
); } } export createForm()(Form); ``` # 4 总结 React 始终强调组合优于继承的理念,期望通过复用小组件来构建大组件使得开发变得简单而又高效,与传统面向对象思想是截然不同的。高阶函数(HOC)的出现替代了原有 Mixin 侵入式的方案,对比隐式的 Mixin 或是继承,HOC 能够在 Devtools 中显示出来,满足抽象之余,也方便了开发与测试。当然,不可过度抽象是我们始终要秉持的原则。希望读者通过本次阅读与讨论,能结合自己具体的业务开发场景,获得一些启发。 > 讨论地址是:[精读《深入理解 React 高阶组件》 · Issue #18 · dt-fe/weekly](http://github.com/dt-fe/weekly/issues/18) > 如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,每周五发布。 ================================================ FILE: 前沿技术/120.精读《React Hooks 最佳实践》.md ================================================ ## 简介 React 16.8 于 2019.2 正式发布,这是一个能提升代码质量和开发效率的特性,笔者就抛砖引玉先列出一些实践点,希望得到大家进一步讨论。 然而需要理解的是,没有一个完美的最佳实践规范,对一个高效团队来说,稳定的规范比合理的规范更重要,因此这套方案只是最佳实践之一。 ## 精读 ### 环境要求 - 拥有较为稳定且理解函数式编程的前端团队。 - 开启 ESLint 插件:[eslint-plugin-react-hooks](https://www.npmjs.com/package/eslint-plugin-react-hooks)。 ### 组件定义 Function Component 采用 `const` + 箭头函数方式定义: ```tsx const App: React.FC<{ title: string }> = ({ title }) => { return React.useMemo(() =>
{title}
, [title]); }; App.defaultProps = { title: 'Function Component' } ``` 上面的例子包含了: 1. 用 `React.FC` 申明 Function Component 组件类型与定义 Props 参数类型。 2. 用 `React.useMemo`  优化渲染性能。 3. 用 `App.defaultProps` 定义 Props 的默认值。 #### FAQ > 为什么不用 React.memo? 推荐使用 `React.useMemo` 而不是 `React.memo`,因为在组件通信时存在 `React.useContext` 的用法,这种用法会使所有用到的组件重渲染,只有 `React.useMemo` 能处理这种场景的按需渲染。 > 没有性能问题的组件也要使用 useMemo 吗? 要,考虑未来维护这个组件的时候,随时可能会通过 `useContext` 等注入一些数据,这时候谁会想起来添加 `useMemo` 呢? > 为什么不用解构方式代替 defaultProps? 虽然解构方式书写 `defaultProps` 更优雅,但存在一个硬伤:对于对象类型每次 Rerender 时引用都会变化,这会带来性能问题,因此不要这么做。 ### 局部状态 局部状态有三种,根据常用程度依次排列: `useState` `useRef` `useReducer` 。 #### useState ```tsx const [hide, setHide] = React.useState(false); const [name, setName] = React.useState('BI'); ``` 状态函数名要表意,尽量聚集在一起申明,方便查阅。 #### useRef ```tsx const dom = React.useRef(null); ``` `useRef` 尽量少用,大量 Mutable 的数据会影响代码的可维护性。 但对于不需重复初始化的对象推荐使用 `useRef` 存储,比如 `new G2()` 。 #### useReducer 局部状态不推荐使用 `useReducer` ,会导致函数内部状态过于复杂,难以阅读。 `useReducer` 建议在多组件间通信时,结合 `useContext` 一起使用。 #### FAQ > 可以在函数内直接申明普通常量或普通函数吗? 不可以,Function Component 每次渲染都会重新执行,常量推荐放到函数外层避免性能问题,函数推荐使用 `useCallback` 申明。 ### 函数 所有 Function Component 内函数必须用 `React.useCallback` 包裹,以保证准确性与性能。 ```tsx const [hide, setHide] = React.useState(false); const handleClick = React.useCallback(() => { setHide(isHide => !isHide) }, []) ``` `useCallback` 第二个参数必须写,[eslint-plugin-react-hooks](https://www.npmjs.com/package/eslint-plugin-react-hooks) 插件会自动填写依赖项。 ### 发请求 发请求分为操作型发请求与渲染型发请求。 #### 操作型发请求 操作型发请求,作为回调函数: ```tsx return React.useMemo(() => { return (
) }, [requestService.addList]) ``` #### 渲染型发请求 渲染型发请求在 `useAsync` 中进行,比如刷新列表页,获取基础信息,或者进行搜索, **都可以抽象为依赖了某些变量,当这些变量变化时要重新取数** : ```tsx const { loading, error, value } = useAsync(async () => { return requestService.freshList(id); }, [requestService.freshList, id]); ``` ### 组件间通信 简单的组件间通信使用透传 Props 变量的方式,而频繁组件间通信使用 `React.useContext` 。 以一个复杂大组件为例,如果组件内部拆分了很多模块, **但需要共享很多内部状态** ,最佳实践如下: #### 定义组件内共享状态 - store.ts ```tsx export const StoreContext = React.createContext<{ state: State; dispatch: React.Dispatch; }>(null) export interface State {}; export interface Action { type: 'xxx' } | { type: 'yyy' }; export const initState: State = {}; export const reducer: React.Reducer = (state, action) => { switch (action.type) { default: return state; } }; ``` #### 根组件注入共享状态 - main.ts ```tsx import { StoreContext, reducer, initState } from './store' const AppProvider: React.FC = props => { const [state, dispatch] = React.useReducer(reducer, initState); return React.useMemo(() => ( ), [state, dispatch]) }; ``` #### 任意子组件访问/修改共享状态 - child.ts ```tsx import { StoreContext } from './store' const app: React.FC = () => { const { state, dispatch } = React.useContext(StoreContext); return React.useMemo(() => (
{state.name}
), [state.name]) }; ``` 如上解决了 **多个联系紧密组件模块间便捷共享状态的问题** ,但有时也会遇到需要共享根组件 Props 的问题,**这种不可修改的状态不适合一并塞到 `StoreContext` 里**,我们新建一个 `PropsContext` 注入根组件的 Props: ```tsx const PropsContext = React.createContext(null) const AppProvider: React.FC = props => { return React.useMemo(() => ( ), [props]) }; ``` #### 结合项目数据流 参考 [react-redux hooks](https://github.com/reduxjs/react-redux/blob/master/docs/api/hooks.md)。 ### debounce 优化 比如当输入框频繁输入时,为了保证页面流畅,我们会选择在 `onChange` 时进行 `debounce` 。然而在 Function Component 领域中,我们有更优雅的方式实现。 > 其实在 Input 组件 `onChange`  使用 `debounce` 有一个问题,就是当 Input 组件 **受控** 时, `debounce` 的值不能及时回填,导致甚至无法输入的问题。 我们站在 Function Component 思维模式下思考这个问题: 1. React [scheduling](https://github.com/dt-fe/weekly/blob/v2/099.%E7%B2%BE%E8%AF%BB%E3%80%8AScheduling%20in%20React%E3%80%8B.md) 通过智能调度系统优化渲染优先级,我们其实不用担心频繁变更状态会导致性能问题。 2. 如果联动一个文本还觉得慢吗? `onChange` 本不慢,大部分使用值的组件也不慢,没有必要从 `onChange` 源头开始就 `debounce` 。 3. 找到渲染性能最慢的组件(比如 iframe 组件),**对一些频繁导致其渲染的入参进行 `useDebounce`** 。 下面是一个性能很差的组件,引用了变化频繁的 `text` (这个 `text` 可能是 `onChange` 触发改变的),我们利用 `useDebounce` 将其变更的频率慢下来即可: ```typescript const App: React.FC = ({ text }) => { // 无论 text 变化多快,textDebounce 最多 1 秒修改一次 const textDebounce = useDebounce(text, 1000) return useMemo(() => { // 使用 textDebounce,但渲染速度很慢的一堆代码 }, [textDebounce]) }; ``` 使用 `textDebounce` 替代 `text` 可以将渲染频率控制在我们指定的范围内。 ### useEffect 注意事项 事实上,`useEffect` 是最为怪异的 Hook,也是最难使用的 Hook。比如下面这段代码: ```tsx useEffect(() => { props.onChange(props.id) }, [props.onChange, props.id]) ``` 如果 `id` 变化,则调用 `onChange`。但如果上层代码并没有对 `onChange` 进行合理的封装,导致每次刷新引用都会变动,则会产生严重后果。我们假设父级代码是这么写的: ```tsx class App { render() { return this.setState({ id })} /> } } ``` 这样会导致死循环。虽然看上去 `` 只是将更新 id 的时机交给了子元素 ``,但由于 `onChange` 函数在每次渲染时都会重新生成,因此引用总是在变化,就会出现一个无限死循环: 新 `onChange` -> `useEffect` 依赖更新 -> `props.onChange` -> 父级重渲染 -> 新 `onChange`... 想要阻止这个循环的发生,只要改为 `onChange={this.handleChange}` 即可,**`useEffect` 对外部依赖苛刻的要求,只有在整体项目都注意保持正确的引用时才能优雅生效。** 然而被调用处代码怎么写并不受我们控制,这就导致了不规范的父元素可能导致 React Hooks 产生死循环。 因此在使用 `useEffect` 时要注意调试上下文,注意父级传递的参数引用是否正确,如果引用传递不正确,有两种做法: 1. 使用 [useDeepCompareEffect](https://github.com/streamich/react-use/blob/master/docs/useDeepCompareEffect.md) 对依赖进行深比较。 2. 使用 `useCurrentValue` 对引用总是变化的 props 进行包装: ```tsx function useCurrentValue(value: T): React.RefObject { const ref = React.useRef(null); ref.current = value; return ref; } const App: React.FC = ({ onChange }) => { const onChangeCurrent = useCurrentValue(onChange) }; ``` `onChangeCurrent` 的引用保持不变,但每次都会指向最新的 `props.onChange`,从而可以规避这个问题。 ## 总结 如果还有补充,欢迎在文末讨论。 如需了解 Function Component 或 Hooks 基础用法,可以参考往期精读: - [精读《React Hooks》](https://github.com/dt-fe/weekly/blob/v2/079.%E7%B2%BE%E8%AF%BB%E3%80%8AReact%20Hooks%E3%80%8B.md) - [精读《怎么用 React Hooks 造轮子》](https://github.com/dt-fe/weekly/blob/v2/080.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%80%8E%E4%B9%88%E7%94%A8%20React%20Hooks%20%E9%80%A0%E8%BD%AE%E5%AD%90%E3%80%8B.md) - [精读《useEffect 完全指南》](https://github.com/dt-fe/weekly/blob/v2/096.%E7%B2%BE%E8%AF%BB%E3%80%8AuseEffect%20%E5%AE%8C%E5%85%A8%E6%8C%87%E5%8D%97%E3%80%8B.md) - [精读《Function Component 入门》](https://github.com/dt-fe/weekly/blob/v2/104.精读《Function%20Component%20入门》.md) > 讨论地址是:[精读《React Hooks 最佳实践》 · Issue #202 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/202) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/121.精读《前端与 BI》.md ================================================ ## 简介 商业智能(Business Intelligence)简称 BI,即通过数据挖掘与分析找到商业洞察,助力商业成功。 一个完整的 BI 链路包含数据采集、数据清洗、数据挖掘、数据展现,其本质是对数据进行多维分析。前端的主要工作在数据展现环节,由于展示方式繁多、分析模型复杂且数据量大,前端环节的复杂度很高。 在 BI 做前端非常有挑战,开发者需要充分理解数据概念,而本身复杂度较高的可视化建站也只是 BI 的基础能力,想要建设 BI 的上层能力,比如探索式分析和数据洞察,都需要在前后端引入更复杂的计算模型。 本文作为一个引子,简单介绍笔者做 BI 的经验,后面如果有机会再写一个系列文章对细节进行阐述。 ## 精读 国内目前处于 BI 1.0 阶段,也就是报表阶段,因此笔者将阐述这个阶段 BI 的核心开发概念。 > BI 2.0 探索式分析阶段是国内数据分析最前沿领域,这部分等开发完成后再分享。 BI 1.0 阶段的核心概念包括 **数据集、渲染引擎、数据模型、可视化** 这四个技术模块。 ### 数据集 数据集即数据的集合,在 BI 领域更多指一种标准化的数据结构。 任何数据都可以封装成数据集,比如 txt 文本、excel、mysql 数据库等等。 数据集的基本形态是二维表格,列头表示字段,每一行就是一份数据,数据展示就是通过对这些数据字段进行多维度分析。 #### 数据集导入 一般来说数据集导入有两种方式,分别是本地文件上传与数据库链接。本地文件上传又分为多种文件类型处理,比如对 excel 的解析,可能还包括数据清洗;数据库链接分析可视化导入与 SQL 输入。 可视化导入需要提前对数据库进行结构分析,绘制出表结构与字段结构,不用理解 SQL 也可以进行可视化操作。 SQL 输入可以利用 [monaco-editor](https://github.com/microsoft/monaco-editor) 等 web 代码编辑器作为输入框,最好能结合智能提示提高 sql 编写效率。sql 智能提示可以参考往期精读 [精读《手写 SQL 编译器 - 智能提示》](https://github.com/dt-fe/weekly/blob/v2/085.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E6%99%BA%E8%83%BD%E6%8F%90%E7%A4%BA%E3%80%8B.md)。 #### 数据集建模 数据集建模一般包含 **维度度量建模、字段配置、层系建模**。 维度度量建模需要智能分析出字段属于维度还是度量,一般会结合字段实际的值或者字段名来智能判断字段类型,如果数据库信息中已存储了字段类型,就可以 100% 准确归类。 字段配置即对字段进行增删或修改,还可以新增聚合字段或对比字段。 聚合字段是指将一个字段表达式封装为一个新字段,这里也会用到一个简单的 sql 编辑器,只需要支持四则运算、字段提示、以及一些基本函数的组合即可。 对比字段是指新增的字段是基于已有字段在某个时间周期内的对比,比如对 UV 字段的年同比就可以封装为一个对比字段。对比字段在前端技术上没有什么难度,仅需理解概念即可。 ### 渲染引擎 渲染引擎包括了对报表进行编辑与渲染的引擎,理论上可以合二为一。 渲染引擎的重要模块包括:画布拖拽、组件编辑、事件中心。 画布拖拽其实包含了组件自定义开发流程,到 CDN 发布、CDN 加载、组件拖拽、画布排版等一系列技术点,每个点展开都有写不完的细节,但好在这套功能属于通用建站基础功能点,本文就不再赘述。 组件编辑中,基本属性的编辑与属于通用建站领域的表单模型范畴,一般通过 UISchema 来描述通用表单,这块也不再赘述。组件编辑的另一部分就是数据编辑,这部分在后面数据模型章节里详细讲。 事件中心是渲染引擎部分,此功能在编辑状态需要禁用。这个功能可以实现图表联动、上卷下钻等数据能力。一个通用事件中心一般包括 **事件触发** 与 **事件响应** 两部分,基本结构如下: ```typescript interface Event { trigger: | { type: 'callback'; callbackName: string; } | { type: 'listener'; eventName: string; } | { type: 'system'; name: string; }; action: | { type: 'dispatch'; eventName: string; } | { type: 'jumpUrl'; url: string; } } ``` `trigger` 即事件触发,包括基本的系统事件 `system`,比如定时器或者初始化自动触发;组件的回调 `callback` 比如当按钮被点击时;事件监听 `listener` 比如另一个事件被触发时,这个事件可能来自于 `action`。 `action` 即事件响应,包括基本的事件触发 `dispatch`,可以触发其他事件,可以构成一个事件链路;其他的 `action` 就是数据相关,可以用来做条件联动、字段联动、数据集联动等等,因为实现各异这里不做介绍。 事件机制还需要支持值传递,即事件触发源的值可以传递到事件响应方。值传递可以在触发源内部进行,比如当触发源是回调函数时,函数参数就自然作为值传递过去,触发源通过 `...args` 方式接收。 #### 数据钻取 配置了层系的字段都可以进行数据钻取。层系可以在数据集配置,也可以在报表编辑页配置,可以理解为一个顺序有关的文件夹,将文件夹作为字段使用时,默认生效的是第一个子元素,之后可以按照顺序分别进行下钻。 比如 “地区” 层系包含了国家、省、市、区,那么就可以按照这个层级进行数据上卷下钻。 如果一个字段是层系字段,图表需要有对应的操作区域进行上卷下钻,数据编辑区域也可以进行同样操作。数据钻取的计算过程不在图表内部处理,而是触发一个状态后,由渲染引擎将这个层系字段实例状态改为下钻到第 N 层,并且每下钻一次就多拿到一列的数据,由图表组件进行下钻展示。 一般来说下钻后数据仍是全量的,有时候为了避免数据量过大,比如在柱状图点击某个柱子进行下钻,只想看这个柱子下钻后的数据:比如 2017、2018、2019 年三年的数据,下钻到月后数据量是 3 x 12 = 36 条,但如果仅在 2019 年进行下钻,只想看 2019 年的 12 条数据,可以转化为下钻 + 筛选条件的模式:全局下钻展开后 36 条,在 2019 年上点击下钻后,增加一个筛选条件(年 = 2019),这样就达到了效果,整个流程对图表组件是无感知的。 ### 数据模型 与通用表单模型 UISchema 相对应,数据模型笔者称之为 CubeSchema,因为 BI 领域对数据的多维处理模型成为 Cube 立方体,数据配置即表示如何对这个立方体进行查询,因此其配置表单成为 CubeSchema。 不管是探索式分析还是 BI 1.0 的报表阶段,数据模型的基本概念是通用的(探索式分析固定了行列,且增加了标记):将字段放置到不同的区域,这些区域的划分方式可以按照功能:横轴、纵轴;按照概念:维度、度量;按照探索分析思路:固化为行、列等等。 这块可能涉及到的技术点有:拖拽、批量选择+拖拽、双击后按照维度度量自动添加、图表切换后区域字段自动迁移、对字段拖拽的系列配置:限制数量、限制类型、限制数据集、是否重复等等。 拖拽可以用 [react-beautiful-dnd](https://github.com/atlassian/react-beautiful-dnd) 等库,与渲染引擎拖拽方案基本类似,遇到有层系的数据集还需支持嵌套层级的拖拽。 图表切换后字段迁移,可以将每个拖拽区域设置若干类型: ```json { "dataType": ["dimension"] } ``` 这样在切换后,维度类型的字段可以自动迁移到维度类型区域,如果对应区域字段数量达到了 `limit` 限制,就继续填充到下一个区域,直到字段用尽或区域填充完为止。 如果在探索式分析场景里,需要提前对字段进行维度度量建模,在切换时按照图表情况进行相应的处理。比如折线图切换到表格的情况:折线图是天然一个维度(主轴) + N 个度量的场景,表格是天然两个维度(行、列)+ 1 个度量的场景(也可以支持多个,对单元格进行再切分即可),那么从折线图切换到表格时,度量就会落到标记的文本区域;如果从拥有行和列的表格切换到柱状图(之所以无法切换到折线图,是因为表格的度量值一般是离散的,而折线图度量值一般是连续的),表格的行与列的字段会落到柱状图的维度轴,表现效果是对维度轴进行下钻。 > [精读《Tableau 探索式模型》](https://github.com/dt-fe/weekly/blob/v2/117.%E7%B2%BE%E8%AF%BB%E3%80%8ATableau%20%E6%8E%A2%E7%B4%A2%E5%BC%8F%E6%A8%A1%E5%9E%8B%E3%80%8B.md) 了解更多探索式分析。 数据模型还包括数据分析相关配置,比如设置对比字段,或者均值线等分析功能。这些数据计算工作放在后端,前端需要将配置项整理到取数接口中,并按照数据驱动的方式展现。 对于对比字段等 “拓展字段” 的分析功能,可以拓展通用取数接口,图表组件无感知,相当于多添加了几个隐藏字段;去特殊值等对标准数据进行操作的情况图表组件也无需感知。 聚类、均值线等需要图表组件额外展示的部分抽象为一套固定的数据格式透传给图表组件,由图表组件自行处理。 可以看出来,都是取数 + 展示,普通的前端业务与 BI 业务开发的区别: 普通前端业务是以业务逻辑为核心的,根据业务需要确定接口格式;BI 业务是以数据为核心的,围绕数据计算模型确定一套固定的接口格式,取数不依赖组件,所有组件对标准数据都有对应的展现。 ### 可视化 与普通可视化组件不同,BI 可视化组件需要对接 CubeSchema 模型,同时还要支持 **大数据性能优化、边界数据展示优化、交互响应**。 对接 CubeSchema 即统一对接二维表格的数据,大部分组件都是二维以上结构展示,因此对接起来并不困难,有一些一维数据结构的组件比如单指标块就要舍弃其中的某一维,需要确定一套规则。 二维以上部分是较为通用的,虽然计算模型是基于 Cube N 维的,但组件可以通过标准轴进行多维度展开,或者说下钻来实现类似效果。对于折线图来说,轴的含义有限,可以用分面的方式展示多维数据。当然也有一些组件只适合展示特定维度数量的数据。 #### 大数据性能优化 可视化组件特别需要关注性能优化,因为 BI 查询出的数据量可能非常大,特别是多层下钻或基于地理的数据。 技术手段包括 GPU 渲染、缓存 canvas、多线程运算等,业务手段包括数据抽样、按需渲染可视区域、限制数据条数等等。 #### 边界数据展示优化 永远不知道数据集会给出怎样的数据,因此 BI 边界情况特别多,可能点非常密集,也可能丢失一些数据导致渲染异常。图表组件需要利用避让算法将密集的数据打散或着色,目的是为了容易阅读,对于丢失的异常数据也要有保护性的补全机制。 #### 交互响应 包括上卷下钻、点选、圈选、高亮等交互操作,这些操作反馈到渲染引擎导致数据变化并将新的数据灌入图表组件。 业务逻辑上这些交互操作并不复杂,难点在使用的可视化库是否有这个能力,以及如何统一交互行为。 ## 总结 BI 领域的四大方向:数据集、渲染引擎、数据模型与可视化都有许多可以做深的技术点,每一块都需要深入沉淀几年技术经验才能做好,需要大量优秀人才通力协作才有可能做好。 目前我们在阿里数据中台正在打造一款面向未来的优秀 BI 工具,如果 BI 领域让你觉得有挑战,随时欢迎你的加入,联系邮箱:ziyi.hzy@alibaba-inc.com > 讨论地址是:[精读《前端与 BI》 · Issue #208 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/208) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/123.精读《用 Babel 创造自定义 JS 语法》.md ================================================ ## 1 引言 在写这次精读之前,我想谈谈前端精读可以为读者带来哪些价值,以及如何评判这些价值。 前端精读已经写到第 123 篇了,大家已经不必担心它突然停止更新,因为我已养成每周写一篇文章的习惯,而读者也养成了每周看一篇的习惯。所以我想说的其实是一种更有生命力的自媒体运作方式,定期更新。一个定期更新的专栏比一个不不定期更新的专栏更有活力,也更受读者喜爱,因为读者能看到文章之间的联系,跟随作者一起成长。个人学习也是如此,养成定期学习的习惯,比在培训班突击几个月更有用,学会在生活中规律的学习,甚至好过读几年名牌大学。 前端精读想带给读者的不仅是一篇篇具体的内容和知识,知识是无穷无尽的,几万篇文章也说不完,但前端精读一直沿用了“引言-概述-精读-总结”这套学习模式,无论是前端任何领域的问题,还是对人生和世界的思考都可以套用,希望能为读者提供一套学习思维框架,让你能学习到如何找到好的文章,以及如何解读它。 至今已经选择了许多源码解读的题材,与培训思维的源码解读不同,我希望你不要带着面试的目的学习源码,因为这样会让你只局限在 react、vue 这种热门的框架上。前端精读选取的框架类型之所以广泛,是希望你能静下心来,吸取不同框架风格与作者的优势,培养一种优雅编码的气质。 进入正题,这次选择的文章 [《用 Babel 创造自定义 JS 语法》](https://lihautan.com/creating-custom-javascript-syntax-with-babel/) 也是培养编码气质的一类文章,虽然对你实际工作用处不大,但这篇文章可以培养几个程序员梦寐以求的能力:深入理解 Babel、深入理解框架拓展机制。理解一个复杂系统或培养框架思维不是一朝一夕的,但持续阅读这种文章可以让你越来越接近掌握它。 之所以选择 Babel,是因为 Babel 处理的一直是语法树相关的底层逻辑,编译原理是程序世界的基座之一,拥有很大的学习价值。所以我们的目的并不是像文章标题说的 - 创造一个自定义 JS 语法,因为你创造的语法只会让 JS 复杂体系更加混乱,但可以让你理解 Babel 解析标准 JS 语法的原理,以及看待新语法提案时,拥有从实现层面思考的能力。 最后,不必多说,能重温 Babel 经典的插件机制,你可以发现 Babel 的插件拓展机制和 Antrl4 很像,在设计业务模块拓展方案时也可以作为参考。 ## 2 概述 我们要利用 Babel 实现 `function @@` 的新语法,用 `@@` 装饰的函数会自动柯里化: ```js // '@@' makes the function `foo` curried function @@ foo(a, b, c) { return a + b + c; } console.log(foo(1, 2)(3)); // 6 ``` 可以看到,`function @@ foo` 描述的函数 `foo` 支持 `foo(1, 2)(3)` 这种柯里化调用。 实现方式分为两步: 1. Fork babel 源码。 2. 创建一个 babel 转换器插件。 不要畏惧这些步骤,“如果你读完了这篇文章,你将成为同事眼中的 Babel 大神” - 原文。 首先 Fork babel 源码到本地,执行下面的命令可以初始化并编译 babel: ```bash $ make bootstrap $ make build ``` babel 使用 [Makefile](https://opensource.com/article/18/8/what-how-makefile) 执行编译命令,并且采用 monorepo 管理,我们这次要关心的是 `package/babel-parser` 这个模块。 ### 词法 首先要了解词法知识,更详细的可以阅读原文或精读之前的一篇系列文章:[精读《词法分析》](https://github.com/dt-fe/weekly/blob/v2/064.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%8D%E6%B3%95%E5%88%86%E6%9E%90%E3%80%8B.md)。 要解析语法,首先要进行词法分析。任何语法输入都是一个字符串,比如 `function @@ foo(a, b, c)`,词法分析就是要将这个长度为 24 的字符拆分为一个个有语义的单词片段:`function` `@@` `foo` `(` `a` .. 由于 `@@` 是我们创造的语法,所以我们第一个任务就是让 babel 词法分析可以识别它。 下面是 `package/babel-parser` 的文件结构: ```text - src/ - tokenizer/ - parser/ - plugins/ - jsx/ - typescript/ - flow/ - ... - test/ ``` 可以看到,分为词法分析 `tokenizer`,语法分析 `parser`,以及支持一些特殊语法的插件,以及测试用例 `test`。 推荐使用 **Test-driven development (TDD) - 测试驱动开发的方式**,就是先写测试用例,再根据测试用例开发。这种开发方式在后端或者 babel 这种底层框架很常见,因为 TDD 方式开发的逻辑能保证测试用例 100% 覆盖,同时先看测试用例也是个很好的切面编程思维。 ```js // packages/babel-parser/test/curry-function.js import { parse } from '../lib'; function getParser(code) { return () => parse(code, { sourceType: 'module' }); } describe('curry function syntax', function() { it('should parse', function() { expect(getParser(`function @@ foo() {}`)()).toMatchSnapshot(); }); }); ``` 可以利用 jest 直接测试这段代码: ```bash BABEL_ENV=test node_modules/.bin/jest -u packages/babel-parser/test/c ``` 结果会出现如下报错: ```text SyntaxError: Unexpected token (1:9) at Parser.raise (packages/babel-parser/src/parser/location.js:39:63) at Parser.raise [as unexpected] (packages/babel-parser/src/parser/util.js:133:16) at Parser.unexpected [as parseIdentifierName] (packages/babel-parser/src/parser/expression.js:2090:18) at Parser.parseIdentifierName [as parseIdentifier] (packages/babel-parser/src/parser/expression.js:2052:23) at Parser.parseIdentifier (packages/babel-pars ``` 第 9 个字符就是 `@`,说明程序现在还不支持函数前面的 `@` 解析。我们还可以在错误堆栈中找到报错位置,并把当前 Token 与下一个 Token 打印出来: ```js // packages/babel-parser/src/parser/expression.js parseIdentifierName(pos: number, liberal?: boolean): string { if (this.match(tt.name)) { // ... } else { console.log(this.state.type); // current token console.log(this.lookahead().type); // next token throw this.unexpected(); } } ``` `this.state.type` 代表当前 Token,`this.lookahead().type` 表示下一个 Token。`lookahead` 是词法分析的专有词,表示向后查看。打印之后,我们会发现输出了两个 `@` Token: ```js TokenType { label: '@', // ... } ``` 下一步,我们需要让 babel 词法分析识别 `@@` 这个 Token。首先需要注册这个 Token: ```js // packages/babel-parser/src/tokenizer/types.js export const types: { [name: string]: TokenType } = { // ... at: new TokenType('@'), atat: new TokenType('@@'), }; ``` 注册了之后,我们要在遍历 Token 时增加判断 “如果当前字符是 `@` 且下一个字符也是 `@`,则整体构成了 `@@` Token 并且光标向后移动两格”: ```js // packages/babel-parser/src/tokenizer/index.js getTokenFromCode(code: number): void { switch (code) { // ... case charCodes.atSign: // if the next character is a `@` if (this.input.charCodeAt(this.state.pos + 1) === charCodes.atSign) { // create `tt.atat` instead this.finishOp(tt.atat, 2); } else { this.finishOp(tt.at, 1); } return; // ... } } ``` 再次运行测试文件,输出变成了: ```js // current token TokenType { label: '@@', // ... } // next token TokenType { label: 'name', // ... } ``` 到这一步,已经能正确解析 `@@` Token 了。 ## 语法 词法已经可以将 `@@` 解析为 `atat` Token,下一步我们就要利用这个 Token,让生成的 AST 结构中包含柯里化函数的信息,并利用 babel 插件在解析时实现柯里化功能。 首先我们可以在 [Babel AST explorer](https://lihautan.com/babel-ast-explorer/#?eyJiYWJlbFNldHRpbmdzIjp7InZlcnNpb24iOiI3LjYuMCJ9LCJ0cmVlU2V0dGluZ3MiOnsiaGlkZUVtcHR5Ijp0cnVlLCJoaWRlTG9jYXRpb24iOnRydWUsImhpZGVUeXBlIjp0cnVlfSwiY29kZSI6ImZ1bmN0aW9uICogZm9vKCkge30ifQ==) 看到 AST 解析的结构,我们拿 generator 函数测试,因为这个函数结构与柯里化函数类似: ![](https://img.alicdn.com/tfs/TB1H4HvioT1gK0jSZFrXXcNCXXa-1180-442.png) 可以看到,babel 通过 `generator` `async` 属性来标识函数是否为 generator 或者 async 函数。同理,增加一个 `curry` 属性就可以实现第一步了: ![](https://img.alicdn.com/tfs/TB1c8jviXP7gK0jSZFjXXc5aXXa-1180-464.png) 要实现如上效果,只需在词法分析 `parser/statement` 文件的 `parseFunction` 处新增 `atat` 解析即可: ```js // packages/babel-parser/src/parser/statement.js export default class StatementParser extends ExpressionParser { // ... parseFunction( node: T, statement?: number = FUNC_NO_FLAGS, isAsync?: boolean = false ): T { // ... node.generator = this.eat(tt.star); node.curry = this.eat(tt.atat); } } ``` `eat` 是吃掉的意思,实际上可以理解为吞掉这个 Token,这样做有两个效果:1. 为函数添加了 `curry` 属性 2. 吞掉了 `@@` 标识,保证所有 Token 都被识别是 AST 解析正确的必要条件。 关于递归下降语法分析的更多知识,可以参考 [精读《手写 SQL 编译器 - 语法分析》](https://github.com/dt-fe/weekly/blob/v2/066.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%AD%E6%B3%95%E5%88%86%E6%9E%90%E3%80%8B.md),或者阅读原文。 我们再次执行测试函数,发现测试通过了,一切都在预料中。 ## babel 插件 现在我们得到了标记了 `curry` 的 AST,那么最后需要一个 babel 解析插件,实现柯里化。 首先我们通过修改 babel 源码的方式实现的效果,是可以转化为自定义 babel parser 插件的: ```js // babel-plugin-transformation-curry-function.js import customParser from './custom-parser'; export default function ourBabelPlugin() { return { parserOverride(code, opts) { return customParser.parse(code, opts); }, }; } ``` 这样就可以实现修改 babel 源码一样的效果,这也是做框架常用的插件机制。 其次我们要理解如何实现柯里化。柯里化可以通过柯里函数包装后实现: ```js function currying(fn) { const numParamsRequired = fn.length; function curryFactory(params) { return function (...args) { const newParams = params.concat(args); if (newParams.length >= numParamsRequired) { return fn(...newParams); } return curryFactory(newParams); } } return curryFactory([]); } // from function @@ foo(a, b, c) { return a + b + c; } // to const foo = currying(function foo(a, b, c) { return a + b + c; }) ``` 柯里化函数通过构造参数数量相关的递归,当参数传入不足时返回一个新函数,并持久化之前传入的参数,最后当参数齐全后一次性调用函数。 我们需要做的是,将 `@@ foo` 解析为 `currying()` 函数包裹后的新函数。 下面就是我们熟悉的 babel 插件部分了: ```js // babel-plugin-transformation-curry-function.js export default function ourBabelPlugin() { return { // ... visitor: { FunctionDeclaration(path) { if (path.get('curry').node) { // const foo = curry(function () { ... }); path.node.curry = false; path.replaceWith( t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(path.get('id.name').node), t.callExpression(t.identifier('currying'), [ t.toExpression(path.node), ]) ), ]) ); } }, }, }; } ``` `FunctionDeclaration` 就是 AST 的 visit 钩子,这个钩子在执行到函数时被触发,我们通过 `path.get('curry')` 拿到 **柯里化函数**,并利用 `replaceWith` 将这个函数构造为一个被 `currying` 函数包裹的新函数。 剩下最后一个问题:`currying` 函数源码放在哪里。 第一种方式,创建类似 `babel-plugin-transformation-curry-function` 这样的插件,在 babel 解析时将 `currying` 函数注册到全局,这是全局思维的方案。 第二种是模块化解决方案,创建一个自定义的 `@babel/helpers`,注册一个 `currying` 标识: ```js // packages/babel-helpers/src/helpers.js helpers.currying = helper("7.6.0")` export default function currying(fn) { const numParamsRequired = fn.length; function curryFactory(params) { return function (...args) { const newParams = params.concat(args); if (newParams.length >= numParamsRequired) { return fn(...newParams); } return curryFactory(newParams); } } return curryFactory([]); } `; ``` 在 visit 函数使用 `addHelper` 方式拿到 `currying`: ```js path.replaceWith( t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(path.get('id.name').node), t.callExpression(this.addHelper("currying"), [ t.toExpression(path.node), ]) ), ]) ); ``` 这样在 babel 转换后,就会自动 import helper,并引用 helper 中导出的 `currying`。 最后原文末尾留下了一些延伸阅读内容,感兴趣的同学可以 [点击到原文](https://lihautan.com/creating-custom-javascript-syntax-with-babel/)。 ## 3 精读 读完这篇文章,相信你不仅对 babel 插件有了更深刻的认识,而且还掌握了如何为 js 添加新语法这种黑魔法。 我来帮你从 babel 这篇文章总结一些编程模型和知识点,借助 babel 创造自定义语法的实例,加深对它们的理解。 ### TDD Test-driven development 即测试驱动的开发模式。 从文章的例子可以看出,创造一个新语法,可以先在测试用例先写上这个语法,通过执行测试命令通过报错堆栈一步步解决问题。这种方式开发可以让测试覆盖率更高,目的更专注,更容易保障代码质量。 ### 联想编程 联想编程不属于任何编程模型,但从简介的思路来看,作者把 “为 babel 创建一个新 js 语法” 看作一种探案式探索过程,通过错误堆栈和代码阅读,一步一步通过合理联想实现最终目的。 在 AST 那一节,还借助了 [Babel AST explorer](https://lihautan.com/babel-ast-explorer/#?eyJiYWJlbFNldHRpbmdzIjp7InZlcnNpb24iOiI3LjYuMCJ9LCJ0cmVlU2V0dGluZ3MiOnsiaGlkZUVtcHR5Ijp0cnVlLCJoaWRlTG9jYXRpb24iOnRydWUsImhpZGVUeXBlIjp0cnVlfSwiY29kZSI6ImZ1bmN0aW9uICogZm9vKCkge30ifQ==) 工具查看 AST 结构,通过联想到 generator 函数找到类似的 AST 结构,并找到拓展 AST 的突破口。 随着解决问题的不同,联想方式也不同,如果能够举一反三,对不同场景都能合理的联想,才算是具备了技术专家的软素质。 ### 词法、语法分析 词法、语法分析属于编译原理的知识,理解词法拆分、递归下降,可以帮助你技术走的更深。 不论是 Babel 插件的使用、还是 Babel 增加自定义 JS 语法,都要具备基本编译原理知识。编译原理知识还能帮助你开发在线编辑器,做智能语法提示等等。 ### 插件机制 如下是 babel 自定义 parser 的插件拓展方式: ```js export default function ourBabelPlugin() { return { parserOverride(code, opts) { return customParser.parse(code, opts); }, }; } ``` 这只是插件拓展的一种,有申明式,也有命令式;有用 JS 书写的,也有用 JSON 书写的。babel 选择了通过对象方式拓展,是比较适合对 AST 结构统一处理的。 做框架首先要确定接口规范,比如 parser,先按照接口规范实现一套官方解析,对接时按照接口进行对接,就可以自然而然被用户自定义插件替代了。 可以参考的文章: [精读《插件化思维》](https://github.com/dt-fe/weekly/blob/v2/053.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%8F%92%E4%BB%B6%E5%8C%96%E6%80%9D%E7%BB%B4%E3%80%8B.md) ### 柯里化 柯里化是面试经常考察的一个知识点,我们能学到的有两点:理解递归、理解如何将函数变成柯里化。 这里再拓展一下,我们还可以想到 JS 尾递归优化。如何快速写一个支持尾递归的函数? ```js const fn = tailCallOptimize(() => { if ( /* xxx */ ) { fn() } }) ``` 通过封装 `tailCallOptimize` 函数,可以很方便的构造一个支持尾递归的函数,这个函数可以这么写: ```js export function tailCallOptimize(f: T): T { let value: any; let active = false; const accumulated: any[] = []; return function accumulator(this: any) { accumulated.push(arguments); if (!active) { active = true; while (accumulated.length) { value = (f as any).apply(this, accumulated.shift()); } active = false; return value; } }; } ``` 感兴趣的读者可以在评论里解释一下这个函数的原理。 ### AST visit 遍历 AST 树常采用的方案是做一个遍历器 visitor,所以在遍历过程中进行拓展常采用 babel 这种方式: ```js return { // ... visitor: { FunctionDeclaration(path) { if (path.get('curry').node) { // const foo = curry(function () { ... }); path.node.curry = false; path.replaceWith( t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(path.get('id.name').node), t.callExpression(t.identifier('currying'), [ t.toExpression(path.node), ]) ), ]) ); } }, }, }; ``` `visitor` 下每一个 key 名都是遍历过程中的拓展点,比如上面的例子,我们可以对函数定义位置进行拓展和改写。 ### 内置函数注册 babel 提供了两种内置函数注册方式,一种类似 polyfill,在全局注册 window 级的变量,另一种是模块化的方式。 除此之外,可以学习的是 babel 通过 `this.addHelper("currying")` 这种插件拓展方式,在编译后会自动从 helper 引入对应的模块,前提是 `@babel/helper` 需要注册 `currying` 这个 helper。 babel 将编译过程隐藏了起来,通过一些高度封装的函数调用,以较为语义化方式书写插件,这样写出来的代码也容易理解。 ## 4 总结 《用 Babel 创造自定义 JS 语法》这篇文章虽然说的是 babel 相关知识,但可以从中提取到许多通用知识,这就是现在还去理解 babel 的原因。 从某个功能点为切面,走一遍框架的完整流程是一种高效的进阶学习方式,如果你也有看到类似这样的文章,欢迎推荐出来。 > 讨论地址是:[精读《用 Babel 创造自定义 JS 语法》 · Issue #210 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/210) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/124.精读《用 css grid 重新思考布局》.md ================================================ ## 1 引言 Flex 与 Grid 相比就像功能键盘和触摸屏。触摸屏的控制力相比功能键盘来说就像是降维打击,因为功能键盘只能上下左右控制(x、y 轴),而触摸屏打破了布局障碍,直接从(z 轴)触达,这样 **无论 UI 内部布局再复杂,都可以通过 touch 直接定位。** Flex 是一维布局方式,我们需要不断嵌套 Div 才能形成复杂结构,而一旦布局产生了变化,原有嵌套结构如果不能 “兼容变化” 到新结构,代码就需要重构。而 Grid 就像触摸屏一样,可以二维布局,即便布局方式做了翻天覆地的调整,也仅需少量修改就能适配。 这就是这次精读 [用 css grid 重新思考布局](https://www.freecodecamp.org/news/css-grid-changes-how-we-can-think-about-structuring-our-content/) 的原因,理解这个革命性布局技术给布局,甚至代码逻辑组织带来的变化。 ## 2 概述 作者首先抛出了 Flex 的问题,其实是 `block` `float` `flex` 这三种布局模式的通病: - 布局结构由 Div 层级结构描述,导致 Div 层级复杂且遇到结构变更时难以维护。 - 定制能力弱。Flex 布局有一些不受控制的智能设定,比如宽度 50% 的子元素会被同级元素挤到 50% 以下,这种智能化在某些场景是需要的,但由于没有提供像 Grid 的 `minmax` 之类的 API,所以定制型不足。 ![](https://img.alicdn.com/tfs/TB1X8Wvi4D1gK0jSZFyXXciOVXa-608-324.png) 举个例子,上图的结构用 Flex 描述可能是这样的: ```html

Ramsey Harper

Graphic Designer

Lorem ipsum dolor sit amet consectetur adipisicing elit. Facere a tempore, dignissimos odit accusantium repellat quidem, sit molestias dolorum placeat quas debitis ipsum esse rerum?

``` 利用 HTML 嵌套结构,我们将图形纵向分成两大块,然后在每块内部继续嵌套划分布局,这是最经典的布局行为了。 ![](https://img.alicdn.com/tfs/TB17_Oqi2b2gK0jSZK9XXaEgFXa-608-324.jpg) 样式文件里,我们需要对每层布局进行描述,同时支持多分辨率弹性布局,包括顶层 `card` 容器在内的一些样式需要做一定调整: ```scss .card { width: 80%; margin: 0 auto; display: flex; flex-direction: column; max-width: 600px; background: #005e9b; flex-basis: 250px; color: white; padding: 2em; text-align: center; } .profile-info { font-weight: 300; opacity: 0.7; } .profile-sidebar { margin-right: 2em; text-align: center; } .profile-name { letter-spacing: 1px; font-size: 2rem; margin: 0.75em 0 0; line-height: 1; } .profile-name::after { content: ""; display: block; width: 2em; height: 1px; background: #5bcbf0; margin: 0.5em auto 0.65em; opacity: 0.25; } .profile-position { text-transform: uppercase; font-size: 0.875rem; letter-spacing: 3px; margin: 0 0 2em; line-height: 1; color: #5bcbf0; } .profile-img { max-width: 100%; border-radius: 50%; border: 2px solid white; } .social-list { list-style: none; justify-content: space-evenly; display: flex; min-width: 125px; max-width: 175px; margin: 0 auto; padding: 0; } .social-link { color: #5bcbf0; opacity: 0.5; } .social-link:hover, .social-link:focus { opacity: 1; } .bio { padding: 2em; display: flex; flex-direction: column; justify-content: center; } @media (min-width: 450px) { .bio { text-align: left; max-width: 350px; } } .bio-title { color: #0090d1; font-size: 1.25rem; letter-spacing: 1px; text-transform: uppercase; line-height: 1; margin: 0; } .bio-body { color: #555; } .profile { display: flex; align-items: flex-start; } @media (min-width: 450px) { .card { flex-direction: row; text-align: left; } .profile-name::after { margin-left: 0; } } ``` 让我们看看 Grid 是怎么做的吧!Grid 有许多 API,我们重点看 `grid-template-areas` 这个属性,利用它,我们可以不关心模块的 HTML 结构,直接平铺方式描述: ```html

Ramsey Harper

Graphic Designer

Lorem ipsum dolor sit amet consectetur adipisicing elit. Facere a tempore, dignissimos odit accusantium repellat quidem, sit molestias dolorum placeat quas debitis ipsum esse rerum?

``` 可以看到,使用 Grid 可以将 UI 结构与 HTML 结构分离,HTML 结构仅描述包含关系,我们只需在样式文件中描述具体 UI 结构。 样式文件只截取 Grid 相关部分: ```scss .card { width: 80%; margin: 0 auto; display: flex; flex-direction: column; max-width: 600px; background: #005e9b; flex-basis: 250px; color: white; padding: 2em; text-align: left; display: grid; grid-template-columns: 1fr 3fr; grid-column-gap: 2em; grid-template-areas: "image name" "image position" "social description"; } .profile-name { grid-area: name; } .profile-position { grid-area: position; } .profile-info { grid-area: description; } .profile-img { grid-area: image; } .social-list { grid-area: social; } ``` 可以看到,`grid-template-areas` 是进一步抽象的语法,将页面结构通过直观的文本描述,无论是理解还是修改都更为轻松。 这种描述方式适配不同分辨率下也具有优势,只要重组 `grid-template-areas` 即可: ```scss @media (min-width: 600px) { .card { text-align: left; grid-template-columns: 1fr 3fr; grid-template-areas: "image name" "image position" "social description"; } } ``` 归根结底,Grid 通过二维结构描述,将子元素布局控制收到了父级,使布局描述更加直观。 最后作者也提到,Flex 依然有使用场景,即简单的一维结构,或者 `space-between` 等 Flex 独有语法的情况。因此推荐整体、复杂的二维布局采用 Grid,一维的简单布局采用 Flex。 ## 3 精读 Grid 的布局思路给了我很多启发,HTML 结构与 UI 结构的分离有助于减少 DIV 的层级结构,使代码看上去更清晰。 也许有人会疑惑,Grid 无非将 HTML 布局部分功能挪到了 CSS,整体复杂度应该不变。其实,从 `grid-template-areas` 这个 API 可以看到,Grid 不仅仅将布局功能抽到 CSS 中,更是将布局描述进行了一层抽象,使代码更易维护。 ### 抽象,再抽象 为什么 Grid 可以对布局进行抽象?因为 Grid 将二维结构都掌握在手中,得到了更大的布局能力,才能进一步将结构化语法抽象为字符串的描述。 抽象的好处是不言而喻的,你觉得一堆嵌套的 DIV 与下面的代码,哪个更易读呢? ```scss .card { grid-template-areas: "image name" "image position" "social description"; } ``` 这就是抽象的好处,一般来说,代码抽象程度越高就越易读,越易维护。 再看一个 Chrome Grid 插件,将 Grid 可视化显示出来,并可以以 UI 方式进行调整: ![](https://img.alicdn.com/tfs/TB1cAmui2b2gK0jSZK9XXaEgFXa-640-400.jpg) UI 是对文本的再抽象,同时可以规避一些不可能存在的语法,比如: ```scss .card { grid-template-areas: "image name" "image position" "social image"; } ``` 布局只能以凸多边形方式拓展,不可能分离,也不可能突然插入一个其他模块而变成凹多边形。因此 UI 可以将这个错误规避,并简化为横竖多条线的方式对 UI 进行划分,显然这种描述方式效率更高。 不得不说,Grid 以及图形化插件的探索,是布局领域的一大进步,是不断抽象的尝试,要解决的问题只有一个:如何提供一种更直观的描述 UI 的方式。 ### 布局对模块化的影响 Grid 将布局方式提高了一个维度,会直接影响到 JS 模块化方式。 尤其是以 JSX 组织代码的情况下,一个模块等于 UI + JS,通过嵌套方式的布局会让我们更倾向于站在 UI 视角划分模块。 ![](https://img.alicdn.com/tfs/TB1WQCvi.Y1gK0jSZFMXXaWcVXa-1052-750.png) 比如对于上图模块,如果用 Flex 方式布局,我们可能会首先创建模块 X 作为左侧容器,子元素是 A 和 B,创建模块 Y 作为右侧容器,子元素是 C 以及新容器 Z,Z 容器的子元素是 D 和 E。 如果你的第一印象是这么组织代码,不得不承认模块化会受到布局方式的影响。虽然许多时候这样划分是正确的,但当这 5 个模块各自没有关联时,我们创建的容器 X、Y、Z 就失去了复用性,在新的组合场景我们又要重新组合一遍。 但是在 Grid 语法中,我们不需要 X、Y、Z,只需要用 [css grid generator](https://cssgrid-generator.netlify.com/) 按照上图的方式拖拖拽拽即可自动生成如下布局代码: ```scss .parent { display: grid; grid-template-columns: 3fr repeat(2, 1fr); grid-template-rows: repeat(5, 1fr); grid-column-gap: 0px; grid-row-gap: 0px; } .div1 { grid-area: 1 / 1 / 3 / 2; } .div2 { grid-area: 3 / 1 / 6 / 2; } .div3 { grid-area: 1 / 2 / 2 / 4; } .div4 { grid-area: 2 / 2 / 6 / 3; } .div5 { grid-area: 2 / 3 / 6 / 4; } ``` 其实 `grid-template-columns` `grid-template-rows` 组合起来使用比 `grid-template-areas` 更强大,但是纯代码方式描述没有 `grid-template-areas` 直观,可是配合一些可视化系统就非常直观了: ![](https://img.alicdn.com/tfs/TB1E.9AiYj1gK0jSZFuXXcrHpXa-2006-1470.png) 将 A ~ E 这 5 个模块布局抽出来后,它们之间的关系就打平了,我们可以完全从逻辑视角审视如何做模块化了。 ## 4 总结 CSS Grid 本质上是一种二维布局的语法,相比 [Block](https://www.w3schools.com/Css/css_inline-block.asp)、[Flex](https://www.w3schools.com/Css/css3_flexbox.asp) 等一维布局方案,多了一个维度可以同时从行与列角度定义布局,因此派生出 `grid-template-areas` 等语法,整体上更内聚更直观,抽象度也更高了。 理解了这些也就理解了布局未来的发展方向,**让布局与 Dom 分离** 一直是前端的一个梦想,开发 UI 部分时,只需关心页面由哪些模块组成,去实现这些模块就行了,而不需要关心模块之间应该如何组合。在描述组合时,可以通过可视化或比较抽象的字符串描述布局的结构,并对应到写好的模块上,这样的代码维护性远高于用 DIV 描述结构的方案。 > 讨论地址是:[精读《用 css grid 重新思考布局》 · Issue #211 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/211) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/125.精读《深度学习 - 函数式之美》.md ================================================ ## 1 引言 函数式语言在深度学习领域应用很广泛,因为函数式与深度学习模型的契合度很高,[The Beauty of Functional Languages in Deep Learning — Clojure and Haskell](https://www.welcometothejungle.co/fr/articles/btc-deep-learning-clojure-haskell) 就很好的诠释了这个道理。 通过这篇文章可以加深我们对深度学习与函数式编程的理解。 ## 2 概述与精读 深度学习是机器学习中基于人工神经网络模型的一个分支,通过模拟多层神经元的自编码神经网络,将特征逐步抽象化,这需要多维度、大数据量的输入。[TensorFlow](https://www.tensorflow.org/) 和 [PyTorch](https://pytorch.org/) 是比较著名的 Python 深度学习框架,同样 [Keras](https://blog.rstudio.com/2017/09/05/keras-for-r/) 在 R 语言中也很著名。然而在生产环境中,基于 **性能和安全性** 的考虑,一般会使用函数式语言 [Clojure](https://www.clojure.org/) 或 [Haskell](https://www.haskell.org/)。 在生产环境中,可能要并发出里几百万个参数,因此面临的挑战是:如何高效、安全的执行这些运算。 **所以为什么函数式编程语言可以胜任深度学习的计算要求呢?** 深度学习的计算模型本质上是数学模型,而数学模型本质上和函数式编程思路是一致的:数据不可变且函数间可以任意组合。这意味着使用函数式编程语言可以更好的表达深度学习的计算过程,因此更容易理解与维护,同时函数式语言内置的 Immutable 数据结构也保障了并发的安全性。 另外函数式语言的函数之间都是相互隔离的,即便在多线程环境下也不会发生竞争和死锁的情况,函数式编程语言会自动处理这些情况。 比如说 [Clojure](https://www.clojure.org/),**它甚至可在两个同时修改同一引用的程序并发运行时,自动重试其中之一,而不需要手动加锁**: ```clojure (import ‘(java.util.concurrent Executors)) (defn test-stm [nitems nthreads niters] (let [refs (map ref (repeat nitems 0)) pool (Executors/newFixedThreadPool nthreads) tasks (map (fn [t] (fn [] (dotimes [n niters] (dosync (doseq [r refs] (alter r + 1 t)))))) (range nthreads))] (doseq [future (.invokeAll pool tasks)] (.get future)) (.shutdown pool) (map deref refs))) (test-stm 10 10 10000) -> (550000 550000 550000 550000 550000 550000 550000 550000 550000 550000) ``` 上面的代码创建了引用(refs),同时创建了多个线程自增这个引用对象,按理说每个线程都修改这个引用会导致竞争状态出现,但从结果来看是正常的,说明 Clojure 引擎在执行时会自动解决这个问题。实际上当两个线程出现竞争而失败时,Clojure 会自动重试其中之一。 > [原文介绍](https://clojure.org/about/concurrent_programming) **Clojure 的另一个优势是并行效率高:** ```clojure (defn calculate-pixels-2 [] (let [n (* *width* *height*) work (partition (/ n 16) (range 0 n)) result (pmap (fn [x] (doall (map (fn [p] (let [row (rem p *width*) col (int (/ p *height*))] (get-color (process-pixel (/ row (double *width*)) (/ col (double *height*)))))) x))) work)] (doall (apply concat result)))) ``` 使用 `partition` 结合 `pmap` 可以使并发效率达到最大化,也就是 CPU 几乎都消耗在实际计算上,而不是并行的任务管理与上下文切换。Clojure 凭借 `partition` 对计算进行分区,采取分而治之并对分区计算结果进行合并的思路优化了并发性能。 > [原文介绍](http://www.fatvat.co.uk/2009/05/jvisualvm-and-clojure.html) Clojure 另一个特性是函数链式调用: ```clojure ;; pipe arg to function (-> "x" f1) ; "x1" ;; pipe. function chaining (-> "x" f1 f2) ; "x12" ``` 其中 `(-> "x" f1 f2)` 等价于 `f2(f1("x"))`,这种描述不仅更简洁清晰,也更接近于实际数学模型。 > [原文介绍](http://xahlee.info/clojure/clojure_function_chaining.html) 最后,Clojure 还具备计算安全性,计算过程不会修改已有的数据,因此在神经网络的任何一层的原始值都会保留,每层计算都可以独立运行且函数永远幂等。 [Haskell](https://www.haskell.org/) 也有独特的优势,**它具有类型推断、惰性求值等特性**,被认为更适合用于机器学习。 类型推断即 Haskell 类型都是静态的,如果试图赋予错误的类型会报错。 Haskell 的另一个优势是可以非常清晰的描述数学模型。 想想一般数学模型是怎么描述函数的: ```text fn => f1 = 1 f2 = 9 f3 = 16 n > 2, fn = 3fn-3 + 2fn-2 + fn-1 ``` 一般语言用 `if-else` 描述等价关系,但 Haskell 可以几乎原汁原味的还原函数定义过程: ```haskell solve :: Int -> Interger solve 1 = 1 solve 2 = 9 solve 3 = 16 solve n = 3 * solve (n - 3) + 2 * solve (n - 2) + solve (n - 1) ``` 这使得阅读 Haskell 代码和阅读数学公式一样轻松。 > [原文](https://blog.jle.im/entry/purely-functional-typed-models-1.html) Haskell 另一个优势是惰性求值,即计算会在真正用到时才进行,而不会在计算前提前消费掉,比如: ```haskell let x = [1..] let y = [2,4 ..] head (tail tail( (zip x y))) ``` 可以看到,`x` 与 `y` 分别是 `1,2,3,4,5,6...` 与 `2,4,6,8...` 的无限数组,而 `zip` 函数将其整合为一个新数组 `(1,2),(2,4),(3,6),(4,8)...` 这也是无限数组,如果将 `zip` 函数执行完那么程序就会永远执行下去。但 Haskell 却不会陷入死循环,而是直接输出第一位数字 `1`。这就是惰性计算的特性,无论数组有多长,只有真正用到某项时才对其进行计算,所以哪怕初始数据量或计算量很大,实际消耗的运算资源只取决于这次计算实际用到的部分。 由于深度学习数据量巨大,惰性求值可以忽略海量数据输入,大大提升计算性能。 ## 3 总结 本文介绍了为什么深度学习更适合使用函数式语言,以及介绍了 Clojure 与 Haskell 语言的共性:安全性、高性能,以及各自独有的特性,证明了为何这两种语言更适合用在深度学习中。 在前端领域说到函数式或函数之美,大部分时候想到的是 Class Component 与 Function Component 的关系,这个理解是较为片面的。通过本文我们可以了解到,函数式的思想与数学表达式思想如出一辙,以写数学公式的思维方式写代码,就是一种较好的函数式编程思路。 函数式应该只有表达式,没有语句,这是因为函数式是为了处理运算而诞生的,因此很适合用在深度学习领域。 > 讨论地址是:[精读《深度学习 - 函数式之美》 · Issue #212 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/212) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/126.精读《Nuxtjs》.md ================================================ ## 1 引言 [Nuxt](https://github.com/nuxt/nuxt.js) 是基于 Vue 的前端开发框架,这次我们通过 [Introduction toNuxtJS](https://www.youtube.com/watch?v=NS0io3Z75GI) 视频了解框架特色以及前端开发框架的基本要素。 > nuxt 与 [next](https://github.com/zeit/next.js) 结构很像,可以结合在一起看 视频介绍了 NuxtJs 的安装、目录结构、页面路由、导航模版、asyncData、meta、vueX。 这是一个入门级视频,所以上面所列举的特征都是一个前端开发框架的最核心的基本要素。一个前端开发框架,安装、目录结构、页面路由、导航模版一定是最要下功夫认真设计的。 asyncData 和 Vuex 都在解决数据问题,meta 则是通过约定语法控制网页 meta 属性,这部分值得与 React 体系做对比,在精读部分再展开。 Nuxtjs 前端开发框架不仅提供了脚手架的基本功能,还对项目结构、代码做了约定,以减少代码量。从这点可以看出,脚手架永远围绕两个核心目标:**让每一行源码都在描述业务逻辑;让每个项目结构都相同且易读**。 20 年前,几百行 HTML、Css、Js 代码就能完成一个完整的项目,只需要遵守 W3C 的基本规范就足够了,每一个项目代码都简单清晰,而且由于没有复杂的业务逻辑,导致代码结构也非常简单。但现在前端项目复杂度逐渐升高,一个大型项目源码数量可能达到几十万行、几百万行,这是 W3C 规范没有设想到的,因此出现了各种工程化与模块化方案解决这个复杂度问题,也引发了各个框架间约定的割裂,且设计合理程度各不相同。 Nuxtjs 等框架要做的就是定义支持现代大型项目的前端研发标准,这个规范具有网络效应,即用的人越多,价值越大。 接下来我们进入正题,看看 Nuxt 脚手架定义了怎样的开发规范。 ## 2 概述 ### 安装 使用 `npx create-nuxt-app app-name` 创建新项目。这个命令与 `create-react-app` 一样,区别主要是模版以及配置不同。 这个命令本质上是拉取一个模版到本地,并安装 `nuxt` 系列脚本作为项目依赖,并自动生成一系列 npmScripts: ```json { "scripts": { "dev": "nuxt", "build": "nuxt build", "start": "nuxt start", "generate": "nuxt generate", "lint": "eslint --ext .js,.vue --ignore-path .gitignore .", "test": "jest" }, "dependencies": { "nuxt": "^2.0.0" } } ``` 之后即可通过 `npm start` 等命令开发项目,对大部分项目来说,npmScripts 启动是最能达成共识的。 这种安装方式另一个好处是,依赖都被安装在了本地,即开发环境 100% 内置在项目中。Nuxt 没有采用全局 cli 命令方式执行,第一是 npmScripts 更符合大家通用习惯,不需要记住不同脚手架繁琐的名称与不同约定的启动命令,第二是全局脚手架一旦进行不兼容升级,老项目就面临维护难题。 ### 目录结构 ```text ├── .nuxt ├── layouts ├── pages ├── store ├── assets ├── static ├── middleware ├── plugins ├── nuxt.config.js ``` **pages** 页面文件存放的目录,路径 + 文件名即路由名,关于更多约定路由的信息,在下一节页面路由详细说明。 **layouts** 模版文件存放的目录,文件名即模版名,页面可以通过定义模版在选择使用的模版。 **store** 全局数据流目录,在 vueX 章节介绍。 **assets**、**static** 分别存放不需被编译的资源文件与非 `.vue` 的静态文件,比如 scss 文件。 由于 `.vue` 文件集成了 html、js、css,因此一般不会再额外定义样式文件在 static 文件夹中。 当然,这是 Vue 生态的特别之处,在 React 生态中会存在大量 `.scss` 文件混杂在各个目录中,比较影响阅读。 **middleware**、**plugins** 中间件与插件,这两个目录是可选的,作为一种定制化拓展能力。 **.nuxt** 为实现约定路由等便捷功能,启动项目时需要自动生成一些文件作为真正项目入口,这些文件就存储在 `.nuxt` 目录下,gitingore 且无需手动修改。 **nuxt.config.js** nuxt 使用 js 文件作为配置文件,比 json 配置文件拓展性更好一些,这个文件也是整个项目唯一的配置文件。 基本上 **pages**、**layouts**、**store**、**assets**、以及唯一的配置文件基本成为现代前端开发框架的标配。 ### 页面路由 nuxt 支持约定路由: ```text ├── pages │ ├── home.vue │ └── index.vue ``` 上述目录结构描述了两个路由:`/` 与 `/home`。 也支持参数路由,只要以下划线作为前缀命名文件,就定义了一个动态参数路由: ```text ├── pages │ ├── videos │ │ └── _id.vue ``` `/videos/*` 都会指向这个文件,且可以通过 `$route.params.id` 拿到这个 url 参数。 另一个特性是嵌套路由: ```text ├── pages │ ├── videos │ │ └── index.vue │ └── videos.vue ``` `videos.vue` 与 `videos/index.vue` 都指向 `/videos` 这个路由,如果这两个文件同时存在,那么外层的 videos 就会作为外层拦截所有 `/videos` 文件夹下的路由,可以通过 `nuxt-child` 透出子元素: ```html # pages/videos.vue ``` ### 导航模版 页面公共逻辑,比如导航条可以放在模版里,模版的目录在 `layouts` 文件夹下。 默认 `layouts/default.vue` 对所有页面生效,但也可以创建例如 `layouts/videos.vue` 特殊导航文件,在 `pages/` 页面文件通过如下申明指定使用这个模版: ```html ``` ### asyncData `asyncData` 是 nuxt 支持的异步取数函数,可以替代 `data`。 `data` 函数: ```html ``` 对于异步场景,可以用 `asyncData` 替代: ```html ``` ### meta nuxt 允许在 `.vue` 页面文件自定义 head 标签信息: ```html ``` 这是开发框架提供的特性,不过在 React 体系下可以通过 `useTitle` 等自定义 Hooks 解决此问题,将框架功能降维到代码功能,会更容易理解些。 ### vueX nuxt 集成了 [vuex](https://github.com/vuejs/vuex),在 `store/` 文件夹下创建数据模型: ```js export const state = () => ({ videos: [], currentVideo: {} }) export const mutations = { SET_VIDEOS (state, videos) { state.videos = videos } SET_CURRENT_VIDEO (state, video) { state.currentVideo = video } } ``` 接下来就能在 `pages` 文件夹下的页面组件使用了: ```html ``` 将 `return` 替换为 `store.commit` 即可,更多语法可以参考 [vuex 文档](https://github.com/vuejs/vuex)。 ## 3 精读 Nuxtjs 框架做了几件事情: 1. 统一执行命令。 2. 统一开发框架。 3. 统一目录与代码规范。 4. 内置公共 utils 函数。 ### 统一执行命令 命令行是所有开发者每天都要用上十几次甚至几十次的场景,试想一下团队中项目分别有如下这么多不同的启动命令会怎么样? 1. npm start. 2. monkey dev. 3. npm run ng. 4. npm run bootstrap & banana start. 5. ... 我永远不知道下一个项目该如何启动,这大大降低了开发效率。更严重的是,有的项目可以通过 `npm run docs` 查看文档,有的项目不能;有的项目 `npm run build` 可以触发编译,有的项目却无需编译,等等,所谓的环境不一致或者说迁移成本,学习成本,都是由最开始负责搭建项目脚手架的同学对架构设计不一致导致的,**然而没有必须用 `monkey dev` 才能运行起来的项目,但项目却可能因为被设计为 `monkey dev` 启动而显得与其他项目格格不入,甚至难以统一维护。** Nuxtjs 等前端开发框架统一执行命令就是为了解决这个问题,统一开发者习惯需要很长的时间周期,但这个趋势不可挡。 ### 统一开发框架 **虽然现在 React、Vue、Angular 框架各有利弊,但如果一个团队的项目同时使用了两个以上的框架,没有人会觉得这是一件好事。** 诚然每个框架都有自己的特点,在不同维度都一些优势,但三大框架能并存,说明各自都没有绝对的杀手锏来消灭对方。 对开源来说,多元化是活力的源动力,但对一家公司来说,多元化就是一场灾难,至今没有一个框架敢说自己的优势是 “与其他框架混合使用可以提升整体开发效率”。 前端开发框架要解决的最重要问题也是这一点,无论如何只能选择一种开发框架,Nuxtjs 选择了 Vue,Nextjs 选择了 React。 ### 统一目录与代码规范 目录和代码规范不会从根本上影响项目的通用性,因为不同的目录结构可以通过映射来兼容,不同的代码规范不会影响代码执行。所以目录与代码规范真正影响的是一个程序员对项目的 “解码成本”。 所谓解码成本,就是程序员理解项目逻辑所需要的成本。如果你是一个销售主管,让团队周报统一用一种格式汇总绝对比 “用自己喜欢的方式汇总” 效率高,而对编程也一样,一个完全不同的目录结构和代码规范对程序员来说是巨大的阅读阻碍,甚至可能引发恶心反应。 所以不同的目录结构和代码规范是没有必要的壁垒,除非你的团队已经对某种规范产生达成了牢固的共识,否则最好和其他团队共享相同的目录结构与代码规范。改变代码规范是一件很难得事情,但只要不同规范的团队间产生了长期合作关系,规范统一就势必会被提上议程,那么为何不能在公司层面早一点达成共识,提前消除这种痛苦呢? 所以统一目录与代码规范是前端开发框架需要优先确定的,很多时候不要去质疑为什么目录叫 `layouts` 而不叫 `layout`,因为这个规范背后形成的协同网络规模越大,叫什么名字就越不重要。 ### 内置公共 utils 函数 让业务开发更聚焦,还可以通过抽取通用的逻辑的方式解决,但需要解决两个问题: 1. 虽然将公共函数抽成 npm 包可以解决代码复用问题,但关键是怎么保证你的代码能被别人复用? 2. 如何让业务通用的 utils 代码有效沉淀并从项目中移除? 脚手架内置公共 utils 函数就为了解决这个问题。上面几个小节解决了通用命令、框架、规范,但实际代码中,`router` `history` `fetch` `store` 等等概念也都是可以统一的,**没有一个项目必须用定制的 `fetch` 函数才能取数,但一开始就定制了 `fetch` 会导致耦合了不可预期的、没有必要的业务逻辑,成为理解与提效的阻碍。** 所以统一这些能统一的包,是进一步提效的关键。也许有人会觉得断了自己造轮子的路,但就像我们如今都不会重写浏览器内核逻辑一样,稳定的逻辑不仅带来了全行业的提效,还催生了前端岗位带来大量的就业,同样的,统一底层通用函数,其实是断了无意义产出这条路,每个人都有追求更高价值事情的权利,不要把自己困在反复造 `fetch` 函数这个低水平的活里。 ## 4 总结 如果一个项目没有使用类似 Nuxtjs 开发框架,它面临的不仅仅是技术选型不统一的问题,久而久之这种项目势必成为 **代码孤岛**,当尘封在代码仓库几年后,一系列文档工具链接都失效后,就成为谁也不想碰,不敢碰的高危代码。 所以我们今天不仅要看到 Nuxtjs 提供的能力对项目开发有多么便捷,更要看到这类框架带来的协同效应有多么巨大,如果它不能成为整个前端的标准,至少要成为你们公司,或者你们团队的标准。 > 讨论地址是:[精读《Nuxtjs》 · Issue #213 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/213) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/127.精读《React Conf 2019 - Day1》.md ================================================ ## 1 引言 [React Conf 2019](https://www.youtube.com/watch?v=RCiccdQObpo) 在今年 10 月份举办,内容质量还是一如既往的高,如果想进一步学习前端或者 React,这个大会一定不能错过。 希望前端精读成为你学习成长路上的布道者,所以本期精读就介绍 React Conf 2019 - Day1 的相关内容。 总的来看,React Conf 今年的内容视野更广了,不仅仅有技术内容,还有宣扬公益、拓展到移动端、后端,最后还有对 web 发展的总结与展望。 前端世界正变得越来越复杂,可以看到大家对未来都充满了希望,永不停歇的探索精神是这场大会的主旋律。 ## 2 概述 & 精读 本期大会思想、设计上的内容较多,具体实现层内容较少,因为行业领导者需要引领规范,而真正技术价值在于思维模型与算法,理解了解题思路,实现它其实并不难。 ### 开发者体验与用户体验 - 开发者体验:DX(develop experience) - 用户体验:UX(user experience) 技术人解决的问题总是围绕 DX 与 UX,而一般来说,优化了 DX 往往会带来 UX 的提升,这是因为一个解决开发者体验的技术创新往往也会带来用户体验的升级,至少也能让开发者有更好的心情、更充足的时间做出好产品。 如何优化开发者体验呢? **易上手** React 确实致力于解决这个问题,因为 React 实际上是一个开发者桥梁,无论你开发 web、ios 还是单片机,都可以通过一套统一的语法去实现。React 是一个协议标准(读到 reactReconciler 章节会更有体感),React 像 HTML,但 React 不止能构建 HTML 应用,React 希望构建一切。 **高效开发** React 解决调试、工具问题,让开发者更高效的完成工作,这也是开发者体验重要组成部分。 **弹性** React 编写的程序拥有良好可维护性,包括数据驱动、模块化等等特征都是为了更好服务于不同规模的团队。 对于 UX 问题,React 也有 Concurrent mode、Suspense 等方案。 虽然 React 还不完美,但 React 致力于解决 DX 与 UX 的目标和效果都是我们有目共睹的,更好的 DX、UX 一定是前端技术未来发展的大趋势。 ### 样式方案 Facebook 使用 css-in-js,而今年的 React conf 给出了一种技术方案,将 413 kb 的样式文件体积降低到 74kb! 一步步了解这个方案,从用法开始: ```tsx const styles = stylex.create({ blue: { color: "blue" }, red: { color: "red" } }); function MyComponent(props) { return I'm red now!; } ``` 如上是这个方案的写法,通过 `stylex.create` 创建样式,通过 `styles()` 使用样式。 **主题方案** 如果使用 CSS 变量定义主题,那么换肤就可以由最外层 `class` 轻松决定了: ```scss .old-school-theme { --link-text: blue; } .text-link { color: var(--link-text); } ``` 字体颜色具体的值由外层 `class` 决定,因此外层的 `class` 就可以控制所有子元素的样式: ```html ``` 将其封装成 React 组件,也不需要用 `context` 等 JS 能力,而是包裹一层 `class` 即可。 ```tsx function ThemeProvider({ children, theme }) { return
{children}
; } ``` **图标方案** 下面是设计师给出的 svg 代码: ```tsx ``` 将其包装为 React 组件: ```tsx function SettingsIcon(props) { return ( ); } ``` 结合上面提到的主题方案,就可以控制 svg 的主题颜色。 ```tsx const styles = stylex.create({ primary: { fill: "var(--primary-icon)" }, gighlight: { fill: "var(--highlight-icon)" } }); function SVGIcon(color, ...props) { return ( {...props} className={styles({ primary: color === "primary", highlight: color === "highlight" })} {children} ); } ``` **减少样式大小的秘密** ```tsx const styles = stylex.create({ blue: { color: "blue" }, default: { color: "red", fontSize: 16 } }); function MyComponent(props) { return ; } ``` 对于上述样式文件代码,最终会编译成 `c1`、`c2`、`c3` 三个 `class`: ```scss .c1 { color: blue; } .c2 { color: red; } .c3 { font-size: 16px; } ``` 出乎意料的是,并没有根据 `blue` 和 `default` 生成对应的 `class`,而是根据实际样式值生成 `class`,这样做有什么好处呢? 首先是加载顺序,`class` 生效的顺序与加载顺序有关,而按照样式值生成的 `class` 可以精确控制样式加载顺序,使其与书写顺序对应: ```tsx // 效果可能是 blue 而不是 red
// 效果一定是 red,因为 css-in-js 在最终编排 class 时,虽然两种样式都存在,但书写顺序导致最后一个优先级最高, // 合并的时候就会舍弃失效的那个 class
``` 这么做永远不会出现头疼的样式覆盖问题。 更重要的是,随着样式文件的增多,`class` 总量会减少。这是因为新增的 `class` 涵盖的属性可能已经被其他 `class` 写到并生成了,此时会直接复用对应属性生成的 `class` 而不会生成新的: ```tsx ``` ```scss .class1 { background-color: mediumseagreen; cursor: default; margin-left: 0px; } .class2 { background-color: thistle; cursor: default; justify-self: flex-start; margin-left: 0px; } ``` 正如这个 Demo 所示,正常情况的 `class1` 与 `class2` 存在许多重复定义的属性,但换成 css-in-js 的方案,编译后的效果等价于将 `class` 复用并拆解了: ```tsx ``` ```scss .classA { cursor: default; } .classB { background-color: mediumseagreen; } .classC { background-color: thistle; } .classD { margin-left: 0px; } .classE { justify-self: flex-start; } ``` 这种方式不仅节省空间、还能自动计算样式优先级避免冲突,并将 413 kb 的样式文件体积降低到 74kb。 ### 字体大小方案 `rem` 的好处是相对的字体大小,使用 `rem` 作为单位可以很方便实现网页字体大小的切换。 但问题是现在工业设计都习惯了以 px 作为单位,所以一种全新的编译方案产生了:在编译阶段将 `px` 自动转换成 `rem`。 这等于让以 `px` 为单位的字体大小可以跟随根节点字体大小随意缩放。 ### 代码检测 静态检测类型错误、拼写错误、浏览器兼容问题。 在线检测 dom 节点元素问题,比如是否有可访问性,比如替代文案 aria-label。 ### 提升加载速度 普通网页的加载流程是这样的: ![](https://img.alicdn.com/tfs/TB1gqmXlAY2gK0jSZFgXXc5OFXa-2102-1094.png) 先加载代码,然后会渲染页面,在渲染的同时发取数请求,等取数完成后才能渲染出真实数据。 那么如何改善这个情况呢?首先是预取数,提前解析出请求并在脚本加载的同时取数,可以节省大量时间: ![](https://img.alicdn.com/tfs/TB1r8Sblrj1gK0jSZFuXXcrHpXa-1704-890.png) 那么下载的代码可以再拆分吗?注意到并不是所有代码都作用于 UI 渲染,我们可以将模块分为 `ImportForDisplay` 与 `importForAfterDisplay` : ![](https://img.alicdn.com/tfs/TB1sGx.lxz1gK0jSZSgXXavwpXa-2662-1352.png) 这样就可以优先加载与 UI 相关的代码,其余逻辑代码在页面展示出之后再加载: ![](https://img.alicdn.com/tfs/TB1_9N.lCf2gK0jSZFPXXXsopXa-2762-1206.png) 这样可以实现源码分段加载,并分段渲染: ![](https://img.alicdn.com/tfs/TB1YJN.lED1gK0jSZFGXXbd3FXa-2620-1308.png) 对取数来说也是如此,并不是所有取数都是初始化渲染阶段必须用上的。可以通过 `relay` 的特性 `@defer` 标记出可以延迟加载的数据: ```relay fragment ProfileData on User { classNameprofile_picture { ... } ...AdditionalData @defer } ``` 这下取数也可以分段了,首屏的数据会优先加载: ![](https://img.alicdn.com/tfs/TB1SIydluH2gK0jSZJnXXaT1FXa-2638-1330.png) 利用 `relay` 还可以以数据驱动方式结合代码拆分: ```relay ... on Post { ... on PhotoPost { @module('PhotoComponent.js') photo_data } ... on VideoPost { @module('VideoComponent.js') video_data } ... on SongPost { @module('SongComponent.js') song_data } } ``` 这样首屏数据中也只会按需加载用到的部分,请求时间可以再次缩短: ![](https://img.alicdn.com/tfs/TB1klKcly_1gK0jSZFqXXcpaXXa-2632-1426.png) 可以看到,与 relay 结合可以进一步优化加载性能。 ### 加载体验 可以 `React.Suspense` 与 `React.lazy` 动态加载组件。通过 `fallback` 指定元素的占位图可以提升加载体验: ```tsx }>
``` `Suspense` 可以被嵌套,资源会按嵌套顺序加载,保证一个自然的视觉连贯性。 ### 智能文档 通过解析 Markdown 自动生成文档大家已经很熟悉了,也有很多现成的工具可以用,但这次分享的文档系统有意思之处在于,可以动态修改源码并实时生效。 ![](https://img.alicdn.com/tfs/TB1p1jKluT2gK0jSZFvXXXnFXXa-1692-1430.png) 不仅如此,还利用了 Typescript + MonacoEditor 在网页上做语法检测与 API 自动提示,这种文档体验上升了一个档次。 虽然没有透露技术实现细节,但从热更新的操作来看像是把编译工作放在了浏览器 web worker 中,如果是这种实现方式,原理与 [CodeSandbox 实现原理](https://segmentfault.com/a/1190000019679430) 类似。 ### GraphQL and Stuff 这一段在安利利用接口自动生成 Typescript 代码提升前后端联调效率的工具,比如 go2dts。 我们团队也开源了基于 swagger 的 Typescript 接口自动生成工具 [pont](https://github.com/alibaba/pont),欢迎使用。 ### React Reconciler 这是知识密度最大的一节,介绍了如何使用 React Reconclier。 React Reconclier 可以创建基于任何平台的 React 渲染器,也可以理解为通过 React Reconclier 可以创建自定义的 ReactDOM。 比如下面的例子,我们尝试用自定义函数 `ReactDOMMini` 渲染 React 组件: ```jsx import React from "react"; import logo from "./logo.svg"; import ReactDOMMini from "./react-dom-mini"; import "./App.css"; function App() { const [showLogo, setShowLogo] = React.useState(true); let [color, setColor] = React.useState("red"); React.useEffect(() => { let colors = ["red", "green", "blue"]; let i = 0; let interval = setInterval(() => { i++; setColor(colors[i % 3]); }, 1000); return () => clearInterval(interval); }); return (
{ setShowLogo(show => !show); }} >
{showLogo && logo /} // 自创语法

Edit src/App.js and save to reload.

Learn React{" "}
); } ReactDOMMini.render(, codument.getElementById("root")); ``` `ReactDOMMini` 是利用 `ReactReconciler` 生成的自定义组件渲染函数,下面是完整的代码: ```typescript import ReactReconciler from "react-reconciler"; const reconciler = ReactReconciler({ createInstance( type, props, rootContainerInstance, hostContext, internalInstanceHandle ) { const el = document.createElement(type); ["alt", "className", "href", "rel", "src", "target"].forEach(key => { if (props[key]) { el[key] = props[key]; } }); // React 事件代理 if (props.onClick) { el.addEventListener("click", props.onClick); } // 自创 api bgColor if (props.bgColor) { el.style.backgroundColor = props.bgColor; } return el; }, createTextInstance( text, rootContainerInstance, hostContext, internalInstanceHandle ) { return document.createTextNode(text); }, appendChildToContainer(container, child) { container.appendChild(child); }, appendChild(parent, child) { parent.appendChild(child); }, appendInitialChild(parent, child) { parent.appendChild(child); }, removeChildFromContainer(container, child) { container.removeChild(child); }, removeChild(parent, child) { parent.removeChild(child); }, insertInContainerBefore(container, child, before) { container.insertBefore(child, before); }, insertBefore(parent, child, before) { parent.insertBefore(child, before); }, prepareUpdate( instance, type, oldProps, newProps, rootContainerInstance, currentHostContext ) { let payload; if (oldProps.bgColor !== newProps.bgColor) { payload = { newBgCOlor: newProps.bgColor }; } return payload; }, commitUpdate( instance, updatePayload, type, oldProps, newProps, finishedWork ) { if (updatePayload.newBgColor) { instance.style.backgroundColor = updatePayload.newBgColor; } } }); const ReactDOMMini = { render(wahtToRender, div) { const container = reconciler.createContainer(div, false, false); reconciler.updateContainer(whatToRender, container, null, null); } }; export default ReactDOMMini; ``` 笔者拆解一下说明: React 之所以具备跨平台特性,是因为其渲染函数 `ReactReconciler` **只关心如何组织组件与组件间关系,而不关心具体实现**,所以会暴露出一系列回调函数。 **创建实例** 由于 React 组件本质是一个描述,即 `tag` + 属性,所以 `Reconciler` 不关心元素是如何创建的,需要通过 `createInstance` 拿到组件基本属性,在 Web 平台利用 DOM API 实现: ```typescript createInstance( type, props, rootContainerInstance, hostContext, internalInstanceHandle ) { const el = document.createElement(type); ["alt", "className", "href", "rel", "src", "target"].forEach(key => { if (props[key]) { el[key] = props[key]; } }); // React 事件代理 if (props.onClick) { el.addEventListener("click", props.onClick); } // 自创 api bgColor if (props.bgColor) { el.style.backgroundColor = props.bgColor; } return el; } ``` 之所以说 React 对 DOM 事件都做了一层代理,是因为 JSX 的所有函数都没有真正透传给 DOM,而是通过类似 `el.addEventListener("click", props.onClick)` 的方式代理实现的。 而自定义这个函数,我们甚至能创建例如 `bgColor` 这种特殊语法,只要解析引擎实现了这个语法的 Handler。 除此之外,还有 **创建、删除实例** 的回调函数,我们都要利用 DOM 平台的 API 重新实现一遍,这样不仅可以实现对浏览器 API 的兼容,还可以对接到比如 react-native 等非 WEB 平台。 **更新组件** 实现了 `prepareUpdate` 与 `commitUpdate` 才能完成组件更新。 `prepareUpdate` 返回的 `payload` 被 `commitUpdate` 函数接收到,并根据接收到的信息决定如何更新实例节点。这个实例节点就是 `createInstance` 回调函数返回的对象,所以如果在 WEB 环境返回的 instance 就是 DOMInstance,后续所有操作都使用 DOMAPI。 总结一下:`react` 主要用平台无关的语法生成具有业务含义的 AST,而利用 `react-reconciler` 生成的渲染函数可以解析这个 AST,并提供了一系列回调函数实现完整的 UI 渲染功能,`react-dom` 现在也是基于 `react-reconciler` 写的。 ### 图标体积优化 Facebook 团队通过优化,将图标大小从 4046.05KB 降低到了 132.95kb,体积减少了惊人的 96.7%,减少体积占总包体积的 19.6%! 实现方式很简单,下面是原始图标使用的代码: ```jsx ; } ``` 可以看到,在这个组件中,按钮与滚动状态判断逻辑混合在了一起。如果我们将 “滚动到一定距离就渲染 UI” 抽象成通用组件 `IfScrollCrossed` 呢? ```jsx import { useState, useEffect } from "react"; function useScrollDistance(distance) { const [crossed, setCrossed] = useState(false); useEffect( function() { const handler = () => setCrossed(window.scrollY > distance); handler(); window.addEventListener("scroll", handler); return () => window.removeEventListener("scroll", handler); }, [distance] ); return crossed; } function IfScrollCrossed({ children, distance }) { const isBottom = useScrollDistance(distance); return isBottom ? children : null; } ``` 有了 `IfScrollCrossed`,我们就能专注写 “点击按钮跳转到顶部” 这个 UI 组件了: ```jsx function onClick() { window.scrollTo({ top: 0, behavior: "smooth" }); } function JumpToTop() { return ; } ``` 最后将他们拼装在一起: ```jsx import React from "react"; // ... const DISTANCE = 500; function MyComponent() { // ... return ( ); } ``` 这么做,我们的 `` 与 `` 组件就是正交关系,而且逻辑更清晰。不仅如此,这样的抽象使 `` 可以被其他场景复用: ```jsx import React from "react"; // ... const DISTANCE_NEWSLETTER = 300; function OtherComponent() { // ... return ( ); } ``` ### Main 组件 上面例子中,`` 就是一个 Main 组件,Main 组件封装一些脏逻辑,即它要负责不同模块的组装,而这些模块之间不需要知道彼此的存在。 一个应用会存在多个 Main 组件,它们负责拼装各种作用域下的脏逻辑。 ### 正交设计的好处 - **容易维护:** 正交组件逻辑相互隔离,不用担心连带影响,因此可以放心大胆的维护单个组件。 - **易读:** 由于逻辑分离导致了抽象,因此每个模块做的事情都相对单一,很容易猜测一个组件做的事情。 - **可测试:** 由于逻辑分离,可以采取逐个击破的思路进行单测。 ### 权衡 如果不采用正交设计,因为模块之间的关联导致应用最终变得难以维护。但如果将正交设计应用到极致,可能会多处许多不必要的抽象,这些抽象的复用仅此一次,造成过度设计。 ## 3 精读 正交设计一定程度可以理解为合理抽象,完全不抽象与过度抽象都是不可取的,因此列举了四块需要抽象的要点:UI 元素、取数逻辑、全局状态管理、持久化。 全局状态管理注入到组件,就是一种正交的抽象模式,即组件不用关心数据从哪来,而直接使用数据,而数据管理完全交由数据流层管理。 取数逻辑往往是可能被忽略的一环,无论是像原文中直接关心到 `fetch` 方法的 UI 组件,还是利用取数工具库关心了 `loading` 状态: ```jsx import useSWR from "swr"; function Profile() { const { data, error } = useSWR("/api/user", fetcher); if (error) return
failed to load
; if (!data) return
loading...
; return
hello {data.name}!
; } ``` 虽然将取数生命周期封装到自定义 hook `useSWR` 中,但 `error` 信息对 UI 组件来说就是一个脏数据:**这让这个 UI 组件不仅要渲染数据,还要担心取数是否会失败,或者是否在 loading 中。** 好在 Suspense 模式解决了这个问题: ```jsx import { Suspense } from "react"; import useSWR from "swr"; function Profile() { const { data } = useSWR("/api/user", fetcher, { suspense: true }); return
hello, {data.name}
; } function App() { return ( loading...
}> ); } ``` 这样 `` 只要专注于做数据渲染,而不用担心 `useSWR('/api/user', fetcher, { suspense: true })` 这个取数过程发生了什么、是否取数失败、是否在 `loading` 中。因为取数状态由 `Suspense` 管理,而取数是否意外失败由 `ErrorBoundary` 管理。 合理的抽象使组件逻辑变得更简单,从而组件嵌套使用使不用担心额外影响。尤其在大型项目中,不要担心正交抽象会使本来就很多的模块数量再次膨胀,因为相比于维护 100 个相互影响,内部逻辑复杂的模块,维护 200 个职责清晰,相互隔离的模块也许会更轻松。 ## 4 总结 从正交设计角度来看,`Hooks` 解决了状态管理与 UI 分离的问题,`Suspense` 解决了取数状态与 UI 分离的问题,`ErrorBoundary` 解决了异常与 UI 分离的问题。 在你看来,React 还有哪些逻辑需要与 UI 分离?分别使用哪些方法呢?欢迎留言。 > 讨论地址是:[精读《正交的 React 组件》 · Issue #221 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/221) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/133.精读《寻找框架设计的平衡点》.md ================================================ ## 1 引言 [尤雨溪](https://github.com/yyx990803) 在 2019 JSConf 的分享 [Seeking the Balance in Framework Design](https://www.youtube.com/watch?v=ANtSWq-zI0s) 十分精彩,道出了如何进行合理的前端框架设计与框架选型。 正如所说,框架对比不能只停留在 Star 数量、Npm 下载量、Stackoverflow 问题量这些简单的数据对比,而要深入到技术细节进行比较。比较框架有多种不同维度,这次分享就从服务范围、渲染机制、状态机制这三个维度进行对比。 ## 2 概述 这次分享的精彩之处在于不偏不倚的站在客观立场分析了框架各维度好的一面与坏的一面,从中我们不仅能学习到一些框架知识,还能培养思辨能力。 ### 服务范围 服务范围是个比较难翻译的单词,在原 PPT 中用了 “Scope” 这个单词表示,可以理解为 “作用域、框架的承诺功能范围、服务配套齐全程度”。比如提供的是一个工具库还是整体框架,插件管理是集中式还是依赖生态。 React 是典型的小服务范围框架,核心包只实现了基本功能,而其他生态基本靠社区拓展;Angular 是典型大服务范围框架,官方对所有业务场景都做了最佳实践能力覆盖;Vue 处在中间区域,通过功能分层,既拥有小服务范围的能力,又可以搭配官方插件实现更多场景化能力。 #### 小服务范围优势 **概念少,易上手** 小的服务范围代表了小的学习成本,因为暴露的基本能力较少,概念也会比较少,对新人上手比较友好。 **生态繁荣,百花齐放** 由于很多功能没有被官方实现,社区就有机会填补这些空白,因此会冒出许多第三方库,而且一旦做得好,就有机会成为 “事实标准”,因此开发者会更加积极参与到社区开发,自己做的框架 “上升空间” 也非常大。 同时,社区的力量会导致多元化,因此整体生态完整度与创新性都会非常亮眼,而且具有持续迭代的能力。 **核心维护成本低** 官方维护的核心代码较少,因此维护成本大大降低,而且官方可以将精力放在更多核心能力增强上,比如 Suspense 等,而不是将精力消耗在生态插件上。 #### 小服务范围的劣势 **复杂场景要引入新概念** 复杂场景无法支持时,就要引入新的概念解决,这导致后续技术选型可能产生分歧,并带来持续的新概念理解成本。 **非官方的开发模式逐渐产生** 随着时间的流逝,会逐渐涌出一些新的设计模式,成为当下几乎是必不可少的方案,但却不会出现在官方文档中,造成选型时的疑惑。Redux 就是一个例子。 **生态变化快,碎片化且持续流失** 非官方的生态也意味着不稳定,而且缺乏统一的管理,碎片化的模块之间可能经常出现不兼容的问题。 而且任何模块都可能被时代无情的淘汰,就像 Flux 到 Redux 再到 Hooks,带来额外的迁移成本和认知成本。谁也不希望自己的项目架构 “变得过时”,或者随时面临被新架构取代的风险,但第三方社区几乎一定代表未来会出现一种模式取代现有模式,只是时间早晚而已。 #### 大服务范围的优势 **大部分业务场景都被内置解决** 减少不必要的技术方案调研与纷争,大服务范围的框架内置的方案就能解决几乎 100% 业务问题,团队再也不会为通用架构问题烦恼了。 **生态稳定、连贯** 稳定是指,官方维护作为背书,几乎不会存在一些生态包突然不维护、与已有版本不兼容、被植入恶意程序等等意外情况。 连贯是指,官方会统一考虑一个改动在所有生态插件造成的影响,并以一个最合理的思路做整体改造,生态包无论是接口还是兼容性都不需要担心,设计思路也会一脉相承。 #### 大服务范围的劣势 **前期上手成本高** 全家桶的概念导致上手难度偏高,因为必须理解所有内置概念后才能开始项目。 **如果内置模块无法满足业务,会觉得有些死板** 一旦发生内置功能无法满足业务的场景,就很难拓展了,因为 all in one 的思路本质上就是排斥自定义拓展的,这点从 [angular-cli](https://github.com/angular/angular-cli) 就能看出来。 之所以觉得死板,是因为这种情况没办法用优雅的方式解决,只能在现有约束的框架内通过某些 “Hack” 方式解决,自然会有种死板的感觉。 #### 中等服务范围的优势 **分层设计,允许新特性渐进加入** Vue 通过分层设计做到了折中,即官方还是会维护生态,只不过生态不是必须的,可以按需使用。这样做的好处是兼顾了一些优势。 **低学习门槛** 与小服务范围框架一样,对于核心包来说学习成本都比较低。 **依然有最佳实践解决所有业务问题** 和大服务范围框架一样,拥有全套官方最佳实践,但不内置,不强求一定要使用,因此你可以按需使用。 #### 中等服务范围的劣势 **维护成本高** 和大服务范围框架一样,虽然生态不强求,但毕竟官方还是要持续维护的,因此维护成本高的问题依然存在。 **生态多样性不高** 虽然生态是按需的,但毕竟中等服务范围的框架官方会实现一套标准生态插件,这会极大影响社区生态的发展空间,导致 “非官方插件没人愿意做”,因此生态多样性会差一些。 ### 渲染机制 渲染机制区别主要在 JSX vs Template 之间,不同的表达方式之间还是存在一些很本质的区别,然而正如一开始所说,无法一言蔽之,必须从多个角度拆解的看。 #### JSX 的优势 **纯 JS 表达 UI** 单这一点就非常重要了,满足了 All In Js 的幻想。毕竟 Html、Css 相比 Js 来说,模块化能力和灵活性都很弱,将其都收敛到 Js 不仅表达方式更统一,更重要的是都获得了与 Js 一样的模块化、灵活性、Typescript 支持等能力。 **视图即数据** 将视图看作一种数据,让针对视图的逻辑测试成为可能。 同时也将视图概念泛化了,因为数据是平台无关的,一份描述视图的 DSL 可以运行在任何平台。 #### JSX 的劣势 **开销大** 页面节点越多,Diff 开销就越大。 **动态渲染很难性能优化** 由于所有 DOM 节点都是动态生成,因此无法根据初始状态结构进行安全的优化。相比之下,Template 模式可以确定哪部分属于变量,哪部分是固定的,对固定部分的 Diff 检测都可以跳过。 **动态调度虽然改善了性能,但依赖更重的运行时** React ConcurrentMode 是一个调度优化器,但实现的逻辑也比较复杂,加重了运行时负担。 #### Template 的优势 **原生性能** 由于 Template 对节点进行直接渲染,因此与原生性能一致。 **Runtime 更小** 由于不需要额外优化,运行时代码会小很多。 #### Template 的劣势 **被 Template 语法约束,且无法拓展** 对于 Template 不支持的,只能选择接受,因为除了框架自己,没有人能拓展 Template 的特性。当遇到一些非常动态场景,但 Template 不支持的情况,只能选择接受,并用比较 Hack 的方式绕过解决,除此之外别无他法。 **模版冗长** JSX 可以利用循环语句或者变量赋值进行模版区块的复用,但 Template 模式每次新模版都要一行一行的打出来,这种冗长的开发体验不太友好。 **运行时解析开销或者依赖编译期逻辑** 要么通过编译器预先生成 AST,要么运行时动态将 Template 解析成 AST,无论哪种方案都有额外的开销,一种是工程依赖的开销,一种是运行时动态解析的性能开销。 #### VDom + Template 的特色 Vue 在 Template 基础上支持了虚拟 DOM,因此兼具两者特色。 性能上,在编译时就进行 AST 解析,减少了运行时解析开销。 功能上,支持模版与 JSX 两种语法。 ### 状态机制 状态机制 [尤雨溪](https://github.com/yyx990803) 在 JSConf 提到要单独拆出来讲,因为内容较多,时间可能不够,本次精读也限于篇幅原因略过: - Mutable vs Immutable。 - 依赖追踪 vs 脏检测。 - 响应式 vs 模拟响应式。 显然,状态机制方案更是仁者见仁智者见智的事情,同样得从多个维度进行独立分析,并根据实际业务场景具体选择。 最后,意识到没有一个绝对均衡的框架设计方案,因为在工程领域,没有最好只有更好。 ## 3 精读 我们再延伸谈一谈为什么框架设计要寻找平衡点。 **框架设计没有银弹** 与数学公式不同,框架设计甚至整个工程技术设计都没有所谓的真理,所谓条条大路通罗马,实现同一个技术目标的众多方案之间也许就是平行关系,可以根据不同维度列出一二三的对比,但无法得出一个总的结论,孰优孰劣。 **使用场景不同** 不同使用场景决定了对框架诉求的不同。 比如开发非常定制、炫酷的可视化大屏,那么前端开发框架基本也用不上,因为关注点不会聚焦在项目路由、UI 描述、甚至是数据流,而是聚焦在性能、图形渲染等问题。解决这些领域的框架可能是 虚幻 4、Unity 等游戏引擎,但普通的前端开发框架绝不会涉足这种领域,框架一定要确定自己功能范围。 即便仅局限在 Web 领域,也需要考虑是否要支持非 Web 场景,那么将 HTML 抽象成一个通用 DSL 就可能是一种选择,但非 Web 领域毕竟不是主打业务领域,在这种业务场景周边生态维护可能就比较少,这也是需要取舍的地方。 **使用的人不同** 不同团队对框架的要求也不同。 刚起步的小团队可能更需要保姆式的框架,因为这样最节省人力成本。对于规模较大的团队,希望对框架拥有较大定制能力时,小服务范围的框架可能更受青睐。当然框架作者可以像 Vue 一样做出渐进式官方能力增强方案,以此满足不同需求的用户,但毕竟也不能将生态完全交给社区,还是要做取舍。 所以当遇到更新更酷的框架时,需要冷静思考的不只是这个框架带来的收益与花费的迁移成本哪个更高,以及团队能否接受这套框架的开发习惯,更需要思考的是这个框架自身做了哪些权衡,如果这些权衡与 React、Vue、Angular 类似,那么仅仅变化了语法或者语言的改动其实意义不大,此时需要慎重考虑。 ## 4 总结 这次没有提到的状态机制对比,你能分别列举出优缺点吗?欢迎留言。 > 讨论地址是:[精读《寻找框架设计的平衡点》 · Issue #223 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/223) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/134.精读《我在阿里数据中台大前端》.md ================================================ ## 1 引言 当下互联网行业里面最流行的就是 ABC: > A: AI 人工智能 B: BIG DATA C: CLOUD 而阿里经济体中的 ABC,其中的 BIG DATA,即是我们 DT https://dt.alibaba.com/ ,我们用大数据赋能商业,创造价值。 而我们说数据中台,其实阿里提出的中台只有两个:业务中台与数据中台。业务中台的目的是让业务能够快速落地,数据中台的目的是完成数据的采集、建设、管理、使用这四个环节,让数据从生产到使用过程变得丝般顺滑,不仅不让数据资产成为累赘,还会最大限度发挥出数据潜藏的价值。 笔者所在的就是数据中台的大前端团队,既为阿里经济体提供数据服务,又着力为上云企业打造属于自己的数据中台,处在前端技术、商业模式、产品设计的最前沿,且听我慢慢道来。 ## 2 精读 ### 全链路数据能力 从能力上看,数据中台处理数据的方方面面,从数据产生开始就进行追踪,不仅打通了数据采集、存储、处理、查询、消费的全链路,还用以下几种方式赋能业务:研发数据管理平台并监控数据质量,研发生意参谋等数据分析产品直接服务大、中、小商家,提供统一数据服务标准化数据使用流程,将数据分析的算法能力服务化,将支撑内部的数据服务上云搭建客户自己的数据中台,研发 BI 平台完成数据决策的最后一环。 ### 全链路数据技术 从技术架构上看,从底层的数据采集技术开始,逐步向上建设了数据计算与管理能力、数据服务、数据平台、数据应用与数据安全。 从使用者角度来看,现在的公司对数据的诉求可以概括为以下几点: 1. 数据从哪来,如何完全数字化:对应全链路数据采集服务。 2. 如何得到想要的数据:数据计算、建模与管理服务。 3. 如何使用数据:统一数据服务平台。 4. 如何利用数据做商业决策:BI 平台。 5. 如何保障数据安全:数据安全服务。 对阿里而言,还会额外考虑下面几点: 1. 如何让数据服务横向支撑所有业务线:数据服务平台化,数据智能化服务平台与 BI 平台。 2. 如何让数据服务普惠到每一个企业:数据服务全面上云。 3. 如何让数据服务更有价值:打通阿里经济体的数据体系,让数据相互产生化学反应。 当然,挑战性也非常大,首先是数据壁垒的挑战,要说服其他团队将数据交给你管理绝非易事。其次是价值挑战,如何证明数据中台存在的价值,并做到肉眼可见的业务增值。最后是技术挑战,对前端来说,几十款数据产品的搭建、几十万张数据报表的搭建,需要一个足够好用的数据产品搭建平台来支持;数据分析产品的下一代探索式分析也对 BI 引擎提出了新的要求;数据可视化远比普通可视化复杂,不仅要考虑大数据下的性能与可读性,还要理解商业,做出能体现数据分析价值的图表。 不论是数据搭建还是数据可视化,都是前端垂直领域的另一条好赛道,不仅有沉甸甸的业务价值,还有全新数据领域的的前端技术挑战,而且随着数据中台影响力的持续扩大,我们的前端技术也会带来业界越来越大的影响力。 ### 如何建设和管理数据 想要数据用的好,首先要管的好,在大数据时代,企业必须建立一套自己的标准数仓系统对数据的采集、运维调度做全链路管理,让大数据变成好数据,让好数据可以发挥价值。 ![](https://img.alicdn.com/tfs/TB1Pz.ZrAL0gK0jSZFtXXXQCXXa-1903-929.png) > Dataphin 数仓建设平台。 数仓的建设需要从物理空间与逻辑空间,也就是底层的表开始整理,通过对数据的采集、清洗、结构化,产出一套规范的数据定义。 所谓规范的数据定义即口径、算法、命名均一致的数据规范,降低数据二义性,提升数据查找效率与准确性。之后对数据建模,建模即是对数据的进一步抽象,可能是抽象为一个 Cube 模型,这样在顶层认知上,所有数据都是不同维度的 Cube,方便统一理解。 最后通过对数据进行在线的、离线的调度计算,产出数据资产。 ### 如何看数据 或导出一个 Excel 文件仔细品味,或如双十一媒体大屏般夺目,或如股票操盘手般紧盯着屏幕,或随时随地的手机浏览。在哪看,怎么看,看什么,决定着同一份数据可带来不同的效果,产生不同的价值。 稳:双十一大屏,零点起得来,24 点收得住,每个彩蛋的出现,每个数字的跳动,如丝般顺滑,这不是播放 VCR,每一帧画面都是真实的数据展现。容:即是生意参谋用户的浏览器兼容,又是多端用户的兼容,也是 BI 分析结果的数据大容量。有容乃大,方显前端功底。 **“如何看数据” 这恰是做为数据前端人的使命和责任。** 不同的人,不同的端,不同的需求,这恰是给数据前端的挑战。而让用户透过数据创造价值,也正是数据前端人的价值。 ### 如何分析数据 大数据浪潮之下,必然会诞生各式各样的数据产品,产品化的方式可以降低数据应用的门槛。我们希望人人都能成为数据分析师,于是 BI (商业智能)产品应运而生,作为大数据行业中的一个重要领域,BI 产品用大数据的方式解决了企业的业务分析需求,支撑企业进行数字化转型,从经验驱动决策转变为数据驱动决策,进而给企业带来超额收益。 ![](https://img.alicdn.com/tfs/TB1KJE0rpP7gK0jSZFjXXc5aXXa-2664-1060.png) > QuickBI 数据分析工具。 **人人都是数据分析师的情况在不断增强。** 根据 Gartner 对 2020 年 BI 产品发展趋势预测: 1. 到 2020 年,为用户提供对内部和外部数据策划目录的访问权限的组织将从分析投资中获得两倍的业务价值。 2. 到 2020 年,业务部门的数据和分析专家数量的增速将是 IT 部门专家的 3 倍,这会迫使企业重新考虑其组织模式和技能。 3. 到 2021 年,自然语言处理和会话分析这两个功能,会在新用户、特别是一线工作人员中,将分析和商业智能产品的使用率从 35% 提升到 50% 以上。 **快速增涨的市场规模。** 根据中国电子信息产业发展研究院发布的《中国大数据产业发展水平评估报告》,预计 2019 年我国大数据核心产业规模突破 5700 亿元,未来 2-3 年的市场规模的增长率仍将保持 35% 左右。未来切入这部分应用环节,BI 商业智能的潜在市场规模将在数百亿的市场空间。 **大数据与前端。** 前端的职业发展除了提升自己的技能技术储备之外,选择合适行业方向和研究领域也尤为重要。如果用路和车的关系来比喻的话,把前端技能比作车的话,各个行业都是路,有的路是乡间小路,有的路是城乡公路,而大数据行业当之无愧是行业中的上高速公路,路况更好,路面更宽,如果你拥有一辆好车,为什么不来高速公路上飞驰呢? 大数据下的前端面临哪些挑战?以 BI 为例,BI 领域的四大方向:数据集、渲染引擎、数据模型与可视化都有许多可以做深的技术点,每一块都需要深入沉淀几年技术经验才能做好,需要大量优秀人才通力协作才有可能做好。你也可以阅读 [精读《前端与 BI》](https://github.com/dt-fe/weekly/blob/v2/121.%E7%B2%BE%E8%AF%BB%E3%80%8A%E5%89%8D%E7%AB%AF%E4%B8%8E%20BI%E3%80%8B.md) 了解更多 BI 相关知识。 ### 我们是数据中台大前端 > “ 前端不是因为我们用 JavaScript,而是因为我们站在业务最前端,解决业务端的问题,所以我们是前端 ”。 BI 分析产品、做数据可视化、做产品搭建 .. 我们早已经跳出了“前端”的传统概念范畴。我们做大数据表格优化、 Web Excel、 SQL 编辑器、智能可视化。在数据中台,我们有着天然的复杂业务场景和海量数据优势,迫使你向自己提出更大的挑战来解决业务上的问题。如果你热爱挑战、热爱技术,请加入我们吧。 **在这里,你可以愉快的使用 React、TypesScript 写业务代码,尝试最新、最炫酷的 React Hooks 新特性,我们团队一直走在前端技术路线的最前沿,渴求技术创新。** 你也不需要担心伙伴的代码风格问题,因为我们有着严格的代码规;你不必担心每个人的代码都是一座孤岛,因为我们会对每一行代码做严格的 review;你不必担心你的成长空间,我们有定期的技术分享、团队内小竞赛,还有足够复杂的业务场景支撑;你也不必担心你会因工作日渐消瘦,下午茶和海量小零食等你来! ## 4 总结 **大数据前端人才缺口在 100 人以上,由于业务增长非常非常迅猛,春节前条件放宽、特批急召!** 如果你对我们感兴趣,请立刻把简历发送到邮箱 **ziyi.hzy@alibaba-inc.com** 吧!绝无仅有的好机会,响应速度绝对超乎你的想象! > 讨论地址是:[精读《我在阿里数据中台大前端》 · Issue #224 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/224) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/138.精读《精通 console.log》.md ================================================ ## 1 引言 本周精读的文章是 [Mastering JS console.log like a Pro](https://medium.com/javascript-in-plain-english/mastering-js-console-log-like-a-pro-1c634e6393f9),一起来更全面的认识 console 吧! ## 2 概述 & 精读 console 的功能主要在于控制台打印,它可以打印任何字符、对象、甚至 DOM 元素和系统信息,下面一一介绍。 ### console.log( ) | info( ) | debug( ) | warn( ) | error( ) 直接打印字符,区别在于展示形态的不同: 新版 chrome 控制台可以将打印信息分类: `log()` 与 `info()` 都对应 `info`,`warn()` 对应 `warnings`,`error()` 对应 `errors`,而 `debug()` 对应 `verbose`,因此建议在合适的场景使用合适的打印习惯,这样排查问题时也可以有针对性的筛选。 比如调试信息可以用 `console.debug` 仅在调试环境下输出,调试者即便开启了调试参数也不会影响正常 `info` 的查看,因为调试信息都输出在 `verbose` 中。 ### 使用占位符 - %o — 对象 - %s — 字符串 - %d — 数字 如下所示,可通过占位符在一行中插入不同类型的值: ### 添加 CSS 样式 - %c - 样式 可以总结出,**console 支持输出复杂的内容,其输出能力堪比 HTML,但输入能力太弱,仅为字符串,因此采用了占位符 + 多入参修饰的设计模式解决这个问题。** ### console.dir( ) 按 JSON 模式输出。笔者在这里也补充一句:`console.log()` 会自动判断类型,如果内容是 DOM 属性,则输出 DOM 树,但 `console.dir` 会强制以 JSON 模式输出,用在 DOM 对象时可强制转换为 JSON 输出。 ### 输出 HTML 元素 按照 HTML ELements 结构输出: 这种输出结构和 Elements 打印形式是一致的,如果要看详细属性,可以使用 `console.dir()`。 ### console.table 在控制台打印一个表格,属于功能增强。虽然仅文本也可以在控制台打印出漂亮的表格,但浏览器调试控制台的功能更强大,`console.table` 只是其富文本能力的一个体现。 ### console.group( ) & console.groupEnd( ) 接下来是另一个富文本能力,按分组输出: 这种带有副作用的 API 显然是为方便阅读而设计的,然而在需要输出大量动态结构化数据的场景下,还需要进行结构转换,是比较麻烦的地方。 ### console.count( ) `count()` 用来打印调用次数,一般用在循环或递归函数中。接收一个 `label` 参数以定制输出,默认直接输出 `1 2 3` 数字。 ### console.assert( ) `console` 版断言工具,当且仅当第一个参数值为 `false` 时才打印第二个参数作为输出。 这种输出结果为 error,所以也可被 `console.error` + 代码级别断言所取代。 ### console.trace( ) 打印此时的调用栈,在打印辅助调试信息时非常有用。 ### console.time( ) 打印代码执行时间,性能优化和监控场景比较常见。 ### console.memory 打印内存使用情况。 ### console.clear( ) 清空控制台输出。 ## 3 总结 `console` 提供了如此多的输出规范,其实也是在变相制定开发规范,毕竟离开发者最近的就是调试控制台,如果你的项目打印规范与标准规范有差异,那么调试时信息看起来就会很别扭。 可以看到,大部分开源库都良好的遵循了这套规范,比如三方库绝不会输出 `log()`,而且将错误、警告与调试信息正确分开,并尽量少的用 CSS 样式、分组、`table` 等功能,因为这些功能干扰性较强,不能保证所有用户都可接受。 相对的,项目源码就比较适合使用一些醒目的自定义规范,只要这套规则能被很好的执行起来。 最后留下一个讨论点:`console` 可以作为调试、招聘信息、隐藏菜单的投放点,你还看到过哪些有意思的 `console` 使用方式呢?欢迎留言。 > 讨论地址是:[精读《精通 console.log》 · Issue #228 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/228) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/139.精读《手写 JSON Parser》.md ================================================ ## 1 引言 `JSON.parse` 是浏览器内置的 API,但如果面试官让你实现一个怎么办?好在有人已经帮忙做了这件事,本周我们一起精读这篇 [JSON Parser with Javascript](https://lihautan.com/json-parser-with-javascript/) 文章吧,再温习一遍大学时编译原理相关知识。 ## 2 概述 & 精读 要解析 JSON 首先要理解语法概念,之前的 [精读《手写 SQL 编译器 - 语法分析》](https://github.com/dt-fe/weekly/blob/v2/066.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%AD%E6%B3%95%E5%88%86%E6%9E%90%E3%80%8B.md) 系列也有介绍过,不过本文介绍的更形象,看下面这个语法图: 这是关于 Object 类型的语法描述图,从左向右看,根据箭头指向只要能走出这个迷宫就属于正确语法。 比如第一行 `{` → `whitespace` → `}` 表示 `{ }` 属于合法的 JSON 语法。 再比如观察向下的一条最长路线:`{` → `whitespace` → `string` → `whitespace` → `:` → `value` → `}` 表示 `{ string : value }` 属于合法的 JSON 语法。 你可能会问,双引号去哪儿了?这就是语法树最核心的概念了,这张图是关于 Object 类型的 **产生式**,同理还有 string、value 的产生式,产生式中可以嵌套其他产生式,甚至形成环路,以此拥有描述纷繁多变语法的能力。 最后我们再看一个环路,即 `{` → `whitespace` → `string` ... `,` → `whitespace` → `string` ... `,` ... `}`,我们发现,只要不走回头路,这条路是可以一直 “绕圈” 下去的,因此 Object 类型拥有了任意数量子字段的能力,只是每形成一个子字段,必须经过 `,` 号分割。 ### 实现 Parser 首先实现一个基本结构: ```js function fakeParseJSON(str) { let i = 0; // TODO } ``` `i` 表示访问字符的下标,当 `i` 走到字符串结尾表示遍历结束。 然后是下一步,用几个函数描述解析语法的过程: ```js function fakeParseJSON(str) { let i = 0; function parseObject() { if (str[i] === '{') { i++; skipWhitespace(); // if it is not '}', // we take the path of string -> whitespace -> ':' -> value -> ... while (str[i] !== '}') { const key = parseString(); skipWhitespace(); eatColon(); const value = parseValue(); } } } } ``` 其中 `skipWhitespace` 表示匹配并跳过空格,所谓匹配意味着匹配成功,此时 `i` 下标可以继续后移,否则匹配失败。下一步则判断如果 `i` 不是结束标志 `}`,则按照 `parseString` 匹配字符串 → `skipWhitespace` 跳过空格 → `eatColon` 吃掉冒号 → `parseValue` 匹配值,这个链路循环。其中吃掉冒号表示 “匹配冒号但不会产生任何结果,所以就像吃掉了一样”,吃这个动作还可以用在其他场景,比如吃掉尾分号。 > 对于看到这儿的小伙伴,笔者要友情提示一下,原文的思路是一种定制语法解析思路,无论是 `eatColon` 还是 `parseValue` 都仅具备解析 JSON 的通用性,但不具备解析任意语法的通用性。如果你想做一个具备解析任何通用语法的解析器,读入的内容应该是语法描述,处理方式必须更加通用,如果感兴趣可以阅读 [精读《手写 SQL 编译器 - 语法分析》](https://github.com/dt-fe/weekly/blob/v2/066.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%AD%E6%B3%95%E5%88%86%E6%9E%90%E3%80%8B.md) 系列文章了解更多。 由于 Object 第一个元素前面不允许加逗号,因此可以利用 `initial` 做一个初始化判定,在初始时机不会吃掉逗号: ```js function fakeParseJSON(str) { let i = 0; function parseObject() { if (str[i] === '{') { i++; skipWhitespace(); let initial = true; // if it is not '}', // we take the path of string -> whitespace -> ':' -> value -> ... while (str[i] !== '}') { if (!initial) { eatComma(); skipWhitespace(); } const key = parseString(); skipWhitespace(); eatColon(); const value = parseValue(); initial = false; } // move to the next character of '}' i++; } } } ``` 那么当第一个子元素前面存在逗号时,由于没有 “吃掉逗号” 这个功能,所以读到逗号会报错,语法解析提前结束。 吃逗号和吃冒号的代码都非常简单,即判断当前字符串必须是 “要吃的那个元素”,并且在吃掉后将 `i` 下标自增 1: ```js function fakeParseJSON(str) { // ... function eatComma() { if (str[i] !== ',') { throw new Error('Expected ",".'); } i++; } function eatColon() { if (str[i] !== ':') { throw new Error('Expected ":".'); } i++; } } ``` 在有了基本判定功能后,`fakeParseJSON` 需要返回 Object,因此我们只需在每个循环中对 Object 赋值,最后一并 return 即可: ```js function fakeParseJSON(str) { let i = 0; function parseObject() { if (str[i] === '{') { i++; skipWhitespace(); const result = {}; let initial = true; // if it is not '}', // we take the path of string -> whitespace -> ':' -> value -> ... while (str[i] !== '}') { if (!initial) { eatComma(); skipWhitespace(); } const key = parseString(); skipWhitespace(); eatColon(); const value = parseValue(); result[key] = value; initial = false; } // move to the next character of '}' i++; return result; } } } ``` 解析 Object 的代码就完成了。 接着试着解析 Array,下面是 Array 的语法图: 我们只需要吃逗号和 `parseValue` 即可: ```js function fakeParseJSON(str) { // ... function parseArray() { if (str[i] === '[') { i++; skipWhitespace(); const result = []; let initial = true; while (str[i] !== ']') { if (!initial) { eatComma(); } const value = parseValue(); result.push(value); initial = false; } // move to the next character of ']' i++; return result; } } } ``` 接下来到了有趣的 `value` 语法图,可以看到 `value` 是许多种基础类型的 “或” 关系组成的: 我们只需要继续拆解分析即可: ```js function fakeParseJSON(str) { // ... function parseValue() { skipWhitespace(); const value = parseString() ?? parseNumber() ?? parseObject() ?? parseArray() ?? parseKeyword('true', true) ?? parseKeyword('false', false) ?? parseKeyword('null', null); skipWhitespace(); return value; } } ``` 其中 `parseKeyword` 函数用来解析一些保留关键字,比如将 `"true"` 解析成布尔类型 `true`: ```js function fakeParseJSON(str) { // ... function parseKeyword(name, value) { if (str.slice(i, i + name.length) === name) { i += name.length; return value; } } } ``` 如上所示,只要在 name 与对应字符相等时,返回第二个传入参数即可。 ### 处理异常输入 一个完整的语法解析功能需要包含错误处理,错误的情况主要分两种: 1. 非法字符。 2. 非正常结尾。 原文提到的 JSON 错误提示优化非常棒,想想你在开发中突然看到下面的提示,是不是很蒙圈: ```text Unexpected token "a" ``` 既然我们是自己写的 JSON 解析器,就可以进行更友好的异常提示,比如: ```text // show { "b"a ^ JSON_ERROR_001 Unexpected token "a". Expecting a ":" over here, eg: { "b": "bar" } ^ You can learn more about valid JSON string in http://goo.gl/xxxxx ``` 更多 Demo 可以查看 [原文](https://lihautan.com/json-parser-with-javascript/)。 ## 3 总结 这篇文章通过一个具体的例子解释如何做语法分析,对于词法解析入门非常直观,如果你想更深入理解语法解析,或者写一个通用语法解析器,可以阅读语法解析系列入门文章,笔者通过实际例子带你一步一步做一个完备的词法解析工具! 语法解析入门系列文章,建议阅读顺序: - [精读《手写 SQL 编译器 - 词法分析》](https://github.com/dt-fe/weekly/blob/v2/064.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%8D%E6%B3%95%E5%88%86%E6%9E%90%E3%80%8B.md) - [精读《手写 SQL 编译器 - 文法介绍》](https://github.com/dt-fe/weekly/blob/v2/065.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E6%96%87%E6%B3%95%E4%BB%8B%E7%BB%8D%E3%80%8B.md) - [精读《手写 SQL 编译器 - 语法分析》](https://github.com/dt-fe/weekly/blob/v2/066.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%AD%E6%B3%95%E5%88%86%E6%9E%90%E3%80%8B.md) - [精读《手写 SQL 编译器 - 回溯》](https://github.com/dt-fe/weekly/blob/v2/067.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E5%9B%9E%E6%BA%AF%E3%80%8B.md) - [精读《手写 SQL 编译器 - 语法树》](https://github.com/dt-fe/weekly/blob/v2/070.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%AD%E6%B3%95%E6%A0%91%E3%80%8B.md) - [精读《手写 SQL 编译器 - 错误提示》](https://github.com/dt-fe/weekly/blob/v2/071.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E9%94%99%E8%AF%AF%E6%8F%90%E7%A4%BA%E3%80%8B.md) - [精读《手写 SQL 编译器 - 性能优化之缓存》](https://github.com/dt-fe/weekly/blob/v2/078.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96%E4%B9%8B%E7%BC%93%E5%AD%98%E3%80%8B.md) - [精读《手写 SQL 编译器 - 智能提示》](https://github.com/dt-fe/weekly/blob/v2/085.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E6%99%BA%E8%83%BD%E6%8F%90%E7%A4%BA%E3%80%8B.md) [syntax-parser](https://github.com/ascoders/syntax-parser) 这个零依赖的通用语法解析库就是根据上述文章一步一步完成的,看完了上面文章,就彻底理解了这个库的源码。 > 讨论地址是:[精读《手写 JSON Parser》 · Issue #233 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/233) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/14.精读《架构设计之 DCI》.md ================================================ 本期精读文章是:[The DCI Architecture](http://www.artima.com/articles/dci_vision.html) # 1 引言 随着前端 ES6 ES7 的一路前行, 我们大前端借鉴和引进了各种其他编程语言中的概念、特性、模式; 我们可以使用函数式 Functional 编程设计,可以使用面向对象 OOP 的设计,可以使用面向接口的思想,也可以使用 AOP, 可以使用注解,代理、反射,各种设计模式; 在大前端辉煌发展、在数据时代的当下 我们一起阅读了一篇设计相关的老文: 《The DCI Architecture》 一起来再探索和复习一下 相关的设计和思想 # 2 内容摘要 DCI 是数据 Data 场景 Context 交互 Interactions 简称, 重点是关注 数据的不同场景的交互行为, 是面向对象系统 状态和行为的一种范式设计; DCI 在许多方面是许多过去范式的统一,多年来这些模式已经成为面向对象编程的辅助工具。 尽管面向切面的编程(AOP)也有其他用途,但 DCI 满足了许多 AOP 的应用以及 Aspects 在解决问题方面的许多目标。根据 AOP 的基本原理,DCI 基于深层次的反射或元编程。 与 Aspects 不同,角色聚合并组合得很好。Context 提供角色集之间的关联的范围关闭,而 Aspect 仅与应用它们的对象配对。 在许多时候,虽然混合本身缺乏我们在 Context 语义中发现的动力 ,但 DCI 反映了混合风格策略。 DCI 实现了多范式设计的许多简单目标,能够将过程逻辑与对象逻辑分开。然而,DCI 具有比多范式设计提供的更强大的技术更好的耦合和内聚效果 结合 ATM 汇款场景案例,讲解了一下 DCI 角色提供了和用户相关 自然的边界,以转账为例,我们实际谈论的是钱的转移,以及源账户和目标账户的角色,算法(用例 角色行为集合)应该是这样: 1.账户拥有人选择从一个账户到另外一个账户的钞票转移。 2.系统显示有效账户 3.用户选择源账户 4.系统显示存在的有效账户 5.账户拥有人选择目标账户。 6.系统需要数额 7.账户拥有人输入数额 8.钞票转移 账户进行中(确认金额 修改账户等操作) 设计者的工作就是把这个用例转化为类似交易的算法,如下: 1.源账户开始交易事务 2.源账户确认余额可用 3.源账户减少其帐目 4.源账户请求目标账户增加其帐目 5.源账户请求目标账户更新其日志 log 6.源账户结束交易事务 7.源账户显示给账户拥有人转账成功。 ```plain template class TransferMoneySourceAccount: public MoneySource { private: ConcreteDerived *const self() { return static_cast(this); } void transferTo(Currency amount) { // This code is reviewable and // meaningfully testable with stubs! beginTransaction(); if (self()->availableBalance() < amount) { endTransaction(); throw InsufficientFunds(); } else { self()->decreaseBalance(amount); recipient()->increaseBalance (amount); self()->updateLog("Transfer Out", DateTime(), amount); recipient()->updateLog("Transfer In", DateTime(), amount); } gui->displayScreen(SUCCESS_DEPOSIT_SCREEN); endTransaction(); } ``` # 3 精读 本次提出独到观点的同学有:[@ascoders](https://github.com/ascoders)、[@TingGe](https://github.com/TingGe)、[@zy](https://github.com/zhaoyangsoft),精读由此归纳。 ## 尝试从人类思维角度出发 理解 DCI 即 数据(data) 场景(context) 交互(interactive)。 DCI 之所以被提出,是因为传统 mvc 代码,在越来越丰富的交互需求中**变得越来越难读**。有人会觉得,复杂的需求 mvc 也可以 cover 住,诚然如此,但很少有人能只读一遍源码就能理解程序处理了哪些事情,这是因为人类思维与 mvc 的传统程序设计思想存在鸿沟,我们需要脑补内容很多,才会觉得难度。 现在仍有大量程序**使用面向对象的思想表达交互行为**,当我们把所有对象之间的关联记录在脑海中时,可能对象之间交互行为会比较清楚,但任无法轻松理解,因为对象的封装会导致内聚性不断增加,交互逻辑会在不同对象之间跳转,对象之间的嵌套关系在复杂系统中无疑是一个理解负担。 DCI 尝试从人类思维角度出发,举一个例子:为什么在看电影时会轻轻松松的理解故事主线呢?回想一下我们看电影的过程,看到一个画面时,我们会思考三件事: 1. 画面里有什么人或物? 2. 人或物发生了什么行为、交互? 3. 现在在哪?厨房?太空舱?或者原始森林? 很快把这三件事弄清楚,我们就能快速理解当前场景的逻辑,并且**轻松理解该场景继续发生的状况**,即便是盗梦空间这种烧脑的电影,当我们搞清楚这三个问题后,就算街道发生了 180 度扭曲,也不会存在理解障碍,反而可以吃着爆米花享受,直到切换到下一个场景为止。 当我们把街道扭曲 180 度的能力放在街道对象上时,理解就变的复杂了:这个函数什么时候被调用?为什么不好好承载车辆而自己发生扭曲?这就像电影开始时,把电影里播放的所有关于街道的状态都走马灯过一遍:我们看到街道通过了车辆、又卷曲、又发生了爆炸,实在觉得莫名其妙。 理解代码也是如此,当交互行为复杂时,把交互和场景分别抽象出来,以场景为切入点交互数据。 举个例子,传统的 mvc 可能会这么组织代码: `UserModel`: ```javascript class My { private name = "ascoders" // 名字 private skills = ["javascript", "nodejs", "切图"] // 技能 private hp = 100 // 生命值?? private account = new Account() // 账户相关 } ``` `UserController`: ```javascript class Controller { private my = new My() private account = new Account() private accountController = new AccountController() public cook() { // 做饭 } public coding() { // 写代码 } public fireball() { // 搓火球术。。? } public underAttack() { // 受到攻击?? } public pay() { // 支付,用到了 account 与 accountController } } ``` 这只是我自己的行为,当我这个对象,与文章对象、付款行为发生联动时,就发生了各种各样的跳转。到目前为止我还不是非常排斥这种做法,毕竟这样是非常主流的,前端数据管理中,不论是 redux,还是 mobx,都类似 MVC。 不论如何,尝试一下 DCI 的思路吧,看看是否会像看电影一样轻松的理解代码: 以上面向对象思想主要表达了 4 个场景,家庭、工作、梦境、购物: 1. home.scene.scala 2. work.scene.scala 3. dream.scene.scala 4. buy.scene.scala 以程序员工作为例,在工作场景下,写代码可以填充我们的钱包,那么我们看到一个程序员的钱包: `codingWallet.scala`: ```scala case class CodingWallet(name: String, var balance: Int) { def coding(line: Int) { balance += line * 1 } } ``` 写一行代码可以赚 1 块钱,它不需要知道在哪个场景被使用,程序员的钱包只要关注把代码变成钱。 交互是基于场景的,所以交互属于场景,写代码赚钱的交互,放在工作场景中: `work.scene.scala`: ```scala object MoneyTransferApp extends App { @context class MoneyTransfer(wallet: CodingWallet, time: int) { // 在这个场景中,工作 1 小时,可以写 100 行代码 // 开始工作! wallet.working role wallet { def working() { wallet.coding(time) } } } // 钱包默认有 3000 元 val wallet = CodingWallet("wallet", 3000) // 初始化工作场景,工作了 1 小时 new MoneyTransfer(wallet, 1) // 此时钱包一共拥有 3100 元 println(wallet.balance) } ``` 小结:,就是把数据与交互分开,额外增加了**场景**,交互属于场景,获取数据进行交互。原文的这张图描述了 DCI 与 MVC 之间的关系: ![image](https://user-images.githubusercontent.com/7970947/27719998-294f4356-5d89-11e7-99af-8811a782cd50.png) ## 发现并梳理现代前端模式和概念的蛛丝马迹 现代前端受益于低门槛和开放,伴随 OO 和各种 MV* 盛行,也出现了越来越多的概念、模式和实践。而 DCI 作为 MVC 的补充,试图通过引入函数式编程的一些概念,来平衡 OO 、数据结构和算法模型。值得我们津津乐道的如 Mixins、Multiple dispatch、 依赖注入(DI)、Multi-paradigm design、面向切面编程(AOP)都是不错的。如果对这些感兴趣,深挖下 AngularJS 在这方面的实践会有不少收获。 当然,也有另辟途径的,如 Flux 则采用了 DDD/CQRS 架构。 软件架构设计,是一个很大的话题,也是值得每位工程师长期实践和思考的内容。个人的几点体会: 1. 一个架构,往往强调职责分离,通过分层和依赖原则,来解决程序内、程序间的相互通讯问题; 2. 知道最好的几种可能的架构,可以轻松地创建一个适合的优化方案; 3. 最后,必须要记住,程序必须遵循的架构。 分享些架构相关的文章: - [Comparison of Architecture presentation patterns MVP(SC),MVP(PV),PM,MVVM and MVC](https://www.codeproject.com/Articles/66585/Comparison-of-Architecture-presentation-patterns-M) - [The DCI Architecture: A New Vision of Object-Oriented Programming](http://www.artima.com/articles/dci_vision.html) - [干净的架构 The Clean Architecture](https://www.bbsmax.com/A/pRdBWY3ezn/) - [MVC 的替代方案](https://gxnotes.com/article/71237.html) - [展示模式架构比较 MVP(SC),MVP(PV),PM,MVVM 和 MVC](http://blog.csdn.net/lihenair/article/details/51791915) - [Software Architecture Design](https://github.com/zenany/weekly/blob/master/resources/software_architecture.md) - [【译】什么是 Flux 架构?(兼谈 DDD 和 CQRS)](https://blog.jimmylv.info/2016-07-07-what-the-flux-on-flux-ddd-and-cqrs/) ## 结合 DCI 设想开发的过程中使用到一些设计方法和原则 我们在开发的过程中多多少少都会使用到一些设计方法和原则 DCI 重点是关注 数据的不同场景的交互行为, 是面向对象系统 状态和行为的一种范式设计; 它能够将过程逻辑与对象逻辑分开,是一种典型的行为模式设计; 很好的点是 它根据 AOP 的基本原理,DCI 提出基于 AOP 深层次的元编程(可以理解成面向接口编程), 去促使系统的内聚效果和降低耦合度; 举个例子: 在一个 BI 系统中, 在业务的发展中, 这个系统使用到了多套的 底层图表库,比如: Echarts, G2,Recharts, FusionChart; 等等; 那么问题来了, 1. 如何去同时支持 这些底层库, 并且达到很容易切换的一个效果? 2. 如何去面向未来的考虑 将来接入更多类型的图表? 3. 如何去考虑扩展业务 对图表的日益增强的业务功能(如: 行列转换、智能格式化 等等) 带着这些问题, 我们再来看下 DCI 给我们的启示, 我们来试试看相应的解法: 1. 图表的模型数据就是 数据 Data , 我们可以把[日益增强的业务功能] 认为是各个场景交互 Interactions; 2. 接入更多类型的图表咋么搞? 不同类型的图表其实是图表数据模型的转换,我们也可以把这些转换的行为过程作为一个个的切片(Aspect),每个切片都是独立的, 松耦合的 ; ![image](https://user-images.githubusercontent.com/1456421/27744526-67fd0e3e-5d85-11e7-9b48-e1934d9b15f3.png) 3. 接入多套底层库怎么搞? 每个图形库的 build 方法,render 方法 , resize 方法,repaint 方法 都不一样 ,怎么搞 ? 我们可以使用 DCI 提到的元编程- 我们在这里理解为面向接口编程, 我们分装一层 统一的接口; 利用面向接口的父类引用指向子类对象 我们就可以很方便的 接入更多的 implement 接入更多的图形库(当然,一个系统统一一套是最好的); # 4 总结 DCI 是数据 Data 场景 Context 交互 Interactions 的简称,DCI 是一种特别关注行为的设计模式(行为模式), DCI 关注数据不同场景的交互行为, 是面向对象 状态和行为的一种范式设计;DCI 尝试从人类思维,过程化设计一些行为; DCI 也会使用一些面向切面和接口编程的设计思想去达到高内聚低耦合的目标。 > 讨论地址是:[精读《架构设计 之 DCI》 · Issue #20 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/20) > 如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题, 欢迎来一起学习 共同探索。 ================================================ FILE: 前沿技术/140.精读《结合 React 使用原生 Drag Drop API》.md ================================================ ## 1 引言 拖拽是前端非常常见的交互操作,但显然拖拽是强 DOM 交互的,而 React 绕过了 DOM 这一层,那么基于 React 的拖拽方案就必定值得聊一聊。 结合 [How To Use The HTML Drag-And-Drop API In React](https://www.smashingmagazine.com/2020/02/html-drag-drop-api-react/) 这篇文章,让我们谈谈 React 拖拽这些事。 ## 2 概述 原文说的比较简单,笔者先快速介绍其中重点部分。 首先拖拽主要的 API 有 4 个:`dragEnter` `dragLeave` `dragOver` `drop`,分别对应拖入、拖出、正在当前元素范围内拖拽、完成拖入动作。 基于这些 API,我们可以利用 React 实现一个拖入区域: ```jsx import React from "react"; const DragAndDrop = props => { const handleDragEnter = e => { e.preventDefault(); e.stopPropagation(); }; const handleDragLeave = e => { e.preventDefault(); e.stopPropagation(); }; const handleDragOver = e => { e.preventDefault(); e.stopPropagation(); }; const handleDrop = e => { e.preventDefault(); e.stopPropagation(); }; return (
handleDrop(e)} onDragOver={e => handleDragOver(e)} onDragEnter={e => handleDragEnter(e)} onDragLeave={e => handleDragLeave(e)} >

Drag files here to upload

); }; export default DragAndDrop; ``` `preventDefault` 指的是阻止默认响应,这个响应可能是跳转页面之类的,`stopPropagation` 是阻止冒泡,这样同样监听了事件的父元素就不会收到响应,我们可以精准作用于嵌套的子元素。 接下来是拖拽状态管理,提到了 `useReducer`,顺便复习一下用法: ```jsx ... const reducer = (state, action) => { switch (action.type) { case 'SET_DROP_DEPTH': return { ...state, dropDepth: action.dropDepth } case 'SET_IN_DROP_ZONE': return { ...state, inDropZone: action.inDropZone }; case 'ADD_FILE_TO_LIST': return { ...state, fileList: state.fileList.concat(action.files) }; default: return state; } }; const [data, dispatch] = React.useReducer( reducer, { dropDepth: 0, inDropZone: false, fileList: [] } ) ... ``` 最后一个关键点在于拖入后的处理,利用 `dispatch` 增加拖入文件、设置拖入状态即可: ```js const handleDrop = e => { ... let files = [...e.dataTransfer.files]; if (files && files.length > 0) { const existingFiles = data.fileList.map(f => f.name) files = files.filter(f => !existingFiles.includes(f.name)) dispatch({ type: 'ADD_FILE_TO_LIST', files }); e.dataTransfer.clearData(); dispatch({ type: 'SET_DROP_DEPTH', dropDepth: 0 }); dispatch({ type: 'SET_IN_DROP_ZONE', inDropZone: false }); } }; ``` `e.dataTransfer.clearData` 函数用于清除拖拽过程中产生的临时变量,这些临时变量可以通过 `e.dataTransfer.xxx =` 的方式赋值,一般用于拖拽过程中值的传递。 总结一下,利用 HTML5 的 API 将拖拽转化为状态,最终通过状态映射到 UI。 原文内容还是比较简单的,笔者在精读部分再拓展一些更体系化的内容。 ## 3 精读 现阶段拖拽主要分为两种,一种是 HTML5 原生规范的拖拽,这种方式在拖拽过程中不会影响 DOM 结构。另一种是完全所见即所得的拖拽方式,拖拽过程中 DOM 位置会随之变动,好处是可以立即反馈拖拽结果,当然缺点是华而不实,一旦用在生产环境,这种拖拽过程可能导致页面结构频繁跳动,反而看不清拖拽效果。 由于本文也采用了第一种拖拽方案,因为笔者再重新整理一遍自己的封装思路。 从使用角度反推,假设我们拥有一个拖拽库,那必定要拥有两个 API: ```jsx import { DragContainer, DropContainer } from 'dnd' const DragItem = ( {({ dragProps }) => (
)} ) const DropItem = ( {({ dropProps }) => (
)} ) ``` `DragContainer` 包裹可以被拖拽的元素,`DropContainer` 包裹可以被拖入的元素,而至于 `dragProps` 与 `dropProps` 需要透传到子元素的 dom 节点,是为了利用 DOM API 控制拖拽效果,这也是拖拽唯一对 DOM 的要求,双方元素都需要有实体 DOM 承载。 而上面例子中给出 `dragProps` 与 `dropProps` 的方式属于 RenderProps,我们可以将 `children` 当作函数执行以达到效果: ```jsx const DragContainer = ({ children, componentId }) => { const { dragProps } = useDnd(componentId) return children({ dragProps }) } const DropContainer = ({ children, componentId }) => { const { dropProps } = useDnd(componentId) return children({ dropProps }) } ``` 那么这里创建了一个自定义 Hook `useDnd` 接收 `dragProps` 与 `dropProps`,这个自定义 Hook 可以这么写: ```jsx const useDnd = ({ componentId }) => { const dragProps = {} const dropProps = {} return { dragProps, dropProps } } ``` 接下来,我们就要分别实现 `drag` 与 `drop` 了。 对 `drag` 来说,只要实现 `onDragStart` 与 `onDragEnd` 即可: ```jsx const dragProps = { onDragStart: ev => { ev.stopPropagation() ev.dataTransfer.setData('componentId', componentId) }, onDragEnd: ev => { // 做一些拖拽结束的清理工作 } } ``` `stopPropagation` 的作用在原文简介中已经介绍过了,`setData` 则是通知拖拽方,当前拖拽的组件 id 是什么,**这是由于拖拽由 `drag` 发起而由 `drop` 响应,因此必须有个数据传输过程,而 `dataTransfer` 就最适合做这件事。** 对于 `drop` 来说,只要实现 `onDragOver` 与 `onDrop` 即可: ```jsx const dropProps = { onDragOver: ev => { // 做一些样式处理,提示用户此时松手会将元素放置在何处 }, onDrop: ev => { ev.stopPropagation() const componentId = ev.dataTransfer.getData('componentId') // 通过 componentId 修改数据,通过 React Rerender 刷新 UI } } ``` 重点在 `onDrop`,它是实现拖拽效果的 “真正执行处”,最终通过修改 UI 的方式更新数据。 存在一种场景,一个容器既可以被拖动,也可以被拖入,这种情况一般这个组件是个容器,但这个容器可以被拖入到其他容器中,可以自由嵌套。 实现这种场景的方式就是将 `DragContainer` 与 `DropContainer` 作用到一个组件上: ```jsx const Box = ( {({ dragProps }) => ( {({ dropProps }) => {
}} )} ) ``` 之所以能嵌套,在于 HTML5 的 API 允许一个元素同时拥有 `onDragStart`、`onDrop` 这两种属性,而上面的语法不过是同时将这两种属性传给组件 DOM。 所以,动手实现一个拖拽库就是这么简单,只要活用 HTML5 的拖拽 API,结合 React 一些特殊语法便够了。 ## 4 总结 最后留下一个思考题,许多具有拖拽功能的系统都具备 “拖拽 placeholder” 的功能,即拖拽元素的过程中,在其 “落点” 位置展示一条横线或竖线,引导出松手后元素位置落点,如图所示: 那么这条辅助线是通过什么方式实现的呢?欢迎在评论区留言!如果你有辅助线实现方案解析的文章,欢迎分享,也可以期待笔者未来专门写一篇 “拖拽 placeholder” 实现剖析的精读。 > 讨论地址是:[精读《手写 JSON Parser》 · Issue #233 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/233) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/141.精读《useRef 与 createRef 的区别》.md ================================================ ## 1 引言 `useRef` 是常用的 API,但还有一个 `createRef` 的 API,你知道他们的区别吗?通过 [React.useRef and React.createRef: The Difference](https://blog.bitsrc.io/react-useref-and-react-createref-the-difference-afedb9877d0f) 这篇文章,你可以了解到何时该使用它们。 ## 2 概述 其实原文就阐述了这样一个事实:`useRef` 仅能用在 FunctionComponent,`createRef` 仅能用在 ClassComponent。 第一句话是显然的,因为 Hooks 不能用在 ClassComponent。 第二句话的原因是,`createRef` 并没有 Hooks 的效果,其值会随着 FunctionComponent 重复执行而不断被初始化: ```tsx function App() { // 错误用法,永远也拿不到 ref const valueRef = React.createRef(); return
; } ``` 上述 `valueRef` 会随着 App 函数的 Render 而重复初始化,**这也是 Hooks 的独特之处,虽然用在普通函数中,但在 React 引擎中会得到超出普通函数的表现,比如初始化仅执行一次,或者引用不变**。 为什么 `createRef` 可以在 ClassComponent 正常运行呢?这是因为 ClassComponent 分离了生命周期,使例如 `componentDidMount` 等初始化时机仅执行一次。 原文完。 ## 3 精读 那么知道如何正确创建 Ref 后,还知道如何正确更新 Ref 吗? 由于 Ref 是贯穿 FunctionComponent 所有渲染周期的实例,理论上在任何地方都可以做修改,比如: ```tsx function App() { const valueRef = React.useRef(); valueRef.current += 1; return
; } ``` 但其实上面的修改方式是不规范的,React 官方文档里要求我们避免在 Render 函数中直接修改 Ref,请先看下面的 FunctionComponent 生命周期图: 从图中可以发现,在 `Render phase` 阶段是不允许做 “side effects” 的,也就是写副作用代码,这是因为这个阶段可能会被 React 引擎随时取消或重做。 修改 Ref 属于副作用操作,因此不适合在这个阶段进行。我们可以看到,在 `Commit phase` 阶段可以做这件事,或者在回调函数中做(脱离了 React 生命周期)。 当然有一种情况是可以的,即 [懒初始化](https://reactjs.org/docs/hooks-faq.html#how-to-create-expensive-objects-lazily): ```ts function Image(props) { const ref = useRef(null); // ✅ IntersectionObserver is created lazily once function getObserver() { if (ref.current === null) { ref.current = new IntersectionObserver(onIntersect); } return ref.current; } // When you need it, call getObserver() // ... } ``` 懒初始化的情况下,副作用最多执行一次,而且仅用于初始化赋值,所以这种行为是被允许的。 为什么对副作用限制的如此严格?因为 FunctionComponent 增加了内置调度系统,为了优先响应用户操作,可能会暂定某个 React 组件的渲染,具体可以看第 99 篇精读:[精读《Scheduling in React》](https://github.com/dt-fe/weekly/blob/v2/099.%E7%B2%BE%E8%AF%BB%E3%80%8AScheduling%20in%20React%E3%80%8B.md) Ref 不仅可以拿到组件引用、创建一个 Mutable 副作用对象,还可以配合 `useEffect` 存储一个较老的值,最常用来拿到 `previousProps`,React 官方利用 Ref 封装了一个简单的 Hooks 拿到上一次的值: ```tsx function usePrevious(value) { const ref = useRef(); useEffect(() => { ref.current = value; }); return ref.current; } ``` 由于 `useEffect` 在 Render 完毕后才执行,因此 `ref` 的值在当前 Render 中永远是上一次 Render 时候的,我们可以利用它拿到上一次 Props: ```tsx function App(props) { const preProps = usePrevious(props); } ``` 要实现这个功能,还是要归功于 `ref` 可以将值 “在各个不同的 Render 闭包中传递的特性”。最后,不要滥用 Ref,Mutable 引用越多,对 React 来说可维护性一般会越差。 ## 4 总结 你还挖掘了 `useRef` 哪些有意思的使用方式?欢迎在评论区留言。 > 讨论地址是:[精读《useRef 与 createRef 的区别》 · Issue #236 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/236) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/142.精读《如何做好 CodeReview》.md ================================================ ## 1 引言 任何软件都是协同开发的,所以 CodeReview 非常重要,它可以帮助你减少代码质量问题,提高开发效率,提升稳定性,同时还能保证软件架构的稳定性,防止代码结构被恶意破坏导致难以维护。 所以 CodeReview 机制是否健全是一个工程团队能否长期健康发展的决定因素之一,这次我们读一篇关于 CodeReview 如何做得更好的文章: [how-to-make-good-code-reviews-better](https://stackoverflow.blog/2019/09/30/how-to-make-good-code-reviews-better/)。 ## 2 概述 & 精读 作者结合自己在 Uber、微软的工作经历介绍了自己对如何做好 CodeReview 的看法。 ### CodeReview 的覆盖范围 **Good CodeReview** 会检查代码的正确性、测试覆盖率、功能变化、是否遵循代码规范与最佳实践、可以指出一些较为明显的改进点,比如难以阅读的写法、未使用到变量、一些边界问题、commit 数量过大需要拆分等等。 **Better CodeReview** 会检查引入代码的必要性,与已有系统是否适配,是否具有可维护性,从抽象角度思考代码是否与已有系统逻辑能够自洽。 > Better CodeReview 会关注在可维护性层面,并具有全局性,往往几个局部正确的代码组合在一起会产生错误的结果,或者是没必要的代码,或者是相互冲突的逻辑。Better CodeReview 更多用在底层架构场景,因为架构底层模块关联比较紧密,需要有整体视角,而业务上层模块间最好采用解耦模式,这样不仅不需要更耗费精力的 Better CodeReview,也是一种更正确的架构设计。 ### CodeReview 的语气 **Good CodeReview** 会给出建设性意见,而不是发表强硬措辞要求对方改正,或认为自己的意见是唯一正确的答案,因为这样的评论其实具有一定攻击性,激发对方的防御心理,产生敌对心态,这样会从内部瓦解一个团队。最好能给出建议,或者多个选择,给对方留有余地。 **Better CodeReview** 永远是考虑全面且正向积极的,会对写的好的地方进行鼓励,对写的不好的地方也体现出善解人意的关怀,考虑到对方可能花费了很多心血,以一种换位思考的鼓励心态进行评论。 > 其实读到语气这一章节,逐渐发现 CodeReview 不仅是一个技术专业行为,还是一个人与人相处的社交行为,有的人平时与人打交道非常谦逊,但在 CodeReview 中就变得尖酸刻薄,显然是只关注到了 CodeReview 的专业性这一面,忽略了社交性这一点。而要做到 Better CodeReview 还要学会换位思考,体现出包容、正向积极的态度,因为你技术经验更丰富,能指出别人的问题很正常,但能保持谦逊,让别人容易接受并受到鼓励,可以让你成为一个有气度的技术专家。 ### 如何完成 CodeReview 的审阅 **Good CodeReview** 不会轻易通过那些开放式 PR,至少在其被得到充分讨论前,但每个 Review 者对自己关注的部分完成 Review 后需要进行反馈,无论是 “看起来不错” 或者用缩写单词 “LGTM”,之后需要有明确的跟进,比如通过协作软件通知作者进行进一步反馈。 **Better CodeReview** 实际执行中会更加灵活一些,对于一些比较紧急的改动会留下改进建议,但快速通过,让作者通过后续代码提交解决遗留的问题。 > 实际工作场景会遇到一些开放式或紧急的提交,良好的 CodeReview 习惯自然是要严谨一些,讨论清楚再通过,并且要及时反馈。但某些比较紧急的提交就要区别对待了,更好的态度是在实践中灵活对待,但及时紧急通过了,也要保证问题在后续得以修复,比如在代码中留一些 "TODO" 或 "FIXME" 的标记,写上对应的负责人与预期解决时间。 ### 从 CodeReview 到直接交流 **Good CodeReview** 会给出完整的评论和修改建议,如果后续提交的代码不符合预期,Review 者可以直接与代码提交者面对面交流,这样可以避免后续花费更多沟通时间。 **Better CodeReview** 会在第一次给出完整的评论和修改建议,如果后续提交代码不符合预期,会立即与代码提交者当面沟通,避免异步沟通带来更多的理解偏差。 > 补充一下,在 PR 内容过多时也可以选择直接与提交者当面沟通,这样可以更多理解作者的想法,使 Review 准确性更高。另外并不要每次都直接交流,异步的 CodeReview 本身就是一种提效方案,这会使你工作节奏把握在自己手中,仅在这种方案出现沟通问题时再选择当面交流。 ### 区分重点 **Good CodeReview** 可以区分提示的重要程度,并在不太重要的改动前面加上 “nit:” 标记,这样可以使提交者的注意力集中在重要的问题上。 **Better CodeReview** 会采取工具手段解决这些问题,比如一些代码 lint 工具,因为这些问题往往是可以被工具自动化解决的。 > 代码自动化工具的目的,很大一部分也是为了保证代码一致性,从而降低 CodeReview 成本,也减少不重要的评论信息出现,让 CodeReview 尽可能反馈逻辑问题而不是格式问题。 ### 针对新人的 CodeReview **Good CodeReview** 对任何人都是用相同评判标准,可以遵循上面几点注意事项。 **Better CodeReview** 会对新人区分对待,对新人给予对多的耐心、解释和评论,甚至给出解决方法,并更积极的给出鼓励。 > 任何人到一家新公司都有适应过程,一视同仁是 base 要求,但如果能给予新人更多关怀就更好啦。 ### 跨办公区、时区的 CodeReview **Good CodeReview** 仅在工作时间有重叠的时间范围内进行 CodeReview,这样能保证对方可以积极响应,在必要时进行语音、视频沟通。 **Better CodeReview** 会注意到更本质的问题,留意跨团队协作的必要性,如果某个模块经常被另一个时区同时修改,也许可以将这个模块交给对方维护,或者将 CodeReview 交给对方团队内部进行会更加高效。 > 笔者所在公司也有跨时区协作情况,但绝大部分场景会避免跨时区的 CodeReview,因为 CodeReview 一般会在同一时区团队内部进行,这样效率更高,应对跨时区协作时,往往也是电话、视频会议优先。 ### 公司支持 **Good CodeReview** 会得到公司组织支持,公司能意识到这么做虽然看起来占用开发时间,但长远来看提升了开发效率,因此能任何 CodeReview 价值。 **Better CodeReview** 会得到公司进一步支持,公司甚至不断研发并完善 CodeReview 系统与流程,通过系统化方案保证上面几项 CodeReview 注意事项是否有在团队内落实,可以全员参与。 > CodeReview 也是一种团队文化和公司文化,公司文化带来的是规章制度与系统工具,团队文化带来的是良好 CodeReview 氛围与更高 CodeReview 的效率。 ## 3 总结 总结一下,良好的 CodeReview 需要做到以下几点: 1. 更全面,从正确性到系统影响评估。 2. 注意语气,从给出建设性一觉到换位思考。 3. 及时完成审阅,从充分讨论到随机应变。 4. 加强交流,从面对面交流到灵活选择最高效的沟通方式。 5. 区分重点,从添加标记到利用工程化工具自动解决。 6. 对新人要更友好。 7. 尽量避免跨时区协作,必要时选择视频会议。 最后,希望 CodeReview 能够得到公司的支持,如果你们公司还没有认可 CodeReview 的价值,可以将这篇文章分享给你的领导。 > 讨论地址是:[精读《如何做好 CodeReview》 · Issue #237 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/237) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/143.精读《Suspense 改变开发方式》.md ================================================ ## 1 引言 很多人都用过 React Suspense,但如果你认为它只是配合 React.lazy 实现异步加载的蒙层,就理解的太浅了。实际上,React Suspense 改变了开发规则,要理解这一点,需要作出思想上的改变。 我们结合 [Why React Suspense Will Be a Game Changer](https://medium.com/react-in-depth/why-react-suspense-will-be-a-game-changer-37b40fea71ec) 这篇文章,带你重新认识 React Suspense。 ## 2 概述 异步加载是前端开发的重要环节,也是一直以来样板代码最严重的场景之一,原文通过三种取数方案的对比,逐渐找到一种最佳的异步取数方式。 在讲解这三种取数方案之前,首先通过下面这张图说明了 Suspense 的功能: ![](https://img.alicdn.com/tfs/TB12.npyoz1gK0jSZLeXXb9kVXa-1024-808.gif) 从上图可以看出,子元素在异步取数时会阻塞父组件渲染,并一直冒泡到最外层第一个 Suspense,此时 Suspense 不会渲染子组件,而是渲染 `fallback`,当所有子组件异步阻塞取消后才会正常渲染。 下面介绍文中给出的三种取数方式,首先是最原始的本地状态管理方案。 ### 本地异步状态管理,直白但不利于维护 在 Suspense 方案出来之前,我们一般都在代码中利用本地状态管理异步数据。 即便代码做了一定抽象,那也只是把逻辑从一个文件移到了另一个问题,可维护性与可拓展性都没有本质的改变,因此基本可以用下面的结构说明: ```javascript class DynamicData extends Component { state = { loading: true, error: null, data: null }; componentDidMount() { fetchData(this.props.id) .then(data => { this.setState({ loading: false, data }); }) .catch(error => { this.setState({ loading: false, error: error.message }); }); } componentDidUpdate(prevProps) { if (this.props.id !== prevProps.id) { this.setState({ loading: true }, () => { fetchData(this.props.id) .then(data => { this.setState({ loading: false, data }); }) .catch(error => { this.setState({ loading: false, error: error.message }); }); }); } } render() { const { loading, error, data } = this.state; return loading ? (

Loading...

) : error ? (

Error: {error}

) : (

Data loaded ?

); } } ``` 如上所述,首先申明本地状态管理至少三种数据:异步状态、异步结果与异步错误,其次在不同的生命周期中处理初始化发请求与重新发请求的问题,最后在渲染函数中根据不同的状态渲染不同的结果,所以实际上我们写了三个渲染组件。 从下面几个角度对上述代码进行评价: - **冗余的三种状态 - 糟糕的开发体验** - 很明显,存储了三套数据,渲染三种结果,不利于开发维护。 - **冗余的样板代码 - 糟糕的开发体验** - 为了管理异步状态,上述代码非常冗长,显然这个问题是存在的。 - **数据与状态封闭性 - 糟糕的用户体验 + 开发体验** - 所有数据与状态管理都存储在每一个这种组件中,将取数状态与组件绑定的结果就是,我们只能忍受组件独立运行的 Loading 逻辑,而无法对他们进行统一管理。 - **重新取数 - 糟糕的开发体验** - 需要在另一个生命周期中申明重新取数,很明显是个麻烦的行为。 - **一闪而过的短暂 Loading - 糟糕的用户体验** - 如果用户网速足够快,则 Loading 时间会非常短,此时一闪而过的 Loading 反而比没有 Loading 更烦人,我们应该在用户感知到卡的时候再出现 Loading 状态。 ### Context 管理状态,有进步但问题依然很多 如果利用 Context 做状态共享,我们将取数的数据管理与逻辑代码写在父组件,子组件专心用于展示,效果会好一些,代码如下: ```javascript const DataContext = React.createContext(); class DataContextProvider extends Component { // We want to be able to store multiple sources in the provider, // so we store an object with unique keys for each data set + // loading state state = { data: {}, fetch: this.fetch.bind(this) }; fetch(key) { if (this.state[key] && (this.state[key].data || this.state[key].loading)) { // Data is either already loaded or loading, so no need to fetch! return; } this.setState( { [key]: { loading: true, error: null, data: null } }, () => { fetchData(key) .then(data => { this.setState({ [key]: { loading: false, data } }); }) .catch(e => { this.setState({ [key]: { loading: false, error: e.message } }); }); } ); } render() { return ; } } class DynamicData extends Component { static contextType = DataContext; componentDidMount() { this.context.fetch(this.props.id); } componentDidUpdate(prevProps) { if (this.props.id !== prevProps.id) { this.context.fetch(this.props.id); } } render() { const { id } = this.props; const { data } = this.context; const idData = data[id]; return idData.loading ? (

Loading...

) : idData.error ? (

Error: {idData.error}

) : (

Data loaded ?

); } } ``` `DataContextProvider` 组件承担了状态管理与异步逻辑工作,而 `DynamicData` 组件只需要从 Context 获取异步状态渲染即可,这样来看至少解决了一部分问题,我们还是从之前的角度进行评价: - **冗余的三种状态 - 糟糕的开发体验** - 问题依然存在,只不过代码的位置转移了一部分到父组件。 - **冗余的样板代码 - 糟糕的开发体验** - 将展示与逻辑分离,成功降低了样板代码数量,至少当一个异步数据复用于多个组件时,不需要写多份样板代码了。 - **数据与状态封闭性 - 糟糕的用户体验 + 开发体验** - 这个问题得到一定程度解决,但是引入了新问题,即这个子组件仅在特定环境下可以正常运行。但在一个良好的设计下,组件运行不应该依赖于它所处的位置。 - **重新取数 - 糟糕的开发体验** - 问题依然存在。 - **一闪而过的短暂 Loading - 糟糕的用户体验** - 问题依然存在。 ### Suspense 管理状态,最棒的方案 利用 Suspense 进行异步处理,代码处理大概是这样的: ```javascript import createResource from "./magical-cache-provider"; const dataResource = createResource(id => fetchData(id)); class DynamicData extends Component { render() { const data = dataResource.read(this.props.id); return

Data loaded ?

; } } class App extends Component { render() { return ( Loading...

}>
); } } ``` 在原文写作的时候,Suspense 仅能对 React.lazy 生效,但现在已经可以对任何异步状态生效了,只要符合 Pending 中 throw promise 的规则。 我们再审视一下上面的代码,可以发现代码量减少了很多,其中和转换成 Function Component 的写法也有关系。 最后还是从如下几个角度进行评价: - **冗余的三种状态 - 糟糕的开发体验** - ⭐️ - 可以看到,组件只要处理成功得到数据的状态即可,三种状态合并成了一种状态。 - **冗余的样板代码 - 糟糕的开发体验** - ⭐️ - 展示与逻辑完全分离,展示只要拿到数据展示 UI 即可。 - **数据与状态封闭性 - 糟糕的用户体验 + 开发体验** - ⭐️ - 这个问题得到了完美的解决,具体看下面详细介绍。 - **重新取数 - 糟糕的开发体验** - ⭐️ - 不需要关心何时需要重新取数,当数据变化时会自动执行。 - **一闪而过的短暂 Loading - 糟糕的用户体验** - 问题依然存在。 为了进一步说明 Suspense 的魔力,笔者特意把这段代码单独拿出来说明: ```javascript class App extends Component { render() { return ( Loading...

}> Loading content...

}>
Loading footer...

}>
); } } ``` 上面代码表明了逻辑与展示的完美分离。 从代码结构上来看,我们可以在任何需要异步取数的组件父级添加 Suspense 达到 Loading 的效果,也就是说,如果只在最外层加一个 Suspense,那么整个应用所有 Loading 都结束后才会渲染,然而我们也能随心所欲的在任何层级继续添加 Suspense,那么对应作用域内的 Loading 就会首先执行完毕,并由当前的 Suspense 控制。 **这意味着我们可以自由决定 Loading 状态的范围组合。** 试想当 Loading 状态交由组件控制的方案一与方案二,是不可能做到合并 Loading 时机的,而 Suspense 方案做到了将 Loading 状态与 UI 分离,我们可以通过添加 Suspense 自由控制 Loading 的粒度。 ## 3 精读 Suspense 对所有子组件异步都可以作用,因此无论是 React.lazy 还是异步取数,都可以通过 Suspense 进行 Pending。 异步时机被 Suspense pending 需要遵循一定规则,这个规则在之前的 [精读《Hooks 取数 - swr 源码》](https://github.com/dt-fe/weekly/blob/v2/128.%E7%B2%BE%E8%AF%BB%E3%80%8AHooks%20%E5%8F%96%E6%95%B0%20-%20swr%20%E6%BA%90%E7%A0%81%E3%80%8B.md) 有介绍过,即 Suspense 要求代码 suspended,即抛出一个可以被捕获的 Promise 异常,在这个 Promise 结束后再渲染组件,因此取数函数需要在 Pending 状态时抛出一个 Promise,使其可以被 Suspense 捕获到。 另外,关于文中提到的 fallback 最小出现时间的保护间隔,目前还是一个 [Open Issue](https://github.com/facebook/react/issues/17351),也许有一天 React 官方会提供支持。 不过即便官方不支持,我们也有方式实现,即让这个逻辑由 fallback 组件实现: ```jsx ; const MyFallback = () => { // 计时器,200 ms 以内 return null,200 ms 后 return }; ``` ## 4 总结 之所以说 Suspense 开发方式改变了开发规则,是因为它做到了将异步的状态管理与 UI 组件分离,所有 UI 组件都无需关心 Pending 状态,而是当作同步去执行,这本身就是一个巨大的改变。 另外由于状态的分离,我们可以利用纯 UI 组件拼装任意粒度的 Pending 行为,以整个 App 作为一个大的 Suspense 作为兜底,这样 UI 彻底与异步解耦,哪里 Loading,什么范围内 Loading,完全由 Suspense 组合方式决定,这样的代码显然具备了更强的可拓展性。 > 讨论地址是:[精读《Suspense 改变开发方式》 · Issue #238 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/238) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/144.精读《Webpack5 新特性 - 模块联邦》.md ================================================ ## 1 引言 先说结论:Webpack5 模块联邦让 Webpack 达到了线上 Runtime 的效果,让代码直接在项目间利用 CDN 直接共享,不再需要本地安装 Npm 包、构建再发布了! 我们知道 Webpack 可以通过 DLL 或者 Externals 做代码共享时 Common Chunk,但不同应用和项目间这个任务就变得困难了,我们几乎无法在项目之间做到按需热插拔。 模块联邦是 Webpack5 新内置的一个重要功能,可以让跨应用间真正做到模块共享,所以这周让我们通过 [webpack-5-module-federation-a-game-changer-in-javascript-architecture](https://indepth.dev/webpack-5-module-federation-a-game-changer-in-javascript-architecture/#its-important-to-note-these-are-special-entry-points-they-are-only-a-few-kb-in-size-containing-a-special-webpack-runtime-that-can-interface-with-the-host-it-is-not-a-standard-entry-point--7/) 这篇文章了解什么是 “模块联邦” 功能。 ## 2 概述 & 精读 ### NPM 方式共享模块 想象一下正常的共享模块方式,对,就是 NPM。 如下图所示,正常的代码共享需要将依赖作为 Lib 安装到项目,进行 Webpack 打包构建再上线,如下图: 对于项目 Home 与 Search,需要共享一个模块时,最常见的办法就是将其抽成通用依赖并分别安装在各自项目中。 虽然 Monorepo 可以一定程度解决重复安装和修改困难的问题,但依然需要走本地编译。 ### UMD 方式共享模块 真正 Runtime 的方式可能是 UMD 方式共享代码模块,即将模块用 Webpack UMD 模式打包,并输出到其他项目中。这是非常普遍的模块共享方式: 对于项目 Home 与 Search,直接利用 UMD 包复用一个模块。但这种技术方案问题也很明显,就是包体积无法达到本地编译时的优化效果,且库之间容易冲突。 ### 微前端方式共享模块 微前端:micro-frontends (MFE) 也是最近比较火的模块共享管理方式,微前端就是要解决多项目并存问题,多项目并存的最大问题就是模块共享,不能有冲突。 由于微前端还要考虑样式冲突、生命周期管理,所以本文只聚焦在资源加载方式上。微前端一般有两种打包方式: 1. 子应用独立打包,模块更解耦,但无法抽取公共依赖等。 2. 整体应用一起打包,很好解决上面的问题,但打包速度实在是太慢了,不具备水平扩展能力。 ### 模块联邦方式 终于提到本文的主角了,作为 Webpack5 内置核心特性之一的 Federated Module: 从图中可以看到,这个方案是直接将一个应用的包应用于另一个应用,同时具备整体应用一起打包的公共依赖抽取能力。 让应用具备模块化输出能力,其实开辟了一种新的应用形态,即 “中心应用”,这个中心应用用于在线动态分发 Runtime 子模块,并不直接提供给用户使用: 对微前端而言,这张图就是一个完美的主应用,因为所有子应用都可以利用 Runtime 方式复用主应用的 Npm 包和模块,更好的集成到主应用中。 模块联邦的使用方式如下: ```js const HtmlWebpackPlugin = require("html-webpack-plugin"); const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin"); module.exports = { // other webpack configs... plugins: [ new ModuleFederationPlugin({ name: "app_one_remote", remotes: { app_two: "app_two_remote", app_three: "app_three_remote" }, exposes: { AppContainer: "./src/App" }, shared: ["react", "react-dom", "react-router-dom"] }), new HtmlWebpackPlugin({ template: "./public/index.html", chunks: ["main"] }) ] }; ``` 模块联邦本身是一个普通的 Webpack 插件 `ModuleFederationPlugin`,插件有几个重要参数: 1. `name` 当前应用名称,需要全局唯一。 2. `remotes` 可以将其他项目的 `name` 映射到当前项目中。 3. `exposes` 表示导出的模块,只有在此申明的模块才可以作为远程依赖被使用。 4. `shared` 是非常重要的参数,指定了这个参数,可以让远程加载的模块对应依赖改为使用本地项目的 React 或 ReactDOM。 比如设置了 `remotes: { app_two: "app_two_remote" }`,在代码中就可以直接利用以下方式直接从对方应用调用模块: ```js import { Search } from "app_two/Search"; ``` 这个 `app_two/Search` 来自于 `app_two` 的配置: ```js // app_two 的 webpack 配置 export default { plugins: [ new ModuleFederationPlugin({ name: "app_two", library: { type: "var", name: "app_two" }, filename: "remoteEntry.js", exposes: { Search: "./src/Search" }, shared: ["react", "react-dom"] }) ] }; ``` 正是因为 `Search` 在 `exposes` 被导出,我们因此可以使用 `[name]/[exposes_name]` 这个模块,这个模块对于被引用应用来说是一个本地模块。 ## 3 总结 模块联邦为更大型的前端应用提供了开箱解决方案,并已经作为 Webpack5 官方模块内置,可以说是继 Externals 后最终的运行时代码复用解决方案。 另外 Webpack5 还内置了大量编译时缓存功能,可以看到,无论是性能还是多项目组织,Webpack5 都在尝试给出自己的最佳思路,期待 Webpack5 正式发布,前端工程化会迈向一个新的阶段。 > 讨论地址是:[精读《Webpack5 新特性 - 模块联邦》 · Issue #239 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/239) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/145.精读《React Router v6》.md ================================================ ## 1 引言 [React Router v6](https://github.com/ReactTraining/react-router) alpha 版本发布了,本周通过 [A Sneak Peek at React Router v6](https://alligator.io/react/react-router-v6/) 这篇文章分析一下带来的改变。 ## 2 概述 ### 更名为 一个不痛不痒的改动,使 API 命名更加规范。 ```jsx // v5 import { BrowserRouter, Switch, Route } from "react-router-dom"; function App() { return ( ); } ``` 在 React Router v6 版本里,直接使用 `Routes` 替代 `Switch`: ```jsx // v6 import { BrowserRouter, Routes, Route } from "react-router-dom"; function App() { return ( } /> } /> ); } ``` ### 升级 在 v5 版本里,想要给组件传参数是不太直观的,需要利用 RenderProps 的方式透传 `routeProps`: ```jsx import Profile from './Profile'; // v5 ( )} /> // v6 } /> } /> ``` 而在 v6 版本中,`render` 与 `component` 方案合并成了 `element` 方案,可以轻松传递 props 且不需要透传 `roteProps` 参数。 ### 更方便的嵌套路由 在 v5 版本中,嵌套路由需要通过 `useRouteMatch` 拿到 `match`,并通过 `match.path` 的拼接实现子路由: ```jsx // v5 import { BrowserRouter, Switch, Route, Link, useRouteMatch } from "react-router-dom"; function App() { return ( ); } function Profile() { let match = useRouteMatch(); return (
); } ``` 在 v6 版本中省去了 `useRouteMatch` 这一步,支持直接用 `path` 表示相对路径: ```jsx // v6 import { BrowserRouter, Routes, Route, Link, Outlet } from "react-router-dom"; // Approach #1 function App() { return ( } /> } /> ); } function Profile() { return (
} /> } />
); } // Approach #2 // You can also define all // in a single place function App() { return ( } /> }> } /> } /> ); } function Profile() { return (
); } ``` 注意 `Outlet` 是渲染子路由的 Element。 ### useNavigate 替代 useHistory 在 v5 版本中,主动跳转路由可以通过 `useHistory` 进行 `history.push` 等操作: ```jsx // v5 import { useHistory } from "react-router-dom"; function MyButton() { let history = useHistory(); function handleClick() { history.push("/home"); } return ; } ``` 而在 v6 版本中,可以通过 `useNavigate` 直接实现这个常用操作: ```jsx // v6 import { useNavigate } from "react-router-dom"; function MyButton() { let navigate = useNavigate(); function handleClick() { navigate("/home"); } return ; } ``` react-router 内部对 history 进行了封装,如果需要 `history.replace`,可以通过 `{ replace: true }` 参数指定: ```jsx // v5 history.push("/home"); history.replace("/home"); // v6 navigate("/home"); navigate("/home", { replace: true }); ``` ### 更小的体积 8kb 由于代码几乎重构,v6 版本的代码压缩后体积从 20kb 缩小到 8kb。 ## 3 精读 react-router v6 源码中有一段比较核心的理念,笔者拿出来与大家分享,对一些框架开发是大有裨益的。我们看 `useRoutes` 这段代码节选: ```jsx export function useRoutes(routes, basename = "", caseSensitive = false) { let { params: parentParams, pathname: parentPathname, route: parentRoute } = React.useContext(RouteContext); if (warnAboutMissingTrailingSplatAt) { // ... } basename = basename ? joinPaths([parentPathname, basename]) : parentPathname; let navigate = useNavigate(); let location = useLocation(); let matches = React.useMemo( () => matchRoutes(routes, location, basename, caseSensitive), [routes, location, basename, caseSensitive] ); // ... // Otherwise render an element. let element = matches.reduceRight((outlet, { params, pathname, route }) => { return ( ); }, null); return element; } ``` 可以看到,利用 `React.Context`,v6 版本在每个路由元素渲染时都包裹了一层 `RouteContext`。 拿更方便的路由嵌套来说: > 在 v6 版本中省去了 `useRouteMatch` 这一步,支持直接用 `path` 表示相对路径。 这就是利用这个方案做到的,因为给每一层路由文件包裹了 Context,所以在每一层都可以拿到上一层的 `path`,因此在拼接路由时可以完全由框架内部实现,而不需要用户在调用时预先拼接好。 再以 `useNavigate` 举例,有人觉得 `navigate` 这个封装仅停留在形式层,但其实在功能上也有封装,比如如果传入但是一个相对路径,会根据当前路由进行切换,下面是 `useNavigate` 代码节选: ```jsx export function useNavigate() { let { history, pending } = React.useContext(LocationContext); let { pathname } = React.useContext(RouteContext); let navigate = React.useCallback( (to, { replace, state } = {}) => { if (typeof to === "number") { history.go(to); } else { let relativeTo = resolveLocation(to, pathname); let method = !!replace || pending ? "replace" : "push"; history[method](relativeTo, state); } }, [history, pending, pathname] ); return navigate; } ``` 可以看到,利用 `RouteContext` 拿到当前的 `pathname`,并根据 `resolveLocation` 对 `to` 与 `pathname` 进行路径拼接,而 `pathname` 就是通过 `RouteContext.Provider` 提供的。 ### 巧用多层 Context Provider 很多时候我们利用 Context 停留在一个 `Provider`,多个 `useContext` 的层面上,这是 Context 最基础的用法,但相信读完 React Router v6 这篇文章,我们可以挖掘出 Context 更多的用法:多层 Context Provider。 **虽然说 Context Provider 存在多层会采取最近覆盖的原则,但这不仅仅是一条规避错误的功能,我们可以利用这个功能实现 React Router v6 这样的改良。** 为了更仔细说明这个特性,这里再举一个具体的例子:比如实现搭建渲染引擎时,每个组件都有一个 id,但这个 id 并不透出在组件的 props 上: ```jsx const Input = () => { // Input 组件在画布中会自动生成一个 id,但这个 id 组件无法通过 props 拿到 }; ``` 此时如果我们允许 Input 组件内部再创建一个子元素,又希望这个子元素的 id 是由 Input 推导出来的,我们可能需要用户这么做: ```jsx const Input = ({ id }) => { return ; }; ``` 这样做有两个问题: 1. 将 id 暴露给 Input 组件,违背了之前设计的简洁性。 2. 组件需要对 id 进行拼装,很麻烦。 这里遇到的问题和 React Router 遇到的一样,我们可以将代码简化成下面这样,但功能不变吗? ```jsx const Input = () => { return ; }; ``` 答案是可以做到,我们可以利用 Context 实现这种方案。关键点就在于,渲染 Input 但组件容器需要包裹一个 Provider: ```jsx const ComponentLoader = ({ id, element }) => { {element}; }; ``` 那么对于内部的组件来说,在不同层级下调用 `useContext` 拿到的 id 是不同的,这正是我们想要的效果: ```jsx const ComponentLoader = ({id,element}) => { const { id: parentId } = useContext(Context) {element} } ``` 这样我们在 `Input` 内部调用的 `` 实际上拼接的实际 id 是 `01`,而这完全抛到了外部引擎层处理,用户无需手动拼接。 ## 4 总结 React Router v6 完全利用 Hooks 重构后,不仅代码量精简了很多,还变得更好用了,等发正式版的时候可以快速升级一波。 另外从 React Router v6 做的这些优化中,我们从源码中挖掘到了关于 Context 更巧妙的用法,希望这个方法可以帮助你运用到其他更复杂的项目设计中。 > 讨论地址是:[精读《React Router v6》 · Issue #241 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/241) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/146.精读《React Hooks 数据流》.md ================================================ ## 1 引言 React Hooks 渐渐被国内前端团队所接受,但基于 Hooks 的数据流方案却还未固定,我们有 “100 种” 类似的选择,却各有利弊,让人难以取舍。 本周笔者就深入谈一谈对 Hooks 数据流的理解,相信读完文章后,可以从百花齐放的 Hooks 数据流方案中看到本质。 ## 2 精读 基于 React Hooks 谈数据流,我们先从最不容易产生分歧的基础方案说起。 ### 单组件数据流 单组件最简单的数据流一定是 `useState`: ```jsx function App() { const [count, setCount] = useState(); } ``` `useState` 在组件内用是毫无争议的,那么下个话题就一定是跨组件共享数据流了。 ### 组件间共享数据流 跨组件最简单的方案就是 `useContext`: ```jsx const CountContext = createContext(); function App() { const [count, setCount] = useState(); return ( ); } function Child() { const { count } = useContext(CountContext); } ``` 用法都是官方 API,显然也是毫无争议的,但问题是数据与 UI 不解耦,这个问题 [unstated-next](https://github.com/jamiebuilds/unstated-next) 已经为你想好解决方案了。 ### 数据流与组件解耦 [unstated-next](https://github.com/jamiebuilds/unstated-next) 可以帮你把上面例子中,定义在 `App` 中的数据单独出来,形成一个自定义数据管理 Hook: ```jsx import { createContainer } from "unstated-next"; function useCounter() { const [count, setCount] = useState(); return { count, setCount }; } const Counter = createContainer(useCounter); function App() { return ( ); } function Child() { const { count } = Counter.useContainer(); } ``` 数据与 `App` 就解耦了,这下 `Counter` 再也不和 `App` 绑定了,`Counter` 可以和其他组件绑定作用了。 这个时候性能问题就慢慢浮出了水面,首当其冲的就是 `useState` 无法合并更新的问题,我们自然想到利用 `useReducer` 解决。 ### 合并更新 `useReducer` 可以让数据合并更新,这也是 React 官方 API,毫无争议: ```jsx import { createContainer } from "unstated-next"; function useCounter() { const [state, dispath] = useReducer( (state, action) => { switch (action.type) { case "setCount": return { ...state, count: action.setCount(state.count), }; case "setFoo": return { ...state, foo: action.setFoo(state.foo), }; default: return state; } return state; }, { count: 0, foo: 0 } ); return { ...state, dispatch }; } const Counter = createContainer(useCounter); function App() { return ( ); } function Child() { const { count } = Counter.useContainer(); } ``` 这下即便要同时更新 `count` 和 `foo`,我们也能通过抽象成一个 `reducer` 的方式合并更新。 然而还有性能问题: ```jsx function ChildCount() { const { count } = Counter.useContainer(); } function ChildFoo() { const { foo } = Counter.useContainer(); } ``` 更新 `foo` 时,`ChildCount` 和 `ChildFoo` 同时会执行,但 `ChildCount` 没用到 `foo` 呀?这个原因是 `Counter.useContainer` 提供的数据流是一个引用整体,其子节点 `foo` 引用变化后会导致整个 Hook 重新执行,继而所有引用它的组件也会重新渲染。 此时我们发现可以利用 Redux `useSelector` 实现按需更新。 ### 按需更新 首先我们利用 Redux 对数据流做一次改造: ```jsx import { createStore } from "redux"; import { Provider, useSelector } from "react-redux"; function reducer(state, action) { switch (action.type) { case "setCount": return { ...state, count: action.setCount(state.count), }; case "setFoo": return { ...state, foo: action.setFoo(state.foo), }; default: return state; } return state; } function App() { return ( ); } function Child() { const { count } = useSelector( (state) => ({ count: state.count }), shallowEqual ); } ``` `useSelector` 可以让 `Child` 在 `count` 变化时才更新,而 `foo` 变化时不更新,这已经接近较为理想的性能目标了。 但 `useSelector` 的作用仅仅是计算结果不变化时阻止组件刷新,但并不能保证返回结果的引用不变化。 ### 防止数据引用频繁变化 对于上面的场景,拿到 `count` 的引用是不变的,**但对于其他场景就不一定了**。 举个例子: ```jsx function Child() { const user = useSelector((state) => ({ user: state.user }), shallowEqual); return ; } ``` **假设 `user` 对象在每次数据流更新引用都会发生变化**,那么 `shallowEqual` 自然是不起作用,那我们换成 `deepEqual`深对比呢?结果是引用依然会变,只是重渲染不那么频繁了: ```jsx function Child() { const user = useSelector( (state) => ({ user: state.user }), // 当 user 值变化时才重渲染 deepEqual ); // 但此处拿到的 user 引用还是会变化 return ; } ``` 是不是觉得在 `deepEqual` 的作用下,没有触发重渲染,`user` 的引用就不会变呢?答案是会变,因为 `user` 对象在每次数据流更新都会变,`useSelector` 在 `deepEqual` 作用下没有触发重渲染,但因为全局 reducer 隐去组件自己的重渲染依然会重新执行此函数,此时拿到的 `user` 引用会不断变化。 因此 `useSelector` `deepEqual` 一定要和 `useDeepMemo` 结合使用,才能保证 `user` 引用不会频繁改变: ```jsx function Child() { const user = useSelector( (state) => ({ user: state.user }), // 当 user 值变化时才重渲染 deepEqual ); const userDeep = useDeepMemo(() => user, [user]); return ; } ``` 当然这是比较极端的情况,只要看到 `deepEqual` 与 `useSelector` 同时作用了,就要问问自己其返回的值的引用会不会发生意外变化。 ### 缓存查询函数 对于极限场景,即便控制了重渲染次数与返回结果的引用最大程度不变,还是可能存在性能问题,这最后一块性能问题就处在查询函数上。 上面的例子中,查询函数比较简单,但如果查询函数非常复杂就不一样了: ```jsx function Child() { const user = useSelector( (state) => ({ user: verySlowFunction(state.user) }), // 当 user 值变化时才重渲染 deepEqual ); const userDeep = useDeepMemo(() => user, [user]); return ; } ``` 我们假设 `verySlowFunction` 要遍历画布中 1000 个组件的 n 3 次方次,那组件的重渲染时间消耗与查询时间相比完全不值一提,我们需要考虑缓存查询函数。 一种方式是利用 [reselect](https://github.com/reduxjs/reselect) 根据参数引用进行缓存。 想象一下,如果 `state.user` 的引用不频繁变化,但 `verySlowFunction` 非常慢,理想情况是 `state.user` 引用变化后才重新执行 `verySlowFunction`,但上面的例子中,`useSelector` 并不知道还能这么优化,只能傻傻的每次渲染重复执行 `verySlowFunction`,哪怕 `state.user` 没有变。 此时我们要告诉引用,`state.user` 是否变化才是重新执行的关键: ```jsx import { createSelector } from "reselect"; const userSelector = createSelector( (state) => state.user, (user) => verySlowFunction(user) ); function Child() { const user = useSelector( (state) => userSelector(state), // 当 user 值变化时才重渲染 deepEqual ); const userDeep = useDeepMemo(() => user, [user]); return ; } ``` 在上面的例子中,通过 `createSelector` 创建的 `userSelector` 会一层层进行缓存,当第一个参数返回的 `state.user` 引用不变时,会直接返回上一次执行结果,直到其应用变化了才会继续往下执行。 > 这也说明了函数式保持幂等的重要性,如果 `verySlowFunction` 不是严格幂等的,这种缓存也无法实施。 看上去很美好,然而实战中你可能发现没有那么美好,因为上面的例子都建立在 **Selector 完全不依赖外部变量**。 ### 结合外部变量的缓存查询 如果我们要查询的用户来自于不同地区,需要传递 `areaId` 加以识别,那么可以拆分为两个 Selector 函数: ```jsx import { createSelector } from "reselect"; const areaSelector = (state, props) => state.areas[props.areaId].user; const userSelector = createSelector(areaSelector, (user) => verySlowFunction(user) ); function Child() { const user = useSelector( (state) => userSelector(state, { areaId: 1 }), deepEqual ); const userDeep = useDeepMemo(() => user, [user]); return ; } ``` 所以为了不在组件函数内调用 `createSelector`,我们需要尽可能将用到外部变量的地方抽象成一个通用 Selector,并作为 `createSelector` 的一个先手环节。 但 `userSelector` 提供给多个组件使用时缓存会失效,原因是我们只创建了一个 Selector 实例,因此这个函数还需要再包装一层高阶形态: ```jsx import { createSelector } from "reselect"; const userSelector = () => createSelector(areaSelector, (user) => verySlowFunction(user)); function Child() { const customSelector = useMemo(userSelector, []); const user = useSelector( (state) => customSelector(state, { areaId: 1 }), deepEqual ); } ``` 所以对于外部变量结合的环节,还需要 `useMemo` 与 `useSelector` 结合使用,`useMemo` 处理外部变量依赖的引用缓存,`useSelector` 处理 Store 相关引用缓存。 ## 3 总结 基于 Hooks 的数据流方案不能算完美,我在写作这篇文章时就感觉到这种方案属于 “浅入深出”,简单场景还容易理解,随着场景逐步复杂,方案也变得越来越复杂。 但这种 Immutable 的数据流管理思路给了开发者非常自由的缓存控制能力,只要透彻理解上述概念,就可以开发出非常 “符合预期” 的数据缓存管理模型,只要精心维护,一切就变得非常有秩序。 > 讨论地址是:[精读《React Hooks 数据流》 · Issue #242 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/242) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/147. 精读《@types react 值得注意的 TS 技巧》.md ================================================ ## 1 引言 从 [@types/react](https://unpkg.com/browse/@types/react@16.9.34/index.d.ts) 源码中挖掘一些 Typescript 使用技巧吧。 ## 2 精读 ### 泛型 extends 泛型可以指代可能的参数类型,但指代任意类型范围太模糊,当我们需要对参数类型加以限制,或者确定只处理某种类型参数时,就可以对泛型进行 extends 修饰。 问题:`React.lazy` 需要限制返回值是一个 `Promise` 类型,且 `T` 必须是 React 组件类型。 方案: ```typescript function lazy>( factory: () => Promise<{ default: T }> ): LazyExoticComponent; ``` `T extends ComponentType` 确保了 T 这个类型一定符合 `ComponentType` 这个 React 组件类型定义,我们再将 T 用到 `Promise<{ default: T }>` 位置即可。 ## 泛型 extends + infer 如果有一种场景,需要拿到一个类型,这个类型是当某个参数符合某种结构时,这个结构内的一种子类型,就需要结合 泛型 extends + infer 了。 问题:`React.useReducer` 第一个参数是 Reducer,第二个参数是初始化参数,其实第二个参数的类型是第一个参数中回调函数第一个参数的类型,那我们怎么将这两个参数的关系联系到一起呢? 方案: ```typescript function useReducer, I>( reducer: R, initializerArg: I & ReducerState, initializer: (arg: I & ReducerState) => ReducerState ): [ReducerState, Dispatch>]; type ReducerState> = R extends Reducer ? S : never; ``` `R extends Reducer` 的意思在上面已经提过了,也就是 R 必须符合 `Reducer` 结构,也就是 `reducer` 必须符合这个结构,之后重点来了:`initializerArg` 利用 `ReducerState` 这个类型直接从 `reducer` 的类型 `R` 中将第一个回调参数挖了出来并返回。 `ReducerState` 定义中 `R extends Reducer ? S : never` 的含义是:如果 R 符合 `Reducer` 类型,则返回类型 `S`,这个 `S` 是 `Reducer` 也就是 State 位置的类型,否则返回 `never` 类型。 所以 infer 表示待推断类型,是非常强大的功能,可以指定在任意位置代指其类型,并配合 extends 判断是否符合结构,可以使类型推断具备一定编程能力。 要用 extends 的另一个原因是,只有 extends 才能将结构描述出来,我们才能精确定义 infer 指代类型的位置。 ### 类型重载 当一个类型拥有多种使用可能性时,可以采用类型重载定义复数类型,Typescript 作用时会逐个匹配并找到第一个满足条件的。 问题:`createElement` 第一个参数支持 FunctionComponent 与 ClassComponent,而且传入参数不同,返回值的类型也不同。 方案: ```typescript function createElement

( type: FunctionComponent

, props?: (Attributes & P) | null, ...children: ReactNode[] ): FunctionComponentElement

; function createElement

( type: ClassType< P, ClassicComponent, ClassicComponentClass

>, props?: (ClassAttributes> & P) | null, ...children: ReactNode[] ): CElement>; ``` 将 `createElement` 写两遍及以上,并配合不同的参数类型与返回值类型即可。 ### 自定义类型收窄 我们可以通过 `typeof` 或 `instanceof` 做一些类型收窄工作,但有些类型甚至自定义类型的收窄判断函数需要自定义,我们可以通过 `is` 关键字定义自定义类型收窄判断函数。 问题:`isValidElement` 判断对象是否是合法的 React 元素,我们希望这个函数具备类型收窄的功能。 方案: ```typescript function isValidElement

( object: {} | null | undefined ): object is ReactElement

; const element: string | ReactElement = ""; if (isValidElement(element)) { element; // 自动推导类型为 ReactElement } else { element; // 自动推导类型为 string } ``` 基于这个方案,我们可以创建一些很有用的函数,比如 `isArray`,`isMap`,`isSet` 等等,通过 `is` 关键字时其被调用时具备类型收窄的功能。 ### 用 Interface 定义函数 一般定义函数类型我们用 `type`,但有些情况下定义的函数既可被调用,也有一些默认属性值需要定义,我们可以继续用 Interface 定义。 问题:`FunctionComponent` 既可以当作函数调用,同时又能定义 `defaultProps` `displayName` 等固定属性。 方案: ```typescript interface FunctionComponent

{ (props: PropsWithChildren

, context?: any): ReactElement | null; propTypes?: WeakValidationMap

; contextTypes?: ValidationMap; defaultProps?: Partial

; displayName?: string; } ``` `(props: PropsWithChildren

, context?: any): ReactElement | null` 表示这种类型的变量可以作为函数执行: ```jsx const App: FunctionComponent = () =>

; App.displayName = "App"; ``` ## 3 总结 看完文章内容,相信你已经可以独立读懂 [@types/react](https://unpkg.com/browse/@types/react@16.9.34/index.d.ts) 这个包的所有类型定义! 更多基础内容可以阅读 [精读《Typescript2.0 - 2.9》](https://github.com/dt-fe/weekly/blob/7de3c77c3bdd7304c9e4b0c0f70c3ba6968ebd29/058.%E7%B2%BE%E8%AF%BB%E3%80%8ATypescript2.0%20-%202.9%E3%80%8B.md) 与 [精读《Typescript 3.2 新特性》](https://github.com/dt-fe/weekly/blob/v2/084.%E7%B2%BE%E8%AF%BB%E3%80%8ATypescript%203.2%20%E6%96%B0%E7%89%B9%E6%80%A7%E3%80%8B.md),由于 TS 更新频繁,后续 TS 技巧可能继续以阅读源码方式进行,希望这次选用的 React 类型源码可以让你印象深刻。 > 讨论地址是:[精读《@types/react 值得注意的 TS 技巧》 · Issue #245 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/245) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/148. 精读《React Error Boundaries》.md ================================================ ## 1 引言 Error Boundaries 是 React16 提出来用来捕获渲染时错误的概念,今天我们一起读一读 [A Simple Guide to Error Boundaries in React](https://alligator.io/react/error-boundaries/) 这篇文章,了解一下这个重要机制。 ## 2 概述 Error Boundaries 可以用来捕获渲染时错误,API 如下: ```jsx class MyErrorBoundary extends Component { state = { error: null, }; static getDerivedStateFromError(error) { // 更新 state,下次渲染可以展示错误相关的 UI return { error: error }; } componentDidCatch(error, info) { // 错误上报 logErrorToMyService(error, info); } render() { if (this.state.error) { // 渲染出错时的 UI return

Something broke

; } return this.props.children; } } ``` - `static getDerivedStateFromError`: 在出错后有机会修改 state 触发最后一次错误 fallback 的渲染。 - `componentDidCatch`: 用于出错时副作用代码,比如错误上报等。 这两种方法中任意一个被定义时,这个组件就会成为 `Error Boundary` 组件,可以阻止子组件渲染时报错。 最后作者还提出一个建议,建议将 Error Boundary 单独作为一个组件,而不是将错误监听方法与业务组件耦合,一方面考虑到复用,另一方面则因为错误检测只对子组件生效。 好吧,其实 React 官方文档比这篇文章介绍的详细的多得多,原文介绍到此结束。 ## 3 精读 [React Error Boundaries 官方文档](https://reactjs.org/docs/error-boundaries.html) 里提到了四种无法 Catch 的错误场景: 1. 回调事件。由于回调事件执行时机不在渲染周期内,因此无法被 Error Boundary Catch 住,如有必要得自行 try/catch。 2. 异步。比如 `setTimeout` 或 `requestAnimationFrame`,和第一条同理。 3. 服务端渲染。 4. Error Boundary 组件自身触发的错误。因为只能捕获其子组件的错误。 这也是使用 Error Boundaries 最容易有疑问的地方。除了上面的情况,笔者结合自身经验再列举几种异常边界场景。 ### 无法捕获编译时错误 很明显,即便是 React 官方 API `Error Boundary` 也只能捕获运行时错误,而对编译时错误无能为力。 编译时错误包括不限于编译环境错误、运行前的框架错误检查提示、TS/Flow 类型错误等,这些都是 `Error Boundary` 无法捕获的,而且没有更好的办法 Catch 住,遇到编译错误就在编译时解决吧,仅关注运行时错误就好了。 ### 可以作用于 Function Component 虽然函数式组件无法定义 `Error Boundary`,但 `Error Boundary` 可以捕获函数式组件的错误,因此可以曲线救国: ```jsx // ErrorBoundary 组件 class ErrorBoundary extends React.Component { // ... } // 可以捕获所有组件异常,包括 Function Component 的子组件 const App = () => { return ( ); }; ``` ### 对 Hooks 也可生效 对于 Hooks 中异常也可以生效,比如下面的代码: ```jsx const Child = (props) => { React.useEffect(() => { console.log(1); props.a.b; console.log(2); }, [props.a.b]); return
; }; ``` 要注意的是,出现在 deps 中的错误会立即被 Catch,导致 `console.log(1)` 都无法打印。但如果是下面的代码,则可以打印出 `console.log(1)`,无法打印出 `console.log(2)`: ```jsx const Child = (props) => { React.useEffect(() => { console.log(1); props.a.b; console.log(2); }, []); return
; }; ``` 所以 React 官网的这句话并不是指 `Error Boundary` 对 Hooks 不生效,而是指 `Error Boundary` 无法以 Hooks 方式指定,对功能是没有影响的: > componentDidCatch and getDerivedStateFromError: There are no Hook equivalents for these methods yet, but they will be added soon. 所以这里的理解要注意一下,另外 React 官方文档 [Hooks FAQ](https://reactjs.org/docs/hooks-faq.html#how-do-lifecycle-methods-correspond-to-hooks) 有很多宝藏,建议抽时间逐条阅读。 ## 4 总结 `Error Boundary` 可以捕获所有子元素渲染时异常,包括 render、各生命周期函数,但也有很多使用限制,希望你可以正确使用它。 错误捕获也不是万能的,更多时候我们要避免并及时修复错误,通过错误捕获降低出错时对用户体验的影响,并在第一时间内监控起来并快速修复。 最后,你有明明正确使用了 `Error Boundary` 却依然无法 Catch 住的错误 Case 吗? > 讨论地址是:[精读《React Error Boundaries》 · Issue #246 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/246) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/149. 精读《React 性能调试》.md ================================================ ## 1 引言 在数据中台做 BI 工具经常面对海量数据的渲染处理,除了组件本身性能优化之外,经常要排查整体页面性能瓶颈点,尤其是维护一些性能做得并不好的旧代码时。 React 性能调试是面对这种问题的必修课,借助 [Profiling React.js Performance](https://addyosmani.com/blog/profiling-react-js/) 这篇文章一起学习一下这个技能吧。 ## 2 精读 本文介绍了众多性能检测工具与方法。 ### React Profiler `Profiler` 这个 API 是一种运行时 Debug 的补充,可以通过其 callback 拿到组件渲染信息,用法如下: ```jsx const Movies = ({ movies, addToQueue }) => (
); function callback( id, phase, actualTime, baseTime, startTime, commitTime, interactions ) {} ``` 这个 callback 会在每次渲染时执行,渲染分为初始化和更新阶段,通过 `phase` 区分,下面是参数详细说明: - id: 传入的 id。 - phase: "mount" 或 "update",表示更新状态。 - actualDuration: 实际渲染耗时。 - baseDuration: 没有使用 memo 时的渲染预计耗时。 - startTime: 开始渲染的时间。 - commitTime: React 提交更新的时间 - interactions: 何种原因导致的渲染,比如 `setState` 或 hooks changed 之类。 注意尽量不要轻易使用 `Profiler` 检测性能,因为 `Profiler` 本身也会消耗性能。 如果不想获得这么详细的渲染耗时,或者不想提前在代码中埋点,可以利用 DevTools 的 Profiler 查看更直观更简洁的渲染耗时: 其中 Ranked 可以展示按照渲染耗时排序后的结果,Interations 需要配合 Tracing API 使用,在后面会提到。 ### Tracing API 利用 `scheduler/tracing` 提供的 `trace` API,我们可以记录某个动作的耗时,比如 “点击添加按钮收藏一个电影” 耗时多久: ```jsx import { render } from "react-dom"; import { unstable_trace as trace } from "scheduler/tracing"; class MyComponent extends Component { addMovieButtonClick = (event) => { trace("Add To Movies Queue click", performance.now(), () => { this.setState({ itemAddedToQueue: true }); }); }; } ``` 在 Interations 中可以看到动作触发的耗时: 这个动作还可以是渲染,比如可以记录 ReactDOM 渲染的耗时: ```jsx import { unstable_trace as trace } from "scheduler/tracing"; trace("initial render", performance.now(), () => { ReactDom.render(, document.getElementById("app")); }); ``` 甚至还可以追踪异步的耗时: ```jsx import { unstable_trace as trace, unstable_wrap as wrap, } from "scheduler/tracing"; trace("Some event", performance.now(), () => { setTimeout( wrap(() => { // 异步操作 }) ); }); ``` 有了 `Profiler` 与 `trace` 这两件武器,我们可以监控任意元素的渲染耗时与交互耗时,几乎可以涵盖所有性能监控需要。 ### Puppeteer 我们还可以利用 Puppeteer 实现自动化操作并打印报告: ```jsx const puppeteer = require("puppeteer"); (async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); const navigationPromise = page.waitForNavigation(); await page.goto("https://react-movies-queue.glitch.me/"); await page.setViewport({ width: 1276, height: 689 }); await navigationPromise; const addMovieToQueueBtn = "li:nth-child(3) > .card > .card__info > div > .button"; await page.waitForSelector(addMovieToQueueBtn); // Begin profiling... await page.tracing.start({ path: "profile.json" }); // Click the button await page.click(addMovieToQueueBtn); // Stop profliling await page.tracing.stop(); await browser.close(); })(); ``` 首先利用 `puppeteer` 创建一个浏览器,新建一个页面并打开 `https://react-movies-queue.glitch.me/` 这个 URL,等待页面加载完毕后利用 DOM 选择器找到按钮,利用 `page.click` API 模拟点击这个按钮,并在前后利用 `page.tracing` 记录性能变化,并将这个文件上传到 DevTools Performance 面板,就会得到一份自动的性能检测报告: 这张图相当重要,是浏览器综合运行开销分析的利器,最上面分为 4 个部分: - FPS:每秒帧数,绿色竖线越高表示 FPS 越高,出现红线则表示出现了卡顿。 - CPU:CPU 资源,用面积图展示消耗 CPU 资源的事件。 - NET:网络消耗,每条横杠表示一种资源的加载。 - HEAP:内存水位,由于短时间内看不出来是否会内存溢出,一般只用来简单看看内存消耗是否符合预期,对于内存溢出的检测需要用持续监控上报的方式。 下面会有一张 Network 详细图解,比如这张图: 细线表示等待的时间,粗线表示实际加载的情况,其中浅色部分表示服务器等待时间,即从发送下载请求到服务器响应第一个字节的时间。这部分可以看出资源并行加载阻塞情况以及资源服务器响应时间是否存在问题。 Timings 展示了几个重要时间节点,这里列举一部分: - FP:First Paint,第一次绘制。 - FCP:First Contentful Paint,第一次内容绘制。 - LCP:Largest Contentful Paint,最大内容绘制。 - DCL:Document Content Loaded,DOM 内容加载完毕。 再下面是 JS 计算消耗,用了一张火焰图,火焰图是性能分析的常用可视化工具。以下面这张图为例: 看火焰图首先看跨度最长的函数,也就是最长的那条线,这是最耗时的部分,从左到右是浏览器脚本的调用顺序,从上到下是函数嵌套的顺序。 我们可以看到鼠标位置的 34 这个函数虽然长,但并不是性能瓶颈,因为下面执行的 n 函数长度和它一样,表示 34 函数的性能几乎无损耗,其性能由其调用的 n 函数决定。 我们可以利用这种方式一步步排查到叶子结点,找到对性能影响最大的元子函数。 ### User Timing API 我们还可以利用 `performance.mark` 自定义性能检测节点: ```jsx // Record the time before running a task performance.mark("Movies:updateStart"); // Do some work // Record the time after running a task performance.mark("Movies:updateEnd"); // Measure the difference between the start and end of the task performance.measure("moviesRender", "Movies:updateStart", "Movies:updateEnd"); ``` 这些节点可以在上面介绍的 Performance 面板中展示出来用于自定义分析。 ## 3 总结 利用 Performance 进行通用性能分析,利用 React Profiler 进行 React 定制性能分析,这两个结合在一起几乎可以完成任何性能检测。 一般来说,首先应该用 React Profiler 进行 React 层面的问题筛查,这样更直观,更容易定位问题。如果某些问题跳出了 React 框架范围,或者不再能以组件粒度进行度量,我们可以回到 Performance 面板进行通用性能分析。 > 讨论地址是:[精读《React 性能调试》 · Issue #247 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/247) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/15.精读《TC39 与 ECMAScript 提案》.md ================================================ 本期精读文章是:[TC39, ECMAScript, and the Future of JavaScript]( https://ponyfoo.com/articles/tc39-ecmascript-proposals-future-of-javascript) # 1 引言 logo 觉得 es6 es7 动不动就加新特性很烦?提案的讨论已经放开了,每个人都可以做 js 的主人,赶快与我一起了解下有哪些特性在日程中! # 2 内容概要 ### TC39 是什么?包括哪些人? 一个推动 JavaScript 发展的委员会,由各个主流浏览器厂商的代表构成。 ### 为什么会出现这样一个组织? 从标准到落地是一个漫长的过程,相信大家上次阅读 web components 就能体会到标准到浏览器支持是一个漫长的过程。 ### TC39 这群人主要的工作是什么? 制定 ECMAScript 标准,标准生成的流程,并实现。 ### 标准的流程是什么样的? 包括五个步骤: - stage0 `strawman` 任何讨论、想法、改变或者还没加到提案的特性都在这个阶段。只有 TC39 成员可以提交。 - stage1 `proposal` (1)产出一个正式的提案。 (2)发现潜在的问题,例如与其他特性的关系,实现难题。 (3)提案包括详细的 API 描述,使用例子,以及关于相关的语义和算法。 - stage2 `draft` (1)提供一个初始的草案规范,与最终标准中包含的特性不会有太大差别。草案之后,原则上只接受增量修改。 (2)开始实验如何实现,实现形式包括 polyfill, 实现引擎(提供草案执行本地支持),或者编译转换(例如 babel) - stage3 `candidate` (1)候选阶段,获得具体实现和用户的反馈。此后,只有在实现和使用过程中出现了重大问题才会修改。 (1)规范文档必须是完整的,评审人和 ECMAScript 的编辑要在规范上签字。 (2)至少要在一个浏览器中实现,提供 polyfill 或者 babel 插件。 - stage4 `finished` (1)已经准备就绪,该特性会出现在下个版本的 ECMAScript 规范之中。。 (2)需要通过有 2 个独立的实现并通过验收测试,以获取使用过程中的重要实践经验。 ### 一般可以去哪里查看 TC39 标准的进程呢? stage0 的提案 https://github.com/tc39/proposals/blob/master/stage-0-proposals.md stage1 - 4 的提案 https://github.com/tc39/proposals ### 我们怎么在程序中应用这些新特性呢? babel 的插件:`babel-presets-stage-0` `babel-presets-stage-1` `babel-presets-stage-2` `babel-presets-stage-3` `babel-presets-stage-4` # 3 精读 本次提出独到观点的同学有: [@huxiaoyun](https://github.com/huxiaoyun) [@monkingxue](https://github.com/monkingxue) [@jasonslyvia](https://github.com/jasonslyvia) [@ascoders](https://github.com/ascoders),精读由此归纳。 ## 3.1 Stage 4 大家庭 ### [Array.prototype.includes](https://github.com/tc39/Array.prototype.includes/) ```javascript assert([1, 2, 3].includes(2) === true); assert([1, 2, 3].includes(4) === false); assert([1, 2, NaN].includes(NaN) === true); assert([1, 2, -0].includes(+0) === true); assert([1, 2, +0].includes(-0) === true); assert(["a", "b", "c"].includes("a") === true); assert(["a", "b", "c"].includes("a", 1) === false); ``` 这个 api 很方便,没有悬念的进入了草案中。 曾争议过是否使用 Array.prototype.contains,但由于 [不兼容因素](https://esdiscuss.org/topic/having-a-non-enumerable-array-prototype-contains-may-not-be-web-compatible) 而换成了 includes。 ### [Exponentiation operator](https://github.com/rwaldron/exponentiation-operator) ```javascript // x ** y let squared = 2 ** 2; // same as: 2 * 2 let cubed = 2 ** 3; // same as: 2 * 2 * 2 ``` 列表中进入了 stage4,但其 git 仓库 readme 还停留在 stage3。。 虽然已经有 `Math.pow` 了,但由于其他语言都支持此方式,js 也就支持了。 ### [Object.values/Object.entries](https://github.com/tc39/proposal-object-values-entries) ```javascript Object.values({ a: 1, b: 2, c: Symbol(), }) // [1, 2, Symbol()] Object.entries({ a: 1, b: 2, c: Symbol(), }) // [["a", 1], ["b", 2], ["c", Symbol()]] ``` 也没有什么争议,Object.keys 都有了,获取 values、entries 也是合理的。 TC39 会议中有争辩过为何不返回迭代器,原因挺有意思,因为 Object.keys 返回的是数组,所以这两个 api 还是与老大哥统一吧。 ### [String.prototype.padStart / String.prototype.padEnd](https://github.com/tc39/proposal-string-pad-start-end) ```javascript "foo".padStart(5, "bar") // bafoo "foo".padEnd(5, "bar") // fooba ``` 解决了字符串补齐需求,很棒! ### [Object.getOwnPropertyDescriptors](https://github.com/tc39/proposal-object-getownpropertydescriptors) ```javascript Object.getOwnPropertyDescriptors({ a: 1}) // { a: { // configurable: true, // enumberable: true, // value: 1, // writable: true // } } ``` 特别是 babel 与 typescript 处理 class property decorator 方式不同的时候(typescript 处理得更成熟一些),会导致 babel 处理装饰器时,成员变量不设置默认值时,configurable 默认为 false,通过这个函数检查变量的配置很方便。 ### [Trailing commas in function parameter lists and calls](https://github.com/tc39/proposal-trailing-function-commas) ```javascript function clownPuppiesEverywhere( param1, param2, // Next parameter that's added only has to add a new line, not modify this line ) { /* ... */ } ``` js 终于原生支持了,以前不支持的时候多加逗号还会报错,需要预编译工具删除最后一个逗号,现在终于名正言顺了。 ### [Async functions](https://github.com/tc39/ecmascript-asyncawait) 这个不用多说了,都说好用。 ### [Shared memory and atomics](https://github.com/tc39/ecmascript_sharedmem) 这是 ECMAScript 共享内存与 Atomics 的规范,涉及内容非常多,主要涉及到 asm.js。 asm.js 是一种性能解决方案,比如可以定义一个精确的 64k 堆: ```javascript var heap = new ArrayBuffer( 0x10000 ) ``` ### [Lifting template literal restriction](https://github.com/tc39/proposal-template-literal-revision) ```javascript styled.div` background-color: red; ` ``` `styled.div = text => {}` 就可以处理了,目前使用最多在 styled-components 库里,这种场景还是蛮方便的。 ## 3.2 Stage 3 大家庭 ### [Function.prototype.toString revision](https://github.com/tc39/Function-prototype-toString-revision) 对函数的 toString 规则进行了修改:http://tc39.github.io/Function-prototype-toString-revision/#sec-function.prototype.tostring 当调用内置函数或 `.bind` 后函数,toString 方法会返回 [NativeFunction](http://tc39.github.io/Function-prototype-toString-revision/#prod-NativeFunction)。 ### [global](https://github.com/tc39/proposal-global) 为 ECMAScript 规范添加 `global` 变量,同构代码再也不用这么写了: ```javascript var getGlobal = function () { // the only reliable means to get the global object is // `Function('return this')()` // However, this causes CSP violations in Chrome apps. if (typeof self !== 'undefined') { return self; } if (typeof window !== 'undefined') { return window; } if (typeof global !== 'undefined') { return global; } throw new Error('unable to locate global object'); }; ``` 虽然前端环境与 nodejs 区别很大,但既然提案进入了 stage3,说明大家非常关注 js 整体的生态,只要整体方向良性发展,相信不久将会进入 stage4。 ### [Rest/Spread Properties](https://github.com/tc39/proposal-object-rest-spread) ```javascript let { x, y, ...z } = { x: 1, y: 2, a: 3, b: 4 }; x; // 1 y; // 2 z; // { a: 3, b: 4 } ``` 不得不说,非常常用,而且 [babel](https://babeljs.io/docs/plugins/transform-object-rest-spread/),[jsTransform](https://github.com/facebookarchive/jstransform),[typescript](https://github.com/Microsoft/TypeScript) 均支持,感觉很快会进入 stage4. ### [Asynchronous Iteration](https://github.com/tc39/proposal-async-iteration) ```javascript const { value, done } = syncIterator.next(); asyncIterator.next().then(({ value, done }) => /* ... */); ``` ```javascript for await (const line of readLines(filePath)) { console.log(line); } ``` ```javascript async function* readLines(path) { let file = await fileOpen(path); try { while (!file.EOF) { yield await file.readLine(); } } finally { await file.close(); } } ``` 异步迭代器实现了 async await 与 generator 的结合。 然而 async await 是使用 generator 的语法糖,generator 也可以通过 switch 等流程控制函数模拟。更重要的是异步在 generator 中本身就可以实现,我在[《Callback Promise Generator Async-Await 和异常处理的演进》](https://github.com/ascoders/blog/issues/14) 文章中提过。 语法的修改一定不能为了方便(在 ECMAScript 中可能出现),但这种混杂的方式容易让人混淆 await 与 generator 之间的关系,是否进入 stage4 还需仔细斟酌。 ### [import()](https://github.com/tc39/proposal-dynamic-import) ```javascript import(`./section-modules/${link.dataset.entryModule}.js`) .then(module => { module.loadPageInto(main); }) .catch(err => { main.textContent = err.message; }); ``` 这个提案主要增加了函数调用版的 import,而 webpack 等构建工具也在积极实现此规范,并作为动态加载的最佳范例。希望这种“官方 Amd”可以早日加入草案。 ### [RegExp Lookbehind Assertions](https://github.com/tc39/proposal-regexp-lookbehind) javascript 正则表达式一直不支持后行断言,不过现在已经进入 stage3,相信不久会进入 stage4. 前向断言: ```javascript /\d+(?=%)/.exec("100% of US presidents have been male") // ["100"] /\d+(?!%)/.exec("that’s all 44 of them") // ["44"] ``` 后向断言: ```javascript /(?<=\$)\d+/.exec("Benjamin Franklin is on the $100 bill") // ["100"] /(?\d{4})-(?\d{2})-(?\d{2})/u; let result = re.exec('2015-01-02'); // result.groups.year === '2015'; // result.groups.month === '01'; // result.groups.day === '02'; // result[0] === '2015-01-02'; // result[1] === '2015'; // result[2] === '01'; // result[3] === '02'; ``` ```javascript let {groups: {one, two}} = /^(?.*):(?.*)$/u.exec('foo:bar'); console.log(`one: ${one}, two: ${two}`); // prints one: foo, two: bar ``` 同时,还支持 **反向引用能力**,可以通过 `\k` 的语法,在正则中表示同一种匹配类型,这个和 ts 范型很像: ```javascript let duplicate = /^(?.*).\k$/u; duplicate.test('a*b'); // false duplicate.test('a*a'); // true ``` 总体来看非常给力,毫无意义的下标也是正则反人类的原因之一,这个提案通过的话,正则会变得更加可读。 ### [s (dotAll) flag for regular expressions](https://github.com/tc39/proposals) ```javascript /foo.bar/s.test('foo\nbar'); // → true ``` 通过添加了新的标识符 `/s`,表示 `.` 这个标志可以匹配任何值。原因是觉得现在正则的做法比较反人类: ```javascript /foo[^]bar/.test('foo\nbar'); // → true /foo[\s\S]bar/.test('foo\nbar'); // → true ``` 从保守派角度来看,可能因为掌握了 `[^]` `[\s\S]` 这种奇技淫巧而沾沾自喜,借此提高正则的门槛,让初学者“看不懂”,而高级语言的第一要义是可读性,`RegExp Unicode Property Escapes` 与 `RegExp named capture groups` 进入草案就是表明了对正则语义化改进的决心,相信这个提案也会被采纳。 ### [Legacy RegExp features in JavaScript](https://github.com/tc39/proposal-regexp-legacy-features) 该提案主要针对 RegExp 遗留的静态属性进行梳理。平时很少接触,希望了解的人解读一下。 ## 3.3 Stage2 大家庭 ### [function.sent metaproperty](https://github.com/allenwb/ESideas/blob/master/Generator%20metaproperty.md) generator 的第一个 `.next` 参数会被抛弃,因为第一次 next 没有对应上任何 `yield`,如下代码就会产生疑惑: ```javascript function *adder(total=0) { let increment=1; while (true) { switch (request = yield total += increment) { case undefined: break; case "done": return total; default: increment = Number(request); } } } let tally = adder(); tally.next(0.1); // argument will be ignored tally.next(0.1); tally.next(0.1); let last=tally.next("done"); console.log(last.value); //1.2 instead of 0.3 ``` 当引入 `function.sent` 后,可以接收来自 next 的传值,**包括初始传值**: ```javascript function *adder(total=0) { let increment=1; do { switch (request = function.sent){ case undefined: break; case "done": return total; default: increment = Number(request); } yield total += increment; } while (true) } let tally = adder(); tally.next(0.1); // argument no longer ignored tally.next(0.1); tally.next(0.1); let last=tally.next("done"); console.log(last.value); //0.3 ``` 这是个很棒的特性,也不存在语意兼容问题,但 api 还是比较怪,而且自此 yield 接收参数也变得没有意义,况且如今 async await 逐渐成为主流,这种修正没有强烈刚需。而且 yield 的语意本身没有错误,这个提案比较危险。 ### [String.prototype.{trimStart,trimEnd}](https://github.com/tc39/proposal-string-left-right-trim) 既然 `padStart` 与 `padEnd` 都进入了 stage4,`trimStart` `trimEnd` 这两个 api 也非常常用,而且从 ES5 将 `String.prototype.trim` 引入了标准来看,这两个非常有望晋升到 stage3。 ### [Class Fields](https://github.com/tc39/proposal-class-fields) ```javascript class Counter extends HTMLElement { x = 0; #y = 1; } ``` 类成员变量,有了它 js 就完整了。虽然觉得似有变量符号很难看,但成员变量绝对是非常有用的语法,在 react 中已经很常用了: ```javascript class Todo extends React.Component { state = { //.. } } ``` ### [Promise.prototype.finally](https://github.com/tc39/proposal-promise-finally) 就像 `try/catch/finally` 一样,try return 了都能执行 finally,是非常方便的,对 promise 来说也是如此,[bluebird](https://github.com/petkaantonov/bluebird) [Q](https://github.com/kriskowal/q/wiki/API-Reference#promisefinallycallback) 等库已经实现了此功能。 但是库实现不足以使其纳入标准,只有当这些需求足够常用和通用时才会考虑。第三方库可能从竞争力角度考虑,多支持一种功能、少些一行代码就是多一份筹码,但语言规范是不能在乎这些的。 ### [Class and Property Decorators](http://tc39.github.io/proposal-decorators/) 类级别的装饰器已经进入 stage2 了,但现代前端开发中已经非常常用,很可能会进一步进入 stage3. 如果这个提案被废弃,那么大部分现代 js 代码将面临大量使用不存在语法的窘境。不过乐观的是,目前还找不到更好的装饰器替代方案,而在 python 中也存在装饰器模式可以参考。 ### [Intl.Segmenter](https://github.com/tc39/proposal-intl-segmenter) ```javascript // Create a segmenter in your locale let segmenter = new Intl.Segmenter("fr", {granularity: "word"}); // Get an iterator over a string let iterator = segmenter.segment("Ceci n'est pas une pipe"); // Iterate over it! for (let {segment, breakType} of iterator) { console.log(`segment: ${segment} breakType: ${breakType}`); break; } // logs the following to the console: // segment: Ceci breakType: letter ``` `Intl.Segmenter` 可以帮助分析单词断句分析,可能在 nlp 领域比较有用,在文本编辑器自动选中功能中也很有用。 虽然不是刚需,但 js 作为网页交互的语言,确实需要解决分析用户输入的问题。 ### [Arbitrary-precision Integers](https://github.com/tc39/proposal-bigint) 新增了基本类型:整数类型,以及 Integer api 与字面语法 1234n。 目前 js 使用 64 位浮点数处理所有计算,直接导致了运算效率低下,这个提案弥补了 js 的计算缺点,希望可以早日进入草案。 提案名称由 Integer 改为 BigInt。 ### [import.meta](https://github.com/tc39/proposals) 提出了使用 `import.meta` 获取当前模块的域信息。类比 nodejs 存在 `__dirname` 等信息标志当前脚本信息,通过浏览器加载的模块也应当拥有这种能力。 目前 js 可以通过如下方式获取脚本信息: ```javascript const theOption = document.currentScript.dataset.option; ``` 这样污染了全局变量,脚本信息应当存储在脚本作用域中,因此提案希望将脚本信息存储在脚本的 `import.meta` 变量中,因此可以这么使用: ```javascript (async () => { const response = await fetch(new URL("../hamsters.jpg", import.meta.url)); const blob = await response.blob(); const size = import.meta.scriptElement.dataset.size || 300; const image = new Image(); image.src = URL.createObjectURL(blob); image.width = image.height = size; document.body.appendChild(image); })(); ``` ## 3.4 Stage1 大家庭 ### [Date.parse fallback semantics](https://github.com/FaustDeGoethe/proposal-date-time-string-format) 通过字符串格式化日期一直是跨浏览器的痛点,本提案希望通过新增 `Date.parse` 标准完成这个功能。 > "The function first attempts to parse the format of the String according to the rules > (including extended years) called out in Date Time String Format (20.3.1.16). If the > String does not conform to that format the function may fall back to any > implementation-specific heuristics or implementation-specific date formats." 正如提案所说,“如果字符串不满足 ISO 8601 格式,可以返回你想返回的任何值” 这样迷惑开发者是没有任何意义的,这样只会让开发者越来越不相信 js 是跨平台的语言。 这么重要的规范居然才 stage1,必须要顶上去。 ### [export * as ns from "mod"; statements](https://github.com/tc39/proposal-export-ns-from) ```javascript export * as someIdentifier from "someModule"; ``` 很方便的 api,很多时候希望导出某个模块的全部接口,又不希望命名冲突,可以少写一行 import。 ### [export v from "mod"; statements](https://github.com/tc39/proposal-export-default-from) 这个提案与 [export * as ns from "mod"; statements](https://github.com/tc39/proposal-export-ns-from) 冲突了,感觉 [export * as ns from "mod"; statements](https://github.com/tc39/proposal-export-ns-from) 提案更清晰一些。 ### [Observable](https://github.com/tc39/proposal-observable) 可观察类型可以从 dom 事件、轮询等触发事件中创建监听并订阅: ```javascript function listen(element, eventName) { return new Observable(observer => { // Create an event handler which sends data to the sink let handler = event => observer.next(event); // Attach the event handler element.addEventListener(eventName, handler, true); // Return a cleanup function which will cancel the event stream return () => { // Detach the event handler from the element element.removeEventListener(eventName, handler, true); }; }); } // Return an observable of special key down commands function commandKeys(element) { let keyCommands = { "38": "up", "40": "down" }; return listen(element, "keydown") .filter(event => event.keyCode in keyCommands) .map(event => keyCommands[event.keyCode]) } let subscription = commandKeys(inputElement).subscribe({ next(val) { console.log("Received key command: " + val) }, error(err) { console.log("Received an error: " + err) }, complete() { console.log("Stream complete") }, }); ``` 这个名字和 Object.observe 很像,不过没什么关系。该功能已经被 [RxJS](https://github.com/ReactiveX/RxJS)、[XStream](https://github.com/staltz/xstream) 等库实现。 ### [String#matchAll](https://github.com/tc39/String.prototype.matchAll) 目前正则表达式想要匹配全部的语法不够语义化,提案希望通过 `matchAll` 返回迭代器来遍历匹配结果,很赞! 现在匹配全部只能使用 `while ((result = patt.exec(str)) != null)` 这种方式遍历,不优雅。 ### [WeakRefs](https://github.com/tc39/proposal-weakrefs) 弱引用,提案地址文档:https://github.com/tc39/proposal-weakrefs/blob/master/specs/Weak%20References%20for%20EcmaScript.pdf 有点像 OC 的弱引用,当对象被释放时,当前持有弱引用的对象也会被 GC 回收,但似乎还没有开始讨论,js 越来越底层了? ### [Frozen Realms](https://github.com/FUDCo/frozen-realms) 增强了 [Realms](https://github.com/tc39/proposal-realms) 提案,利用不可变结构,实现结构共享。 ### [Math Extensions](https://github.com/rwaldron/proposal-math-extensions) Math 函数的拓展包含的函数:https://rwaldron.github.io/proposal-math-extensions/ 这个函数拓展很给力,特别是设计游戏,计算角度的时候: ```javascript Math.DEG_PER_RAD // Math.PI / 180 ``` `Math.DEG_PER_RAD` 是一种单位,让角度可以用 0~360 为周期的数字表示,比如射击子弹时的角度、或者做可视化时都非常有用,类比 css 中的:`transform: rotate(180deg);`。 ### [of and from on collection constructors](https://github.com/tc39/proposal-setmap-offrom) 该提案设计了 Set、Map 类型的 `of` `from` 方法,具体见此:https://tc39.github.io/proposal-setmap-offrom/ 问题由于: ```javascript Reflect.construct(Array, [1,2,3]) // [1,2,3] Reflect.construct(Set, [1,2,3]) // Uncaught TypeError: undefined is not a function ``` 因为 Set 接收的参数是数组,而 construct 会调用 `CreateListFromArrayLike` 将参数打平,变成了 `new Set(1, 2, 3)` 传入,实际上是语法错误的,因此作者提议新增下 Set、Map 的 `of` `from` 方法。 Set、Map 在国内环境用的比较少,也很少有人计较这个问题,不过从技术角度来看,确实需要修复。。 ### [Generator arrow functions (=>*)](https://esdiscuss.org/topic/generator-arrow-functions) 还是挺有必要的,毕竟都出箭头函数了,也要支持一下箭头函数的 generator 语法。 ### [Promise.try](https://github.com/tc39/proposal-promise-try) 同理,各大库都有实现,好处是所有错误都可以通过 `.catch` 捕获,而不用担心同步、异步错误的抛出。 ### [Null Propagation](https://docs.google.com/presentation/d/11O_wIBBbZgE1bMVRJI8kGnmC6dWCBOwutbN9SWOK0fU/view#slide=id.p) 超级有用,看代码就知道了: ```javascript const firstName = message.body?.user?.firstName || 'default' ``` 该功能完全等同: ```javascript const firstName = (message && message.body && message.body.user && message.body.user.firstName) || 'default' ``` 希望立刻进入 stage4. ### [Math.signbit: IEEE-754 sign bit](http://jfbastien.github.io/papers/Math.signbit.html) 当值为 负数 或 -0 时返回 `true`。由于 `Math.sign` 不区分 +0 与 -0,因此提案建议增加此函数,而且此函数在 c、c++、go 语言都有实现。 ### [Error stacks](https://github.com/tc39/proposal-error-stacks) 提案建议将 `Error.prototype.stack` 作为标准,这对错误上报与分析特别有用,强烈支持。 ### [do expressions](https://gist.github.com/dherman/1c97dfb25179fa34a41b5fff040f9879) ```javascript return ( ) ``` `jsx` 再也不用写得超长了,`styled-components` 中被诟病的分支判断难以阅读的问题也会烟消云散,因为我们有 `do`! ### [Realms](https://github.com/tc39/proposal-realms) ```javascript let realm = new Realm(); let outerGlobal = window; let innerGlobal = realm.global; let f = realm.evalScript("(function() { return 17 })"); f() === 17 // true Reflect.getPrototypeOf(f) === outerGlobal.Function.prototype // false Reflect.getPrototypeOf(f) === innerGlobal.Function.prototype // true ``` `Realms` 提供了 global 环境的隔离,eval 执行代码时不再会污染全局,简直是测试的福利,脑洞很大。 ### [Temporal](https://github.com/maggiepint/proposal-temporal) 与 `Date` 类似,但功能更强: ```javascript var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59); var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, options); var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, 59); var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, 59, options); var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, 59, 123); var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, 59, 123, options); var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, 59, 123, 456789); var ldt = new temporal.LocalDateTime(2017, 12, 31, 23, 59, 59, 123, 456789, options); // add/subtract time (Dec 31 2017 23:00 + 2h = Jan 1 2018 01:00) var addHours = new temporal.LocalDateTime(2017, 12, 31, 23, 00).add(2, 'hours'); // add/subtract months (Mar 31 - 1M = Feb 28) var addMonths = new temporal.LocalDateTime(2017,03,31).subtract(1, 'months'); // add/subtract years (Feb 29 2020 - 1Y = Feb 28 2019) var subtractYears = new temporal.LocalDateTime(2020, 02, 29).subtract(1, 'years'); ``` 还自带时区转换 api 等等,如果进入草案,可以放弃 moment 这个重量级库了。 ### [Float16 on TypedArrays, DataView, Math.hfround](https://docs.google.com/presentation/d/1Ta_IbravBUOvu7LUhlN49SvLU-8G8bIQnsS08P3Z4vY/edit?usp=sharing) 由于大多数 WebGL 纹理需要半精度以上的浮点数计算,推荐了 4 个 api: - Float16Array - DataView.prototype.getFloat16 - DataView.prototype.setFloat16 - Math.hfround(x) ### [Atomics.waitNonblocking](https://github.com/lars-t-hansen/moz-sandbox/blob/master/sab/waitNonblocking.md) ```javascript var sab = new SharedArrayBuffer(4096); var ia = new Int32Array(sab); ia[37] = 0x1337; test1(); function test1() { Atomics.waitNonblocking(ia, 37, 0x1337, 1000).then(function (r) { console.log("Resolved: " + r); test2(); }); } var code = ` var ia = null; onmessage = function (ev) { if (!ia) { console.log("Aux worker is running"); ia = new Int32Array(ev.data); } console.log("Aux worker is sleeping for a little bit"); setTimeout(function () { console.log("Aux worker is waking"); Atomics.wake(ia, 37); }, 1000); }`; function test2() { var w = new Worker("data:application/javascript," + encodeURIComponent(code)); w.postMessage(sab); Atomics.waitNonblocking(ia, 37, 0x1337).then(function (r) { console.log("Resolved: " + r); test3(w); }); } function test3(w) { w.postMessage(false); Atomics.waitNonblocking(ia, 37, 0x1337).then(function (r) { console.log("Resolved 1: " + r); }); Atomics.waitNonblocking(ia, 37, 0x1337).then(function (r) { console.log("Resolved 2: " + r); }); Atomics.waitNonblocking(ia, 37, 0x1337).then(function (r) { console.log("Resolved 3: " + r); }); } ``` 该 api 可以在多线程操作中,有顺序的操作同一个内存地址,如上代码变量 `ia` 虽然在多线程中执行,但每个线程都会等资源释放后再继续执行。 ### [Numeric separators](https://github.com/tc39/proposal-numeric-separator) ```javascript 1_000_000_000 // Ah, so a billion 101_475_938.38 // And this is hundreds of millions let fee = 123_00; // $123 (12300 cents, apparently) let fee = 12_300; // $12,300 (woah, that fee!) let amount = 12345_00; // 12,345 (1234500 cents, apparently) let amount = 123_4500; // 123.45 (4-fixed financial) let amount = 1_234_500; // 1,234,500 ``` 提案希望 js 支持分隔符使大数字阅读性更好(不影响计算),很多语言都有实现,很人性化。 # 4 总结 每个草案都觉得很靠谱,涉及语义化、无障碍、性能、拓展语法、连接 nodejs 等方面,虽然部分提案[从语言设计角度是错误的](http://www.yinwang.org/blog-cn/2013/04/18/language-design-mistake2),但 js 运行在网页端,涉及到人机交互、网络加载等问题,遇到的问题自然比任何语言都要复杂,每个提案都是从实践中出发,相信这种道路是正确的。 由于篇幅与时间限制,stage0 的提案等下次再讨论。特别提一点,stage0 的 [Cancellation API](https://github.com/tc39/proposal-cancellation) 很值得大家关注,取消异步操作是人心所向,大势所趋啊。 感谢所有参与讨论的同学,你们的支持会转化为我们的动力,每周更新,风雨无阻。 > 讨论地址是:[精读《TC39, ECMAScript, and the Future of JavaScript》 · Issue #21 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/21) > 如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,每周五发布。 > 访问 [原始文章地址](https://github.com/dt-fe/weekly/blob/master/15.%E7%B2%BE%E8%AF%BB%20TC39%20%E4%B8%8E%20ECMAScript%20%E6%8F%90%E6%A1%88.md) , 获得更好阅读效果。 ================================================ FILE: 前沿技术/150. 精读《Deno 1.0 你需要了解的》.md ================================================ ## 1 引言 Deno 是什么?Deno 和 Node 有什么关系?Deno 和我有什么关系? Deno 将于 2020-05-13 发布 1.0,如果你还有上面的疑惑,可以和我一起通过 [Deno 1.0: What you need to know](https://blog.logrocket.com/deno-1-0-what-you-need-to-know/) 这篇文章一起了解 Deno 基础知识。 希望你带着疑问思考,未来 10 年看今天,会不会出现 Deno 官方生态壮大,完全替代 Node 进而影响到 Web 生态的局面呢?这个思考结果会影响到你未来职业发展,你需要学会自己思考,并对这个思考结果负责。 ## 2 介绍 & 精读 Deno 的作者是 Ryan Dahl,他是 Nodejs 背后的策划者,曾经说过 [我对 Nodejs 感到遗憾的 10 件事](https://www.youtube.com/watch?v=M3BM9TB-8yA)。这也是为什么新开一个坑的原因,但 Deno 并不定位为 Nodejs 的替代品,从整体功能来看,Deno 有更大的野心,据我的推测是想要取代现在陈旧的前后端开发模式,让 Deno 一统前后端开发全流程。 Nodejs 是由 C++ 写的,而 Deno 则是由 Rust 写的,并选择了 [Tokio](https://tokio.rs/) 这个异步编程框架,并使用 V8 引擎解析 Javascript,并内置了对 Ts 的解析。 ### 安装 Deno 支持如下安装方式: **Shell:** ```shell curl -fsSL https://deno.land/x/install/install.sh | sh ``` **PowerShell:** ```shell iwr https://deno.land/x/install/install.ps1 -useb | iex ``` **Homebrew:** ```shell brew install deno ``` **Chocolatey:** ```shell choco install deno ``` 脚本执行方式为 `deno run`,可以类比为 `node`,但功能不同且支持远程文件,实际上远程依赖是 Deno 的一大特色,也是有争议的地方: ```shell deno run https://deno.land/std/examples/welcome.ts ``` 在 ts 文件中允许用远程脚本加载资源,这个后面还会提到: ```ts import { serve } from "https://deno.land/std@v0.42.0/http/server.ts"; const s = serve({ port: 8000 }); console.log("http://localhost:8000/"); for await (const req of s) { req.respond({ body: "Hello World\n" }); } ``` ### 安全性 Deno 是默认安全的,这体现在默认没有环境、网络访问权限、文件读写权限、运行子进程的能力。所以如果直接运行一个依赖权限的文件会报错: ```shell deno run file-needing-to-run-a-subprocess.ts # error: Uncaught PermissionDenied: access to run a subprocess, run again with the --allow-run flag ``` 可以通过参数方式允许权限的执行,有 `--allow-read`、`--allow-write`、`--allow-net` 等: ```shell deno --allow-read=/etc ``` 上面表示 `/etc` 文件夹下的文件拥有文件读权限。 除了直接加参数调用、Bash 脚本调用外,还可以用 Make 运行,或者使用类似的 [drake](https://deno.land/x/drake/) 启动。 或者使用 `deno install` 命令,将脚本转化为一个快捷指令: ```shell deno install --allow-net --allow-read -n serve https://deno.land/std/http/file_server.ts ``` `-n` 表示 `--name`,可以对这个脚本进行重命名,比如上面的例子中,`serve` 命令就等同于 `deno run --allow-net --allow-read https://deno.land/std/http/file_server.ts`。 ### 标准库 Deno 在标准库上很有特点,对常用功能提供了官方版本,保证可用性与稳定性。原文中列出了一些与 Npm 三方库的对比: | Deno Module | Description | npm | Equivalents | | ----------- | --------------------------------------------------------------------------------- | --- | ------------------------ | | colors | Adds color to the terminal | | chalk, kleur, and colors | | datetime | Helps working with the JavaScript Date object | | | encoding | Adds support for external data scructures like base32, binary, csv, toml and yaml | | | flags | Helps working with command line arguments | | minimist | | fs | Helps with manipulation of the file system | | | http | Allows serving local files over HTTP | | http-server | | log | Used for creating logs | | winston | | testing | For unit testing assertion and benchmarking | | chai | | uuid | UUID generation | | uuid | | ws | Helps with creating WebSocket client/server | | ws | 从这个点上来看,Deno 既做运行环境又做基础生态,缓解了 Npm 生态下选择困难症,这件事需要辩证来看:集成了官方包对功能确定的模块来说是很有必要的,而且提高了底层库的稳定性;但 Deno 生态也有三方库,而且本质上三方库和官方库在功能上没有任何壁垒,因为实现代码都类似,唯一区别是谁能为其稳定性站台,假设微软和 Deno 同时出了基于 Npm 生态与 Deno 生态官方库,都保证会持续维护,你更相信谁呢?官方是否有优势要取决于官方自身的实力。 ### 内置 Typescript Deno 内置支持了 TS,因此不需要 `ts-node` 我们就可以用 `deno run test.ts` 运行 Typescript 文件。值得注意的是,Deno 内部也是利用 Typescript 引擎解析为 Js 后交由 V8 引擎解析,因此本质上没太大的变化,只是这样 Deno 的生态会更规范。 由于内置了 TS 支持,自然也不需要写 `tsconfig.json` 配置了,但你依然可以定制它: ```shell deno run -c tsconfig.json [file-to-run.ts] ``` Deno 默认还开启了 TS 严格模式,所以看到这里,可以认为 Deno 是为了构建高质量理想库而诞生的运行环境,基于已有的生态来做,但做了更多内置技术选型,这和 Facebook 的 [rome](https://github.com/facebookexperimental/rome) 很像,但做的却更彻底。 其实从实现上来看,我们基于 Javascript 生态也能写出 `deno run test.ts` 这样类似的引擎,只不过是由 JS 驱动执行,可能编译还会选择 Webpack,但 Deno 本身基于 Rust 实现,并重新实现了一套模块加载标准,可以说从更底层的方式重新解读了 W3C 标准规范,以期望解决 Javascript 生态的各种痛点问题。 ### 支持 Web 标准 Deno 还支持 W3C 标准规范,因此像 `fetch`、`setTimeout` 等 API 都可以被直接使用,如果你按照 Deno 支持的那几个函数写代码,可以保证在 Deno、Node、Web 三个平台实现跨平台运行。 虽然距离完全实现 W3C 所有标准规范还有一些路要走,但我们看到了 Deno 兼容规范的决心。 ### ESModule 模块化是 Deno 的亮点,Deno 使用官方 ESModule 规范,但引用路径必须加上后缀: ```ts import * as log from "https://deno.land/std/log/mod.ts"; import { outputToConsole } from "./view.ts"; ``` Deno 不需要申明依赖,代码的引用路径就是依赖申明,会包括完整的路径以及文件后缀,也支持网络资源,可以摆脱 NPM 中心化的包管理模式,因为这个路径可以是任何网络地址。 ### 包管理 对于 `import * as log from "https://deno.land/std/log/mod.ts";` 这行代码,Deno 会下载到一个缓存文件夹,用户不会感知到这个文件夹与这个过程的存在,也就是说,Deno 环境中是没有 `node_modules` 的。 也可以通过 `deno --reload` 的方式强制刷新缓存。 但这里也要辩证的看待 “Deno 去中心化” 这件事,虽然引用了网络源,但会引发下面几个问题: 1. 实际上还存在一个 "node_modules",只是用户看不到。 2. 网络下载速度放到运行时,第一次启动还是很慢。 3. 普通模式下无 lock,必须配合 `deps.ts` 使用,这个后面会提到。 即使被打上 “中心化恶人” 的 npm 也有去中心化的一面,因为 npm 支持私有化部署,无论是速度还是稳定性都可以由公司自己掌控,从稳定性来说还是 npm 拥有压倒性优势。 ### 三方库 Deno 还有第三方库生态,截止目前共有 [221 个三方库](<[](https://deno.land/x/)>)。 由于 Deno 走网络资源,我们可以借助 [Pika](https://www.pika.dev/cdn) 提供的 CDN 服务直接引用网络资源包: ```jsx import * as pkg from "https://cdn.pika.dev/preact@^10.3.0"; ``` 虽然这样看上去很轻量,但对公司来说还是需要自建一个 “Pika” 保障稳定性,以及做全球 CDN 缓存等的工作。 ### 告别 package.json npm 生态下包信息存放在 `package.json`,包含但不限于下面的内容: - 项目元信息。 - 项目依赖和版本号。 - 依赖还进行分类,比如 `dependencies`、`devDependencies` 甚至 `peerDependencies`。 - 标记入口,`main` 和 `module`,还有 TS 用的 `types` 与 `typings`,脚手架的 `bin` 等等。 - npm scripts。 随着标准的不断更新,`package.json` 信息已经非常臃肿了。 对于 Deno 来说,则使用 `deps.ts` 集中管理依赖: ```ts export { assert } from "https://deno.land/std@v0.39.0/testing/asserts.ts"; export { green, bold } from "https://deno.land/std@v0.39.0/fmt/colors.ts"; ``` `deps.ts` 就是一个普通文件,只是将项目的依赖精确描述出来,这样其他地方引用 `assert` 时,就可以这么写了: ```ts // import { assert } from "https://deno.land/std@v0.39.0/testing/asserts.ts"; import { assert } from "./deps.ts"; ``` 如果需要锁定依赖,可以通过 `deno --lock=lock.json` 方式申明。 ### deno doc `deno doc ` 命令可以根据文件按照 JS Doc 规则生成文档,同时也支持 TS 语法,比如下面这段代码: ```ts /** Asynchronously fulfill a response with a file from the local file * system. */ export async function send( { request, response }: Context, path: string, options: SendOptions = { root: "" } ): Promise { // ... } ``` 生成文档如下: ```text function send(_: Context, path: string, options: SendOptions): Promise Asynchronously fulfill a response with a file from the local file system. ``` deno 本身文档就是用这个命令生成的,可以 [访问官方文档](https://doc.deno.land/) 查看使用效果。 ### 内置工具链 前端 Javascript 工具链相当混乱,虽然业界已有 Umi 等框架做了开箱即用的封装,但回到 Javascript 设计的初衷就是可以在浏览器直接使用的,包括浏览器对不依赖构建工具的模块化支持,注定了未来 Webpack 一定会被消灭。 Deno 通过内置一套工具链的方式解决这个问题,包括: - 测试:提供 `deno test` 命令与 `Deno.test()` 测试函数。 - 格式化:提供 [vscode 插件](https://marketplace.visualstudio.com/items?itemName=axetroy.vscode-deno)。 - 编译:提供 `deno bundle` 命令。 不过值得注意的是,在最重要的编译环节,`deno bundle` 目前提供的能力是相对欠缺的,比如还不支持 Tree Shaking。 用 Rust 等语言提升构建效率是业界一直在尝试的事,比如 @陈成 就基于 [esbuild](https://github.com/evanw/esbuild) 做了 [@umijs/plugin-esbuild](https://umijs.org/zh-CN/plugins/plugin-esbuild) 插件用于提升 Umi 构建速度,但为了防止生产构建产物与 Webpack 默认规则不一致,仅使用了其压缩(minifier)功能。 对 deno 来说也一样,目前其实没有任何证据表明 deno 的构建结果可以完美适配 webpack 环境,所以请勿认为 deno 发布了 1.0 版本就等于可以在生产环境使用。 ## 3 总结 正如原文结尾所说的,Deno 虽然将要发布 1.0 版本,但仍不能完全替代 Nodejs,这背后的原因主要是历史兼容成本,也就是完整支持整个 Node 生态不只是设计的问题,更是一个体力活,需要一个个高地去攻克。 同样 Deno 对 Web 的支持也让人耳目一新,但仍不能放到生产环境使用,除了官方和三方生态还在逐渐完善外,`deno bundle` 对 Tree Shaking 能力的缺失以及构建产物无法保证与现在的 Webpack 完全相同,这样会导致对稳定性要求极高的大型应用迁移成本非常高。 最亮眼的改动是模块化部分,依赖完全去中心化从长远来看是一个非常好的设计,只是基础设施和生态要达到一个较为理想的水平。 最后,让我们站在一个预言者角度思考一下 Deno 到底会不会火吧: Deno 做的初心是做一个更好的 Node,但很不幸,对于这种级别的生态底层工具来说,重新做一个并重新火起来的难度,不亚于重新做一个阿里巴巴并取代现在阿里的难度。也就是不同的时间点做同一件事,哪怕后者可以吸取教训,大概率也无法复制以前成功的路线。 从 Deno 的功能来看,解决了 Node 很多痛点,其中就包括去中心化管理,有点云开发的意思,但在 2020 年,基于 Nodejs 和 Webpack 的云开发都搞出来了,说实话是没有 Deno 什么空间的。从功能上来看,开篇就说了 Deno 基于 V8 解析 Javascript,对于性能和功能都没有革命性提升,从技术上作出突破也几乎不可能了。 Deno 的思想确实比 Node 先进,但不能说比 Node 好十倍,则无法撼动 Node 的生态,即便是 Node 作者自己可能也不行。 然而我上面说的可能都是错的。 > 讨论地址是:[精读《Deno 1.0 你需要了解的》 · Issue #248 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/248) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/152. 精读《recoil》.md ================================================ ## 1 引言 [Recoil](https://recoiljs.org/) 是 Facebook 公司出的数据流管理方案,有一定思考的价值。 Recoil 是基于 Immutable 的数据流管理方案,这也是它值得被拿出来看的最重要原因,如果要用 Mutable 方式管理 React 数据流,直接看 [mobx-react](https://github.com/mobxjs/mobx-react) 就足够了。 然而 React Immutable 特性带来的可预测性非常利于调试和维护: 1. 断点调试时变量的值与当前执行位置无关,已创建过的值不会突然 Mutable 突变,非常可预测。 2. 在 React 框架下组件更新机制单一,只有引用变化才触发重渲染,而没有 Mutable 模式下 ForceUpdate 的心智负担。 当然 Immutable 模式下存在一定编码心智负担,所以各有优劣。 > 但 Recoil 和 Redux 一样,并不代表 React 官方数据流管理方案,因此不用带着官方光环去看它。 ## 2 简介 Recoil 解决 React 全局数据流管理的问题,采用分散管理原子状态的设计模式,支持派生数据与异步查询,在基本功能上可以覆盖 Redux。 ### 状态作用域 和 Redux 一样,全局数据流管理需要存在作用域 `RecoilRoot`: ```jsx import React from "react"; import { RecoilRoot } from "recoil"; function App() { return ( ); } ``` `RecoilRoot` 在被嵌套时,最内层的 `RecoilRoot` 会覆盖外层的配置及状态值。 ### 定义数据 与 Redux 集中定义 `initState` 不同,Recoil 采用 `atom` 以分散方式定义数据: ```jsx const textState = atom({ key: "textState", default: "", }); ``` 其中 `key` 必须在 `RecoilRoot` 作用域内唯一,也可以认为是 state 树打平时 key 必须唯一的要求。 `default` 定义默认值,既然数据定义分散了,默认值定义也是分散的。 ### 读取数据 与 Redux 的 Connect 或 useSelector 类似,Recoil 采用 Hooks 方式读取数据: ```jsx import { useRecoilValue } from "recoil"; function App() { const text = useRecoilValue(textState); } ``` `useRecoilValue` 与 `useSetRecoilState` 都可以获取数据,区别是 `useRecoilState` 还可以获取写数据的函数: ```jsx import { useRecoilState } from "recoil"; function App() { const [text, setText] = useRecoilState(useRecoilState); } ``` ### 修改数据 与 Redux 集中定义纯函数 `reducer` 修改数据不同,Recoil 采用 Hooks 方式写数据。 除了上面提到的 `useRecoilState` 之外,还有一个 `useSetRecoilState` 可以仅获取写函数: ```jsx import { useSetRecoilState } from "recoil"; function App() { const setText = useSetRecoilState(useRecoilState); } ``` `useSetRecoilState` 与 `useRecoilState`、`useRecoilValue` 的不同之处在于,数据流的变化不会导致组件 Rerender,因为 `useSetRecoilState` 仅写不读。 这也导致 Recoil API 偏多被诟病,这也是 Immutable 模式下存的编码心智负担,虽然很好理解,但也只有 `useSelector` 或 Recoil 这样拆分 API 的方式可以解决。 > 另外还提供了 `useResetRecoilState` 重置到默认值并读取。 ### 仅读不订阅 与 ReactRedux 的 `useStore` 类似,Recoil 提供了 `useRecoilCallback` 用于只读不订阅场景: ```jsx import { atom, useRecoilCallback } from "recoil"; const itemsInCart = atom({ key: "itemsInCart", default: 0, }); function CartInfoDebug() { const logCartItems = useRecoilCallback(async ({ getPromise }) => { const numItemsInCart = await getPromise(itemsInCart); console.log("Items in cart: ", numItemsInCart); }); } ``` `useRecoilCallback` 通过回调方式定义要读取的数据,这个数据变化也不会导致当前组件重渲染。 ### 派生值 与 Mobx `computed` 类似,recoil 提供了 `selector` 支持派生值,这是比较有特色的功能: ```jsx import { atom, selector, useRecoilState } from "recoil"; const tempFahrenheit = atom({ key: "tempFahrenheit", default: 32, }); const tempCelcius = selector({ key: "tempCelcius", get: ({ get }) => ((get(tempFahrenheit) - 32) * 5) / 9, set: ({ set }, newValue) => set(tempFahrenheit, (newValue * 9) / 5 + 32), }); function TempCelcius() { const [tempF, setTempF] = useRecoilState(tempFahrenheit); const [tempC, setTempC] = useRecoilState(tempCelcius); } ``` `selector` 提供了 `get`、`set` 分别定义如何赋值与取值,所以其与 `atom` 定义一样可以被 `useRecoilState` 等三套 API 操作,这里甚至不用看源码就能猜到,`atom` 应该是基于 `selector` 的一个特定封装。 ### 异步读取 基于 `selector` 可以实现异步数据读取,只要将 `get` 函数写成异步即可: ```jsx const currentUserNameQuery = selector({ key: "CurrentUserName", get: async ({ get }) => { const response = await myDBQuery({ userID: get(currentUserIDState), }); if (response.error) { throw response.error; } return response.name; }, }); function CurrentUserInfo() { const userName = useRecoilValue(currentUserNameQuery); return
{userName}
; } function MyApp() { return ( Loading...
}> ); } ``` 1. 异步状态可以被 `Suspense` 捕获。 2. 异步过程报错可以被 `ErrorBoundary` 捕获。 如果不想用 `Suspense` 阻塞异步,可以换 `useRecoilValueLoadable` 这个 API 在当前组件内管理异步状态: ```jsx function UserInfo({ userID }) { const userNameLoadable = useRecoilValueLoadable(userNameQuery(userID)); switch (userNameLoadable.state) { case "hasValue": return
{userNameLoadable.contents}
; case "loading": return
Loading...
; case "hasError": throw userNameLoadable.contents; } } ``` ### 依赖外部变量 与 `reselect` 一样,Recoil 也面临状态管理不纯粹的问题,即数据读取依赖外部变量,这样会面临较为复杂的缓存计算问题,甚至还出现了 `re-reselect` 库。 因为 Recoil 本身是原子化状态管理的,所以这个问题相对好解决: ```jsx const myMultipliedState = selectorFamily({ key: "MyMultipliedNumber", get: (multiplier) => ({ get }) => { return get(myNumberState) * multiplier; }, }); function MyComponent() { const number = useRecoilValue(myMultipliedState(100)); } ``` 当外部传参 `multiplier` 与依赖值 `myNumberState` 不变时,就不会重新计算。 Recoil 在 `get` 与 `set` 函数定义 `Atom` 时,内部会自动生成依赖,这个部分做的比较好。 > 依赖外部变量使用了 Family 后缀,比如 selector -> selectorFamily;atom -> atomFamily。 ## 3 精读 Recoil 以原子化方式对状态进行分离管理,确实比较契合 Immutable 的编程模式,尤其在缓存处理时非常亮眼,但编程领域中,优势换一个角度看往往就变成了劣势,我们还是要客观评价一下 Recoil。 ### Immutable 心智负担 API 较多,在简介中也提到了,这可能是 Immutable 自带的硬伤,而不仅仅是 Recoil 的问题。 Immutable 模式中,对数据流只有读与写两种诉求,**而申明式编程讲究的是数据变化后 UI 自动 Rerender,那么对数据的读自然而然就被赋予了订阅其变化后触发 Rerender 的期待**,但是写与读不同,为什么 `setState` 强调用回调方式写数据?因为回调方式的写不依赖读,有写诉求的组件没必要与读挂上钩,也就是写组件的地方不一定要订阅对应数据。 Recoil 提供了 `useRecoilState` 作为读写双重 API,仅在既读又写的场景使用,而 `useRecoilValue` 仅仅是为了简化 API,替换为 `useRecoilState` 不会有性能损失,而 `useSetRecoilValue` 则必须认真对待,在仅写不读的场景必须严格使用这个 API。 那 `useState` 为什么默认是读写的?因为 `useState` 是单组件状态管理的场景,一个定义在组件内的状态不可能只写不读,但 Recoil 是全局状态解决方案,读写分离的场景下,对于只写的组件很有必要脱离对数据的订阅实现性能最大化。 ### 条件访问数据 这也是 Hooks 的通病,由于 Hooks 不能写在条件语句中,因此要利用 Hooks 获取一个带有条件判断的数据时,必须回到 `selector` 模式: ```jsx const articleOrReply = selectorFamily({ key: "articleOrReply", get: ({ isArticle, id }) => ({ get }) => { if (isArticle) { return get(article(id)); } return get(reply(id)); }, }); ``` 这样的代码其实挺冗余的,其实在 Mutable 模式下可以 `isArticle ? store.articles[id] : store.replies[id]` 就能搞定的模式,必须单独抽一个 `selector` 出来写上头十行代码,显得非常繁琐。 ### Recoil 的本质 从 Hooks API 到派生值,这两个核心特点恰巧是对 Context 与 useMemo 的封装。 首先基于 Hooks 的 `useContext` 已经足够轻量易用,可以认为 `atom` 与 `useRecoilState`、`useRecoilValue`、`useSetRecoilValue` 分别对应封装后的 `createContext` 与 `useContext`。 再看 `useMemo`,大部分情况我们可以利用 `useMemo` 造出派生值,这对应了 Recoil 的 `selector` 和 `selectorFamily`。 所以 Recoil 本质更像一个模式化封装库,针对数据驱动易于数据原子化管理的场景,并做到高性能。 ## 3 总结 无论你用不用 Recoil,我们都可以从 Recoil 这儿学到 React 状态管理的基本功: 1. 对象的读与写分离,做到最优按需渲染。 2. 派生的值必须严格缓存,并在命中缓存时引用保证严格相等。 3. 原子存储的数据相互无关联,所有关联的数据都使用派生值方式推导。 > 讨论地址是:[精读《recoil》· Issue #251 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/251) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/153. 精读《snowpack》.md ================================================ ## 1 引言 基于 webpack 构建的大型项目开发速度已经非常慢了,前端开发者已经逐渐习惯忍受超过 100 秒的启动时间,超过 30 秒的 reload 时间。即便被寄予厚望的 webpack5 内置了缓存机制也不会得到质的提升。但放到十年前,等待时间是几百毫秒。 好在浏览器支持了 [ESM import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 模块化加载方案,终于原生支持了文件模块化,这使得本地构建不再需要处理模块化关系并聚合文件,这甚至可以将构建时间从 30 秒降低到 300 毫秒。 当然基于 [ESM import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 的构建框架不止 [snowpack](https://www.snowpack.dev/) 一个,还有比如基于 vue 的 [vite](https://github.com/vitejs/vite),因为浏览器支持模块化是一个标准,而不与任何框架绑定,未来任何构建工具都会基于此特性开发,这意味着在未来的五年,前端构建一定会回到十年前的速度,这个趋势是明显、确定的。 [ESM import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 带来的最直观的改变有下面三点: 1. `node_modules` 完全不需要参与到构建过程,仅这一点就足以让构建效率提升至少 10 倍。 2. 模块化交给浏览器管理,修改任何组件都只需做单文件编译,时间复杂度永远是 O(1),reload 时间与项目大小无关。 3. 浏览器完全模块化加载文件,不存在资源重复加载问题,这种原生的 TreeShaking 还可以做到访问文件时再编译,做到单文件级别的按需构建。 所以可以说 [ESM import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 模式下的开发效率,能做到与十年前修改 HTML 单文件的零构建效率几乎相当。 ## 2 简介 & 精读 snowpack 核心特征: - 开发模式启动仅需 50ms 甚至更少。 - 热更新速度非常快。 - 构建时可以结合任何 bundler,比如 webpack。 - 内置支持 TS、JSX、CSS Modules 等。 - 支持自定义构建脚本以及三方插件。 ### 安装 ```bash yarn add --dev snowpack ``` 通过 `snowpack.config.json` 文件配置,并能自动读取 `babel.config.json` 生效 babel 插件。 ### 开发调试 调试 `snowpack dev`,编译 `snowpack build`,会自动以 `src/index` 作为应用入口进行编译。 `snowpack dev` 命令几乎是零耗时的,因为文件仅会在被浏览器访问时进行按需编译,因此构建速度是理想的最快速。 当浏览器访问文件时,snowpack 会将文件做如下转换: ```jsx // Your Code: import * as React from "react"; import * as ReactDOM from "react-dom"; // Build Output: import * as React from "/web_modules/react.js"; import * as ReactDOM from "/web_modules/react-dom.js"; ``` 目的就是生成一个相对路径,并启动本地服务让浏览器可以访问到这些被 import 的文件。其中 `web_modules` 是 snowpack 对 `node_modules` 构建的结果。 在这之前也会对 Typescript 文件做 tsc 编译,或者 babel 编译。 ### 编译 编译命令 `snowpack build` 默认方式与 `snowpack dev` 相同: 也可以指定以 webpack 作为构建器: ```json // snowpack.config.json { // Optimize your production builds with Webpack "plugins": [ [ "@snowpack/plugin-webpack", { /* ... */ } ] ] } ``` 除了默认构建方式之外,还支持自定义文件处理,通过 `snowpack.config.json` 配置 `scripts` 指定: ```json { "extends": "@snowpack/app-scripts-react", "scripts": { "build:scss": "sass $FILE" }, "plugins": [] } ``` 比如上述语法支持了对 `scss` 文件编译的拓展。 **"build:\*": "..."** 对文件后缀进行编译,比如:`"build:js,jsx": "babel --filename $FILE"` 指定了对 `js,jsx` 后缀的文件进行 babel 构建。 **"run:\*": "..."** 仅执行一次,可以用来做 lint,也可以用来配合批量文件处理命令,比如 `tsc`: `"run:tsc": "tsc"` **"mount:\*": "mount DIR [--to /PATH]"** 将文件部署到某个 URL 地址,比如 `"mount:public": "mount public --to /"` 意味着将 `public` 文件夹下的文件部署到 `/` 这个 URL 地址。 还有 `proxy` 等 API 就不一一列举了,详细可以见 [官方文档](https://www.snowpack.dev/)。 我们可以从构建命令体会到 snowpack 的理念,**将源码以流式方式编译后,直接部署到本地 server 提供的 URL 地址,浏览器通过一个 main 入口以 [ESM import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 的方式加载这些文件。** 所以所有加载与构建逻辑都是按需的,snowpack 要做的只是将本地文件逐个构建好并启动本地服务给浏览器调用。 前端开发离不开 `node_modules`,snowpack 通过 `snowpack install` 的方式支持了这一点。 ### snowpack install 这个命令已经被 `snowpack dev` 内置了,所以 `snowpack install` 仅用来理解原理。 以下是 `snowpack install` 执行的结果: ```js ✔ snowpack install complete. [0.88s] ⦿ web_modules/ size gzip brotli ├─ react-dom.js 128.93 KB 39.89 KB 34.93 KB └─ react.js 0.54 KB 0.32 KB 0.28 KB ⦿ web_modules/common/ (Shared) └─ index-8961bd84.js 10.83 KB 3.96 KB 3.51 KB ``` 可以看到,`snowpack` 遍历项目源码对 `node_modules` 的访问,并对 `node_modules` 进行了 Web 版 `install`,可以认为 `npm install` 是将 npm 包安装到了本地,而 `snowpack install` 是将 `node_modules` 安装到了 Web API,所以这个命令只需构建一次,`node_modules` 就变成了可以按需被浏览器加载的静态资源文件。 同时源码中对 npm 包的引用都会转换为对 `web_modules` 这个静态资源地址的引用: ```jsx import * as ReactDOM from "react-dom"; // 转换 import * as React from "/web_modules/react.js"; ``` 但同时可以看到 snowpack 对前端生态的高要求,如果某些包通过 webpack 别名设置了一些 magic 映射,就无法通过文件路径直接映射,所以 snowpack 生态成熟需要一段时间,但模块标准化一定是趋势,不规范的包在未来几年内会逐步被淘汰。 ### 2020 年适合使用 snowpack 吗 答案是还不适合用在生产环境。 当然用在开发环境还是可以的,但需要承担三个风险: 1. 开发与生产环境构建结果不一致的风险。 2. 项目生态存在非 [ESM import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 模块化包而导致大量适配成本的风险。 3. 项目存在大量 webpack 插件的 magic 魔法,导致标准化后丢失定制打包逻辑的风险。 但可以看到,这些风险的原因都是非标准化造成的。我们站在 2020 年看以前浏览器非标准化 API 适配与兼容工作,可能会觉得不可思议,为什么要与那些陈旧非标准化的语法做斗争;相应的,2030 年看 2020 年的今天可能也觉得不可思议,为什么很多项目存在大量 magic 自定义构建逻辑,明明标准化构建逻辑已经完全够用了 :P。 所以我们要看到未来的趋势,也要理解当下存在的问题,不要在生态尚未成熟的时候贸然使用,但也要跟进前端规范化的步伐,在合适的时机跟上节奏,毕竟 bundleless 模式带来的开发效率提升是非常明显的。 ## 3 总结 前端发展到 2020 年这个时间点,代码规范已经基本稳定,工程化要做的事情已经从新增功能逐渐转移到研发提效上了,因此提升开发时热更新速度、构建速度是当下前端工程化的重中之重。 snowpack 代表的 bundleless 方案肯定是光明的未来,带来的构建提效非常明显,人力充足的前端团队与不需要考虑浏览器兼容性的敏捷小团队都已经开始实践 bundleless 方案了。 但对于业务需要兼容各浏览器的大团队来说,目前 bundleless 方案仅可用于开发环境,生产环境还是需要 webpack 打包,因此 webpack 生态还可以继续繁荣几年,直到大的前端团队也抛弃它为止。 如果看未来十年,可能前端工程化构建脚本都不需要了,浏览器可以直接运行源码。在这一点上,以 snowpack 为代表的 bundleless 模式着实跨越了一大步。 > 讨论地址是:[精读《snowpack》· Issue #252 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/252) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/154. 精读《用 React 做按需渲染》.md ================================================ ## 1 引言 BI 平台是阿里数据中台团队非常重要的平台级产品,要保证报表编辑与浏览的良好体验,性能优化是必不可少的。 当前 BI 工具普遍是报表形态,要知道报表形态可不仅仅是一张张图表组件,与这些组件关联的筛选条件和联动关系错综复杂,任何一个筛选条件变化就会导致其关联项重新取数并重渲染组件,而报表数据量非常大,一个表格组件加载百万量级的数据稀松平常,为了维持这么大量级数据量下的正常展示,按需渲染是必须要做的功课。 这里说的按需渲染不是指 ListView 无限滚动,因为报表的布局模式有流式布局、磁贴布局和自由布局三套,每种布局风格差异很大,无法用固定的公式计算组件是否可见,因此我们选择初始化组件全量渲染,阻止非首屏内组件的重渲染。因为初始条件下还没有获取数据,全量渲染不会造成性能问题,这是这套方案成立的前提。 所以我今天就专门介绍如何利用 DOM 判断组件在画布中是否可见这个技术方案,从架构设计与代码抽象的角度一步步分解,不仅希望你能轻松理解这个技术方案如何实现,也希望你能掌握这其中的诀窍,学会举一反三。 ## 2 精读 我们以 React 框架为例,做按需渲染的思维路径是这样的: 得到组件 `active` 状态 -> 阻塞非 `active` 组件的重渲染。 这里我选择从结果入手,先考虑如何阻塞组件渲染,再一步步推导出判断组件是否可见这个函数怎么写。 ### 阻塞组件重渲染 我们需要一个 `RenderWhenActive` 组件,支持一个 `active` 参数,当 `active` 为 true 时这一层是透明的,当 `active` 为 false 时阻塞所有渲染。 再具体描述一下,其效果是这样的: 1. inActive 时,任何 props 变化都不会导致组件渲染。 2. 从 inActive 切换到 active 时,之前作用于组件的 props 要立即生效。 3. 如果切换到 active 后 props 没有变化,也不应该触发重渲染。 4. 从 active 切换到 inActive 后不应触发渲染,且立即阻塞后续重渲染。 我们可以写一个 `RenderWhenActive` 组件轻松实现此功能: ```jsx const RenderWhenActive = React.memo(({ children }) => children, (prevProps, nextProps) => ( !nextProps.active )) ``` ### 获取组件 active 状态 在进一步思考之前,我们先不要掉到 “如何判断组件是否显示” 这个细节中,可以先假设 “已经有了这样一个函数”,我们应该如何调用。 很显然我们需要一个自定义 Hook:`useActive` 判断组件是否是激活态,并拿到 `active` 返回值传递给 `RenderWhenActive` 组件: ```jsx const ComponentLoader = ({ children }) => { const active = useActive(); return {children}; }; ``` 这样,渲染引擎利用 `ComponentLoader` 渲染的任何组件就具备了按需渲染的功能。 ### 实现 useActive 到现在,组件与 Hook 侧的流程已经完整串起来了,我们可以聚焦于如何实现 `useActive` 这个 Hook。 利用 Hooks 的 API,可以在组件渲染完毕后利用 `useEffect` 判断组件是否 Active,并利用 `useState` 存储这个状态: ```jsx export function useActive(domId: string) { // 所有元素默认 unActive const [active, setActive] = React.useState(false); React.useEffect(() => { const visibleObserve = new VisibleObserve(domId, "rootId", setActive); visibleObserve.observe(); return () => visibleObserve.unobserve(); }, [domId]); return active; } ``` 初始化时,所有组件 active 状态都是 false,然而这种状态在 `shouldComponentUpdate` 并不会阻塞第一次渲染,因此组件的 dom 节点初始化仍会渲染出来。 在 `useEffect` 阶段注册了 `VisibleObserve` 这个自定义 Class,用来监听组件 dom 节点在其父级节点 `rootId` 内是否可见,并在状态变更时通过第三个回调抛出,这里将 `setActive` 作为第三个参数,可以及时改变当前组件 active 状态。 `VisibleObserve` 这个函数拥有 `observe` 与 `unobserve` 两个 API,分别是启动监听与取消监听,利用 `useEffect` 销毁时执行 return callback 的特性,监听与销毁机制也完成了。 下一步就是如何实现最核心的 `VisibleObserve` 函数,用来监听组件是否可见。 ### 监听组件是否可见的准备工作 在实现 `VisibleObserve` 之前,想一下有几种方法实现呢?可能你脑海中冒出了很多种奇奇怪怪的方案。是的,判断组件在某个容器内是否可见有许多种方案,即便从功能上能找到最优解,但从兼容性角度来看也无法找到完美的方案,因此这是一个拥有多种实现可能性的函数,在不同版本的浏览器采用不同方案才是最佳策略。 处理这种情况的方法之一,就是做一个抽象类,让所有实际方法都继承并实现抽象类,这样我们就拥有了多套 “相同 API 的不同实现”,以便在不同场景随时切换使用。 利用 `abstract` 创建抽象类 `AVisibleObserve`,实现构造函数并申明两个 public 的重要函数 `observe` 与 `unobserve`: ```jsx /** * 监听元素是否可见的抽象类 */ abstract class AVisibleObserve { /** * 监听元素的 DOM ID */ protected targetDomId: string; /** * 可见范围根节点 DOM ID */ protected rootDomId: string; /** * Active 变化回调 */ protected onActiveChange: (active?: boolean) => void; constructor(targetDomId: string, rootDomId: string, onActiveChange: (active?: boolean) => void) { this.targetDomId = targetDomId; this.rootDomId = rootDomId; this.onActiveChange = onActiveChange; } /** * 开始监听 */ abstract observe(): void; /** * 取消监听 */ abstract unobserve(): void; } ``` 这样我们就可以实现多套方案。稍加思索可以发现,我们只要两套方案,一套是利用 `setInterval` 实现的轮询检测的笨方法,一种是利用浏览器高级 API `IntersectionObserver` 实现的新潮方法,由于后者有兼容性要求,前者就作为兜底方案实现。 因此我们可以定义两套对应方法: ```jsx class IntersectionVisibleObserve extends AVisibleObserve { constructor(/**/) { super(targetDomId, rootDomId, onActiveChange); } observe() { // balabala.. } unobserve() { // balabala.. } } class SetIntervalVisibleObserve extends AVisibleObserve { constructor(/**/) { super(targetDomId, rootDomId, onActiveChange); } observe() { // balabala.. } unobserve() { // balabala.. } } ``` 最后再做一个总类作为调用入口: ```jsx /** * 监听元素是否可见总类 */ export class VisibleObserve extends AVisibleObserve { /** * 实际 VisibleObserve 类 */ private actualVisibleObserve: AVisibleObserve = null; constructor(targetDomId: string, rootDomId: string, onActiveChange: (active?: boolean) => void) { super(targetDomId, rootDomId, onActiveChange); // 根据浏览器 API 兼容程度选用不同 Observe 方案 if ('IntersectionObserver' in window) { // 最新 IntersectionObserve 方案 this.actualVisibleObserve = new IntersectionVisibleObserve(targetDomId, rootDomId, onActiveChange); } else { // 兼容的 SetInterval 方案 this.actualVisibleObserve = new SetIntervalVisibleObserve(targetDomId, rootDomId, onActiveChange); } } observe() { this.actualVisibleObserve.observe(); } unobserve() { this.actualVisibleObserve.unobserve(); } } ``` 在构造函数就判断了当前浏览器是否支持 `IntersectionObserver` 这个 API,然而无论何种方案创建的实例都继承于 `AVisibleObserve`,所以我们可以用统一的 `actualVisibleObserve` 成员变量存放。 `observe` 与 `unobserve` 阶段都可以无视具体类的实现,直接调用 `this.actualVisibleObserve.observe()` 与 `this.actualVisibleObserve.unobserve()` 这两个 API。 这里体现的思想是,父类关心接口层 API,子类关心基于这套接口 API 如何具体实现。 接下来我们看看低配版(兼容)与高配版(原生)分别如何实现。 ### 监听组件是否可见 - 兼容版本 兼容版本模式中,需要定义一个额外成员变量 `interval` 存储 SetInterval 引用,在 `unobserve` 的时候 `clearInterval`。 其判断可见函数我抽象到了 `judgeActive` 函数中,核心思想是判断两个矩形(容器与要判断的组件)是否存在包含关系,如果包含成立则代表可见,如果包含不成立则不可见。 下面是完整实现函数: ```jsx class SetIntervalVisibleObserve extends AVisibleObserve { /** * Interval 引用 */ private interval: number; /** * 检查是否可见的时间间隔 */ private checkInterval = 1000; constructor(targetDomId: string, rootDomId: string, onActiveChange: (active?: boolean) => void) { super(targetDomId, rootDomId, onActiveChange); } /** * 判断元素是否可见 */ private judgeActive() { // 获取 root 组件 rect const rootComponentDom = document.getElementById(this.rootDomId); if (!rootComponentDom) { return; } // root 组件 rect const rootComponentRect = rootComponentDom.getBoundingClientRect(); // 获取当前组件 rect const componentDom = document.getElementById(this.targetDomId); if (!componentDom) { return; } // 当前组件 rect const componentRect = componentDom.getBoundingClientRect(); // 判断当前组件是否在 root 组件可视范围内 // 长度之和 const sumOfWidth = Math.abs(rootComponentRect.left - rootComponentRect.right) + Math.abs(componentRect.left - componentRect.right); // 宽度之和 const sumOfHeight = Math.abs(rootComponentRect.bottom - rootComponentRect.top) + Math.abs(componentRect.bottom - componentRect.top); // 长度之和 + 两倍间距(交叉则间距为负) const sumOfWidthWithGap = Math.abs( rootComponentRect.left + rootComponentRect.right - componentRect.left - componentRect.right, ); // 宽度之和 + 两倍间距(交叉则间距为负) const sumOfHeightWithGap = Math.abs( rootComponentRect.bottom + rootComponentRect.top - componentRect.bottom - componentRect.top, ); if (sumOfWidthWithGap <= sumOfWidth && sumOfHeightWithGap <= sumOfHeight) { // 在内部 this.onActiveChange(true); } else { // 在外部 this.onActiveChange(false); } } observe() { // 监听时就判断一次元素是否可见 this.judgeActive(); this.interval = setInterval(this.judgeActive, this.checkInterval); } unobserve() { clearInterval(this.interval); } } ``` 根据容器 `rootDomId` 与组件 `targetDomId`,我们可以拿到其对应 DOM 实例,并调用 `getBoundingClientRect` 拿到其对应矩形的位置与宽高。 算法思路如下: 设容器为 root,组件为 component。 1. 计算 root 与 component 长度之和 `sumOfWidth` 与宽度之和 `sumOfHeight`。 2. 计算 root 与 component 长度之和 + 两倍间距 `sumOfWidthWithGap` 与 宽度之和 + 两倍间距 `sumOfHeightWithGap`。 3. `sumOfWidthWithGap - sumOfWidth` 的差值就是横向 gap 距离,`sumOfHeightWithGap - sumOfHeight` 的差值就是横向 gap 距离,两个值都为负数表示在内部。 其中的关键是,从横向角度来看,下面的公式可以理解为宽度之和 + 两倍的宽度间距: ```jsx // 长度之和 + 两倍间距(交叉则间距为负) const sumOfWidthWithGap = Math.abs( rootComponentRect.left + rootComponentRect.right - componentRect.left - componentRect.right ); ``` 而 `sumOfWidth` 是宽度之和,这之间的差值就是两倍间距值,正数表示横向没有交集。当横纵两个交集都是负数时,代表存在交叉或者包含在内部。 ### 监听组件是否可见 - 原生版本 如果浏览器支持 `IntersectionObserver` 这个 API 就好办多了,以下是完整代码: ```jsx class IntersectionVisibleObserve extends AVisibleObserve { /** * IntersectionObserver 实例 */ private intersectionObserver: IntersectionObserver; constructor(targetDomId: string, rootDomId: string, onActiveChange: (active?: boolean) => void) { super(targetDomId, rootDomId, onActiveChange); this.intersectionObserver = new IntersectionObserver( changes => { if (changes[0].intersectionRatio > 0) { onActiveChange(true); } else { onActiveChange(false); // 因为虚拟 dom 更新导致实际 dom 更新,也会在此触发,判断 dom 丢失则重新监听 if (!document.body.contains(changes[0].target)) { this.intersectionObserver.unobserve(changes[0].target); this.intersectionObserver.observe(document.getElementById(this.targetDomId)); } } }, { root: document.getElementById(rootDomId), }, ); } observe() { if (document.getElementById(this.targetDomId)) { this.intersectionObserver.observe(document.getElementById(this.targetDomId)); } } unobserve() { this.intersectionObserver.disconnect(); } } ``` 通过 `intersectionRatio > 0` 就可以判断元素是否出现在父级容器中,如果 `intersectionRatio === 1` 则表示组件完整出现在容器内,此处我们的要求是任意部分出现就 active。 有一点要注意的是,这个判断与 SetInterval 不同,由于 React 虚拟 DOM 可能会更新 DOM 实例,导致 `IntersectionObserver.observe` 监听的 DOM 元素被销毁后,导致后续监听失效,因此需要在元素隐藏时加入下面的代码: ```jsx // 因为虚拟 dom 更新导致实际 dom 更新,也会在此触发,判断 dom 丢失则重新监听 if (!document.body.contains(changes[0].target)) { this.intersectionObserver.unobserve(changes[0].target); this.intersectionObserver.observe(document.getElementById(this.targetDomId)); } ``` 1. 当元素判断不在可视区域时,也包含了元素被销毁。 2. 因此通过 `body.contains` 判断元素是否被销毁,如果被销毁则重新监听新的 DOM 实例。 ## 3 总结 总结一下,按需渲染的逻辑的适用面不仅仅在渲染引擎,但对于 ProCode 场景直接编写的代码中,要加入这段逻辑就显得侵入性较强。 或许可视区域内按需渲染可以做到前端开发框架内部,虽然不属于标准框架功能,但也不完全属于业务功能。 这次留下一个思考题,如果让手写的 React 代码具备按需渲染功能,怎么设计更好呢? > 讨论地址是:[精读《用 React 做按需渲染》· Issue #254 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/254) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/157. 精读《如何比较 Object 对象》.md ================================================ ## 1 引言 Object 类型的比较是非常重要的基础知识,通过 [How to Compare Objects in JavaScript](https://dmitripavlutin.com/how-to-compare-objects-in-javascript/) 这篇文章,我们可以学到四种对比方法:引用对比、手动对比、浅对比、深对比。 ## 2 简介 ### 引用对比 下面三种对比方式用于 Object,皆在引用相同是才返回 `true`: - `===` - `==` - `Object.is()` ```js const hero1 = { name: "Batman", }; const hero2 = { name: "Batman", }; hero1 === hero1; // => true hero1 === hero2; // => false hero1 == hero1; // => true hero1 == hero2; // => false Object.is(hero1, hero1); // => true Object.is(hero1, hero2); // => false ``` ### 手动对比 写一个自定义函数,按照对象内容做自定义对比也是一种方案: ```js function isHeroEqual(object1, object2) { return object1.name === object2.name; } const hero1 = { name: "Batman", }; const hero2 = { name: "Batman", }; const hero3 = { name: "Joker", }; isHeroEqual(hero1, hero2); // => true isHeroEqual(hero1, hero3); // => false ``` 如果要对比的对象 key 不多,或者在特殊业务场景需要时,这种手动对比方法其实还是蛮实用的。 但这种方案不够自动化,所以才有了浅对比。 ### 浅对比 浅对比函数写法有很多,不过其效果都是标准的,下面给出了一种写法: ```js function shallowEqual(object1, object2) { const keys1 = Object.keys(object1); const keys2 = Object.keys(object2); if (keys1.length !== keys2.length) { return false; } for (let key of keys1) { if (object1[key] !== object2[key]) { return false; } } return true; } ``` 可以看到,浅对比就是将对象每个属性进行引用对比,算是一种性能上的平衡,尤其在 redux 下有特殊的意义。 下面给出了使用例子: ```js const hero1 = { name: "Batman", realName: "Bruce Wayne", }; const hero2 = { name: "Batman", realName: "Bruce Wayne", }; const hero3 = { name: "Joker", }; shallowEqual(hero1, hero2); // => true shallowEqual(hero1, hero3); // => false ``` 如果对象层级再多一层,浅对比就无效了,此时需要使用深对比。 ### 深对比 深对比就是递归对比对象所有简单对象值,遇到复杂对象就逐个 key 进行对比,以此类推。 下面是一种实现方式: ```js function deepEqual(object1, object2) { const keys1 = Object.keys(object1); const keys2 = Object.keys(object2); if (keys1.length !== keys2.length) { return false; } for (const key of keys1) { const val1 = object1[key]; const val2 = object2[key]; const areObjects = isObject(val1) && isObject(val2); if ( (areObjects && !deepEqual(val1, val2)) || (!areObjects && val1 !== val2) ) { return false; } } return true; } function isObject(object) { return object != null && typeof object === "object"; } ``` 可以看到,只要遇到 Object 类型的 key,就会递归调用一次 `deepEqual` 进行比较,否则对于简单类型直接使用 `!==` 引用对比。 值得注意的是,数组类型也满足 `typeof object === "object"` 的条件,且 `Object.keys` 可以作用于数组,且 `object[key]` 也可作用于数组,因此数组和对象都可以采用相同方式处理。 有了深对比,再也不用担心复杂对象的比较了: ```js const hero1 = { name: "Batman", address: { city: "Gotham", }, }; const hero2 = { name: "Batman", address: { city: "Gotham", }, }; deepEqual(hero1, hero2); // => true ``` 但深对比会造成性能损耗,不要小看递归的作用,在对象树复杂时,深对比甚至会导致严重的性能问题。 ## 3 精读 ### 常见的引用对比 引用对比是最常用的,一般在做 props 比较时,只允许使用引用对比: ```js this.props.style !== nextProps.style; ``` 如果看到有深对比的地方,一般就要有所警觉,这里是真的需要深对比吗?是不是其他地方写法有问题导致的。 比如在某处看到这样的代码: ```js deepEqual(this.props.style, nextProps.style); ``` 可能是父组件一处随意拼写导致的: ```jsx const Parent = () => { return ; }; ``` 一个只解决局部问题的同学可能会采用 `deepEqual`,OK 这样也能解决问题,但一个有全局感的同学会这样解决问题: ```js this.props.style === nextProps.style; ``` ```jsx const Parent = () => { const style = useMemo(() => ({ color: "red" }), []); return ; }; ``` 从性能上来看,`Parent` 定义的 `style` 只会执行一次且下次渲染几乎没有对比损耗(依赖为空数组),子组件引用对比性能最佳,这样的组合一定优于 `deepEqual` 的例子。 ### 常见的浅对比 浅对比也在判断组件是否重渲染时很常用: ```jsx shouldComponentUpdate(nextProps) { return !shallowEqual(this.props, nextProps) } ``` 原因是 `this.props` 这个对象引用的变化在逻辑上是无需关心的,因为应用只会使用到 `this.props[key]` 这一层级,再考虑到 React 组件生态下,Immutable 的上下文保证了任何对象子属性变化一定导致对象整体引用变化,可以放心的进行浅对比。 最少见的就是手动对比和深对比,如果你看到一段代码中使用了深对比,大概率这段代码可以被优化为浅对比。 ## 4 总结 虽然今天总结了 4 种比较 Object 对象的方式,但在实际项目中,应该尽可能使用引用对比,其次是浅对比和手动对比,最坏的情况是使用深对比。 > 讨论地址是:[精读《如何比较 Object 对象》· Issue #258 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/258) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/158. 精读《Typescript 4》.md ================================================ ## 1 引言 随着 [Typescript 4 Beta](https://devblogs.microsoft.com/typescript/announcing-typescript-4-0-beta/) 的发布,又带来了许多新功能,其中 Variadic Tuple Types 解决了大量重载模版代码的顽疾,使得这次更新非常有意义。 ## 2 简介 ### 可变元组类型 考虑 `concat` 场景,接收两个数组或者元组类型,组成一个新数组: ```typescript function concat(arr1, arr2) { return [...arr1, ...arr2]; } ``` 如果要定义 `concat` 的类型,以往我们会通过枚举的方式,先枚举第一个参数数组中的每一项: ```typescript function concat<>(arr1: [], arr2: []): [A]; function concat(arr1: [A], arr2: []): [A]; function concat(arr1: [A, B], arr2: []): [A, B]; function concat(arr1: [A, B, C], arr2: []): [A, B, C]; function concat(arr1: [A, B, C, D], arr2: []): [A, B, C, D]; function concat(arr1: [A, B, C, D, E], arr2: []): [A, B, C, D, E]; function concat(arr1: [A, B, C, D, E, F], arr2: []): [A, B, C, D, E, F];) ``` 再枚举第二个参数中每一项,如果要完成所有枚举,仅考虑数组长度为 6 的情况,就要定义 36 次重载,代码几乎不可维护: ```typescript function concat(arr1: [], arr2: [A2]): [A2]; function concat(arr1: [A1], arr2: [A2]): [A1, A2]; function concat(arr1: [A1, B1], arr2: [A2]): [A1, B1, A2]; function concat( arr1: [A1, B1, C1], arr2: [A2] ): [A1, B1, C1, A2]; function concat( arr1: [A1, B1, C1, D1], arr2: [A2] ): [A1, B1, C1, D1, A2]; function concat( arr1: [A1, B1, C1, D1, E1], arr2: [A2] ): [A1, B1, C1, D1, E1, A2]; function concat( arr1: [A1, B1, C1, D1, E1, F1], arr2: [A2] ): [A1, B1, C1, D1, E1, F1, A2]; ``` 如果我们采用批量定义的方式,问题也不会得到解决,因为参数类型的顺序得不到保证: ```typescript function concat(arr1: T[], arr2, U[]): Array; ``` 在 Typescript 4,可以在定义中对数组进行解构,通过几行代码优雅的解决可能要重载几百次的场景: ```typescript type Arr = readonly any[]; function concat(arr1: T, arr2: U): [...T, ...U] { return [...arr1, ...arr2]; } ``` 上面例子中,`Arr` 类型告诉 TS `T` 与 `U` 是数组类型,再通过 `[...T, ...U]` 按照逻辑顺序依次拼接类型。 再比如 `tail`,返回除第一项外剩下元素: ```typescript function tail(arg) { const [_, ...result] = arg; return result; } ``` 同样告诉 TS `T` 是数组类型,且 `arr: readonly [any, ...T]` 申明了 `T` 类型表示除第一项其余项的类型,TS 可自动将 `T` 类型关联到对象 `rest`: ```typescript function tail(arr: readonly [any, ...T]) { const [_ignored, ...rest] = arr; return rest; } const myTuple = [1, 2, 3, 4] as const; const myArray = ["hello", "world"]; // type [2, 3, 4] const r1 = tail(myTuple); // type [2, 3, ...string[]] const r2 = tail([...myTuple, ...myArray] as const); ``` 另外之前版本的 TS 只能将类型解构放在最后一个位置: ```typescript type Strings = [string, string]; type Numbers = [number, number]; // [string, string, number, number] type StrStrNumNum = [...Strings, ...Numbers]; ``` 如果你尝试将 `[...Strings, ...Numbers]` 这种写法,将会得到一个错误提示: ```text A rest element must be last in a tuple type. ``` 但在 Typescript 4 版本支持了这种语法: ```typescript type Strings = [string, string]; type Numbers = number[]; // [string, string, ...Array] type Unbounded = [...Strings, ...Numbers, boolean]; ``` 对于再复杂一些的场景,例如高阶函数 `partialCall`,支持一定程度的柯里化: ```typescript function partialCall(f, ...headArgs) { return (...tailArgs) => f(...headArgs, ...tailArgs); } ``` 我们可以通过上面的特性对其进行类型定义,将函数 `f` 第一个参数类型定义为有顺序的 `[...T, ...U]`: ```typescript type Arr = readonly unknown[]; function partialCall( f: (...args: [...T, ...U]) => R, ...headArgs: T ) { return (...b: U) => f(...headArgs, ...b); } ``` 测试效果如下: ```typescript const foo = (x: string, y: number, z: boolean) => {}; // This doesn't work because we're feeding in the wrong type for 'x'. const f1 = partialCall(foo, 100); // ~~~ // error! Argument of type 'number' is not assignable to parameter of type 'string'. // This doesn't work because we're passing in too many arguments. const f2 = partialCall(foo, "hello", 100, true, "oops"); // ~~~~~~ // error! Expected 4 arguments, but got 5. // This works! It has the type '(y: number, z: boolean) => void' const f3 = partialCall(foo, "hello"); // What can we do with f3 now? f3(123, true); // works! f3(); // error! Expected 2 arguments, but got 0. f3(123, "hello"); // ~~~~~~~ // error! Argument of type '"hello"' is not assignable to parameter of type 'boolean' ``` 值得注意的是,`const f3 = partialCall(foo, "hello");` 这段代码由于还没有执行到 `foo`,因此只匹配了第一个 `x:string` 类型,虽然后面 `y: number, z: boolean` 也是必选,但因为 `foo` 函数还未执行,此时只是参数收集阶段,因此不会报错,等到 `f3(123, true)` 执行时就会校验必选参数了,因此 `f3()` 时才会提示参数数量不正确。 ### 元组标记 下面两个函数定义在功能上是一样的: ```typescript function foo(...args: [string, number]): void { // ... } function foo(arg0: string, arg1: number): void { // ... } ``` 但还是有微妙的区别,下面的函数对每个参数都有名称标记,但上面通过解构定义的类型则没有,针对这种情况,Typescript 4 支持了元组标记: ```typescript type Range = [start: number, end: number]; ``` 同时也支持与解构一起使用: ```typescript type Foo = [first: number, second?: string, ...rest: any[]]; ``` ### Class 从构造函数推断成员变量类型 构造函数在类实例化时负责一些初始化工作,比如为成员变量赋值,在 Typescript 4,在构造函数里对成员变量的赋值可以直接为成员变量推导类型: ```typescript class Square { // Previously: implicit any! // Now: inferred to `number`! area; sideLength; constructor(sideLength: number) { this.sideLength = sideLength; this.area = sideLength ** 2; } } ``` 如果对成员变量赋值包含在条件语句中,还能识别出存在 `undefined` 的风险: ```typescript class Square { sideLength; constructor(sideLength: number) { if (Math.random()) { this.sideLength = sideLength; } } get area() { return this.sideLength ** 2; // ~~~~~~~~~~~~~~~ // error! Object is possibly 'undefined'. } } ``` 如果在其他函数中初始化,则 TS 不能自动识别,需要用 `!:` 显式申明类型: ```typescript class Square { // definite assignment assertion // v sideLength!: number; // ^^^^^^^^ // type annotation constructor(sideLength: number) { this.initialize(sideLength); } initialize(sideLength: number) { this.sideLength = sideLength; } get area() { return this.sideLength ** 2; } } ``` ### 短路赋值语法 针对以下三种短路语法提供了快捷赋值语法: ```typescript a &&= b; // a && (a = b) a ||= b; // a || (a = b) a ??= b; // a ?? (a = b) ``` ### catch error unknown 类型 Typescript 4.0 之后,我们可以将 catch error 定义为 `unknown` 类型,以保证后面的代码以健壮的类型判断方式书写: ```typescript try { // ... } catch (e) { // error! // Property 'toUpperCase' does not exist on type 'unknown'. console.log(e.toUpperCase()); if (typeof e === "string") { // works! // We've narrowed 'e' down to the type 'string'. console.log(e.toUpperCase()); } } ``` PS:在之前的版本,`catch (e: unknown)` 会报错,提示无法为 `error` 定义 `unknown` 类型。 ### 自定义 JSX 工厂 TS 4 支持了 `jsxFragmentFactory` 参数定义 Fragment 工厂函数: ```json { "compilerOptions": { "target": "esnext", "module": "commonjs", "jsx": "react", "jsxFactory": "h", "jsxFragmentFactory": "Fragment" } } ``` 还可以通过注释方式覆盖单文件的配置: ```typescript // Note: these pragma comments need to be written // with a JSDoc-style multiline syntax to take effect. /** @jsx h */ /** @jsxFrag Fragment */ import { h, Fragment } from "preact"; let stuff = ( <>
Hello
); ``` 以上代码编译后解析结果如下: ```typescript // Note: these pragma comments need to be written // with a JSDoc-style multiline syntax to take effect. /** @jsx h */ /** @jsxFrag Fragment */ import { h, Fragment } from "preact"; let stuff = h(Fragment, null, h("div", null, "Hello")); ``` ### 其他升级 其他的升级快速介绍: **构建速度提升**,提升了 `--incremental` + `--noEmitOnError` 场景的构建速度。 **支持 `--incremental` + `--noEmit` 参数同时生效。** **支持 `@deprecated` 注释,** 使用此注释时,代码中会使用 ~~删除线~~ 警告调用者。 **局部 TS Server 快速启动功能,** 打开大型项目时,TS Server 要准备很久,Typescript 4 在 VSCode 编译器下做了优化,可以提前对当前打开的单文件进行部分语法响应。 **优化自动导入,** 现在 `package.json` `dependencies` 字段定义的依赖将优先作为自动导入的依据,而不再是遍历 `node_modules` 导入一些非预期的包。 除此之外,还有几个 Break Change: `lib.d.ts` 类型升级,主要是移除了 `document.origin` 定义。 覆盖父 Class 属性的 getter 或 setter 现在都会提示错误。 通过 `delete` 删除的属性必须是可选的,如果试图用 `delete` 删除一个必选的 key,则会提示错误。 ## 3 精读 Typescript 4 最大亮点就是可变元组类型了,但可变元组类型也不能解决所有问题。 拿笔者的场景来说,函数 `useDesigner` 作为自定义 React Hook 与 `useSelector` 结合支持 connect redux 数据流的值,其调用方式是这样的: ```typescript const nameSelector = (state: any) => ({ name: state.name as string, }); const ageSelector = (state: any) => ({ age: state.age as number, }); const App = () => { const { name, age } = useDesigner(nameSelector, ageSelector); }; ``` `name` 与 `age` 是 Selector 注册的,内部实现方式必然是 `useSelector` + reduce,但类型定义就麻烦了,通过重载可以这么做: ```typescript import * as React from 'react'; import { useSelector } from 'react-redux'; type Function = (...args: any) => any; export function useDesigner(); export function useDesigner( t1: T1 ): ReturnType ; export function useDesigner( t1: T1, t2: T2 ): ReturnType & ReturnType ; export function useDesigner< T1 extends Function, T2 extends Function, T3 extends Function >( t1: T1, t2: T2, t3: T3, t4: T4, ): ReturnType & ReturnType & ReturnType & ReturnType & ; export function useDesigner< T1 extends Function, T2 extends Function, T3 extends Function, T4 extends Function >( t1: T1, t2: T2, t3: T3, t4: T4 ): ReturnType & ReturnType & ReturnType & ReturnType & ; export function useDesigner(...selectors: any[]) { return useSelector((state) => selectors.reduce((selected, selector) => { return { ...selected, ...selector(state), }; }, {}) ) as any; } ``` 可以看到,笔者需要将 `useDesigner` 传入的参数通过函数重载方式一一传入,上面的例子只支持到了三个参数,如果传入了第四个参数则函数定义会失效,因此业界做法一般是定义十几个重载,这样会导致函数定义非常冗长。 但参考 TS4 的例子,我们可以避免类型重载,而通过枚举的方式支持: ```typescript type Func = (state?: any) => any; type Arr = readonly Func[]; const useDesigner = ( ...selectors: T ): ReturnType & ReturnType & ReturnType & ReturnType => { return useSelector((state) => selectors.reduce((selected, selector) => { return { ...selected, ...selector(state), }; }, {}) ) as any; }; ``` 可以看到,最大的变化是不需要写四遍重载了,但由于场景和 `concat` 不同,这个例子返回值不是简单的 `[...T, ...U]`,而是 `reduce` 的结果,所以目前还只能通过枚举的方式支持。 当然可能存在不用枚举就可以支持无限长度的入参类型解析的方案,因笔者水平有限,暂未想到更好的解法,如果你有更好的解法,欢迎告知笔者。 ## 4 总结 Typescript 4 带来了更强类型语法,更智能的类型推导,更快的构建速度以及更合理的开发者工具优化,唯一的几个 Break Change 不会对项目带来实质影响,期待正式版的发布。 > 讨论地址是:[精读《Typescript 4》· Issue #259 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/259) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/159. 精读《对低代码搭建的理解》.md ================================================ ## 1 引言 在说低代码搭建之前,首先要理解什么是搭建(本文搭建指通过 Web 交互搭建一个自定义的新页面)。 **我认为搭建的本质是提效** ,而提效又分为对研发人员的提效,以及对客户的提效: - 对研发人员的提效:相对于 Pro Code 模式,搭建的抽象程度更高,通过牺牲部分定制性换来更高效的开发方式。 - 对客户的提效:如果用户有任何搭建 Web 应用的诉求,本质上从阿里云购买服务器自建是最普适的方案,但由于专业性要求高,用户群会很窄,因此需要针对不同用户的诉求开发定制方案,本质上是通过降低通用性换取更低的上手成本,或者针对某个领域降低上手成本,比如 BI 搭建。 提效虽然被说烂了,但软件工程发展中,几乎大部分工作都能归结到在提效。比如 Vscode、Typescript 提升编码效率;React、Vue 框架提升程序研发效率;工作台、可持续集成提升协同开发效率,等等,连微软都称自己的使命是赋能全球每一人、每个组织成就不凡,很大程度上就是在说提升整个社会的生产效能。 低代码开发平台(Low-Code Development Platform)则更进一步,允许通过零代码或少量代码就可以快速创建应用。 从实践结果来看,完全零代码想要覆盖所有领域是不可能的,而 100% 全代码是可以覆盖所有领域,但研发成本太高,所以介于两者之间的低代码模式是值得尝试的,因为许多定制场景往往不需要太多高深的代码就能搞定,很多复杂逻辑可能几个简单的赋值语句、或者条件语句就可以搞定,但如果不允许写代码,其使用成本甚至比写少量代码还要高。 所以搭建本质解决的是提效问题,考虑提效就要看性价比,是使用者学习几行简单代码后,利用低代码平台效率更高,还是使用者坚持不写代码,使用繁琐的搭建交互成本更高?有人说代码学不会,但简单代码本质和搭建无异,都是对电脑指令的输入。 还有一些场景将背后复杂度转移到了其他链路,比如数据搭建场景,虽然搭建器没有低代码能力,但却能实现复杂业务逻辑,原因是这个复杂度被 SQL 层吃掉了,既然复杂度无法消除,那么哪一层实现的效率更高,就由哪一层去做才是合理的。 ## 2 精读 低代码不仅仅包括 “能写代码”,主要具备如下四个特性:物料接入、编排能力、渲染能力、出码能力。 ### 物料接入 通用搭建引擎要能够接入通用物料,即组件自身不关心搭建环境,就可以被搭建平台所使用。 这需要搭建平台本身不对组件代码实现有入侵,可以对组件暴露的 props 做完全控制,要做到自动识别组件有哪些 props 变量,并根据类型自动推荐编辑表单类型。 除了简单的文本、数字、下拉框等编辑器 Setter 之外,还有如下几种复杂编辑器: - 回调函数编辑器。 - Node 节点编辑器。 - 文本国际化编辑器。 - 表达式编辑器。 回调函数编辑器与表达式编辑器都是低代码能力的体现,本质上就是利用代码描述某个变量值或者回调。 Node 节点编辑器专门处理节点类型 props 参数,比如 `props.header`、`propder.footer`,在代码模式描述为组件,在可视化模式需转化为画布下钻模式进行编辑。 ### 编排能力 编排能力包含页面编排与逻辑编排,是低代码搭建的核心能力。 #### 页面编排 页面编排包含很多交互行为,比如拖拽组件、布局,其中布局大有可为,比如云凤蝶的编辑模式,通过自由拖拽布局,降低了使用者对 DOM 流式布局的理解成本,但通过自适应四周边距模拟出了流式布局自动撑开容器,容器间碰撞挤压的效果。 组件与组件形成的组合可以形成一个新的物料,一般称为模版,比如一个页面整体也可以称为模版,这个模版组件的 id 就是页面根节点的容器组件。但模版也有不能满足的场景,比如期望组件形成的组合拥有一套全新配置,此时就延伸出低代码业务组件的概念,可以认为将模版当作一个整体编辑,可以为模版设置任意的编辑表单,这个编辑表单的值可以透传到里面每个组件中读取。 #### 逻辑编排 逻辑编排是低代码能力的核心,在低代码引擎中,所有组件参数都可以用低代码描述,比如一个 `props.color` 可以通过颜色选择器选一个固定值,也可以转换为表达式模式写一段代码。 这段代码除了拥有普通 JS 能力外,还拥有基本状态管理的能力,即可以访问当前作用域下的状态 `this.state`,而状态作用域又被容器所分割,容器分为持有状态的容器与不持有状态的,一个持有状态容器内的子组件状态是互通的。 除了基本状态管理能力外,还拥有访问上下文能力,即调用引擎一些 API 对画布进行操作,一般都用于组件回调,在回调里调用 `this.setState` 设置状态也属于操作上下文的行为。除了上下文外,还有风格化、国际化、取数等能力可以通过 `this` 访问到,其中取数能力专门抽到引擎层做,就是为了让所有组件与取数逻辑解耦,组件只要拿到数据、isFetching,而不需要真正发送取数请求。 逻辑编排的另一个维度就是可视化,将上述低代码能力通过可视化方式表达为逻辑节点与线条,在描述与维护复杂逻辑时有一定优势。 ### 渲染能力 搭建特殊之处在于,搭建过程几乎只能在 PC 端完成,但发布后的应用往往有多端渲染的诉求,比如越来越多的公司使用手机查看 BI 报表,甚至报表需要嵌入到微信、支付宝小程序中;PC 搭建的表单往往也有大量手机端填报的诉求。 所以编辑和渲染端应该是分离的,但为了保证逻辑一致性,核心代码需要复用,所以搭建引擎最好采用 UI 无关的内核 + 业务层拓展 UI 实现方式来做,UI 无关的内核只负责存储、操作画布数据,排除设计器附加的一堆 Panel 后,渲染时可以复用逻辑内核往往就足够了。 组件的跨端复用也是必须的,现在跨端渲染的技术方案也有不少。 ### 出码能力 LowCode 与 ProCode 互转也是一大难题,首先互转的好处不必多说,可以自由的在提效与定制间切换,一定是最理想的开发模式,但实现起来有不少阻碍。 首先是 LowCode 转 ProCode,这个比较简单,原因是 LowCode 本身用 JSON 定义,代码是 JSON 的超集,从子集转换到超集本身没有技术障碍。 从 ProCode 转换到 LowCode 就麻烦了,一种方式是限定 ProCode 的能力,甚至用一种新的语法替代原生 JS,本质上都是通过将 ProCode 的能力范围限制住,使得 LowCode 可以接住。另一种方式是不对称转换,即从 ProCode 转换为 LowCode 后会存在功能缺失,或者即便功能不缺失,但 LowCode 无法对应的功能无法在搭建平台编辑。 ### 运行时能力 只拥有上述低代码能力的搭建平台还是太通用了,虽然功能很强大,但在具体的业务场景不一定有多大的提效,具体的业务场景要有具体的解决方案,搭建本质是提效的,如果原子化、低代码的内容太多,就本末倒置,只是用另一种方式写代码罢了,并没有真正做到利用搭建提升开发效率。 通用的业务定制方式有如下三种: - 定制业务组件:比如将某个复杂业务系统 80% 场景都要用到的组件固化为一个业务定制组件,省去了大部分配置时间,让使用者感受到提效。 - 定制业务模版和低代码业务组件:更进一步,将业务模版固化下来,本质上类似代码模版,或者利用低代码业务组件,在不开发新组件的前提下,制作一个针对某个业务场景的混合组件。 - 定制业务配置项:有些业务场景专业度很高,一方面是用户群不一样,一方面是搭建效率考虑,都应该提供一种基于业务角度出发的配置项,既符合业务思考逻辑,又节省配置步骤。 以上通用方式都是通过引擎已有的开放能力可以做到的,但对数据场景来说,有一些依赖引擎运行时能力场景,需要将引擎运行时能力抽象出来,配合低代码实现。 比如让当前页面所有配置相同数据集的组件自动建立筛选联动关联,虽然筛选联动关联可以通过低代码方式配置,但当画布组件数量变化时,或者有组件动态调用 API 新增组件时,静态的配置很难满足动态关联场景,此时我们可以拓展出一些全局运行时能力,让组件实现这些运行时能力时可以拿到画布信息,在引擎实际调用时再动态运行,而不是编辑生成一份静态 JSON 与渲染完全割裂。 运行时能力在不同平台针对不同垂直场景时会存在差异,如果希望打通底层引擎,可以提供拓展插槽,提供动态注册引擎运行时能力的机制。 ## 3 总结 一个低代码搭建平台通吃一切场景是不可能的,只要有人愿意为垂直业务场景做 “量身定制”,用户就会立刻觉得搭建效率得到了提升,我们应当站在用户的角度,以用户利益最大化的方式做平台。 但搭建平台维护成本很高,每个业务场景都单独维护一套肯定不是长久之计,我们需要设计一套有弹性的低代码核心引擎,各个业务都可以基于他为自己的用户群 “量身定制” 一套专属设计器,共享搭建引擎通用的能力与协议,并自由拓展定制能力。 所以不仅渲染态是多态的,设计器也应该是多态的,其中可以被固化为标准的部分需要沉淀下来,比如物料接入规范、编排能力、出码能力、运行时能力,让各个搭建平台做到合而不同。 国内外都有非常多做的相当不错的搭建系统,但要不就太通用,具体场景提效不明显,要不就太垂直,换一个业务场景做不了。现在阿里中后台低代码搭建组织就在制定规范,将引擎通用能力固化为标准协议,让不同搭建平台可以对齐规范与功能,未来还会不断收敛核心引擎实现,基于它可以打造出千千万万个垂直领域的搭建平台,贴着业务做搭建提效,同时引擎内核与规范还能保持互通。 笔者所在阿里数据中台体验技术团队就是中后台低代码搭建组织的一员,将数据搭建领域做到极致。在技术上,我们在打通中后台搭建与数据搭建的技术方案,在产品上,我们正在逐渐统一阿里集团数据搭建平台,对外也携 QuickBI 成为国内唯一一家进入 Gartner 象限的 BI 产品,未来可期。 阿里数据中台体验技术团队正在火热招人中,如果感兴趣可以联系 ziyi.hzy@alibaba-inc.com 。 > 讨论地址是:[精读《对低代码搭建的理解》· Issue #260 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/260) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/16.精读《CSS Animations vs Web Animations API》.md ================================================ 本期精读文章 [CSS Animations vs Web Animations API | CSS-Tricks](https://css-tricks.com/css-animations-vs-web-animations-api/) 译文地址 [CSS Animation 与 Web Animation API 之争](https://zhuanlan.zhihu.com/p/27867539?refer=FrontendMagazine) # 1. 引言 logo 前端是一个很神奇的工种,一个合格的前端至少要熟练的使用 3 个技能,html、css 和 javascript。在传统的前端开发领域它们三个大多时候是各司其职,分别负责布局、样式以及交互。而在当代的前端开发中,由于多种原因 javascript 做的事情愈来愈多,大有一统全栈之势。服务端的 nodejs,让前端同学可以用自己的语言来开发 server。即便是在前端,我们现在好像也很少写 html 了,在 React 中出来了 JSX,在其他的开发体系中也有与之类似的前端模板代替了 html。我们好像也很少写 css 了,sass、less、stylus 等预处理器以及 css in js 出现。此外,很多 css 领域的的工作也可以通过 javascript 以更加优雅和高效的方式实现。今天我们来一起聊聊 CSS 动画与 WEB Animation API 的优劣。 # 2. 内容概要 JavaScript 规范确实借鉴了很多社区内的优秀类库,通过原生实现的方式提供更好的性能。WAAPI 提供了与 jQuery 类似的语法,同时也做了很多补充,使得其更加的强大。同时 W3C 官方也为开发者提供了 [web-animations/web-animations-js](https://github.com/web-animations/web-animations-js/tree/master) polyfill。下面简单回顾下文章内容: WAAPI 提供了很简洁明了的,我们可以直接在 dom 元素上直接调用 animate 函数: ```javascript var element = document.querySelector('.animate-me'); var animation = element.animate(keyframes, 1000); ``` 第一个参数是一个对象数组,每个对象表示动画中的一帧: ```javascript var keyframes = [ { opacity: 0 }, { opacity: 1 } ]; ``` 这与 css 中的 keyframe 定义类似: ```css 0% { opacity: 0; } 100% { opacity: 1; } ``` 第二个参数是 duration,表示动画的时间。同时也支持在第二个参数中传入配置项来指定缓动方式、循环次数等。 ```javascript var options = { iterations: Infinity, // 动画的重复次数,默认是 1 iterationStart: 0, // 用于指定动画开始的节点,默认是 0 delay: 0, // 动画延迟开始的毫秒数,默认 0 endDelay: 0, // 动画结束后延迟的毫秒数,默认 0 direction: 'alternate', // 动画的方向 默认是按照一个方向的动画,alternate 则表示交替 duration: 700, // 动画持续时间,默认 0 fill: 'forwards', // 是否在动画结束时回到元素开始动画前的状态 easing: 'ease-out', // 缓动方式,默认 "linear" }; ``` 有了这些配置项,基本可以满足开发者的动画需求。同时,文中也提到了在 WAAPI 中很多专业术语与 CSS 变量有所不同,不过这些变化也更显简洁。 在 dom 元素上调用 animate 函数之后返回 animation 对象,或者通过 ele.getAnimation 方法获取 dom 上的 animation 对象。借此开发者可以通过 promise 和 event 两种方式对动画进行操作: ## 1. event 方式 ```javascript myAnimation.onfinish = function() { element.remove(); } ``` ## 2. promise 方式 ```javascript myAnimation.finished.then(() => element.remove()) ``` 通过这种方式相对 dom 事件获取更加的简洁优雅。 # 3. 精读 参与本次精度的同学主要来自 [前端外刊评论 - 知乎专栏](https://zhuanlan.zhihu.com/FrontendMagazine) 的留言,该部分主要由文章评论总结而出。 ## WAAPI 优雅简洁 web animation 的 api 设计优雅而又全面。文中比对了常见的 WAAPI 与 CSS Animation 对照关系,我们可以看到 WAAPI 更加简洁,而且语法上也更加容易为开发者接受。确实,在写一些复杂的动画逻辑时,需要灵活控制性强的接口。我们可以看到,在处理串连多个动画、截取完整动画的一部分时更加方便。如果非要说有什么劣势,个人在开发中感觉 keyframe 的很多只都只能使用字符串,不过这也是将 css 写在 js 中最常见的一种方式了。 ## 低耦合 CSS 动画中,如果需要控制动画或者过渡的开始或结束只能通过相应的 dom 事件来监听,并且在回调函数中操作,这也是受 CSS 本身语言特性约束所致。也就是说很多情况下,想要完成一个动画需要结合 CSS 和 JS 来共同完成。使用 WAAPI 则有 promise 和 event 两种方式与监听 dom 事件相对应。从代码可维护性和完整性上看 WAAPI 有自身语言上的优势。 ## 兼容性和流畅度 兼容性上 WAAPI 常用方法已经兼容了大部分现代的浏览器。如果想现在就玩玩 WAAPI,可以使用官方提供的 polyfill。而 CSS 动画我们也用了很久,基本作为一种在现代浏览器中提升体验的方式,对于老旧的浏览器只能用一些优雅的降级方案。至于流畅度的问题,文中也提到性能与 CSS 动画一般,而且提供了性能优化的方案。 # 4. 总结 目前看来,CSS 动画可以做到的,使用 WAAPI 同样可以实现。至于浏览器支持问题,WAAPI 尚需要 polyfill 支持,不过 CSS 动画也同样存在兼容性问题。可能现在新的 API 的接受度还不够,但正如文章结尾处所说:『现有的规范和实现看起来更像是一项伟大事业的起点。』 > 讨论地址是:[精读《CSS Animations vs Web Animations API》 · Issue #22 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/22) > > 如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,每周五发布。 ================================================ FILE: 前沿技术/160. 精读《函数缓存》.md ================================================ ## 1 引言 函数缓存是重要概念,本质上就是用空间(缓存存储)换时间(跳过计算过程)。 对于无副作用的纯函数,在合适的场景使用函数缓存是非常必要的,让我们跟着 https://whatthefork.is/memoization 这篇文章深入理解一下函数缓存吧! ## 2 概述 假设又一个获取天气的函数 `getChanceOfRain`,每次调用都要花 100ms 计算: ```jsx import { getChanceOfRain } from "magic-weather-calculator"; function showWeatherReport() { let result = getChanceOfRain(); // Let the magic happen console.log("The chance of rain tomorrow is:", result); } showWeatherReport(); // (!) Triggers the calculation showWeatherReport(); // (!) Triggers the calculation showWeatherReport(); // (!) Triggers the calculation ``` 很显然这样太浪费计算资源了,当已经计算过一次天气后,就没有必要再算一次了,我们期望的是后续调用可以直接拿上一次结果的缓存,这样可以节省大量计算。因此我们可以做一个 `memoizedGetChanceOfRain` 函数缓存计算结果: ```jsx import { getChanceOfRain } from "magic-weather-calculator"; let isCalculated = false; let lastResult; // We added this function! function memoizedGetChanceOfRain() { if (isCalculated) { // No need to calculate it again. return lastResult; } // Gotta calculate it for the first time. let result = getChanceOfRain(); // Remember it for the next time. lastResult = result; isCalculated = true; return result; } function showWeatherReport() { // Use the memoized function instead of the original function. let result = memoizedGetChanceOfRain(); console.log("The chance of rain tomorrow is:", result); } ``` 在每次调用时判断优先用缓存,如果没有缓存则调用原始函数并记录缓存。这样当我们多次调用时,除了第一次之外都会立即从缓存中返回结果: ```jsx showWeatherReport(); // (!) Triggers the calculation showWeatherReport(); // Uses the calculated result showWeatherReport(); // Uses the calculated result showWeatherReport(); // Uses the calculated result ``` 然而对于有参数的场景就不适用了,因为缓存并没有考虑参数: ```jsx function showWeatherReport(city) { let result = getChanceOfRain(city); // Pass the city console.log("The chance of rain tomorrow is:", result); } showWeatherReport("Tokyo"); // (!) Triggers the calculation showWeatherReport("London"); // Uses the calculated answer ``` 由于参数可能性很多,所以有三种解决方案: ### 1. 仅缓存最后一次结果 仅缓存最后一次结果是最节省存储空间的,而且不会有计算错误,但带来的问题就是当参数变化时缓存会立即失效: ```jsx import { getChanceOfRain } from "magic-weather-calculator"; let lastCity; let lastResult; function memoizedGetChanceOfRain(city) { if (city === lastCity) { // Notice this check! // Same parameters, so we can reuse the last result. return lastResult; } // Either we're called for the first time, // or we're called with different parameters. // We have to perform the calculation. let result = getChanceOfRain(city); // Remember both the parameters and the result. lastCity = city; lastResult = result; return result; } function showWeatherReport(city) { // Pass the parameters to the memoized function. let result = memoizedGetChanceOfRain(city); console.log("The chance of rain tomorrow is:", result); } showWeatherReport("Tokyo"); // (!) Triggers the calculation showWeatherReport("Tokyo"); // Uses the calculated result showWeatherReport("Tokyo"); // Uses the calculated result showWeatherReport("London"); // (!) Triggers the calculation showWeatherReport("London"); // Uses the calculated result ``` 在极端情况下等同于没有缓存: ```jsx showWeatherReport("Tokyo"); // (!) Triggers the calculation showWeatherReport("London"); // (!) Triggers the calculation showWeatherReport("Tokyo"); // (!) Triggers the calculation showWeatherReport("London"); // (!) Triggers the calculation showWeatherReport("Tokyo"); // (!) Triggers the calculation ``` ### 2. 缓存所有结果 第二种方案是缓存所有结果,使用 Map 存储缓存即可: ```jsx // Remember the last result *for every city*. let resultsPerCity = new Map(); function memoizedGetChanceOfRain(city) { if (resultsPerCity.has(city)) { // We already have a result for this city. return resultsPerCity.get(city); } // We're called for the first time for this city. let result = getChanceOfRain(city); // Remember the result for this city. resultsPerCity.set(city, result); return result; } function showWeatherReport(city) { // Pass the parameters to the memoized function. let result = memoizedGetChanceOfRain(city); console.log("The chance of rain tomorrow is:", result); } showWeatherReport("Tokyo"); // (!) Triggers the calculation showWeatherReport("London"); // (!) Triggers the calculation showWeatherReport("Tokyo"); // Uses the calculated result showWeatherReport("London"); // Uses the calculated result showWeatherReport("Tokyo"); // Uses the calculated result showWeatherReport("Paris"); // (!) Triggers the calculation ``` 这么做带来的弊端就是内存溢出,当可能参数过多时会导致内存无限制的上涨,最坏的情况就是触发浏览器限制或者页面崩溃。 ### 3. 其他缓存策略 介于只缓存最后一项与缓存所有项之间还有这其他选择,比如 LRU(least recently used)只保留最小化最近使用的缓存,或者为了方便浏览器回收,使用 WeakMap 替代 Map。 最后提到了函数缓存的一个坑,必须是纯函数。比如下面的 CASE: ```jsx // Inside the magical npm package function getChanceOfRain() { // Show the input box! let city = prompt("Where do you live?"); // ... calculation ... } // Our code function showWeatherReport() { let result = getChanceOfRain(); console.log("The chance of rain tomorrow is:", result); } ``` `getChanceOfRain` 每次会由用户输入一些数据返回结果,导致缓存错误,原因是 “函数入参一部分由用户输入” 就是副作用,我们不能对有副作用的函数进行缓存。 这有时候也是拆分函数的意义,将一个有副作用函数的无副作用部分分解出来,这样就能局部做函数缓存了: ```jsx // If this function only calculates things, // we would call it "pure". // It is safe to memoize this function. function getChanceOfRain(city) { // ... calculation ... } // This function is "impure" because // it shows a prompt to the user. function showWeatherReport() { // The prompt is now here let city = prompt("Where do you live?"); let result = getChanceOfRain(city); console.log("The chance of rain tomorrow is:", result); } ``` 最后,我们可以将缓存函数抽象为高阶函数: ```jsx function memoize(fn) { let isCalculated = false; let lastResult; return function memoizedFn() { // Return the generated function! if (isCalculated) { return lastResult; } let result = fn(); lastResult = result; isCalculated = true; return result; }; } ``` 这样生成新的缓存函数就方便啦: ```jsx let memoizedGetChanceOfRain = memoize(getChanceOfRain); let memoizedGetNextEarthquake = memoize(getNextEarthquake); let memoizedGetCosmicRaysProbability = memoize(getCosmicRaysProbability); ``` `isCalculated` 与 `lastResult` 都存储在 `memoize` 函数生成的闭包内,外部无法访问。 ## 3 精读 ### 通用高阶函数实现函数缓存 原文的例子还是比较简单,没有考虑函数多个参数如何处理,下面我们分析一下 Lodash `memoize` 函数源码: ```jsx function memoize(func, resolver) { if ( typeof func != "function" || (resolver != null && typeof resolver != "function") ) { throw new TypeError(FUNC_ERROR_TEXT); } var memoized = function () { var args = arguments, key = resolver ? resolver.apply(this, args) : args[0], cache = memoized.cache; if (cache.has(key)) { return cache.get(key); } var result = func.apply(this, args); memoized.cache = cache.set(key, result) || cache; return result; }; memoized.cache = new (memoize.Cache || MapCache)(); return memoized; } ``` 原文有提到缓存策略多种多样,而 Lodash 将缓存策略简化为 key 交给用户自己管理,看这段代码: ```jsx key = resolver ? resolver.apply(this, args) : args[0]; ``` 也就是缓存的 key 默认是执行函数时第一个参数,也可以通过 `resolver` 拿到参数处理成新的缓存 key。 在执行函数时也传入了参数 `func.apply(this, args)`。 最后 `cache` 也不再使用默认的 Map,而是允许用户自定义 `lodash.memoize.Cache` 自行设置,比如设置为 WeakMap: ```jsx _.memoize.Cache = WeakMap; ``` ### 什么时候不适合用缓存 以下两种情况不适合用缓存: 1. 不经常执行的函数。 2. 本身执行速度较快的函数。 对于不经常执行的函数,本身就不需要利用缓存提升执行效率,而缓存反而会长期占用内存。对于本身执行速度较快的函数,其实大部分简单计算速度都很快,使用缓存后对速度没有明显的提升,同时如果计算结果比较大,反而会占用存储资源。 对于引用的变化尤其重要,比如如下例子: ```jsx function addName(obj, name){ return { ...obj, name: } } ``` 为 `obj` 添加一个 key,本身执行速度是非常快的,但添加缓存后会带来两个坏处: 1. 如果 `obj` 非常大,会在闭包存储完整 `obj` 结构,内存占用加倍。 2. 如果 `obj` 通过 mutable 方式修改了,则普通缓存函数还会返回原先结果(因为对象引用没有变),造成错误。 如果要强行进行对象深对比,虽然会避免出现边界问题,但性能反而会大幅下降。 ## 4 总结 函数缓存非常有用,但并不是所有场景都适用,因此千万不要极端的将所有函数都添加缓存,仅限于计算耗时、可能重复利用多次,且是纯函数的。 > 讨论地址是:[精读《函数缓存》· Issue #261 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/261) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/161.精读《可视化搭建思考 - 富文本搭建》.md ================================================ ## 1 引言 [「可视化搭建系统」——从设计到架构,探索前端的领域和意义](https://juejin.im/post/6854573220532748302) 这篇文章主要分析了现阶段可视化搭建的几种表现形式和实现原理,并重点介绍了基于富文本的可视化搭建思路,让人耳目一新。 基于富文本的可视化搭建看似很新颖,但其实早就被广泛使用了,任何一个富文本编辑器几乎都有插入表格功能,这就是一个典型插入自定义组件的场景。 使用过 [语雀](https://www.yuque.com/) 的同学应该知道,这个产品的富文本编辑器可以插入各种各样自定义区块,是 “最像搭建” 的富文本编辑器。 那么积木式搭建和富文本搭建存在哪些差异,除了富文本更倾向于记录静态内容外,还有哪些差异,两者是否可以结合?本文将围绕这两点进行讨论。 ## 2 精读 还是先顺着原文谈谈对可视化搭建的理解: 可视化搭建是通过可视化方式代替开发。**前端代码开发主要围绕的是 html + js + css**,那么无论是 markdown 语法,还是创建另一套模版语言亦或 JSON 构成的 DSL,**都是用一种 dsl + 组件 + css 的方式代替 html + js + css**,可视化搭建则更进一步,用 ui 代替了 dsl + 组件,**即精简为 ui 操作 + css**。 可以看到,这种转换的推演过程存在一定瑕疵,因为每次转换都有部分损耗: **用 dsl + 组件 代替 html + js。** 如果 dsl 拓展得足够好,理论上可以达到 html 的水平,尤其在垂直业务场景是不需要那么多特殊 html 标签的。 但用组件代替 js 就有点奇怪了,首先并不是所有 js 逻辑都沉淀在组件里,一定有组件间的联动逻辑是无法通过一个组件 js 完成的,另一方面如果将 js 逻辑寄托在组件代码里,本质上是没有提效的,用源码开发项目与开发搭建平台的组件都是 pro code,更极端一点来说,无论是组件间联动还是整个应用都可以用一个组件来写,那搭建平台就无事可做了,这个组件也成了整个应用,game over。 为了弥补这块缺憾,低代码能力的呼声越来越高,而低代码能力的核心在于设计是否合理,比如暴露哪些 API 可以覆盖大部分需求?写多少代码合适,如何以最小 API 透出最大弥补组件间缺失的 js 能力?目前来看,以状态数据驱动的低代码是相对优雅的。 **用 ui 操作 代替 dsl + 组件。** UI 操作并不是标准的,相比直接操作模版或者 JSON DSL,UI 化后就仁者见仁智者见智了,但 UI 化带来的效率提升是巨大的,因为所见即所得是生产力的源泉,从直观的 UI 布局来看,就比维护代码更轻松。但 UI 化也存在两个问题,一个是可能有人觉得不如 markdown 效率高,另一个是功能有丢失。 对于第一点 UI 操作效率不如 markdown 高,可能很多程序员都崇尚用 markdown 维护文档而不是富文本,原因是觉得程序员维护代码的效率反而比所见即所得高,但那可能是错觉,原因是还没有遇到好用的富文本编辑器,体验过语雀富文本编辑器后,相信大部分程序员都不会再想回头写 markdown。当然语雀富文本战胜 markdown 的原因有很多,我觉得主要两点是吸收并兼容了 markdown 操作习惯,与支持了更多仅 UI 能做到的拓展能力,对 markdown 形成降维打击。 第二点功能丢失很好理解,markdown 有一套标准语法和解析器可以验证,但 UI 操作并没有标准化,也没有独立验证系统,如果无法回退到源码模式,UI 没有实现的功能就做不到。 回到富文本搭建上,其实富文本搭建和普通网页构建并没有本质区别。html 是超文本标记语言,富文本是跨平台文档格式,从逻辑上这两个格式是可以互转的,只要富文本规则作出足够多的拓展,就可以大致覆盖 html 的能力。 但富文本搭建有着显著的特征,就是光标。 ### 积木式搭建和富文本搭建的区别 富文本以文本为中心,因此编辑文字的光标会常驻,编辑的核心逻辑是排版文字,并考虑如何在文字周围添加一些自定义区块。 有了光标后,圈选也非常重要,因为大家编辑文字时有一种很自然的想法是,任何文字圈选后复制,可以粘贴到任何地方,那么所有插入到富文本中的自定义组件也要支持被圈选,被复制。 实际上富文本内插入自定义区块也可以转换为积木式搭建方案解决,比如下面的场景: ```text 文本 A 图表 B 文本 C ``` 我们在文本 A 与 文本 C 之间插入图表 B,也可以理解为拖拽了三个组件:文本组件 A + 图表组件 B + 文本组件 C,然后分别编辑这三个组件,微调样式后可以达到与富文本一样的编辑效果,甚至加上自由布局后,在布局能力上会超越富文本。 虽然功能层面上富文本略有输给积木式搭建,但富文本在编辑体验上是胜出的,对于文字较多的场景,我们还是会选择富文本方式编辑而不是积木式搭建拖拽 N 个文本组件。 所以微软 OneNote 也吸取了这个经验,毕竟笔记本主要还是记录文字,因此还是采用富文本的编辑模式,但创造性的加入了一个个独立区块,点击任何区域都会创造一个区块,整个文档可以由一个区块构成,也可以是多个区块组合而成,这样对于连贯性的文字场景可以采用一个富文本区块,对于自定义区块较多,比如大部分是图片和表格的,还可以回到积木式搭建的体验。由于 OneNote 采用绝对定位模拟流式布局的思路,当区块重叠时还可以自动挤压底部区块,因此多区块模式下编辑体验还是相对顺畅的。 可以看出来这是一种结合的尝试,从前端角度来看,富文本本质上是对一个 div 进行 contenteditable 申明,那么一个应用可以整体是 contenteditable 的,也可以局部几个区块是,这种代码层面的自由度体现在搭建上就是积木式搭建可以与富文本搭建自由结合。 ### 积木式搭建与富文本搭建如何结合 对于积木式搭建来说,富文本只是其中一个组件,在不考虑有富文本组件时是完全没有富文本能力的。比如一个搭建平台只提供了几个图表和基础控件,你是不可能在其基础上使用富文本能力的,甚至连写静态文本都做不到。 所以富文本只是搭建中一个组件,就像 contenteditable 也只能依附于一个标签,整个网页还是由标签组成的。但对于一个提供了富文本组件的积木式搭建系统来说,文字与控件混排又是一个痛点,毕竟要以一个个区块组件的方式去拖拽文本节点,成本比富文本模式大得多。 所以理想情况是富文本与整个搭建系统使用同一套 DSL 描述结构,富文本只是在布局上有所简化,简化为简单的平铺模式即可,但因为 DSL 描述打通,富文本也可以描述使用搭建提供的任意组件嵌套在内,所以只要用户愿意,可以将富文本组件拉到最大,整个页面都基于富文本模式去搭建,这就变成了富文本搭建,也可以将富文本缩小,将普通控件以积木方式拖拽到画布中,走积木式搭建路线。 用代码方式描述积木式搭建: ```html

header

footer

``` 上述模式需要拖拽 `bar-chart`、`div`、`p`、`line-chart`、`p` 共 5 个组件。富文本模式则类似下面的结构: ```html

header

footer

``` 只要拖拽 `bar-chart`、`div` 两个组件即可,`div` 内部的文字通过光标输入,`line-chart` 通过富文本某个按钮或者键盘快捷键添加。 可以看到虽然操作方式不同,但本质上描述协议并没有本质区别,我们理论上可以将任何容器标签切换为富文本模式。 ## 3 总结 富文本是一种重要的交互模式,可以基于富文本模式做搭建,也可以在搭建系统中嵌入富文本组件,甚至还可以追求搭建与富文本的结合。 富文本组件既可以是搭建系统中一个组件,又可以在内部承载搭建系统的所有组件,做到这一步才算是真正发挥出富文本的潜力。 > 讨论地址是:[精读《可视化搭建思考 - 富文本搭建》· Issue #262 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/262) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/162.精读《Tasks, microtasks, queues and schedules》.md ================================================ ## 1 引言 本周跟着 [Tasks, microtasks, queues and schedules](https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/) 这篇文章一起深入理解这些概念间的区别。 先说结论: - Tasks 按顺序执行,浏览器可能在 Tasks 之间执行渲染。 - Microtasks 也按顺序执行,时机是: - 如果没有执行中的 js 堆栈,则在每个回调之后。 - 在每个 task 之后。 ## 2 概述 ### Event Loop 在说这些概念前,先要介绍 Event Loop。 首先浏览器是多线程的,每个 JS 脚本都在单线程中执行,每个线程都有自己的 Event Loop,同源的所有浏览器窗口共享一个 Event Loop 以便通信。 Event Loop 会持续循环的执行所有排队中的任务,浏览器会为这些任务划分优先级,按照优先级来执行,这就会导致 Tasks 与 Microtasks 执行顺序与调用顺序的不同。 ### promise 与 setTimeout 看下面代码的输出顺序: ```js console.log("script start"); setTimeout(function () { console.log("setTimeout"); }, 0); Promise.resolve() .then(function () { console.log("promise1"); }) .then(function () { console.log("promise2"); }); console.log("script end"); ``` 正确答案是 `script start`, `script end`, `promise1`, `promise2`, `setTimeout`,在线程中,同步脚本执行优先级最高,然后 promise 任务会存放到 Microtasks,setTimeout 任务会存放到 Tasks,Microtasks 会优先于 Tasks 执行。 Microtasks 中文可以翻译为微任务,只要有 Microtasks 插入,就会不断执行 Microtasks 队列直到结束,在结束前都不会执行到 Tasks。 ### 点击冒泡 + 任务 下面给出了更复杂的例子,提前说明后面的例子 Chrome、Firefox、Safari、Edge 浏览器的结果完全不一样,但只有 Chrome 的运行结果是对的!为什么 Chrome 是对的呢,请看下面的分析: ```html
``` ```js // Let's get hold of those elements var outer = document.querySelector(".outer"); var inner = document.querySelector(".inner"); // Let's listen for attribute changes on the // outer element new MutationObserver(function () { console.log("mutate"); }).observe(outer, { attributes: true, }); // Here's a click listener… function onClick() { console.log("click"); setTimeout(function () { console.log("timeout"); }, 0); Promise.resolve().then(function () { console.log("promise"); }); outer.setAttribute("data-random", Math.random()); } // …which we'll attach to both elements inner.addEventListener("click", onClick); outer.addEventListener("click", onClick); ``` 点击 `inner` 区块后,正确输出顺序应该是: ```text click promise mutate click promise mutate timeout timeout ``` 逻辑如下: 1. 点击触发 `onClick` 函数入栈。 2. 立即执行 `console.log('click')` 打印 `click`。 3. `console.log('timeout')` 入栈 Tasks。 4. `console.log('promise')` 入栈 microtasks。 5. `outer.setAttribute('data-random')` 的触发导致监听者 `MutationObserver` 入栈 microtasks。 6. `onClick` 函数执行完毕,此时线程调用栈为空,开始执行 microtasks 队列。 7. 打印 `promise`,打印 `mutate`,此时 microtasks 已空。 8. 执行冒泡机制,outer div 也触发 `onClick` 函数,同理,打印 `promise`,打印 `mutate`。 9. 都执行完后,执行 Tasks,打印 `timeout`,打印 `timeout`。 ### 模拟点击冒泡 + 任务 如果将触发 `onClick` 行为由点击改为: ```js inner.click(); ``` 结果会不同吗?答案是会(单元测试与用户行为不符合,单测也有无解的时候)。然而四大浏览器的执行结果也是完全不一样,但从逻辑上讲仍然 Chrome 是对的,让我们看下 Chrome 的结果: ```text click click promise mutate promise timeout timeout ``` 逻辑如下: 1. `inner.click()` 触发 `onClick` 函数入栈。 2. 立即执行 `console.log('click')` 打印 `click`。 3. `console.log('timeout')` 入栈 Tasks。 4. `console.log('promise')` 入栈 microtasks。 5. `outer.setAttribute('data-random')` 的触发导致监听者 `MutationObserver` 入栈 microtasks。 6. 由于冒泡改为 js 调用栈执行,所以此时 js 调用栈未结束,不会执行 microtasks,反而是继续执行冒泡,outer 的 `onClick` 函数入栈。 7. 立即执行 `console.log('click')` 打印 `click`。 8. `console.log('timeout')` 入栈 Tasks。 9. `console.log('promise')` 入栈 microtasks。 10. `MutationObserver` 由于还没调用,因此这次 `outer.setAttribute('data-random')` 的改动实际上没有作用。 11. js 调用栈执行完毕,开始执行 microtasks,按照入栈顺序,打印 `promise`,`mutate`,`promise`。 12. microtasks 执行完毕,开始执行 Tasks,打印 `timeout`,`timeout`。 ## 3 精读 基于任务调度这么复杂,且浏览器实现方式很不同,下面两件事是我很不推荐的: 1. 业务逻辑 “巧妙” 依赖了 microtasks 与 Tasks 执行逻辑的微妙差异。 2. 死记硬背调用顺序。 且不说依赖了调用顺序的业务逻辑本身就很难维护,不同浏览器之间对任务调用顺序还是不同的,这可能源于对 W3C 标准规范理解的偏差,也可能是 BUG,这会导致依赖于此的逻辑非常脆弱。 虽然上面两个例子非常复杂,但我们也不必把这个例子当作经典背诵,只要记住文章开头提到的执行逻辑就可以推导: - Tasks 按顺序执行,浏览器可能在 Tasks 之间执行渲染。 - Microtasks 也按顺序执行,时机是: - 如果没有执行中的 js 堆栈,则在每个回调之后。 - 在每个 task 之后。 记住 `Promise` 是 `Microtasks`,`setTimeout` 是 `Tasks`,JS 一次 Event Loop 完毕后,即调用栈没有内容时才会执行 `Microtasks` -> `Tasks`,在执行 `Microtasks` 过程中插入的 `Microtasks` 会按顺序继续执行,而执行 `Tasks` 中插入的 `Microtasks` 得等到调用栈执行完后才继续执行。 上面说的内容都是指一次 Event Loop 时立即执行的优先级,不要和执行延迟时间弄混淆了。 把 JS 线程的 Event Loop 当作一个函数,函数内同步逻辑执行优先级是最高的,如果遇到 `Microtasks` 或 `Tasks` 就会立即记录下来,当一次 Event Loop 执行完后立即调用 `Microtasks`,等 `Microtasks` 队列执行完毕后可能进行一些渲染行为,等这些浏览器操作完成后,再考虑执行 `Tasks` 队列。 ## 4 总结 最后,还是要强调一句,不要依赖 `Microtasks` 与 `Tasks` 的执行顺序,尤其在申明式编程环境中,我们可以把 `Microtasks` 与 `Tasks` 都当作是异步内容,在渲染时做好状态判断即可,不用关心先后顺序。 > 讨论地址是:[精读《Tasks, microtasks, queues and schedules》· Issue #264 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/264) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/163.精读《Spring 概念》.md ================================================ [spring](https://spring.io/) 是 Java 非常重要的框架,且蕴含了一系列设计模式,非常值得研究,本期就通过 [Spring 学习](https://www.cnblogs.com/wmyskxz/p/8820371.html) 这篇文章了解一下 spring。 ## spring 为何长寿 spring 作为一个后端框架,拥有 17 年历史,这在前端看来是不可思议的。前端几乎没有一个框架可以流行超过 5 年,就最近来看,react、angular、vue 三大框架可能会活的久一点,他们都是前端相对成熟阶段的产物,我们或多或少可以看出一些设计模式。然而这些前端框架与 spring 比起来还是差距很大,我们来看看 spring 到底强大在哪。 ### 设计模式 设计模式是一种思想,不依附于任何编程语言与开发框架。比如你学会了工厂设计模式,可以在后端用,也可以转到前端用,可以在 Go 语言用,也可以在 Typescript 用,可以在 React 框架用,也可以在 Vue 里用,所以设计模式是一种具有迁移能力的知识,学会后可以受益整个职业生涯,而语言、框架则不具备迁移性,前端许多同学都把精力花在学习框架特性上,遇到前端技术迭代时期就尴尬了,这就是为什么大公司面试要问框架原理,就是看看你能否抓住一些不变的东西,所以洋洋洒洒的说上下文相关的细节也不是面试官想要的,真正想听到的是你抽象后对框架原理共性的总结。 spring 框架就用到了许多设计模式,包括: 工厂模式:用工厂生产对象实例来代替原始的 new。所谓工厂就是屏蔽实例话的细节,调用处无需关心实例化对象需要的环境参数,提升可维护性。spring 的 BeanFactory 创建 bean 对象就是工厂模式的体现。 代理模式:允许通过代理对象访问目标对象。Spring 实现 AOP 就是通过动态代理模式。 单例模式:单实例。spring 的 bean 默认都是单例。 包装器模式:将几个不同方法通用部分抽象出来,调用时通过包装器内部引导到不同的实现。比如 spring 连接多种数据库就使用了包装器模式简化。 观察者模式:这个前端同学很熟悉,就是事件机制,spring 中可以通过 ApplicationEvent 实践观察者模式。 适配器模式:通过适配器将接口转换为另一个格式的接口。spring AOP 的增强和通知就使用了适配器模式。 模板方法模式:父类先定义一些函数,这些函数之间存在调用关联,将某些设定为抽象函数等待子类继承时去重写。spring 的 `jdbcTemplate`、`hibernateTemplate` 等数据库操作类使用了模版方法模式。 ### 全家桶 spring 作为一个全面的 java 框架,提供了系列全家桶满足各种场景需求:spring mvc、spring security、spring data、spring boot、spring cloud。 - spring boot:简化了 spring 应用配置,约定大于配置的思维。 - spring data:是一个数据操作与访问工具集,比如支持 jdbc、redis 等数据源操作。 - spring cloud:是一个微服务解决方案,基于 spring boot,集成了服务发现、配置管理、消息总线、负载均衡、断路器、数据监控等各种服务治理能力。 - spring security:支持一些安全模型比如单点登录、令牌中继、令牌交换等。 - spring mvc:MVC 思想的 web 框架。 ## IOC IOC(Inverse of Control)控制反转。IOC 是 Spring 最核心部分,因为所有对象调用都离不开 IOC 模式。 假设我们有三个类:Country、Province、City,最大的类别是国家,其次是省、城市,国家类需要调用省类,省类需要调用城市类: ```java public class Country { private Province province; public Country(){ this.province = new Province() } } public class Province { private City city; public Province(){ this.city = new City() } } public class City { public City(){ // ... } } ``` 假设来了一个需求,City 实例化时需增加人口(people)参数,我们就要改动所有类代码: ```java public class Country { private Province province; public Country(int people){ this.province = new Province(people) } } public class Province { private City city; public Province(int people){ this.city = new City(people) } } public class City { public City(int people){ // ... } } ``` 那么在真实业务场景中,一个底层类可能被数以千计的类使用,这么改显然难以维护。IOC 就是为了解决这个问题,它使得我们可以只改动 City 的代码,而不用改动其他类的代码: ```java public class Country { private Province province; public Country(Province province){ this.province = province } } public class Province { private City city; public Province(City city){ this.city = city } } public class City { public City(int people){ // ... } } ``` 可以看到,增加 `people` 属性只需要改动 city 类。然而这样做也是有成本的,就是类实例化步骤会稍微繁琐一些: ```java City city = new City(1000); Province province = new Province(city); Country country = new Country(province); ``` 这就是控制反转,由 Country 依赖 Province 变成了类依赖框架(上面的实例化代码)注入。 然而手动维护这种初始化依赖是繁琐的,spring 提供了 bean 容器自动做这件事,我们只需要利用装饰器 Autowired 就可以自动注入依赖: ```java @Component public class Country { @Autowired private Province province; } @Component public class Province { @Autowired public City city; } @Component public class City { } ``` 实际上这种自动分析并实例化的手段,不仅比手写方便,还能解决循环依赖的问题。在实际场景中,两个类相互调用是很常见的,假设现在有 A、B 类相互依赖: ```java @Component public class A { @Autowired private B b; } @Component public class B { @Autowired public A a; } ``` 那么假设我们想获取 A 实例,会经历这样一个过程: ```text 获取 A 实例 -> 实例化不完整 A -> 检测到注入 B -> 实例化不完整 B -> 检测到注入 A -> 注入不完整 A -> 得到完整 B -> 得到完整 A -> 返回 A 实例 ``` 其实 spring 仅支持单例模式下非构造器的循环依赖,这是因为其内部有一套机制,让 bean 在初始化阶段先提前持有对方引用地址,这样就可以同时实例化两个对象了。 除了方便之外,IOC 配合 spring 容器概念还可以使获取实例时不用关心一个类实例化需要哪些参数,只需要直接申明获取即可,这样在类的数量特别多,尤其是大量代码不是你写的情况下,不需要阅读类源码也可以轻松获取实例,实在是大大提升了可维护性。 说到这就提到了 Bean 容器,在 spring 概念中,Bean 容器是对 class 的加强,如果说 Class 定义了类的基本含义,那 Bean 就是对类进行使用拓展,告诉我们应该如何实例化与使用这个类。 举个例子,比如利用注解描述的这段 Bean 类: ```java @Configuration public class CityConfig { @Scope("prototype") @Lazy @Bean(initMethod = "init", destroyMethod = "destroy") public City city() { return new City() } } ``` 可以看到,额外描述了是否延迟加载,是否单例,初始化与析构函数分别是什么等等。 下面给出一个从 Bean 获取实例的例子,采用比较古老的 xml 配置方式: ```java public interface City { Int getPeople(); } ``` ```java public class CityImpl implements City { public Int getPeople() { return 1000; } } ``` 接下来用 xml 描述这个 bean: ```xml ``` `bean` 支持的属性还有很多,由于本文并不做入门教学,就不一一列举了,总之 `id` 是一个可选的唯一标志,接下来我们可以通过 `id` 访问到 city 的实例。 ```java public class App { public static void main(String[] args) { ApplicationContext context = new ClassPathXmlApplicationContext("classpath:application.xml"); // 从 context 中读取 Bean,而不 new City() City city = context.getBean(City.class); System.out.println(city.getPeople()); } } ``` 可以看到,程序任何地方使用 city 实例,只需要调用 `getBean` 函数,就像一个工厂把实例化过程给承包了,我们不需要关心 City 构造函数要传递什么参数,不需要关心它依赖哪些其他的类,只要这一句话就可以拿到实例,是不是在维护项目时省心了很多。 ## AOP AOP(Aspect Oriented Program)面向切面编程。 AOP 是为了解决主要业务逻辑与次要业务逻辑之间耦合问题的。主要业务逻辑比如登陆、数据获取、查询等,次要业务逻辑比如性能监控、异常处理等等,次要业务逻辑往往有:不重要、和业务关联度低、贯穿多处业务逻辑的特性,如果没有好的设计模式,只能在业务代码里将主要逻辑与次要逻辑混合起来,但 AOP 可以做到主要、次要业务逻辑隔离。 使用 AOP 就是在定义在哪些地方(类、方法)切入,在什么地方切入(方法前、后、前后)以及做什么。 比如说,我们想在某个方法前后分别执行两个函数计算执行时间,下面是主要业务逻辑: ```java @Component("work") public class Work { public void do() { System.out.println("执行业务逻辑"); } } ``` 再定义切面方法: ```java @Component @Aspect class Broker { @Before("execution(* xxx.Work.do())") public void before(){ // 记录开始时间 } @After("execution(* xxx.Work.do())") public void after(){ // 计算时间 } } ``` 再通过 xml 定义扫描下这两个 Bean,就可以在运行 `work.do()` 之前执行 `before()`,之后执行 `after()`。 还可以完全覆盖原函数,利用 `joinPoint.proceed()` 可以执行原函数: ```java @Component @Aspect class Broker { @Around("execution(* xxx.Work.do())") public void around(ProceedingJoinPoint joinPoint) { // 记录开始时间 try { joinPoint.proceed(); } catch (Throwable throwable) { throwable.printStackTrace(); } // 计算时间 } } ``` 关于表达式 `"execution(* xxx.Work.do())"` 是用正则的方式匹配,`*` 表示任意返回类型的方法,后面就不用解释了。 可以看到,我们可以在不修改原方法的基础上,在其执行前后增加自定义业务逻辑,或者监控其报错,非常适合做次要业务逻辑,且由于不与主要业务逻辑代码耦合,保证了代码的简洁,且次要业务逻辑不容易遗漏。 ## 总结 IOC 特别适合描述业务模型,后端天然需要这一套,然而随着前端越做越重,如果某个业务场景下需要将部分业务逻辑放到前端,也是非常推荐使用 IOC 设计模式来做,这是后端沉淀了近 20 年的经验,没有必要再另辟蹊径。 AOP 对前端有帮助但没有那么大,因为前端业务逻辑较为分散,如果要进行切面编程,往往用 `window` 事件监听来做会更彻底,可能这都是前端没有流行 AOP 的原因。当然前端约定大于配置的趋势下,比如打点或监控都集成到框架内部,往往也做到了业务代码无感,剩下的业务代码也就没有 AOP 的需求。 最后,spring 的低侵入式设计,使得业务代码不用关心框架,让业务代码能够快速在不同框架间切换,这不仅方便了业务开发者,更使得 spring 走向成功,这是前端还需要追赶的。 > 讨论地址是:[精读《Spring 概念》· Issue #265 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/265) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/164.精读《数据搭建引擎 bi-designer API-设计器》.md ================================================ bi-designer 是阿里数据中台团队自研的前端搭建引擎,基于它开发了阿里内部最大的数据分析平台,以及阿里云上的 QuickBI。 > bi-designer 目前没有开源,因此文中使用的私有 npm 源 `@alife/bi-designer` 是无法在公网访问的。 本文介绍 bi-designer 设计器的使用 API。 bi-designer 设计有如下几个特点: - **心智统一:编辑模式与渲染模式统一**。 - **通用搭建:支持接入任意通用 npm 组件**。 - **低入侵:围绕数据分析能力做了增强,但对组件代码无入侵**。 ## 渲染画布 做搭建,第一步是将画布渲染出来,需要用到 `Designer` 与 `Canvas` 组件: ```jsx import { Designer, Canvas } from '@alife/bi-designer' export () => ( ) ``` - `Designer`:数据容器,用于管理渲染引擎数据流。 - 参数 `defaultPageSchema`:页面 DSL 默认值。 - 参数 `defaultMode`:控制编辑渲染状态,`edit` or `render`。 - `Canvas`:渲染画布的所有组件,会根据 DSL 结构将组件一一渲染出来。 ## 编辑模式 编辑模式 = 渲染画布(编辑模式)+ 拓展一些自定义面板。 ```jsx import { Designer, Canvas } from '@alife/bi-designer' export () => (
Header
Footer
) ``` 编辑模式的拓展采用了 JSX 模式,没有增加任何新的语法,只要放置任意数量的组件,并将画布 `Canvas` 摆放在想要的位置即可。 `defaultMode` 描述了当前引擎所处状态,有 `edit` 与 `render` 两个可选值,可以通过 `{ mode } = useDesigner(modeSelector)` 获取。bi-designer 没有对 `mode` 做任何特殊处理,我们可以在 panel、组件中判断不同的 `mode` 走不同的逻辑,以此区分编辑与渲染态。 ## 页面 DSL 结构 `pageSchema` 描述了页面 DSL 信息,其结构是一个 `Map<组件 id, 组件实例信息>`。 这里统一一下名词: - 组件实例信息:`componentInstance`。 - 组件元信息:`componentMeta`。 那么 `pageSchema` 的结构大致如下: ```json { "componentInstances": { "1": { "id": "1", "componentName": "root", }, "2": { "id": "2", "parentId": "1", "componentName": "button", } } } ``` 根据 `id` `parentId` 关系描述了组件父子关系,对于同一个父节点在流式布局下的顺序,还会增加 `index` 标记顺序。 ## 注册组件 DSL 描述信息中最重要的是 `componentName`,为了告诉渲染引擎这个组件是什么,我们需要将组件元信息(`componentMetas`)传递给 `Designer`: ```jsx import { Designer, Canvas, Interfaces } from '@alife/bi-designer' export () => ( ) const componentMetas: Interfaces.ComponentMetas = { button: { componentName: 'button', element: Button } } ``` 关于 `componentMeta` 会在下一篇精读详细介绍,这里只说明两个最重要的属性: - `componentName`:组件名,唯一。 - `element`:组件 UI 对象,对应一个 React 组件实例。 注意这里就留下了不少拓展空间,`componentMetas` 可以存储在服务端,`element` 可以远程异步加载,也可以在项目代码中固化,但传递给渲染引擎的 API 是固定的。 ## 布局 bi-designer 支持流式布局、磁贴布局、自由布局三种模式,通过 `Designer.layout` 属性定义: ```jsx import { Designer, Canvas, Interfaces } from '@alife/bi-designer' import { LayoutMover } from '@alife/bi-designer-stream-layout' export () => ( ) ``` 我们提供了三种不同的布局包,切换对应的包即可切换布局,你甚至可以再包裹一层,通过代码控制在运行时切换布局。 `layout` 会包裹在每个组件外层,无论是流式、磁贴还是自由布局,都可以通过附着在每个组件外层来实现。 ## 操作/获取画布内容 只要在数据容器 `Designer` 下,就可以通过 `useDesigner()` 获取画布信息或者修改画布内容。 举个例子,比如实现组件配置面板,需要获取到 **当前选中组件**,以及实现操作 **更新 DSL 中某个组件信息**: ```jsx import { Designer, Canvas, useDesigner, selectedComponentsSelector } from '@alife/bi-designer'; const EditPanel = () => { const { updateComponentById, selectedComponents } = useDesigner(selectedComponentsSelector()); // 在合适的时候调用 updateComponentById 更新 selectedComponents // 渲染组件配置表单.. } export () => ( ) ``` 我们在 `Canvas` 下面渲染了一个自定义组件 `EditPanel` 作为组件配置面板,这个配置面板中,最重要的是这块代码: ```jsx import { useDesigner, selectedComponentsSelector } from '@alife/bi-designer'; const { updateComponentById, selectedComponents } = useDesigner(selectedComponentsSelector()); ``` - `useDesigner` 是 React Hook,导出的函数都是静态的,不会因为画布信息变更而导致组件重渲染。 - 如果需要监听一些会变化的元素,比如当前选中组件,就需要用 Selector 完成,当这些信息变更时,使用了这些 Selector 的组件也会重渲染,具体 Selector 有很多,比如: - `selectedComponentsSelector`: 当前选中的组件。 - `pageSchemaSelector`: 当前画布 DSL。 - `modeSelector`: 当前渲染模式。等等。 - 对画布组件操作有几个重要的静态方法,包括: - `updateComponentById`: 更新某个 id 组件信息。 - `addComponent`: 添加组件。 - `deleteComponent`: 删除组件。 - `moveComponent`: 移动组件。等等。 - 除此之外,`useDesigner` 还提供了很多有用的方法,在用到时再介绍。 ## 主题风格 通过 `pageSchema.theme` 设置主题风格: ```jsx import { Designer } from '@alife/bi-designer' const App = () => ( ) ``` 我们也可以在运行时使用 `setTheme` 动态修改主题风格,做到动态切换主题: ```jsx const { setTheme, theme } = useDesigner(); return ; }; ``` - fetchData :主动取数函数,调用后可以立即重新取数。 主动取数调用后,取数结果依然通过 props.data 返回。 ### 自定义取数参数 fetchData 可以传入参数 getFetchParam 自定义取数参数: ```jsx const NameList: Interfaces.ComponentElement = ({ fetchData }) => { const { fetchData } = useDesigner(); const handleFetchData = React.useCallback(() => { fetchData({ getFetchParam: ({ param, context }) => ({ ...param, top: 1, }), }); }, [fetchData]); return ; }; ``` 要注意,非独立取数模式下即便修改了取数参数,下一次由外部触发的取数会重置取数参数。 ### 独立取数 独立取数可以通过 standalone 参数申明,此时触发取数不会导致组件 Rerender 并拿到新 data ,而是返回一个 Promise 由组件自行处理。 ```jsx const NameList: Interfaces.ComponentElement = ({ fetchData }) => { const { fetchData } = useDesigner(); const handleFetchData = React.useCallback(async () => { const data = await fetchData({ standalone: true, }); // 组件自己处理取数结果 data }, [fetchData]); return ; }; ``` 这种独立取数场景可以适应下钻等组件自由取数的场景。 独立取数模式下当然也可以结合 getFetchParam 一起使用。 ### 主动取消取数 通过 cancelFetch 可以主动取消取数: ```jsx const NameList: Interfaces.ComponentElement = ({ cancelFetch }) => { const { cancelFetch } = useDesigner(); return ; }; ``` - cancelFetch :取消取数函数,调用后立即生效。取数完成后再调用则无作用。 ### 优化取数性能 是否重新取数由 getFetchParam 返回值是否有变化决定,默认写法会进行 deepEqual 判断: ```jsx import { Interfaces } from "@alife/bi-design"; const componentMeta: Interfaces.ComponentMeta = { getFetchParam: ({ componentInstance }) => { // 引擎会对返回值进行深对比 return { name: componentInstance?.props?.name }; }, }; ``` 但是下面两种情况可能会产生性能问题: 1. 返回值数据结构非常大,导致频繁 deepEqual 开销明显增大。 2. 生成取数参数的逻辑本身就耗时,导致频繁执行 getFetchParam 函数本身的开销明显增大。 我们对这种情况提供了一种优化方案,利用 shouldFetch 主动阻止不必要的取数,具体参考 组件阻止自动取数。 ## 组件取数事件钩子 如果想在取数后做一些更新,但不想触发额外的重渲染,可以在“组件取数事件钩子”里做。 ### 取数完成后 afterFetch 钩子在取数完成后执行: ```jsx import { Interfaces } from "@alife/bi-designer"; const componentMeta: Interfaces.ComponentMeta = { afterFetch: ({ data, context, componentInstance }) => { context.updateComponentById(componentInstance.id, (each) => fp.set("props.value", "newValue", each) ); }, }; ``` - data :取数结果,即 fetcher 的返回值。 - context :上下文。 - componentInstance :组件实例信息。 - componentMeta :组件元信息。 在取数钩子触发的数据流变更事件(比如 updateComponentById )不会触发额外重渲染,其渲染时机与取数结束后时机合并。 ## 组件定时取数 对于需要定时刷新重新取数的实时数据,可以配置 autoFetchInterval 实现定时自动取数功能: ```jsx import { Interfaces } from "@alife/bi-designer"; const componentMeta: Interfaces.ComponentMeta = { autoFetchInterval: () => 1000, }; ``` - autoFetchInterval :自动重新取数间隔,单位 ms,不设置则无此功能。 ## 组件强制取数 正常情况取数参数变化才会重新取数,但如有强制取数的诉求,可执行 forceFetch : ```jsx import { useDesigner } from "@alife/bi-designer"; export default () => { const { forceFetch } = useDesigner(); // 指定某个组件重新取数 // forceFetch('jtw4x8ns') }; ``` - forceFetch :强制取数函数,传参为组件 ID。 ## 组件筛选 ### 触发筛选行为 任何组件都可以作为筛选条件,只要实现 onFilterChange 接口就具备了筛选能力,通过 filterValue 可以拿到当前组件筛选值,下面创建一个具有筛选功能的组件: ```jsx import { useDesigner } from "@alife/bi-designer"; const SelectFilter = () => { const { filterValue, onFilterChange } = useDesigner(); return ( ); }; ``` 当组件触发 onFilterChange 时则视为触发筛选,其作用的组件会触发 组件取数。 ### 通过表达式设置任意 key 注意, onFilterChange 与 filterValue 可以映射到组件任意 key,只需要如下定义: ```jsx { props: { onChange: { type: "JSExpression", value: "this.onFilterChange" }, value: { type: "JSExpression", value: "this.filterValue" } } } ``` 组件的 props.onChange 与 props.value 就拥有了 onFilterChange 与 filterValue 的能力。 ### 设置筛选作用的组件 那么如何定义被作用的组件呢?由于筛选关联属于运行时能力,我们需要用到 组件运行时配置 功能。 运行时能力中,筛选关联功能属于 ComponentMeta.eventConfigs 中 filterFetch 部分能力 ,即筛选条件的作用范围,在列表中的组件会在当前组件触发 onFilterChange 时触发取数: ```jsx import { Interfaces, createComponentInstancesArray } from "@alife/bi-designer"; const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance, pageSchema }) => createComponentInstancesArray(pageSchema?.componentInstances) // 找到画布中所有 name-list 组件 ?.filter((each) => each.componentName === "name-list") ?.map((each) => ({ // 事件类型是筛选触发取数 type: "filterFetch", // 条件由当前组件触发 source: componentInstance.id, // 作用于找到的 name-list 组件 target: each.id, })), }; ``` 上面的例子,我们通过 eventConfigs 将所有组件名为 name-list 都做了绑定,当然你也可以根据 componentInstance.props 根据组件当前配置来绑定,自由使用。 同理,还可以实现条件反向绑定,只要设置 source 和 target 即可,source 是触发 onFilterChange 的组件,target 是被作用取数的组件。 注意: componentInstances 包含所有组件,包括自身及 root 根节点,如果要绑定所有组件,一般情况下需要排除掉自身和 root 节点: ```jsx { eventConfigs: componentInstances?.filter( // 不选中 root 节点 (each) => each.componentName !== "root" && // 不选中自己 each.componentId === componentInstance.id ); // ... } ``` ### 传递额外筛选信息 考虑到筛选条件正向、反向绑定,或者同一个筛选条件组件针对同一个组件有多个不同筛选功能,bi-designer 支持 source 与 target 重复的多对多,比如: ```jsx import { Interfaces } from "@alife/bi-designer"; const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance, pageSchema }) => [ { type: "filterFetch", source: componentInstance.id, target: 1, payload: "作用于取数参数", }, { type: "filterFetch", source: componentInstance.id, target: 1, payload: "作用于字段筛选", }, ], }; ``` 在上面的例子中,我们可以将当前组件连续绑定多个同一个目标( target ),为了区分作用,我们可以申明 payload ,这个 payload 最终会传递到 target 组件的 getFetchParam.filters 参数中,可以通过 eachFilter.payload 访问,具体见文档 组件取数 。 对于同一个组件连续绑定多个相同目标组件场景较少,但对于 A 组件配置绑定 B,B 组件配置被 A 绑定的场景还是很多的。 ### 筛选依赖 筛选条件间存在的依赖关系称为筛选依赖。 #### 筛选 Ready 依赖 筛选 Ready 依赖由 filterReady 定义: ```jsx import { Interfaces, createComponentInstancesArray } from "@alife/bi-designer"; const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance, pageSchema }) => createComponentInstancesArray(pageSchema?.componentInstances) // 找到画布中所有 input 组件 ?.filter((each) => each.componentName === "input") ?.map((each) => ({ type: "filterReady", source: each.id, target: componentInstance.id, })), }; ``` target 依赖 source ,当筛选条件 source 变化时, target 组件的筛选就会失效并且被置空。 - source :一旦触发 onFilterChange 。 - target :组件筛选 Ready 就置为 false,且 filterValue 置为 null。 #### 筛选 Value 依赖 筛选 Value 依赖由 filterValue 定义: ```jsx import { Interfaces, createComponentInstancesArray } from "@alife/bi-designer"; const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance, pageSchema }) => createComponentInstancesArray(pageSchema?.componentInstances) // 找到画布中所有 input 组件 ?.filter((each) => each.componentName === "input") ?.map((each) => ({ type: "filterValue", source: each.id, target: componentInstance.id, })), }; ``` target 依赖 source ,当筛选条件 source 变化时, target 组件的 filterValue 将被赋值为 from 的 filterValue 。 - source :一旦触发 onFilterChange 。 - target :组件 filterValue 就会被置为 source 组件 filterValue 的值。 ## 组件筛选默认值 默认情况下,组件筛选器的默认值为 undefined ,并且后续筛选条件变更由组件 onFilterChange 行为控制(具体可以看 组件筛选 文档)。 但如果配置了筛选默认值,或者默认从 URL 参数等,让组件筛选拥有默认值,这个需求也是非常合理的,可以通过 defaultFilterValue 定义: ```jsx import { Interfaces } from "@alife/bi-designer"; const componentMeta: Interfaces.ComponentMeta = { // 组件筛选默认值 defaultFilterValue: ({ componentInstance }) => componentInstance.props.defaultFilterValue, }; ``` 注意此为筛选条件默认值,后续筛选条件变化不会再受此参数控制。 ## 组件主题风格 组件可以通过两种方式读取主题风格配置: 1. JS:通过例如 props.theme.primaryColor 读取。 2. CSS:通过例如 var(--primaryColor) 读取。 ### JS 模式 ```jsx import { themeSelector, useDesigner } from "@alife/bi-designer"; const Component: Interfaces.ComponentElement = () => { const { theme } = useDesigner(themeSelector()); return
文本
; }; ``` ### CSS 模式 ```jsx import "./index.scss"; const Component: Interfaces.ComponentElement = () => { return
文本
; }; ``` ```css .custom-text { color: var(--primaryColor); } ``` CSS 模式的 Key 与 JS 变量的 Key 完全相同。 ## 组件国际化 组件配置通过 JSExpression 方式使用国际化: ```jsx const defaultPageSchema: Interfaces.PageSchema = { componentInstances: { test: { id: "tg43g42f", componentName: "expressionComponent", props: { variable: { type: "JSExpression", value: 'this.i18n["中国"]', }, }, }, }, }; ``` 通过 this.i18n 即可根据 key 访问国际化内容。 - 国际化内容配置 - 配置国际化。 - JSExpression 说明 - JSExpression。 ## 组件配置订正 当组件实例版本低于最新版本号时,说明产生了回滚,也会按照顺序依次订正。 > 注:需要考虑数据回滚的组件,在发布前要把 undo 逻辑写好并测试后提前上线,之后再进行项目正式上线,以保证回滚后可以正确执行 undo 。 > > 组件配置订正在 ComponentMeta.revises 中定义: ```jsx import { Interfaces } from "@alife/bi-designer"; const componentMeta: Interfaces.ComponentMeta = { revises: [ { version: 1, redo: async (prevProps) => { return prevProps; }, undo: async (prevProps) => { return prevProps; }, }, ], }; ``` - version :订正的版本号。 - redo :升级到这个版本订正逻辑。 - undo :回退到这个版本订正逻辑。 - Return :新的组件 props 。 ## 组件吸顶 ### 全局吸顶 组件吸顶通过 ComponentMeta.fixTop 定义: ```jsx import { Interfaces } from "@alife/bi-designer"; const componentMeta: Interfaces.ComponentMeta = { fixTop: ({ componentInstance }) => true, }; ``` - 配置 fixTop 后即可吸顶,不需要组件做额外支持。 - 如果置顶的组件具有筛选功能,吸顶后仍具有筛选功能。 ### 组件内吸顶 通过 ComponentMeta.fixTopInsideParent 来设置组件在父容器内吸顶。 - 平滑取消滚动: 设置 ComponentMeta.smoothlyFadeOut 可以实现该效果。 - 直接让组件回到原位置: 不需要任何配置。 ```jsx import { Interfaces } from "@alife/bi-designer"; const componentMeta: Interfaces.ComponentMeta = { fixTop: () => true, fixTopInsideParent: () => true, smoothlyFadeOut: () => true, }; ``` ### 设置吸顶组件自定义样式 设置 ComponentMeta.getFixTopStyle 来自定义组件吸顶后的样式,一般拿来设置 zIndex 。 ```typescript type getFixTopStyle = (componentInfo: { componentInstance: ComponentInstance; componentMeta: ComponentMeta; dom: HTMLElement; context: any; }) => React.CSSProperties; import { Interfaces } from "@alife/bi-designer"; const componentMeta: Interfaces.ComponentMeta = { getFixTopStyle: () => ({ zIndex: 1000000, }), }; ``` ## 组件渲染完成标识 **默认组件渲染完毕不需要主动上报**,下面是自动上报机制: - 组件 initFetch 为 false 时,组件 DOM Ready 作为渲染完成时机。 - 组件 initFetch 为 true 时,组件取数完毕后且 DOM Ready 作为渲染完成时机。 ### 主动上报渲染完成标识 对于特殊组件,比如 DOM 渲染完毕不是时机加载完毕时机时,可以选择主动上报: ```jsx import { Interfaces, useDesigner } from "@alife/bi-designer"; const customOnRendered: Interfaces.ComponentElement = () => { const { onRendered } = useDesigner(); return
点我后这个组件才算渲染完成
; }; const customOnRenderedMeta: Interfaces.ComponentMeta = { manualOnRendered: true, }; ``` - manualOnRendered :设置为 true 时禁用自动上报。 - onRendered :主动上报组件渲染完毕,仅第一次生效。 ## 组件阻止自动取数 对于需要精细化控制取数时机的场景,可以使用 shouldFetch 控制组件取数时机: ```jsx import { Interfaces } from "@alife/bi-designer"; const componentMeta: Interfaces.ComponentMeta = { shouldFetch: ({ prevComponentInstance, nextComponentInstance, prevFilters, nextFilters, componentMeta, context, }) => true, }; ``` shouldFetch 返回 false 则阻止自动取数逻辑,不会执行到 getFetchParam 与 fetcher 。 - prevComponentInstance :上一次组件实例信息。 - nextComponentInstance :下一次组件实例信息。 - prevFilters :上一次筛选条件信息。 - nextFilters :下一次筛选条件信息。 - componentMeta :组件元信息。 - context :上下文。 对于取数参数没变化时仍要重新取数,参考 组件强制取数。 - shouldFetch 不会阻塞 组件强制取数、组件定时自动取数、组件主动取数。 - shouldFetch 会阻塞 initFetch=true 初始化取数。 ## 组件按需取数 默认 bi-designer 取数是全量并发的,也就是无论组件是否出现在可视区域内,都会第一时间取数,但取数结果不会造成非可视区域组件的刷新。 如果考虑到浏览器请求并发限制,需要优先发起可视区域内组件的取数,可以将 fetchOnlyActive 设置为 true : ```jsx const componentMeta = { componentName: "line-chart", fetchonlyActive: () => true, }; ``` 当组件开启此功能后: - 在可视区域内组件才会发起自动取数。 - 当组件从非可视区域出现在可视区域时,如果需要则会自动发起取数。 ## 组件回调事件 组件回调可以触发事件,通过运行时配置 ComponentMeta.eventConfigs 中 callback 定义: ```jsx import { Interfaces } from "@alife/bi-designer"; const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance, pageSchema }) => [ { type: "callback", callbackName: "onClick", }, ], }; ``` - callbackName :回调函数名。 定义了回调时机后,我们可以触发一些 action 实现自定义效果,在后面的 更新组件 Props、更新组件配置、更新取数参数 了解详细内容。 ## 事件 - 更新组件 Props 更新组件配置属于 Action 之 setProps : ```jsx import { Interfaces } from '@alife/bi-designer' const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance, pageSchema }) => [{ type: 'callback', callbackName: 'onClick', source: componentInstance.id, target: componentInstance.id action: { type: 'setProps', setProps: (props, eventArgs) => { return { ...props, color: 'red' } } } }] } ``` 如上配置,效果是将 props.color 设置为 red 。 eventArgs 是事件参数,比如 onClick 如下调用: ```jsx props.onClick("jack", 19); ``` ```jsx setProps: (props, eventArgs) => { return { ...props, name: eventArgs[0], age: eventArgs[1], }; }; ``` 如果有多个事件同时作用于同一个组件的 setProps ,则 setProps 函数会依次触发多次。 ## 事件 - 更新取数参数 更新组件取数参数属于 Action 之 setFetchParam : ```jsx import { Interfaces } from "@alife/bi-designer"; const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance, pageSchema }) => [ { type: "callback", callbackName: "onClick", action: { type: "setFetchParam", setFetchParam: (param, eventArgs) => { return { ...param, count: true, }; }, }, }, ], }; ``` 如上配置,效果是在取数参数中增加一项 count:true 。 ## 事件 - 更新筛选条件 更新筛选条件属于 Action 之 setFilterValue : ```jsx import { Interfaces } from "@alife/bi-designer"; const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance, pageSchema }) => [ { type: "callback", callbackName: "onClick", action: { type: "setFilterValue", setFilterValue: (filterValue, eventArgs) => { return "uv"; }, }, }, ], }; ``` 如上配置,效果是将目标组件的筛选条件值改为 uv 。 ## 总结 以上就是结合了通用搭建与 BI 特色功能的搭建引擎对组件功能的支持,如果你对功能、或者 API 有任何问题或建议,欢迎联系我。 > 讨论地址是:[精读《数据搭建引擎 bi-designer API-组件》· Issue #269 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/269) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/166.精读《BI 搭建 - 筛选条件》.md ================================================ 筛选条件是 BI 搭建的核心概念,我们大部分所说的探索式分析、图表联动也都属于筛选条件的范畴,**其本质就是一个组件对另一个组件的数据查询起到筛选作用**。 ## 筛选组件是如何作用的 我们最常见的筛选条件就是表单场景的查询控件,如下图所示: 若干 “具有输出能力” 的组件作为筛选组件,点击查询按钮时触发其作用组件重新取数。 注意这里 “具有输出能力” 的组件不仅是输入框等具有输入性质的组件,其实所有具备交互能力的组件都可以,甚至可以由普通组件承担筛选触发的能力: 一个表格的表头点击也可以触发筛选行为,或者柱状图的一个柱子被点击都可以,只要进行到这层抽象,**组件间联动本质也属于筛选行为**。 同样重要的,筛选作用的组件也可以是具备输入能力的组件: 当目标组件是具备筛选能力组件时,这就是筛选联动场景了,所以 **筛选联动也属于普通筛选行为**。至于目标组件触发取数后,是否立即修改其筛选值,进而触发后续的筛选联动,就完全由业务特性决定了。 一个组件也可以自己联动自己筛选,比如折线图点击下钻的场景,就是自己触发了筛选,作用到自己的例子。 ## 什么是筛选组件 **任何组件都可以是筛选组件**。 可能最容易理解的是输入框、下拉框、日期选择器等具备输入特征的组件,这些组件只能说天然适合作为筛选组件,但不代表系统设计要为这些组件特殊处理。 扩大想一想,其实普通的按钮、表格、折线图等等 **具有展示属性的组件也具有输入特性的一面**,比如按钮被点击时触发查询、单元格被点击时想查询当前城市的数据趋势、折线图某条线被点击时希望自身从年下钻到月等等。 所以 **不存在筛选组件这概念,而是任何组件都具有筛选的能力**,因此筛选是一种任何组件都具有的能力,而不局限在某几个组件上,一旦这么设计,可以做到以下几点: 1. 实现输入类组件到展示类组件的筛选,符合基本筛选诉求。 2. 实现展示类组件到展示类组件的筛选,属于图表联动图表的高级功能。 3. 实现输入类组件到输入类组件的筛选,属于筛选联动功能。 4. 实现组件自身到自身的筛选,实现下钻功能。 下面介绍 bi-designer 的筛选条件设计。 ## 筛选条件设计 基于上述分析,bi-designer 在组件元信息中没有增加所谓的筛选组件类型,而是将其设定为一种筛选能力,任何组件都能触发。 ### 如何触发筛选 组件调用 `onFilterChange` 即可完成筛选动作: ```jsx import { useDesigner } from "@alife/bi-designer"; const InputFilter = () => { const { onFilterChange } = useDesigner(); return ( () => onFilterChange(event.target.value)} /> ); }; ``` 但这种开发方式违背了 **低侵入** 的设计理念,我们可以采用组件与引擎解构的方式,让输入框变更的时候直接调用 `props.onChange` ,这个组件保持了最大的独立性: ```jsx const InputFilter = ({ onChange }) => { return () => onChange(event.target.value)} />; }; ``` 那渲染引擎怎么将 `onFilterChange` 映射到 `props.onChange` 呢?如下配置 DSL 即可: ```json { "props": { "onChange": { "type": "JSExpression", "value": "this.onFilterChange" } } } ``` ### 筛选影响哪些组件 一般筛选组件会选择作用于的目标组件,类似下图: 这些信息会存储在筛选组件的组件配置中,即 `componentInstance.props`,筛选目标组件在 `componentMeta.eventConfigs` 组件元信息的事件中配置: ```jsx import { Interfaces } from "@alife/bi-designer"; const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance }) => componentInstance.props.targets?.map((target) => ({ // 筛选取数 type: "filterFetch", // 触发组件 source: componentInstance.id, // 作用组件 target: target.id, })), }; ``` 如上所示,假设作用于组件存储在 `props.targets` 字段中,我们将其 `map` 一下都设置为 `filterFetch` 类型,表示筛选作用,`source` 触发源是自己,`target` 目标组件是存储的 `target.id`。 这样当 `source` 组件调用了 `onFilterChange`,`target` 组件就会触发取数,并在取数参数中拿到作用于其的筛选组件信息与筛选值。 ### 组件如何感知筛选条件 组件取数是结合了筛选条件一起的,只要如上设置了 `filterFetch`,渲染引擎会自动在计算取数参数的回调函数 `getFetchParam` 中添加 `filters` 代表筛选组件信息,组件可以结合自身 `componentInstance` 与 `filters` 推导出最终取数参数: 最终,组件元信息只要写一个 `getFetchParam` 回调函数即可,**可以自动拿到作用于它的筛选组件,而不用关心是哪些配置导致了关联,只要响应式的去处理筛选作用即可**。 ```jsx import { Interfaces } from "@alife/bi-designer"; const componentMeta: Interfaces.ComponentMeta = { // 组装取数参数 getFetchParam: ({ componentInstance, filters }) => { // 结合 componentInstance 与 filters.map... 返回取数参数 }, }; ``` ## 筛选组件间联动带来的频繁取数问题 对于筛选联动的复杂场景,会遇到频繁取数的问题。 假设国家、省、市三级联动筛选条件同时 `filterFetch` 作用于一个表格,这个表格取数的筛选条件需要同时包含国家、省、市三个参数,但我们又设置了 国家、省、市 这三个筛选组件之间的 `filterFetch` 作为筛选联动,那么国家切换后、省改变、联动市改变,这个过程筛选值会变化三次,但我们只想表格组件取数函数仅执行最后的一次,怎么办呢? 如上图所示,其实每个筛选条件在渲染引擎数据流中还存储了一个 `ready` 状态,表示筛选条件是否就绪,**一个组件关联的筛选条件只要有一个 `ready` 不为 `true`,组件就不会触发取数**。 因此我们需要在筛选变化的过程中,总是保证一个筛选组件的 `ready` 为 `false`,等筛选间联动完毕了,所有筛选器的 `ready` 为 `true`,组件才会取数,我们可以使用 `filterReady` 筛选依赖配置: ```jsx import { Interfaces, createComponentInstancesArray } from "@alife/bi-designer"; const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance }) => componentInstance.props.targets?.map((target) => ({ // 筛选就绪依赖 type: "filterReady", // 触发组件 source: componentInstance.id, // 作用组件 target: target.id, })), }; ``` 这样配置后,当 `source` 组件触发 `onFilterChange` 后,`target` 组件的筛选 `ready` 会立即设置为 `false`,只有 `target` 组件取完数后主动触发 `onFilterChange` 才会将自己的 `ready` 重新置为 `true`。**That'a all,其他流程没有任何感知**。 ## 若干筛选组件聚合成一个查询控件 除了联动外,也会存在防止频繁查询的诉求,希望将多个筛选条件绑定成一个大筛选组件,在点击 “查询” 按钮时再取数: 可以利用 **筛选作用域** 轻松实现此功能,只需要两步: ### 筛选组件设置独立筛选作用域 ```jsx import { Interfaces } from "@alife/bi-designer"; const componentMeta: Interfaces.ComponentMeta = { // 通过 componentInstance 判断,如果是全局筛选器内部,则设置 filterScope filterScope: ({ componentInstance }) => ["my-custom-scope-name"], }; ``` 这样,这批筛选组件就与其作用的组件属于不同的 **筛选作用域** 了,所以筛选不会对其立即生效,功能实现了一半。 ### 确认按钮点击时调用 `submitFilterScope` ```jsx import { useDesigner } from '@alife/bi-designer' const componentMeta: Interfaces.ComponentMeta = { const { submitFilterScope } = useDesigner() // 点击确认按钮时,调用 submitFilterScope('my-custom-scope-name') }; ``` 你可以在点击查询按钮后调用 `submitFilterScope` 并传入对应作用域名称,这样作用域内筛选组件就会立即对其 `target` 组件生效了。 至于确认按钮、UI 上的聚合,这些你可以写一个自定义组件去做,利用 `ComponentLoader` 把筛选组件聚合到一起加载,总之功能与 UI 是解耦的。 如果你对原理感兴趣,可以再多看一下这张图: ### 突破筛选作用域 然而实际场景中,可能存在更复杂的组合,见下面的例子: 筛选器 1 同时对 筛选器 2、表格 产生筛选作用 `filterFetch`,但对 表格 的作用希望通过查询按钮拦截住,而对 筛选器 2 的作用希望能立即生效,对于这个例子有两种方式解决: 最简单的方式就是将 筛选器 1、筛选器 2 设置为相同作用域 `group1`,这样就通过作用域分割自然实现了效果,**而且这本质上是两个筛选器 UI 不在一起,但筛选作用域相同的例子**: 但是再变化一下,如果筛选器 2 也对表格产生筛选作用,那我们将 筛选器 1、筛选器 2 放入同一个 `group1` 等于对表格的查询都会受到 “查询” 按钮的控制,但 **我们又希望筛选器 2 可以立即作用于表格**: 如图所示,我们只能将 筛选器 1 的筛选作用域设置为 `group1`,这样 筛选器 2 与 表格 属于同一个筛选作用域,他们之间筛选会立即生效,我们只要解决 筛选器 1 不能立即作用于 筛选器 2 的问题即可,可以通过 `ignoreFilterScope` 方式突破筛选作用域: ```jsx import { Interfaces } from "@alife/bi-designer"; const componentMeta: Interfaces.ComponentMeta = { eventConfigs: ({ componentInstance }) => componentInstance.props.targets?.map((target) => ({ // 筛选取数 type: "filterFetch", // 触发组件 source: componentInstance.id, // 作用组件 target: target.id, // 突破筛选作用域 ignoreFilterFetch: true, })), }; ``` 我们只要在 `source: 筛选器1` `target: 筛选器2` 的 `filterFetch` 配置中,将 `ignoreFilterFetch` 设置为 `true`,这个 `filterFetch` 就会忽略筛选作用域,实现立即 筛选器 1 立即作用到 筛选器 2 的效果。 ## 总结 你还有哪些特殊的筛选诉求?可以用这套筛选设计解决吗? > 讨论地址是:[精读《BI 搭建 - 筛选条件》· Issue #270 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/270) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/17.精读《如何安全地使用 React context》.md ================================================ # 精读《如何安全地使用 React context》 本期精读文章是:[How to safely use React context](https://medium.com/@mweststrate/how-to-safely-use-react-context-b7e343eff076) ## 1 引言 在 React 源码中,context 始终存在,却在 React 0.14 的官方文档中才有所体现。在目前最新的官方文档中,仍不建议使用 context,也表明 context 是一个实验性的 API,在未来 React 版本中可能被更改。那么哪些场景下需要用到 context,而哪些情况下应该避免使用,context 又有什么坑呢?让我们一起来讨论一下。 ## 2 内容概要 React context 可以把数据直接传递给组件树的底层组件,而无需中间组件的参与。Redux 作者 Dan Abramov 为 contenxt 的使用总结了一些注意事项: * 如果你是一个库的作者,需要将信息传递给深层次组件时,context 在一些情况下可能无法更新成功。 * 如果是界面主题、本地化信息,context 被应用于不易改变的全局变量,可以提供一个高阶组件,以便在 API 更新时只需修改一处。 * 如果库需要你使用 context,请它提供高阶组件给你。 正如 Dan 第一条所述,在 React issue 中,经常能找到 React.PureComponent、shouldComponentUpdate 与包含 Context 的库结合后引发的一些问题。原因在于 shouldComponentUpdate 会切断子树的 rerender,当 state 或 props 没有发生变化时,可能意外中断上层 context 传播。也就是当 shouldComponentUpdate 返回 false 时,context 的变化是无法被底层所感知的。 因此,我们认为 context 应该是不变的,在构造时只接受 context 一次,使用 context,应类似于依赖注入系统来进行。结合精读文章的示例总结一下思路,不变的 context 中包含可变的元素,元素的变化触发自身的监听器实现底层组件的更新,从而绕过 shouldComponentUpdate。 最后作者提出了 Mobx 下的两种解决方案。context 中的可变元素可用 observable 来实现,从而避免上述事件监听器编写,因为 observable 会帮你完成元素改变后的响应。当然 Provider + inject 也可以完成,具体可参考精读文章中的代码。 ## 3 精读 本次提出独到观点的同学有: [@monkingxue](https://www.zhihu.com/people/turbe-xue) [@alcat2008](https://github.com/alcat2008) [@ascoders](https://www.zhihu.com/people/huang-zi-yi-83),精读由此归纳。 ### context 的使用场景 > In some cases, you want to pass data through the component tree without having to pass the props down manually at every level. context 的本质在于为组件树提供一种跨层级通信的能力,原本在 React 只能通过 props 逐层传递数据,而 context 打破了这一层束缚。 context 虽然不被建议使用,但在一些流行库中却非常常见,例如:[react-redux](https://github.com/reactjs/react-redux)、[react-router](https://github.com/ReactTraining/react-router)。究其原因,我认为是单一顶层与多样底层间不是单纯父子关系的结果。例如:react-redux 中的 Provider,react-router 中的 Router,均在顶层控制 store 信息与路由信息。而对于 Connect 与 Route 而言,它们在 view 中的层级是多样化的,通过 context 获取顶层 Provider 与 Router 中的相关信息再合适不过。 ### context 的坑 * context 相当于一个全局变量,难以追溯数据源,很难找到是在哪个地方中对 context 进行了更新。 * 组件中依赖 context,会使组件耦合度提高,既不利于组件复用,也不利于组件测试。 * 当 props 改变或是 setState 被调用,getChildContext 也会被调用,生成新的 context,但 shouldComponentUpdate 返回的 false 会 block 住 context,导致没有更新,这也是精读文章的重点内容。 ## 4 总结 正如精读文章开头所说,context 是一个非常强大的,具有很多免责声明的特性,就像伊甸园中的禁果。的确,引入全局变量似乎是应用混乱的开始,而 context 与 props/state 相比也实属异类。在业务代码中,我们应抵制使用 context,而在框架和库中可结合场景适当使用,相信 context 也并非洪水猛兽。 > 讨论地址是:[精读《How to safely use React context》· Issue #23 · dt-fe/weekly](http://github.com/dt-fe/weekly/issues/23) > 如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,每周五发布 ================================================ FILE: 前沿技术/18.精读《设计完美的日期选择器》.md ================================================ # 1. 摘要 日期选择器作为基础组件重要不可或缺的一员,大家已经快习惯它一成不变的样子,输入框+日期选择弹出层。但到业务中,这种墨守成规的样子真的能百分百契合业务需求吗。这篇文章从多个网站的日期选择场景出发,企图归纳出日期选择器的最佳实践。这篇文章对移动端的日期选择暂无涉猎,都是 PC 端,列举出通用场景,每个类型日期选择器需要考虑的设计。 文章链接:Designing The Perfect Date And Time Picker 感谢本期评论官 @黄子毅 @流形 @王亮 @赵阳 @不知名的花瓣工程师 # 2. 设计原则 ## 2.1 通用设计 1)明确需求,是实现日期选择、日期区间选择、时间选择 2)用户选中日期后是否需要自动触发下一步?尤其是在某些固定业务流程中 3)日期选择器是否是最佳的日期选择方法?如果提供预定义的日期选择按钮是不是更快呢? 4)如何避免展示不可用日期? 5)是否需要根据上下文自动定位? 适用于生日选择场景。 ## 2.2 输入框设计 1)用户是否可以自定义输入日期,还是只能通过点击选择程序给出的日期?有时候直接输入的效率明显高于点击选择,在很多银行流水查询的场景中就提供自定义输入。 2)用户自定义输入如何保证日期格式正确性? 3)是否需要提供预设场景输入? 比如昨天,三天前,七天前,30 天前?像很多数据分析场景,分析师会关注数据周期,比如流量的周环比,月环比,年环比。 4)是否需要包含默认值?如果有默认,应该是什么?像 google flight 根据用户历史数据提供默认值,临近节假日默认填充节假日。同时像有些数据场景,数据存在延迟,需要默认提供 T-1/T-2 ,避免用户选择当天。 5)当用户激活输入框时,是否保留默认值? 6)是否提供重置按钮? 7)是否提供『前一项』『现在』『后一项』导航?这个设计点我第一次看到,专门附图说明。 ![](https://pic3.zhimg.com/v2-cd71b6e05dec1c801794415816b6369a_b.png) ## 2.3 日期弹出层设计 1)理想状态下,任何日期选择都应该在三步之内完成 2)日期选择弹出层的触发方式? 是点输入框就还是点日期小图标? 3)默认情况下,展示多少周、月、天? 4)周的定义是周一到周日 还是 周日到周六? 5)如何提示当前时间和当前时间? 6)是否需要提供『前一项』『现在』『后一项』导航?如果提供,选择天、月、年的场景下如何展示? 7)提示用户最关心的信息,比如 价格、公共假期,可采用背景色、点标记 8)是否用户点击非弹出层自动关闭弹出层?是否需要提供关闭按钮? 9)是否可以不和输入框联动? 10)用户可以重置选中的日期吗? ## 2.4 日期区间设计 1)理想状态下,任何日期区间选择需要在六步之内完成 2)用户选中后是否立刻做背景色提示? 3)当用户选择时,区间是否需要随着用户动作改变?比如用户 hover 时,动态改变选中区间。 4)是否提供快捷键切换 日、月、年选择? 5)是分成两个日期选择器还是采用区间形式? 6)如何去除某些特殊时间点? 比如春节、节假日。 ## 2.5 时间选择设计 1)最简单的方法是竖直的日期,水平的时间选择 2)更有用的是先提供日期还是时间选择? 时间选择可以作为一个过滤项,移除某些不可用的日期,这个也很有用。 3)提供最常使用的时间片段,并提供快捷键选择。 # 3. 文章中亮点设计 ## 3.1 google flight ![](https://pic4.zhimg.com/v2-2cb10cf0f88fc046d32482e8fe0cd837_b.png) 这个案例在最小的范围内提供用户找出最优选择。虽然第一眼看到这个方法,我懵了一秒,但仔细一看发现这种展现方法完美的给出了各种组合。 ## 3.2 春夏秋冬 ![](https://pic2.zhimg.com/v2-d3250f633f8ff1a075279fbfbf43cfb9_b.png) 这个案例另辟蹊径增加了季节的概念,在某些旅游、机票类业务场景季节是非常必要的概念,提供超出月更粗粒度的日期范围选择。 ## 3.3 枚举选择时间 ![](https://pic4.zhimg.com/v2-e2d05d6a438b19d5acc7b6a2db6d8d1f_b.png) 使用一系列的按钮代替时间选择器,比如像我们的作息时间表,大部分是把时间划分成有规律的时间段供用户选择,固化用户选择。 ## 3.4 对话式交互 ![](https://pic1.zhimg.com/v2-cd4874c5dc98505c56b05dbd3193fa78_b.gif) 采用与用户交互的方式选择日期,如果今后应用上 AI,单纯的日期选择器是不是会消失不见呢?.. ## 3.5 特殊标识周末 ![](https://pic1.zhimg.com/v2-d8410bede19d7bd4c212ad216ebd0770_b.png) 在机票、旅行场景中,周末是大家最有可能出行的时间点,采用竖线划分的方式着重标注提醒。 # 4. 总结 ![](https://pic3.zhimg.com/v2-ec840145feb22eeac76e5a0503828436_b.png) 总得来说,日期选择器是一个业务组件,虽然现有很多组件库把它纳入 UI 基础组件。但在每个不通的业务场景和需求下的展现形式、交互都会有所有不同。首先一定一定要明确确定需要日期选择器的场景,尤其是与日期强关联的业务,比如机票定价、日程安排,结合到日期选择器中更直观,提高用户对信息的检索效率。满足用户需求场景的同时,尽量减少用户操作链路。 看到最后点个赞呗,给你比小心心 ❤ ~~ ================================================ FILE: 前沿技术/19.精读《最佳前端面试题》及面试官技巧.md ================================================ 本期精读的文章是:[The-Best-Frontend-JavaScript-Interview-Questions](https://performancejs.com/post/hde6d32/The-Best-Frontend-JavaScript-Interview-Questions-%28written-by-a-Frontend-Engineer%29) 讨论前端面试哪些问题,以及如何面试。 # 1 引言 logo 又到了招聘的季节,如何为自己的团队找到真正优秀的人才?问哪些问题更合适?我们简单总结一把。 # 2 内容概要 [The-Best-Frontend-JavaScript-Interview-Questions](https://performancejs.com/post/hde6d32/The-Best-Frontend-JavaScript-Interview-Questions-%28written-by-a-Frontend-Engineer%29) 从 概念 - 算法 coding - 调试 - 设计 这 4 步全面了解候选人的基本功。 # 3 精读 本精读由 [ascoders](https://github.com/ascoders) [camsong](https://github.com/camsong) [jasonslyvia](https://github.com/jasonslyvia) 讨论而出。 网络技术发展非常迅速,前端变化尤为快,对优秀人才的面试方式在不同时期会有少许不同。 ### 整体套路 在面试之前,第一步要询问自己,是否对当前岗位的职责、要求有清晰的认识?不知道自己岗位要招什么样的人,也无法组织好面试题。 认真阅读简历,这是对候选人起码的尊重,同时也是对自己的负责。阅读简历是为了计划面试流程,不应该对所有候选人都准备相同的问题。 具体流程我们一般会通过: 1. 开场白 2. 候选人自我介绍 3. 面试 4. 附加信息 5. 结束 开场白是最重要的,毕竟候选人如果拒绝了本次面试,后面的流程都不会存在。其次,通过候选人自我介绍,了解简历中你所疑惑的地方。简历是为了突出重点,快速判断是否基本匹配岗位要求,一旦确认了面试,全面了解候选人经验是对双方的负责。接下来重点讨论面试过程。 ### 开放性问题 面试的目的是挖掘对方的优点,而不是拿面试官自己的知识点与对方知识点做交集,看看能否匹配上 80%。但受主观因素影响,又不宜询问太多开放性问题,因此开放问题很讲究技巧。 正如上面所说,我推荐以开放性问题开场,这样便于了解候选人的经历、熟悉哪些技术点,便于后面的技术提问。如果开场就以准备好的题目展开车轮战,容易引起候选人心里紧张,同时我们问的问题不一定是候选人所在行的,技术问题不是每一个都那么重要,很多时候我们只看到了候选人的冰山一角,但此时气氛已经尴尬,很多时候会遗漏优秀人才。 开放性问题最好基于行为面试法询问(Star 法则): - Situation: 场景 - 当时是怎样的场景 - Task: 任务 - 当时的任务是什么 - Action: 我采取了怎样的行动 - Result: 达到了什么样的结果 行为面试法的好处在于还原当时场景,不但让面试官了解更多细节,也开拓了面试者的思维,让面试过程更加高效、全面。 举一个例子,比如考察候选人是否聪明,star 法则会这样询问: > 在刚才的项目中,你提到了公司业务发展很快,人手不够,你是如何应对的呢? 相比不推荐的 “假设性问题” 会如此提问: > 假如让你学习一个新技术,你会如何做? 更不推荐的是 “引导性问题”: > 你觉得自己聪明吗? 相比于 star 法则,其他方式的提问,不但让候选人觉得突兀,不好回答,而且容易被主观想法带歪,助长了面试中投机的气氛。至于对 star 法则都精心编排的候选人,我还没有遇到过,如果遇到了肯定会劝他转行做演员 —— 开玩笑的,会通过后续技术问题甄别候选人是否有真本领。 ### 技术问题 亘古不变的问题就是考察基本功了,然而基本功随着技术的演进会有所调整,Html Css Js 这三个维度永远是不变的,但旧的 api 是否考察,取决于是否有最新 api 代替了它,如果有,在浏览器兼容性达标的基础上,可以只考察替代的 api,当然了解历史会更好。 > 比如 `proxy` 与 `defineProperty` 需要结合考察,因为 `proxy` 不兼容任何 IE 浏览器,候选人需要全面了解这两种用法。 变的地方在于对候选人使用技术框架的提问。在开放性问题中已经做好了铺垫,那无论候选人时以什么框架开发的,或者不使用框架开发,最好按照候选人的使用习惯提问。比如候选人使用 Angular 框架的开发经验较多,就重点考察对 Angular 框架设计、实现原理是否了解,实际使用中是否遇到过问题,以及对问题的解决方法,这也回到了 star 法则。 如果候选人能总结出比如当前流行的 Vue React Angular 这三个框架核心实现思想的异同,就是加分项。 对与老旧的问题,比如 jquery 的问题,也会问与设计思想相关的问题,比如候选人不知道 `$.delegate`,也不知道其已被 `$on` 在 Jq3.0 取代,这不代表候选人能力不行,最多说明候选人比较年轻。此时应该通过引导的方式,让其思考如何优化 `$.bind` 方法的性能,通过逐步引导,判断候选人的思维活跃度有多强。 ### 如何防止被套路 把面试官经验抛出来,怕不怕让候选人有所准备呢? —— 说实在的,几乎所有候选人都是有准备的,也不差这一篇文章。 以上是开玩笑。 面试主要是看候选人基础有多扎实,和思维能力。基础主要指的是,候选人提前了解了多少前端相关知识,比如对闭包的理解,对原生 api 的理解?如果候选人没接触过这两个知识点,会有两种情况: - **这些知识点看完需要多久?如果是闭包和原生 api 的定义与用法,候选人这方面的缺陷可以通过 5 分钟来弥补,那么这种问题到底想考什么?我们真的在乎这 5 分钟看文档的时间吗?此时应该了解候选人对知识点的感悟,或者学习方式,因为这两点的差距可能几年都无法弥补** - **如果候选人学习能力非常强,但几乎所有前端知识点都不了解,弥补完大概一共要花 1000*5 分钟,这时候量变引发质变了,是不是说明候选人本身对技术的热情存在问题?** 通过了基础问题还远远不够。甚至当问一个复杂的问题的时候,如果候选人瞬间把答案完美流畅表达出来,说明这个问题基本上白问了。 **技术面更应该考察候选人的思考过程和基于此来表达出的技术能力和项目经验**。如果候选人基础没有落下太多,思维足够灵活,在过往项目中主动学习,并主导解决过项目问题,说明已经比较优秀了,我们招的每一人都应当拥有激情与学习能力。 所以,当问到候选人不了解的知识点时,通过引导并挖掘出候选人拥有多少问题解决能力,才是最大的权重项,如果这个问题候选人也提前准备了,那说明准备对了。 ### 非技术相关 最后考察候选人的发展潜力与工作态度,我们一般通过询问简单的算法问题,进一步了解候选人是否对技术真正感兴趣,而不只是对前端工程感兴趣。同时,算法问题也考察候选人解决抽象问题的能力,或者让候选人设计一个组件,通过对组件需求的不断升级,考察候选人是否能及时给出解决方案。 最后是工作态度,首先会考察人品,对不懂的知识点装懂是违背诚信的行为,任何团队都不会要的。同时,**不正视自己技术存在的盲点,将是技术发展的最大阻碍**。不过这里也不怕被候选人套路,如果全部都回答不懂那也不用考虑了。 # 3 总结 由于经验不多,只能编出这些体会,希望求职者多一些真诚,少一些套路,就一定会找到满意的工作。 > 讨论地址是:[精读《最佳前端面试题》及前端面试官技巧 · Issue #27 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/27) > 如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,每周五发布。 ================================================ FILE: 前沿技术/190.精读《DOM diff 原理详解》.md ================================================ DOM diff 作为工程问题,需要具有一定算法思维,因此经常出现在面试场景中,毕竟这是难得出现在工程领域的算法问题。 无论出于面试目的,还是深入学习目的,都有必要将这个问题搞懂,因此前端精读我们就专门用一个章节说清楚此问题。 ## 精读 Dom diff 是所有现在框架必须做的事情,这背后的原因是,由 Jquery 时代的面向操作过程转变为数据驱动视图导致的。 为什么 Jquery 时代不需要 Dom diff?因为 Dom diff 交给业务处理了,我们调用 `.append` 或者 `.move` 之类 Dom 操作函数,就是显式申明了如何做 Dom diff,这种方案是最高效的,因为怎么移动 Dom 只有业务最清楚。 但这样的问题也很明显,就是业务心智负担太重,对于复杂系统,需要做 Dom diff 的地方太多,不仅写起来繁琐,当状态存在交错时,面向过程的手动 Dom diff 容易出现状态遗漏,导致边界错误,就算你没有写出 bug,代码的可维护性也绝对算不上好。 解决方案就是数据驱动,我们只需要关注数据如何映射到 UI,这样无论业务逻辑再复杂,我们永远只需要解决局部状态的映射,这极大降低了复杂系统的维护复杂度,以前需要一个老手写的逻辑,现在新手就能做了,这是非常了不起的变化。 但有利也有弊,这背后 Dom diff 就要交给框架来做了,所以是否能高效的做 Dom diff,是一个数据驱动框架能否应用于生产环境的重要指标,接下来,我们来看看 Dom diff 是如何做的吧。 ### 理想的 Dom diff 如图所示,理想的 Dom diff 自然是滴水不漏的复用所有能复用的,实在遇到新增或删除时,才执行插入或删除。这样的操作最贴近 Jquery 时代我们手写的 Dom diff 性能。 可惜程序无法猜到你的想法,想要精确复用就必须付出高昂的代价:时间复杂度 O(n³) 的 diff 算法,这显然是无法接受的,因此理想的 Dom diff 算法无法被使用。 > 关于 O(n³) 的由来。由于左树中任意节点都可能出现在右树,所以必须在对左树深度遍历的同时,对右树进行深度遍历,找到每个节点的对应关系,这里的时间复杂度是 O(n²),之后需要对树的各节点进行增删移的操作,这个过程简单可以理解为加了一层遍历循环,因此再乘一个 n。 ### 简化的 Dom diff 如图所示,只按层比较,就可以将时间复杂度降低为 O(n)。按层比较也不是广度遍历,其实就是判断某个节点的子元素间 diff,跨父节点的兄弟节点也不必比较。 这样做确实非常高效,但代价就是,判断的有点傻,比如 ac 明明是一个移动操作,却被误识别为删除 + 新增。 好在跨 DOM 复用在实际业务场景中很少出现,因此这种笨拙出现的频率实际上非常低,这时候我们就不要太追求学术思维上的严谨了,毕竟框架是给实际项目用的,实际项目中很少出现的场景,算法是可以不考虑的。 下面是同层 diff 可能出现的三种情况,非常简单,看图即可: 那么同层比较是怎么达到 O(n) 时间复杂度的呢?我们来看具体框架的思路。 ### Vue 的 Dom diff Vue 的 Dom diff 一共 5 步,我们结合下图先看前三步: 如图所示,第一和第二步分别从首尾两头向中间逼近,尽可能跳过首位相同的元素,因为我们的目的是 **尽量保证不要发生 dom 位移**。 这种算法一般采用双指针。如果前两步做完后,发现旧树指针重合了,新树还未重合,说明什么?说明新树剩下来的都是要新增的节点,批量插入即可。很简单吧?那如果反过来呢?如下图所示: 第一和第二步完成后,发现新树指针重合了,但旧树还未重合,说明什么?说明旧树剩下来的在新树都不存在了,批量删除即可。 当然,如果 1、2、3、4 步走完之后,指针还未处理完,那么就进入一个小小算法时间了,我们需要在 O(n) 时间复杂度内把剩下节点处理完。熟悉算法的同学应该很快能反映出,一个数组做一些检测操作,还得把时间复杂度控制在 O(n),得用一个 Map 空间换一下时间,实际上也是如此,我们看下图具体做法: 如图所示,1、2、3、4 步走完后,Old 和 New 都有剩余,因此走到第五步,第五步分为三小步: 1. 遍历 Old 创建一个 Map,这个就是那个换时间的空间消耗,它记录了每个旧节点的 index 下标,一会好在 New 里查出来。 2. 遍历 New,顺便利用上面的 Map 记录下下标,同时 Old 在 New 中不存在的说明被删除了,直接删除。 3. 不存在的位置补 0,我们拿到 `e:4 d:3 c:2 h:0` 这样一个数组,下标 0 是新增,非 0 就是移过来的,批量转化为插入操作即可。 最后一步的优化也很关键,我们不要看见不同就随便移动,为了性能最优,要保证移动次数尽可能的少,那么怎么才能尽可能的少移动呢?假设我们随意移动,如下图所示: 但其实最优的移动方式是下面这样: 为什么呢?因为移动的时候,其他元素的位置也在相对变化,可能做了 A 效果同时,也把 B 效果给满足了,也就是说,找到那些相对位置有序的元素保持不变,让那些位置明显错误的元素挪动即是最优的。 什么是相对有序?`a c e` 这三个字母在 Old 原始顺序 `a b c d e` 中是相对有序的,我们只要把 `b d` 移走,这三个字母的位置自然就正确了。因此我们只需要找到 New 数组中的 **最长子序列**。具体的找法可以当作一个小算法题了,由于知道每个元素的实际下标,比如这个例子中,下标是这样的: `[b:1, d:3, a:0, c:2, e:4]` 肉眼看上去,连续自增的子串有 `b d` 和 `a c e`,由于 `a c e` 更长,所以选择后者。 换成程序去做,可以采用贪心 + 二分法进行查找,详细可以看这道题 [最长递增子序列](https://leetcode-cn.com/problems/longest-increasing-subsequence/),时间复杂度 O(nlogn)。由于该算法得出的结果顺序是乱的,Vue 采用提前复制数组的方式辅助找到了正确序列。 ### React 的 Dom diff 假设这么一种情况,我们将 a 移到了 c 后,那么框架从最终状态倒推,如何最快的找到这个动机呢?React 采用了 **仅右移策略**,即对元素发生的位置变化,只会将其移动到右边,那么右边移完了,其他位置也就有序了。 我们看图说明: 遍历 Old 存储 Map 和 Vue 是一样的,然后就到了第二步遍历 New,`b` 下标从原来的 `1` 变成了 `0`,需要左移才行,但我们不左移,我们只右移,因为所有右移做完后,左移就等于自动做掉了(前面的元素右移后,自己自然被顶到前面去了,实现了左移的效果)。 同理,c 下标从 `2` 变成了 `1`,需要左移才行,但我们继续不动。 a 的下标从 `0` 变成 `2`,终于可以右移了! 后面的 d、e 下标没变,就不用动。我们纵观整体可以发现,b 和 c 因为前面的 a 被抽走了,自然发生了左移。这就是用一个右移代替两个左移的高效操作。 同时我们发现,这也确实找到了我们开始提到的最佳位移策略。 那这个算法真的有这么聪明吗?显然不是,这个算法只是歪打误撞碰对了而已,**有用右移替代左移的算法,就有用左移替代右移的算法**,既然选择了右移替代左移,那么一定丢失了左移代替右移的效率。 什么时候用左移代替右移效率最高?就是把数组最后一位移到第一位的场景: 显然左移只要一步,那么右移就是 n-1 步,在这个例子就是 4 步,我们看右移算法图解: 首先找到 e,位置从 `4` 变成了 `0`,但我们不能左移!所以只能保持不动,悲剧从此开始。 虽然算法已经不是最优了,但该做的还是要做,其实之前有一个 lastIndex 概念没有说,因为 e 已经在 `4` 的位置了,所以再把 a 从 `0` 挪到 `1` 已经不够了,此时 a 应该从 `0` 挪到 `5`。 方法就是记录 `lastIndex = max(oldIndex, newIndex)` => `lastIndex = max(4, 0)`,下一次移动到 `lastIndex + 1` 也就是 `5`: 发现 a 从 `0` 变成了 `5`(注意,此时考虑到 lastIndex 因素),所以右移。 同理,b、c、d 也一样。我们最后发现,发生了 4 次右移,e 也因为自然左移了 4 次到达了首位,符合预期。 所以这是一个有利有弊的算法。新增和删除比较简单,和 Vue 差不多。 PS:最新版 React Dom diff 算法如有更新,欢迎在评论区指出,因为这种算法看来不如 Vue 的高效。 ## 总结 Dom diff 总结有这么几点考虑: 1. 完全对比 O(n³) 无法接受,故降级为同层对比的 O(n) 方案。 2. 为什么降级可行?因为跨层级很少发生,可以忽略。 3. 同层级也不简单,难点是如何高效位移,即最小步数完成位移。 4. Vue 为了尽量不移动,先左右夹击跳过不变的,再找到最长连续子串保持不动,移动其他元素。 5. React 采用仅右移方案,在大部分从左往右移的业务场景中,得到了较好的性能。 > 讨论地址是:[精读《DOM diff 原理详解》· Issue #308 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/308) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/191.精读《高性能表格》.md ================================================ 每个前端都想做一个完美的表格,业界也在持续探索不同的思路,比如钉钉表格、语雀表格。 笔者所在数据中台团队也对表格有着极高的要求,尤其是自助分析表格,需要兼顾性能与交互功能,本文便是记录自助分析表格高性能的研发思路。 ## 精读 要做表格首先要选择基于 DOM 还是 Canvas,这是技术选型的第一步。比如钉钉表格就是 [基于 Canvas 实现的](https://zhuanlan.zhihu.com/p/340423350),当然这不代表 Canvas 实现就比 DOM 实现要好,从技术上各有利弊: - Canvas 渲染效率比 DOM 高,这是浏览器实现导致的。 - DOM 可拓展性比 Canvas 好,渲染自定义内容首选 DOM 而非 Canvas。 技术选型要看具体的业务场景,钉钉表格其实就是在线 Excel,Excel 这种形态决定了单元格内一定是简单文本加一些简单图标,因此不用考虑渲染自定义内容的场景,所以选择 Canvas 渲染在未来也不会遇到不好拓展的麻烦。 而自助分析表格天然可能拓展图形、图片、操作按钮到单元格中,对轴的拖拽响应交互也非常复杂,为了不让 Canvas 成为以后拓展的瓶颈,还是选择 DOM 实现比较妥当。 那问题来了,既然 DOM 渲染效率天然比 Canvas 低,我们应该如何用 DOM 实现一个高性能表格呢? 其实业界已经有许多 DOM 表格优化方案了,主要以按需渲染、虚拟滚动为主,即预留一些 Buffer 区域用于滑动时填充,表格仅渲染可视区域与 Buffer 区域部分。但这些方案都不可避免的存在快速滑动时白屏问题,笔者通过不断尝试终于发现了一种完美解决的方案,我们一起往下看吧! ### 单元格使用 DIV 绝对定位 即每个单元格都是用绝对定位的 DIV 实现,整个表格都是有独立计算位置的 DIV 拼接而成的: 这样做的前提是: 1. 所有单元格位置都要提前计算,这里可以利用 web worker 做并行计算。 2. 单元格合并仅是产生一个更大的单元格,它的定位方式与小单元格并无差异。 带来的好处是: 1. 滚动时,单元格可以最大程度实现复用。 2. 对于合并的单元格,只会让可视区域渲染的总单元格数更小,更利于性能提升,而不是带来性能负担。 如图所示有 16 个单元格,当我们向右下滑动一格时,中间 3x3 即 9 个格子的区域是完全不会重新渲染的,这样零散的绝对定位分布可以最大程度维持单元格本来的位置。我们可以认为,任何一格单元格只要自身不超出屏幕范围,就不会随着滚动而重渲染。 如果你采用 React 框架来实现,只要将每个格子的 key 设置为唯一的即可,比如当前行列号。 ### 模拟滚动而非原生滚动 一般来说,轴因为逻辑特殊,其渲染逻辑和单元格会分开维护,因此我们将表格分为三个区域:横轴、纵轴、单元格。 显然,常识是横轴只能纵向滚动,纵轴只能横向滚动,单元格可以横纵向滚动,那么横向和纵向滚动条就只能出现在单元格区域: 这样会存在三个问题: 1. 单元格使用原生滚动,横纵轴只能在单元格区域监听滚动后,通过 `.scroll` 模拟滚动,这必然会导致单元格与轴滚动有一定错位,即轴的滚动有几毫秒的滞后感。 2. 鼠标放在轴上时无法滚动,因为只有单元格是 `overflow: auto` 的,而轴区域 `overflow: hidden` 无法触发滚动。 3. 快速滚动出现白屏,即便留了 Buffer 区域,在快速滚动时也无能为力,这是因为渲染速度跟不上滚动导致的。 经过一番思考,我们只要将方案稍作调整,就能同时解决上面三个问题:即不要使用原生的滚动条,而是使用 `.scroll` 代替滚动,用 `mousewheel` 监听滚动的触发: 这样做带来什么变化呢? 1. 轴、单元格区域都使用 `.scroll` 触发滚动,使得轴和单元格不会出现错位,因为轴和单元格都是用 `.scroll` 触发的滚动。 2. 任何位置都能监听滚动,使得轴上也能滚动了,我们不再依赖 `overflow` 属性。 3. 快速滚动时惊喜的发现不会白屏了,原因是用 `js` 控制触发的滚动发生在渲染完成之后,所以浏览器会在滚动发生前现完成渲染,这相当有趣。 模拟滚动时,实际上整个表格都是 `overflow: hidden` 的,浏览器就不会给出自带滚动条了,我们需要用 DIV 做出虚拟滚动条代替,这个相对容易。 ### 零 buffer 区域 当我们采用模拟滚动方案时,相当于采用了在滚动时 “高频渲染” 的方案,因此不需要使用截留,更不要使用 Buffer 区域,因为更大的 Buffer 区域意味着更大的渲染开销。 当我们把 Buffer 区域移除时,发现整个屏幕内渲染单元格在 1000 个以内时,现代浏览器甚至配合 Windows 都能快速完成滚动前刷新,并不会影响滚动的流畅性。 当然,滚动过快依然不是一件好事,既然滚动是由我们控制的,可以稍许控制下滚动速度,控制在每次触发 `mousewheel` 位移不超过 200 左右最佳。 ### 预计算 像单元格合并、行列隐藏、单元格格式化等计算逻辑,最好在滚动前提前算掉,否则在快速滚动时实时计算必然会带来额外的计算成本损耗。 但是这种预计算也有弊端,当单元格数量超过 10w 时,计算耗时一般会超过 1 秒,单元格数量超过 100w 时,计算耗时一般会超过 10 秒,用预计算的牺牲换来滚动的流畅,还是有些遗憾,我们可以再思考以下,能否降低预计算的损耗? ### 局部预计算 局部预计算就是一种解决方案,即便单元格数量有一千万个,但我们如果仅计算前 1w 个单元格呢?那无论数据量有多大,都不会出现丝毫卡顿。 但局部预计算有着明显缺点,即表格渲染过程中,局部计算结果并不总等价于全局计算结果,典型的有列宽、行高、跨行跨列的计算字段。 我们需要针对性解决,对于单元格宽高计算,必须采用局部计算,因为全量计算的损耗非常大。但局部计算肯定是不准确的,如下图所示: 但出于性能考虑,我们初始化可能仅能计算前三行的高度,此时,我们需要在滚动时做两件事情: 1. 在快速滚动的时候,向 web worker 发送预计要滚动到的位置,增量计算这些位置文字宽度,并实时修正列总宽。(因为列总宽算完只要存储最大值,所以已计算的数量级会被压缩为 O(1))。 2. 宽度计算完毕后,快速刷新当前屏幕单元格宽度,但在宽度校准的同时,维持可视区域内左对齐不变,如下图所示: 这样滚动过程中虽然单元格会被突然撑开,但位置并不会产生相对移动,与提前全量撑开后视觉内容相同,因此用户体验并不会有实际影响,但计算时间却由 O(row * column) 下降到 O(1),只要计算一个常数量级的单元格数目。 计算字段也是同理,可以在滚动时按片预计算,但要注意仅能在计算涉及局部单元格的情况下进行,如果这个计算是全局性质的,比如排名,那么局部排序的排名肯定是错误的,我们必须进行全量计算。 好在,即便是全量计算,我们也只需要考虑一部分数据,假设行列数量都是 n,可以将计算复杂度由 O(n²) 降低为 O(n): 这种计算字段的处理无法保证支持无限数量级的数据,但可以大大降低计算时间,假设 1000w 单元格计算时间开销是 60s,这是一个几乎不能忍受的时间,假设 1000w 单元格是 1w 行 * 1k 列形成的,我们局部计算的开销是 1w 行(100ms) + 1k 列(10ms) = 0.1s,对用户来说几乎感受不到 1000w 单元格的卡顿。 在 10w 行 * 10w 列的情况下,等待时间是 1+1 = 2s,用户会感受到明显卡顿,但总单元格数量可是惊人的 100 亿,光数据可能就几 TB 了,不可能出现这种规模的聚合数据。 ### Map Reduce 前端计算还可以采用多个 web worker 加速,总之不要让用户电脑的 CPU 闲置。我们可以通过 `window.navigator.hardwareConcurrency` 获取硬件并行能支持的最大 web worker 数量,我们就实例化等量的 web worker 并行计算。 拿刚才排名的例子来说,同样 1000w 单元格数量,如果只有一列呢?那行数就是扎扎实实的 1000w,这种情况下,即便 O(n) 复杂度计算耗时也可能突破 60s,此时我们就可以分段计算。我的电脑 `hardwareConcurrency` 值为 8,那么就实例化 8 个 web worker,分别并行计算第 `0 ~ 125w`, `125w ~ 250w` ..., `875w ~ 1000w` 段的数据分别进行排序,最后得到 8 段有序序列,在主 worker 线程中进行合并。 我们可以采用分治合并,即针对依次收到的排序结果 x1, x2, x3, x4...,将收到的结果两两合并成 x12, x34, ...,再次合并为 x1234 直到合并为一个数组为止。 当然,Map Reduce 并不能解决所有问题,假设 1000w 数据计算耗时 60s,我们分为 8 段并行,每一段平均耗时 7.5s,那么第一轮排序总耗时为 7.5s。分治合并时间复杂度为 O(kn logk),其中 k 是分段数,这里是 8 段,logk 约等于 3,每段长度 125w 是 n,那么一个 125w 数量级的二分排序耗时大概是 4.5s,时间复杂度是 O(n logn),所以等价为 logn = 4.5s, k x logk 等于几?这里由于 k 远小于 n,所以时间消耗会远小于 4.5s,加起来耗时不会超过 10s。 ## 总结 如果你想打造高性能表格,DIV 性能足够了,只要注意实现的时候稍加技巧即可。你可以用 DIV 实现一个兼顾性能、拓展性的表格,是时候重新相信 DOM 了! 笔者建议读完本文的你,按照这样的思路做一个小 Demo,同时思考,这样的表格有哪些通用功能可以抽象?如何设计 API 才能成为各类业务表格的基座?如何设计功能才能满足业务层表格繁多的拓展诉求? > 讨论地址是:[精读《高性能表格》· Issue #309 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/309) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/192.精读《DOM diff 最长上升子序列》.md ================================================ 在 [精读《DOM diff 原理》](https://github.com/ascoders/weekly/blob/v2/190.%E7%B2%BE%E8%AF%BB%E3%80%8ADOM%20diff%20%E5%8E%9F%E7%90%86%E8%AF%A6%E8%A7%A3%E3%80%8B.md) 一文中,我们提到了 Vue 使用了一种贪心 + 二分的算法求出最长上升子序列,但并没有深究这个算法的原理,因此特别开辟一章详细说明。 另外,最长上升子序列作为一道算法题,是非常经典的,同时在工业界具有实用性,且有一定难度的,因此希望大家务必掌握。 ## 精读 什么是最长上升子序列?就是求一个数组中,最长连续上升的部分,如下图所示: 如果序列本身就是上升的,那就直接返回其本身;如果序列没有任何一段是上升的,则返回任何一个数字都可以。图中可以看到,虽然 `3, 7, 22` 也是上升的,但因为 `22` 之后接不下去了,所以其长度是有 3,与 `3, 7, 8, 9, 11, 12` 比起来,肯定不是最长的,因此找起来并不太容易。 在具体 DOM diff 场景中,为了保证尽可能移动较少的 DOM,我们需要 **保持最长上升子序** 不动,只移动其他元素。为什么呢?因为最长上升子序列本身就相对有序,只要其他元素移动完了,答案也就出来了。还是这个例子,假设原本的 DOM 就是这样一个递增顺序(当然应该是 1 2 3 4 连续的下标,不过对算法来说是否连续间隔不影响,只要递增即可): 如果保持最长上升子序不变,只需要移动三次即可还原: 其他任何移动方式都不会小于三步,**因为我们已经最大程度保持已经有序的部分不动了**。 那么问题是,如何将这个最长上升子序列找出来?比较容易想到的解法分别有:暴力、动态规划。 ### 暴力解法 > 时间复杂度: O(2ⁿ) 我们最终要生成一个最长子序列长度,那么就来模拟生成这个子序列的过程吧,只不过这个过程是暴力的。 暴力模拟生成一个子序列怎么做呢?就是从 [0,n] 范围内每次都尝试选或不选当前数,前提是后选的数字要比前面的大。由于数组长度为 n,每个数字都可以选或不选,也就是每个数字有两种选择,所以最多会生成 2ⁿ 个结果,从里面找到最长的长度,即为答案: 这么傻试下去,必然能试出最长的那一段,在遍历过程中记录最长的那一段即可。 由于这个方法效率太低了,所以并不推荐,但这种暴力思维还是要掌握的。 ### 动态规划 > 时间复杂度: O(n²) 如果用动态规划思路考虑此问题,那么 DP(i) 的定义按照经验为:以第 i 个字符串结尾时,最长子序列长度。 这里有个经验,就是动规一般 DP 返回值就是答案,字符串问题常常是以第 i 个字符串结尾,这样扫描一遍即可。而且最长子序列是有重复子问题的,即第 i 个的答案运算中,包括了前面一些的计算,为了不重复计算,才使用动态规划。 那么就看第 i 项的结果和前面哪些结果有关系了,为了方便理解如图所示: 假设我们看 8 这个数字,也就是 DP(4) 是多少。由于此时前面的 DP(0), DP(1) ... DP(3) 都已经算出来了,我们看看 DP(4) 和前面的计算结果有什么关系。 简单观察可以发现,如果 `nums[i] > nums[i-1]`,那么 DP(i) 就等于 `DP(i-1) + 1`,这个是显而易见的,即如果 8 比 4 大,那么 8 这个位置的答案,就是 4 这个位置的答案长度 + 1,如果 8 这个位置数值是 3,小于 4,那么答案就是 1,因为前面的不满足上升关系,只能用 3 这个数字孤军奋战啦。 但仔细想想会发现,这个子序列不一定非要是连续的,万一第 i 项和第 i-2, i-3 项组合一下,也许会比与第 i-1 项组合起来更长哦?我们可以举个反例: 很显然,`1, 2, 3, 4` 组合起来是最长的上升子序列,如果你只看 `5, 4`,那么得出的答案只能是 `4`。 正是由于不连续这个特点,我们对于第 i 项,需要和第 j 项依次对比,其中 `j=[0,i-1]`,只有和所有前项都比一遍,我们才放心,第 i 项找到的结果确实是最长的: 那么时间复杂度怎么算呢?动态规划解法中,我们首先从 0 循环到 n,然后对于其中每个 i,都做了一遍 `[0,i-1]` 的额外循环,所以计算次数是 `1 + 2 + ... + n = n * (n + 1) / 2`,剔除常数后,数量级是 O(n²)。 ### 贪心 + 二分 > 时间复杂度: O(nlogn) 说实话,一般能想到动态规划解法就很不错了,再进一步优化时间复杂度就非常难想了。如果你没做过这道题,并且想挑战一下,读到这里就可以停止了。 好,公布答案了,说实话这个方法不像正常人类思维想出来的,具有很大的思维跳跃性,因此我也无法给出思维推导过程,直接说结论吧:贪心 + 二分法。 如果非要说是怎么想的,我们可以从时间复杂度上事后诸葛亮一下,一般 n² 时间复杂度再优化就会变成 nlogn,而一次二分查找的时间复杂度是 logn,所以就拼命想办法结合吧。 具体方案就一句话:**用栈结构,如果值比栈内所有值都大则入栈,否则替换比它大的最小数,最后栈的长度就是答案**: 先解释下时间复杂度,因为操作原因,栈内存储的数字都是升序的,因此可以采用二分法比较与插入,复杂度为 logn,外层 n 循环,所以整体时间复杂度为 O(nlogn)。另外这个方案的问题是,答案的长度是准确的,但栈内数组可能是错误的。如果要完全理解这句话,就得完全理解这个算法的原理,理解了原理才知道如何改进以得到正确的子序列。 接着要解释原理了,**开始的思考并不复杂,可以边喝茶边看**。首先我们要有个直观的认识,就是为了让最长上升子序列尽可能的长,我们就要尽可能保证挑选的数字增速尽可能的慢,反之就尽可能的快。比如如果我们挑选的数字是 `0, 1, 2, 3, 4` 那么这种贪心就贪的比较稳,因为已经尽可能增长缓慢了,后面遇到的大概率可以放进来。但如果我们挑选的是 `0, 1, 100` 那挑到 `100` 的时候就该慌了,因为一下增加到 `100`,后面 `100` 以内的数字不就都放弃了吗?这个时候要 `100` 不见得是明智的选择,丢掉反而可能未来空间更大,这其实就是贪心的思考,所谓局部最优解就是全局最优解。 但上面的思路显然不完整,我们继续想,如果读到 `0, 1, 100` 的时候,万一后面没有数字了,那么 `100` 还是可以放进来的嘛,虽然 `100` 很大,但毕竟是最后一个,还是有用的。**所以从左到右遍历的时候,遇到更大的数字优先要放进来**,重点在于,如果继续往后读取,读到了比 `100` 还小的数字,怎么办? 到这里如果无法做出思维的跳跃,分析就只能止步于此了。你可能觉得还能继续分析,比如遇到 `5` 的时候,显然要把 `100` 挤掉啊,因为 `0, 1, 5` 和 `0, 1, 100` 长度都是 3,但 `0, 1, 5` 的 “潜力” 明显比 `0, 1, 100` 大,所以长度不变,一个潜力更大,肯定要替换!这个思路是对的,但换一个场景,如果遇到的是 `3, 7, 11, 15`, 此时你遇到了 `9`,怎么换?如果出于潜力考虑,`3, 7, 9` 的潜力最好,但长度从 4 牺牲到了 3,你也搞不清楚后面是不是就没有比 `9` 大的了,如果没有了,这个长度反而没有原来 4 来的更优;如果出于长度考虑,留着 `3, 7, 11, 15`,那万一后面连续来几个 `10, 12, 13, 14` 也傻眼了,有点鼠目寸光的感觉。 所以问题就是,遇到下一个数字要怎么处理,才不至于在未来产生鼠目寸光的情况,要 “抓住稳稳的幸福”。**这里开始出现跳跃性思维了**,答案就是上面方案里提到的 “如果值比栈内所有值都大则入栈,否则替换比它大的最小数”。这里体现出跳跃思维,实现现在和未来两手抓的核心就是:**牺牲栈内容的正确性,保证总长度正确的情况下,每一步都能抓住未来最好的机遇。** 只有总长度正确了,才能保证得到最长的序列,至于牺牲栈内容的正确性,确实付出了不小的代价,但换来了未来的可能性,至少长度上可以得到正确结果,如果内容也要正确的话,可以加一些辅助手段解决,这个后面再说。所以总的来说,这个牺牲非常值得,下面通过图来介绍,为什么牺牲栈内容正确性可以带来长度的正确以及抓住未来机遇。 我们举一个极端的例子:`3, 7, 11, 15, 9, 11, 12`,如果固守一开始找到的 `3, 7, 11, 15`,那长度只有 4,但如果放弃 `11, 15`,把 `3, 7, 9, 11, 12` 连起来,长度更优。按照贪心算法,我们首先会依次遇到 `3` `7` `11` `15`,由于每个数字都比之前的大,所以没什么好思考的,直接塞到栈里: **遇到 `9` 的时候精彩了**,此时 `9` 不是最大的,我们为了抓住稳稳的幸福,干脆把比 `9` 稍大一点的 `11` 替换了,这样会产生什么结果? 首先数组长度没变,因为替换操作不会改变数组长度,此时如果 `9` 后面没有值了,我们也不亏,此时输出的长度 4 依然是最优的答案。我们继续,下一步遇到 `11`,我们还是把比它稍大的 `15` 替换掉: 此时我们替换了最后一个数字,发现 `3, 7, 9, 11` 终于是个合理的顺序了,而且长度和 `3, 7, 11, 15` 一样,**但是更有潜力**,接下来 `12` 就理所应当的放到最后,拿到了最终答案:5。 到这里其实并没有说清楚这个算法的精髓,我们还是回到 `3, 7, 9, 15` 这一步,搞清楚 `9` 为什么可以替换掉 `11`。 假设 `9` 后面是一个很大的 `99`,那么下一步 `99` 会直接追加到后面: 此时我们拿到的是 `3, 7, 9, 15, 99`,但是你仔细看会发现,原序列里 `9` 在 `15` 后面的,因为我们的插入导致 `9` 放到 `15` 前面了,所以这显然不是正确答案,但长度却是正确的,因为这个答案就相当于我们选择了 `3, 7, 11, 15, 99`!为什么可以这么理解呢?因为 **只要没有替换到最后一个数,我们心里的那个队列其实还是原始队列。** **即,只要栈没有被替换完,新插入的值永远只起到一个占位作用,目的是为了让新来的值好插入,但如果真的没有新来的值可插入了,那虽然栈内容不对,但至少长度是对的,因为 `9` 在没替换完的时候其实不是 `9`,它只是一个占位,背后的值还是 `11`**。所以不管怎么换,只要没替换掉最后一个,这个替换操作都是无效的,我们再拿一个例子来看: 可见,`1, 2, 3, 4` 不能把 `7, 8, 9, 10, 11` 都替换完,因此最后结果是 `1, 2, 3, 4, 11`,但这没关系,只要没替换完,答案就是 `7, 8, 9, 10, 11`,只是我们没有记录下来罢了,但仅看长度的话,这两个没有任何区别啊,所以是没问题的。那如果 `1, 2, 3, 4, 5, 6` 呢?我们看看能替换完是什么情况: 可见,当替换到 `5` 的时候,这个序列顺序就正确了,因为 `1, 2, 3, 4, 5` 已经完全能代替 `7, 8, 9, 10, 11` 了,而且潜力比它大,我们找到了最优局部解。所以 `1, 2, 3, 4, 11` 这里的 `1, 2, 3, 4` 就像卧底一样,在 `11` 还在的时候,还忍气吞声的称 `7, 8, 9, 10, 11` 为老大(其实是 `1` 称 `7` 为老大,`2` 称 `8` 为老大,依此类推),但当 `5` 进来的时候,`1, 2, 3, 4, 5` 就可以和 `7, 8, 9, 10, 11` 翻脸了,因为它的实力已经超出原来老大实力了。 那我们前面看似无关紧要的替换,其实就为了不断寻找未来可能的最优解,直到有出头之日那一天,如果没有出头之日,做一个小弟也挺好,长度还是对的;如果有出头之日,那最大长度就更新了,所以这种贪心可以同时兼顾正确性与效率。 最后我们看看,如何在找到答案的同时,还能找到正确的序列呢? ### 找出正确的序列 找出正确的序列并不容易,让我们看下面这个情况: 贪心算法结束后,总长度是对的,但很明显顺序还是错的。为了方便计算,我们存储时转化为下标: 并且使用二维数组存储,这样被替换的数字可以被保留下来。当计算完毕后,我们从最后一位开始向前查找,**一旦发现一个值不是单调递减的,就向数组上方继续查找,直到首节点。** 因此上面的例子,最终顺序下标是 `[0, 1, 2, 3, 4, 5, 9]`,对应数字为 `[10, 20, 30, 40, 50, 60, 61]`,**而且这个数字是潜力最大的最长子序列。** ## 总结 那么 Vue 最终采用贪心计算最长上升子序列,付出了多少代价呢?其实就是 O(n) 与 O(nlogn) 的关系,我们看图: 可以看到,O(nlogn) 时间复杂度增长趋势勉强可以接受,特别是在工程场景中,一个父节点的子节点个数不可能太多的情况下,不会占用太多分析的时间,带来的好处就是最少的 DOM 移动次数。是比较完美的算法与工程结合的实践。 > 讨论地址是:[精读《DOM diff 最长上升子序列》· Issue #310 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/310) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/193.精读《React Server Component》.md ================================================ 截止目前,React Server Component 还在开发与研究中,因此不适合投入生产环境使用。但其概念非常有趣,值得技术人学习。 目前除了国内各种博客、知乎解读外,最一手的学习资料有下面两处: 1. [Dan 的 Server Component 介绍视频](https://reactjs.org/blog/2020/12/21/data-fetching-with-react-server-components.html) 2. [Server Component RFC 草案](https://github.com/josephsavona/rfcs/blob/server-components/text/0000-server-components.md) 我会结合这些一手资料,与一些业界大牛的解读,系统的讲清楚 React Server Component 的概念,以及我对它的一些理解。 首先我们来看,为什么需要提出 Server Component 这个概念: Server Component 概念的提出,是为了解决 "用户体验、可维护性、性能" 这个不可能三角,所谓不可能三角就是,最多同时满足两条,而无法三条都同时满足。 简单解释一下,用户体验体现在页面更快的响应、可维护性体现在代码应该高内聚低耦合、性能体现在请求速度。 - 保障 **用户体验、可维护性**,用一个请求拉取全部数据,所有组件一次性渲染。但当模块不断增多,无用模块信息不敢随意删除,请求会越来越大,越来越冗余,导致瓶颈卡在取数这块,也就是 **性能不好**。 - 保障 **用户体验、性能**,考虑并行取数,之后流程不变,那么以后业务逻辑新增或减少一个模块,我们就要同时修改并行取数公共逻辑与对应业务模块,**可维护性不好**。 - 保障 **可维护性、性能**,可以每个模块独立取数,但在父级渲染完才渲染子元素的情况下,父子取数就变成了串行,页面加载被阻塞,**用户体验不好**。 一言蔽之,在前后端解耦的模式下,唯一连接的桥梁就是取数请求。要把用户体验做好,取数就要提前并行发起,而前端模块是独立维护的,所以在前端做取数聚合这件事,必然会破坏前端可维护性,而这并行这件事放在后端的话,会因为后端不能解析前端模块,导致给出的聚合信息滞后,久而久之变得冗余。 要解决这个问题,就必须加深前端与后端的联系,所以像 GraphQL 这种前后端约定方案是可行的,但因为其部署成本高,收益又仅在前端,所以难以在后端推广。 Server Component 是另一种方案,通过启动一个 Node 服务辅助前端,但做的不是 API 对接,而是运行前端同构 js 代码,直接解析前端渲染模块,从中自动提取请求并在 Node 端直接与服务器通信,因为服务端间通信成本极低、前端代码又不需要做调整,请求数据也是动态按需聚合的,因此同时解决了 "用户体验、可维护性、性能" 这三个问题。 其核心改进点如下图所示: 如上图所示,这是前后端正常交互模式,可以看到,`Root` 与 `Child` 串行发了两个请求,因为网络耗时与串行都是严重阻塞部分,因此用红线标记。 Server Component 可以理解为下图,不仅减少了一次网络损耗,请求也变成了并行,请求返回结果也从纯数据变成了一个同时描述 UI DSL 与数据的特殊结构: 到此,恭喜你已经理解了 Server Component 核心概念,如果你只想泛泛了解一下,读到这里就可以结束了。如果你还想深入了解其实现细节,请继续阅读。 ## 概述 概括的说,Server Component 就是让组件拥有在服务端渲染的能力,从而解决不可能三角问题。也正因为这个特性,使得 Server Component 拥有几种让人眼前一亮的特性,都是纯客户端组件所不具备的: - **运行在服务端的组件只会返回 DSL 信息,而不包含其他任何依赖**,因此 Server Component 的所有依赖 npm 包都不会被打包到客户端。 - **可以访问服务端任何 API**,也就是让组件拥有了 Nodejs 能拥有的能力,你理论上可以在前端组件里干任何服务端才能干的事情。 - **Server Component 与 Client Component 无缝集成**,可以通过 Server Component 无缝调用 Client Component。 - **Server Component 会按需返回信息**,在当前逻辑下,走不到的分支逻辑的所有引用都不会被客户端引入。比如 Server Component 虽然引用了一个巨大的 npm 包,但某个分支下没有用到这个包提供的函数,那客户端也不会下载这个巨大的 npm 包到本地。 - **由于返回的不是 HTML,而是一个 DSL,所以服务端组件即便重新拉取,已经产生的 State 也会被维持住**。比如说 A 是 ServerComponent,其子元素 B 是 Client Component,此时对 B 组件做了状态修改比如输入一些文字,此时触发 A 重新拉取 DSL 后,B 已经输入的文字还会保留。 - **可以无缝与 Suspense 结合**,并不会因为网络原因导致连 Suspense 的 loading 都不能及时展示。 - **共享组件可以同时在服务端与客户端运行**。 ### 三种组件 Server Component 将组件分为三种:Server Component、Client Component、Shared Component,分别以 `.server.js`、`.client.js`、`.js` 后缀结尾。 其中 `.client.js` 与普通组件一样,但 `.server.js` 与 `.js` 都可能在服务端运行,其中: - `.server.js` 必然在服务端执行。 - `.js` 在哪执行要看谁调用它,如果是 `.server.js` 调用则在服务端执行,如果是 `.client.js` 调用则在客户端执行,因此其本质还要接收服务端组件的约束。 下面是 RFC 中展示的 Server Component 例子: ```typescript // Note.server.js - Server Component import db from 'db.server'; // (A1) We import from NoteEditor.client.js - a Client Component. import NoteEditor from 'NoteEditor.client'; function Note(props) { const {id, isEditing} = props; // (B) Can directly access server data sources during render, e.g. databases const note = db.posts.get(id); return (

{note.title}

{note.body}
{/* (A2) Dynamically render the editor only if necessary */} {isEditing ? : null }
); } ``` 可以看到,**这就是 Node 与 React 混合语法**。服务端组件有着苛刻的限制条件:**不能有状态,且 `props` 必须能被序列化**。 很容易理解,因为服务端组件要被传输到客户端,就必须经过序列化、反序列化的过程,JSX 是可以被序列化的,props 也必须遵循这个规则。另外服务端不能帮客户端存储状态,因此服务端组件不能用任何 `useState` 等状态相关 API。 但这两个问题都可以绕过去,即将状态转化为组件的 `props` 入参,由 `.client.js` 存储,见下图: 或者利用 Server Component 与 Client Component 无缝集成的能力,将状态与无法序列化的 `props` 参数都放在 Client Component,由 Server Component 调用。 ### 优点 #### 零客户端体积 这句话听起来有点夸张,但其实在 Server Component 限定条件下还真的是。看下面代码: ```typescript // NoteWithMarkdown.js import marked from 'marked'; // 35.9K (11.2K gzipped) import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped) function NoteWithMarkdown({text}) { const html = sanitizeHtml(marked(text)); return (/* render */); } ``` `marked` 与 `sanitize-html` 都不会被下载到本地,所以如果只有这一个文件传输,客户端的理论增加体积就是 `render` 函数序列化后字符串大小,可能不到 1KB。 当然这背后也是限制换来的,首先这个组件没有状态,无法在客户端实时执行,而且在服务端运行也可能消耗额外计算资源,如果某些 npm 包计算复杂度较高的话。 这个好处可以理解为,`marked` 这个包仅在服务端读取到内存一次,以后只要后客户端想用,只需要在服务端执行 `marked` API 并把输出结果返回给客户端,而不需要客户端下载 `marked` 这个包了。 #### 拥有完整服务端能力 由于 Server Component 在服务端执行,因此可以执行 Nodejs 的任何代码。 ```typescript // Note.server.js - Server Component import fs from 'react-fs'; function Note({id}) { const note = JSON.parse(fs.readFile(`${id}.json`)); return ; } ``` 我们可以把对请求的理解拔高一个层次,即 `request` 只是客户端发起的一个 Http 请求,其本质是访问一个资源,在服务端就是个 IO 行为。对于 IO,我们还可以通过 `file` 文件系统写入删除资源、`db` 通过 sql 语法直接访问数据库,或者 `request` 直接在服务器本地发出请求。 #### 运行时 Code Split 我们都知道 webpack 可以通过静态分析,将没有使用到的 import 移出打包,而 Server Component 可以在运行时动态分析,将当前分支逻辑下没有用到的 import 移出打包: ```typescript // PhotoRenderer.js import React from 'react'; // one of these will start loading *once rendered and streamed to the client*: import OldPhotoRenderer from './OldPhotoRenderer.client.js'; import NewPhotoRenderer from './NewPhotoRenderer.client.js'; function Photo(props) { // Switch on feature flags, logged in/out, type of content, etc: if (props.useNewPhotoRenderer) { return ; } else { return ; } } ``` 这是因为 Server Component 构建时会进行预打包,运行时就是一个动态的包分发器,完全可以通过当前运行状态比如 `props.xxx` 来区分当前运行到哪些分支逻辑,而没有运行到哪些分支逻辑,并且仅告诉客户端拉取当前运行到的分支逻辑的缺失包。 纯前端模式与之类似的写法是: ```typescript const OldPhotoRenderer = React.lazy(() => import('./OldPhotoRenderer.js')); const NewPhotoRenderer = React.lazy(() => import('./NewPhotoRenderer.js')); ``` 只是这种写法不够原生,且实际场景往往只有前端框架把路由自动包一层 Lazy Load,而普通代码里很少出现这种写法。 #### 无客户端往返的数据端取数 一般考虑到取数网络消耗,我们往往会将其处理成异步,然后在数据返回前展示 Loading: ```typescript // Note.js function Note(props) { const [note, setNote] = useState(null); useEffect(() => { // NOTE: loads *after* rendering, triggering waterfalls in children fetchNote(props.id).then(noteData => { setNote(noteData); }); }, [props.id]); if (note == null) { return "Loading"; } else { return (/* render note here... */); } } ``` 这是因为单页模式下,我们可以快速从 CDN 拿到这个 DOM 结构,但如果再等待取数,整体渲染就变慢了。而 Server Component 因为本身就在服务端执行,因此可以将拿 DOM 结构与取数同时进行: ```typescript // Note.server.js - Server Component function Note(props) { // NOTE: loads *during* render, w low-latency data access on the server const note = db.notes.get(props.id); if (note == null) { // handle missing note } return (/* render note here... */); } ``` 当然这个前提是网络消耗敏感的情况,如果本身就是一个慢 SQL 查询,耗时几秒的情况下,这样做反而适得其反。 #### 减少 Component 层次 看下面的例子: ```js // Note.server.js // ...imports... function Note({id}) { const note = db.notes.get(id); return ; } // NoteWithMarkdown.server.js // ...imports... function NoteWithMarkdown({note}) { const html = sanitizeHtml(marked(note.text)); return
; } // client sees:
``` 虽然在组件层面抽象了 `Note` 与 `NoteWithMarkdown` 两个组件,但由于真正 DOM 内容实体只有一个简单的 `div`,所以在 Server Component 模式下,返回内容就会简化为这个 `div`,而无需包含那两个抽象的组件。 ### 限制 Server Component 模式下有三种组件,分别是 Server Component、Client Component、Shared Component,其各自都有一些使用限制,如下: **Server Component**: - ❌ 不能用 `useState`、`useReducer` 等状态存储 API。 - ❌ 不能用 `useEffect` 等生命周期 API。 - ❌ 不能用 `window` 等仅浏览器支持的 API。 - ❌ 不能用包含了上面情况的自定义 Hooks。 - ✅ 可无缝访问服务端数据、API。 - ✅ 可渲染其他 Server/Client Component **Client Component**: - ❌ 不能引用 Server Component。 - ✅ 但可以在 Server Component 中出现 Client Component 调用 Server Component 的情况,比如 ``。 - ❌ 不能调用服务端 API 获取数据。 - ✅ 可以用一切 React 与浏览器完整能力。 **Shared Component**: - ❌ 不能用 `useState`、`useReducer` 等状态存储 API。 - ❌ 不能用 `useEffect` 等生命周期 API。 - ❌ 不能用 `window` 等仅浏览器支持的 API。 - ❌ 不能用包含了上面情况的自定义 Hooks。 - ❌ 不能引用 Server Component。 - ❌ 不能调用服务端 API 获取数据。 - ✅ 可以同时在服务器与客户端使用。 其实不难理解,因为 Shared Component 同时在服务器与客户端使用,因此兼具它们的劣势,带来的好处就是更强的复用性。 ## 精读 要快速理解 Server Component,我觉得最好也是最快的方式,就是找到其与十年前 PHP + HTML 的区别。看下面代码: ```php $link = mysqli_connect('localhost', 'root', 'root'); mysql_select_db('test', $link); $result = mysql_query('select * from table'); while($row=mysql_fetch_assoc($result)){ echo "".$row["id"].""; } ``` 其实 PHP 早就是一套 "Server Component" 方案了,在服务端直接访问 DB、并返回给客户端 DOM 片段。 React Server Component 在折腾了这么久后,可以发现,最大的区别是将返回的 HTML 片段改为了 DSL 结构,这其实是浏览器端有一个强大的 React 框架在背后撑腰的结果。而这个带来的好处除了可以让我们在服务端能继续写 React 语法,而不用退化到 "PHP 语法" 以外,更重要的是组件状态得以维持。 另一个重要不同是,PHP 无法解析现在前端生态下任何 npm 包,所以无从解析模块化的前端代码,所以虽然直觉上感觉 PHP 效率与 Server Component 并无区别,但背后的成本是得写另一套不依赖任何 npm 包、JSX 的语法来返回 HTML 片段,Server Component 大部分特性都无法享受到,而且代码也无法复用。 所以,本质上还是 HTML 太简单了,无法适应如今前端的复杂度,而普通后端框架虽然后端能力强大,但在前端能力上还停留在 20 年前(直接返回 DOM),唯有 Node 中间层方案作为桥梁,才能较好的衔接现代后端代码与现代前端代码。 ### PHP VS Server Component 其实在 PHP 时代,前后端都可以做模块化。后端模块化显而易见,因为可以将后端代码模块化的开发,最后打包至服务器运行。前端也可以在服务端模块化开发,只要我们将前后端代码剥离出来即可,下图青色是后端部分,红色是前端部分: 但这有个问题,因为后端服务对浏览器来说是无状态的,所以后端模块化本身就符合其功能特征,但前端页面显示在用户浏览器,每次都通过路由跳转到新页面,显然不能最大程度发挥客户端持续运行的优势,我们希望在保持前端模块化的基础上,在浏览器端有一个持续运行的框架优化用户体验,因此 Server Component 其实做了下面的事情: 这样做有两大好处: 1. 兼顾了 PHP 模式下优势,即前后端代码无缝混合,带来一系列体验和能力增强。 2. 前后端还是各自模块化编写,图中红色部分是随前端项目整体打包的,因此开发还是保留了模块化特点,且在浏览器上还保持了 React 现代框架运行,无论是单页还是数据驱动等特性都能继续使用。 ## 总结 Server Component 还没有成熟,但其理念还是很靠谱的。 想要同时实现 "用户体验、可维护性、性能",重后端,或者重前端的方案都不可行,只有在前后端取得一种平衡才能达到。Server Component 表达了一种职业发展理念,即未来前后端还是会走向全栈,这种全栈是前后端同时做深,从而让程序开发达到纯前端或纯后端无法达到的高度。 2021 年国内开发环境依然比较落后,所谓全栈,往往指的是 “前后端都懂一点”,各端都做不深,难以孵化出 Server Component 这种概念。当然,这也是我们继续向世界学习的动力。 也许 PHP 与 Server Component 的区别,就是检验一个人是真全栈还是伪全栈的试金石,快去问问你的同事吧! > 讨论地址是:[精读《React Server Component》· Issue #311 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/311) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/194.精读《算法基础数据结构》.md ================================================ 掌握了不同数据结构的特点,可以让你在面对不同问题时,采用合适的数据结构处理,达到事半功倍的效果。 所以这次我们详细介绍各类数据结构的特点,希望你可以融会贯通。 ## 精读 ### 数组 数组非常常用,它是一块连续的内存空间,因此可以根据下标直接访问,其查找效率为 O(1)。 但数组的插入、删除效率较低,只有 O(n),原因是为了保持数组的连续性,必须在插入或删除后对数组进行一些操作:比如插入第 K 个元素,需要将后面元素后移;而删除第 K 个元素,需要将后面元素前移。 ### 链表 链表是为了解决数组问题而发明出来的,它提升了插入、删除效率,而牺牲了查找效率。 链表的插入、删除效率是 O(1),因为只要将对应位置元素断链、重连就可以完成插入、删除,而无需关心其他节点。 相应的查找效率就低了,因为存储空间不是连续的,所以无法像数组一样通过下标直接查找,而需要通过指针不断搜索,所以查找效率为 O(n)。 顺带一提,链表可以通过增加 `.prev` 属性改造为双向链表,也可以通过定义两个 `.next` 形成二叉树(`.left` `.right`)或者多叉树(N 个 `.next`)。 ### 栈 栈是一种先入后出的结构,可以用数组模拟。 ```typescript const stack: number[] = [] // 入栈 stack.push(1) // 出栈 stack.pop() ``` ### 堆 堆是一种特殊的完全二叉树,分为大顶堆与小顶堆。 大顶堆指二叉树根节点是最大的数,小顶堆指二叉树根节点是最小的数。为了方便说明,以下以大顶堆举例,小顶堆的逻辑与之相反即可。 大顶堆中,任意节点都比其叶子结点大,所以根节点是最大的节点。这种数据结构的优势是可以以 O(1) 效率找到最大值(小顶堆找最小值),因为直接取 `stack[0]` 就是根节点。 这里稍微提一下二叉树与数组结构的映射,因为采用数组方式操作二叉数,无论操作还是空间都有优势:第一项存储的是节点总数,对于下标为 K 的节点,其父节点下标是 `floor(K / 2)`,其子节点下标分别是 `K * 2`、`K * 2 + 1`,所以可以快速定位父子位置。 而利用这个特性,可以将插入、删除的效率达到 `O(logn)`,因为可以通过上下移动的方式调整其他节点顺序,而对于一个拥有 n 个节点的完全二叉树,树的深度为 `logn`。 ### 哈希表 哈希表就是所谓的 Map,不同 Map 实现方式不同,常见的有 HashMap、TreeMap、HashSet、TreeSet。 其中 Map 和 Set 实现类似,所以以 Map 为例讲解。 首先将要存储的字符求出其 ASCII 码值,再根据比如余数等方法,定位到一个数组的下标,同一个下标可能对应多个值,因此这个下标可能对应一个链表,根据链表进一步查找,这种方法称为拉链法。 如果存储的值超过一定数量,链表的查询效率就会降低,可能会升级为红黑树存储,总之这样的增、删、查效率为 `O(1)`,但缺点是其内容是无序的。 为了保证内容有序,可以使用树状结构存储,这种数据结构称为 HashTree,这样时间复杂度退化为 `O(logn)`,但好处是内容可以是有序的。 ### 树 & 二叉搜索树 二叉搜索树是一种特殊二叉树,更复杂的还有红黑树,但这里就不深入了,只介绍二叉搜索树。 二叉搜索树满足对于任意节点,`left 的所有节点 < 根节点 < right 的所有节点`,注意这里是所有节点,因此在判断时需要递归考虑所有情况。 二叉搜索树的好处在于,访问、查找、插入、删除的时间复杂度均为 O(logn),因为无论何种操作都可以通过二分方式进行。但在最坏的情况会降级为 O(n),原因是多次操作后,二叉搜索树可能不再平衡,最后退化为一个链表,就变成了链表的时间复杂度。 更好的方案有 AVL 树、红黑树等,像 JAVA、C++ 标准库实现的二叉搜索树都是红黑树。 ### 字典树 字典树多用于单词搜索场景,只要给定一个单独开头,就可以快速查找到后面有几种推荐词。 比如上面的例子,输入 "o",就可以快速查找到后面有 "ok" 与 "ol" 两个单词。要注意的是,每个节点都要有一个属性 `isEndOfWord` 表示到当前为止是否为一个完整的单词:比如 `go` 与 `good` 两个都是完整的单词,但 `goo` 不是,因此第二个 `o` 与第四个 `d` 都有 `isEndOfWord` 标记,表示读到这里就查到一个完整的单词了,叶子结点的标记也可以省略。 ### 并查集 并查集用来解决团伙问题,或者岛屿问题,即判断多个元素之间是属于某个集合。并查集的英文是 Union and Find,即归并与查找,因此并查集数据结构可以写成一个类,提供两个最基础的方法 `union` 与 `find`。 其中 `union` 可以将任意两个元素放在一个集合,而 `find` 可以查找任意元素属于哪个根集合。 并查集使用数组的数据结构,只是有以下特殊含义,设下标为 k: - `nums[k]` 表示其所属的集合,如果 `nums[k] === k` 表示它是这个集合的根节点。 如果要数一共有几个集合,只要数有多少满足 `nums[k] === k` 条件的数目即可,就像数有几个团伙,只要数有几个老大即可。 并查集的实现不同,数据也会有微妙的不同,高效的并查集在插入时,会递归将元素的值尽量指向根老大,这样查找判断时计算的快一些,但即便指向的不是根老大,也可以通过递归的方式找到根老大。 ### 布隆过滤器 Bloom Filter 只是一个过滤器,可以用远远超过其他算法的速度把未命中的数据排除掉,但未排除的也可能实际不存在,所以需要进一步查询。 布隆过滤器是如何做到这一点的呢?就是通过二进制判断。 如上图所示,我们先存储了 a、b 两个数据,将其转化为二进制,将对应位置改为 1,那么当我们再查询 a 或 b 时,因为映射关系相同,所以查到的结果肯定存在。 但查询 c 时,发现有一项是 0,说明 c 一定不存在;但查询 d 时,恰好两个都查到是 1,但实际 d 是不存在的,这就是其产生误差的原因。 布隆过滤器在比特币与分布式系统中使用广泛,比如比特币查询交易是否在某个节点上,就先利用布隆过滤器挡一下,以快速跳过不必要的搜索,而分布式系统计算比如 Map Reduce,也通过布隆过滤器快速过滤掉不在某个节点的计算。 ## 总结 最后给出各数据结构 “访问、查询、插入、删除” 的平均、最差时间复杂度图: 这个图来自 [bigocheatsheet](https://www.bigocheatsheet.com/#graphs),你也可以点开链接直接访问。 学习了这些基础数据结构之后,希望你可以融会贯通,善于组合这些数据结构解决实际的问题,同时还要意识到没有任何一个数据结构是万能的,否则就不会有这么多数据结构需要学习了,只用一个万能的数据结构就行了。 对于数据结构的组合,我举两个例子: 第一个例子是如何以 O(1) 平均时间复杂度查询一个栈的最大或最小值。此时一个栈是不够的,需要另一个栈 B 辅助,遇到更大或更小值的时候才入栈 B,这样栈 B 的第一个数就是当前栈内最大或最小的值,查询效率是 O(1),而且只有在出栈时才需要更新,所以平均时间复杂度整体是 O(1)。 第二个例子是如何提升链表查找效率,可以通过哈希表与链表结合的思路,通过空间换时间的方式,用哈希表快速定位任意值在链表中的位置,就可以通过空间翻倍的牺牲换来插入、删除、查询时间复杂度均为 O(1)。虽然哈希表就能达到这个时间复杂度,但哈希表是无序的;虽然 HashTree 是有序的,但时间复杂度是 O(logn),所以只有通过组合 HashMap 与链表才能达到有序且时间复杂度更优,但牺牲了空间复杂度。 包括最后说的布隆过滤器也不是单独使用的,它只是一个防火墙,用极高的效率阻挡一些非法数据,但没有阻挡住的不一定就是合法的,需要进一步查询。 所以希望你能了解到各个数据结构的特征、局限以及组合的用法,相信你可以在实际场景中灵活使用不同的数据结构,以实现当前业务场景的最优解。 > 讨论地址是:[精读《算法基础数据结构》· Issue #312 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/312) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/195.精读《新一代前端构建工具对比》.md ================================================ 本周精读的文章是 [Comparing the New Generation of Build Tools](https://css-tricks.com/comparing-the-new-generation-of-build-tools/)。 前端工程领域近期出了不少新工具,这些新工具都运用了一些新技术或者跨领域技术,实现了一些突破,因此有必要了解一下这些工具都有什么特性,以及是否可以投入生产环境。 由于原文比较啰嗦,所以具体用法和支持细节不在这里展开,如果想进一步了解细节,可以直接阅读 [原文]((https://css-tricks.com/comparing-the-new-generation-of-build-tools/))。 ## 精读 按照从底层到上层的封装粒度,以 esbuild、snowpack、vite、wmr 的顺序介绍。 ### esbuild esbuild 使用 go 语言编写,由于相对 node 更为底层,且不提供 AST 操作能力,所以代码执行效率更高,根据其官方 benchmark 介绍提速有 10~100 倍: esbuild 有两大功能,分别是 bundler 与 minifier,其中 bundler 用于代码编译,类似 babel-loader、ts-loader;minifier 用于代码压缩,类似 terser。 使用 esbuild 编译代码方法如下: ```typescript esbuild.build({ entryPoints: ["src/app.jsx"], outdir: "dist", define: { "process.env.NODE_ENV": '"production"' }, watch: true, }); ``` 但由于 esbuild 无法操作 AST,所以一些需要操作 AST 的 babel 插件无法与之兼容,导致生产环境很少直接使用 esbuild 的 bundler 模块。 幸运的是 minifier 模块可以直接替换 terser 使用,可以用于生产环境: ```typescript esbuild.transform(code, { minify: true, }); ``` 由于 esbuild 牺牲了一些包大小换取了更高的执行效率,因此压缩后包体积会稍微大一些,不过也就是 177KB 与 165KB 的区别,几乎可以忽略。 esbuild 比较底层,所以可以与后续介绍的上层构建工具结合使用,当然根据工具设计理念,是否内置,内置到什么程度,以及是否允许通过插件替换就是另一回事了。 ### snowpack snowpack 是一个相对轻量的 bundless 方案,之前也写过一篇 [精读 snowpack](https://github.com/ascoders/weekly/blob/master/%E5%89%8D%E6%B2%BF%E6%8A%80%E6%9C%AF/153.%20%E7%B2%BE%E8%AF%BB%E3%80%8Asnowpack%E3%80%8B.md),其实 bundless 就是利用浏览器支持的 [ESM import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) 特性,利用浏览器进行模块间依赖加载,而不需要在编译时进行。 跳过编译时依赖加载可以省很多事,比如不用考虑 tree shaking 问题,也不用为了最终产物加速而使用缓存,相当于这些工作交给最终执行的浏览器了,而浏览器作为最终运行时容器,比编译时工具更了解应该如何按需加载。 仅从编译时来看,修改单个文件的编译速度与项目整体大小有关,而若不考虑整体项目,仅编译单个文件(最多递归一下有限的依赖模块,解决比如 TS 类型变量判断问题)时间复杂度一定是 O(1) 的。 实际上我们很少单独使用 snowpack,因为其编译使用的 esbuild 还未达到 1.0 稳定版本,在生态兼容与产物稳定性上存在风险,所以编译打包时往往采用 rollup 或 webpack,但这种割裂也导致了开发与生产环境不一致,这往往代表着更大的风险,因此在 vite 框架可以看到这块的取舍。 snowpack 是开箱即用的: ```json // package.json "scripts": { "start": "snowpack dev", "build": "snowpack build" }, ``` 我们还可以增加 `snowpack.config.js` 配置文件开启 `remote` 模式: ```js // snowpack.config.js module.exports = { packageOptions: { "source": "remote", } }; ``` `remote` 模式是 [Streaming Imports](https://www.snowpack.dev/guides/streaming-imports#how-streaming-imports-work),即不用安装对应的 npm 包到本地,snowpack 自动从 [skypack](https://www.skypack.dev/) 读取文件并缓存起来。 snowpack 看起来更多是对 bundless 纯粹的尝试,而不是一个适合满足日常开发的工具,因为日常开发需要一个一站式工具,这就是后面说的 vite 与 wmr。 ### vite 可以理解为结合了 snowpack 特色的一站式构建工具,从开发到发布全套流程都帮你搞定。 涉及的用法非常多,具体内容可以看 [官方文档](https://vitejs.dev/)。 与 snowpack 不同的是,snowpack 生产打包的产物是独立的文件,而 vite 没有采用 esbuild 而是 rollup 打包,目的是为了打包为一个整体,并规避 esbuild 不稳定的风险。 另外由于 vite 集成化更高,比 snowpack 多了许多功能,比如 css 拆分、多页、使用 esbuild 进行依赖预构建、monorepo 支持、对多框架支持、SSR 等等。具体可以看 [文档介绍](https://vitejs.dev/guide/comparisons.html#snowpack)。然而原文说这有利有弊,好处是开箱即用,弊端是缺乏定制的灵活性。 其实革命性突破主要是 bundless,在这基础上发展出一系列便捷的功能,这值得每一个工程化团队学习。其实就算决定再造一个轮子,也是维持 90% 功能不变的基础上,在默认的偏好设置做一些微调,而这些大多可以用 [插件](https://vitejs.dev/guide/api-plugin.html) 解决。 总结下来,Vite 是一个既积极拥抱新特性,又为生产环境考虑的工程化全家桶,相比之下,技术栈过于前沿的工具只能称为玩具,而 Vite 是真的可以用一用的。 ### wmr 由 preact 作者开发,可以理解为 preact 版的 vite。所以对于 preact 技术栈的开发者更加友好,集成度更高。 原文提到的另一个特色是,wmr 使用了 [htm](https://github.com/developit/htm) 转换 JSX,使其获得了更加精确的报错体验,即可以精确到源码行的同时指定到具体列。 综合功能和 vite 差不多,单页 + ssr 都支持,如果你平时使用 preact,或者想开发一个体积极小的项目,可以考虑用 wmr 全家桶。 ## 总结 新一代前端构建工具最大特色有两个:更底层的语言编写、bundless,如果用一个词描述就是高性能。积极拥抱浏览器新特性或者知识跨界都可以帮助前端领域取得新的突破。 另外构建工具已经变得越来越集成化,从仅用于编译的 esbuild,到支持开发的 snowpack,再到内置了最佳实践、甚至支持比如 ssr 等后端能力、最后到垂直场景的 [vitePress](https://github.com/vuejs/vitepress),每抽象一次,都更开箱即用,但带来的灵活性降低也成为各团队自己造轮子的理由,越上层越是有自己造轮子的冲动。 这和可视化领域很像,可视化从最底层的 svg、canvas、webgl 到基于其封装的命令式框架,再到数据驱动开发框架、完全 JSON 配置化的图表库、甚至到零配置,根据数据猜配置的智能化项目,也是配置越来越少,但灵活度越来越低,使用什么层次的完全看项目对细节的要求。 不过工程化相对还是标准化的,因为可视化面向的是用户,而工程化面向的是程序员,我们不能控制用户需求,但可以控制程序员的开发习惯 :P。 最后,除了升级你的构建工具外,换一台 M1 芯片电脑也可以极大提升开发效率,笔者亲测 webpack 构建速度提升 3 倍! > 讨论地址是:[精读《新一代前端构建工具对比》· Issue #316 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/316) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/196.精读《前端职业规划 - 2021 年》.md ================================================ 不知道你上次思考前端职业规划是什么时候? 如果你是一位学生,你肯定对前端这个职业感到陌生,你虽然没有经验,但却对未来充满好奇,你有大把时间来思考,但可能摸不着方向,有种拳头打在棉花上的无力感。 如果你已经参加了工作,不论是刚开始实习,还是工作了 3 年、5 年甚至 10 年,一定觉得非常充实,但真正用于思考的时间足够吗?如果维持现状,再过 5 年自己的提升点在哪里?如果你对这些结论不清晰,很可能是缺乏了对职业规划的思考。 这种缺乏职业规划的焦虑已经发展成为了商机。当你没有清晰职业规划,正在迷茫的时候,培训机构站出来说,是不是对职业规划充满焦虑?如果是,可以订购我们的课程,名牌大厂 P10 带你跑赢职场。其实课程确实是干货,但一个具体课程并不能代替你自己的思考,你需要自己想明白自己想要的,而不是被别人灌输思想,因为职场没有标准路线,但培训机构的文案确实有标准写法。 所以这篇前端职业规划是站在我自己角度写的,你如果也在思考长线发展问题,可以作为参考。 我总结出三个主要思考方向,分别是 **知识分类**、**领域深耕**、**经济视角**。 **知识分类** 指的是你对知识的理解是否成体系。现在全球每天新增的知识,一个人穷尽一生也学不完,如果不建立一套你自己的知识筛选标准,长期发展就无从谈起。 **领域深耕** 是实践,天天学习也是没有用的,你必须要做出什么有价值的事情,才能为行业带来贡献,或者说将知识转化为财富。当然不同职业学习与实践的比例是不同的,比如理论物理可能模糊了学习与实践的边界,而在职场环境的工程师,更容易区分什么是学习,什么是实践。 **经济视角** 是说你要能够带着经济视角看问题。可以说没有经济活动,我们一切学习、生产、职业都没有任何意义,因为推动我们学习、推动社会生产的动力是交易,没有经济活动就没有需求,需求是推动一切活动的基础。稍微理解了经济和生产的关系,就能理解为什么技术要为商业服务,因为任何技术都要有转化为商业价值的潜力才值得被研究,大到社会价值,小到产品价值,都一样。 下面我分别讲讲自己对每个方向的理解。 ## 知识分类 作为前端,为了保持技术敏锐度,我们会订阅许多专栏了解新知识。仅我知道的周更专栏就有 30 个,其实根据一些专门整理好的专栏检索网站,每周甚至可以看到超过 100 种不同的前端专栏。大部分专栏都在做文章聚合,每篇专栏聚合的文章一般有 5 篇到 30 篇不等,这样即便去除重复,一周至少有几百篇新的前端技术文章等你去读,所以有些同学会觉得焦虑,甚至喊出学不动了。 我每周写前端精读恰好也要找一些文章阅读,但几年下来,我恰恰觉得每周根本找不到有用的素材。就以本周的 [javascript weekly](https://javascriptweekly.com/issues/539) 为例,我摘了一些文章标题: - [DOM Events: A Way to Visualize and Experiment with the DOM Event System](https://javascriptweekly.com/link/108484/web)。 - [Introducing WebContainers: Run Node.js Natively in the Browser](https://blog.stackblitz.com/posts/introducing-webcontainers/)。 - [New & Updated Course: Complete Intro to React v6 with Brian Holt](https://javascriptweekly.com/link/108483/web) - [Parcel 2 Beta 3: A Wild Rust Appears!](https://v2.parceljs.org/blog/beta3/) - [2D Optics Demos in JavaScript](https://javascriptweekly.com/link/108493/web) - [A Complete Beginner's Guide to Next.js](https://www.youtube.com/watch?v=nBkRxwHMrto) - [How to Create Reusable Web Components with Lit and Vue](https://javascriptweekly.com/link/108496/web) 第一篇是通过可视化帮你理解 DOM 事件的文章,UI 很有意思,但 DOM 事件作为前端基础,精读实在不适合拿过来炒冷饭,这个知识点讲一遍就行了,没必要做成 UI 后再讲一遍。 第二篇是讲一项技术可以让 Node 运行在浏览器的,这确实是一个新技术,但现阶段我们没必要为这项技术找场景,只要知道有这个东西就行了,没必要仔细阅读。第三篇是对 React 的完整教程,非常体系化,但没有新东西,适合前端新人读,所以也不需要看。 再后面几篇分别是框架升级带来的特性介绍、一个有趣的可视化效果、Next.js 新手入门、如何用 [Lit](https://lit.dev/) 框架开发组件。这些知识从直觉来看属于可读可不读的,读了吧觉得好像对自己没什么成长,不读又觉得错过了什么,真的像鸡肋。 如果你看到这些 Feed 流也有犹豫的感觉,我建议你建立一套前端知识分类体系。就像学习武功,如果你不了解什么是基本功,什么是花拳绣腿,那么每天面临几百本推送过来的 “武学新闻” 确实是无从学起,而且也学不过来。 在技术领域,知识分类体系是有规可循的,大致可以讲知识分为两种类型:通用、行业知识。 通用知识是指最为基础、适用面也最大的知识,比如数理化,这些知识我们上学时都学过,工作中用到的知识都是建立在这些通用知识基础之上的,比如没有一定数学基础就难以学习计算机可视化领域,因为其中会大量运用数学知识。 通用知识最有用,也最保值,所以学校时就安排给我们了,那么大学其实就在教通用行业知识,所以这个阶段如果没有打牢的基础,想要弥补也很简单,只要按照大学教材温习一遍就好了,对于计算机领域的通用知识一般有计算机原理、操作系统、设计模式、编译原理、数据结构、算法等。 领域通用知识看上去比较死板,而初入工作的同学一般都在做拧螺丝钉的事,往往会忽略行业通用知识的重要性,但当你不断深入接触公司核心技术时,会发现大量运用了大学里教的那些通用知识,等用到的时候再学就迟了。 如果说行业通用知识的保值时间是 30 年,那接下来提到的行业专用知识的保值时间只有 1 年。行业专用知识就是我们在 Weekly 上看到的大部分内容,也包括培训班帮我们速成的前端框架、API 等知识。这些知识非常有用,接地气,而且刚接触工作时第一时间就要用到,但这些知识最大的问题就是太过于上层,以至于同类产品过多,可替代性强,知识点可以随着新版本发布全变了样。 就像项目脚手架工具,现在每天都会出一个基于 webpack 或者 rollup 包装的新品牌,这种脚手架就不值得学习,你也不需要把新出的脚手架当作新知识,因为这些知识的生命周期大部分不到一年,大多没有人用,最重要的是除了名字以外,组成要素里没有任何新知识,所以读完源码也学不到新知识。更最重要的是,你无法根据这些知识生产同类产品,所以如果你真的想学脚手架相关知识,认真读好一个主流脚手架源码就行了,以后除了工作中用到,不需要看任何使用文档。 对于架构能力也一样,我们在工作中通过踩坑甚至把一个项目做失败得出的经验,可能只是设计模式这本书里提到的一个常见误区;我们在设计一个非常复杂的系统时,用到的模块通信设计,可能只是操作系统设计里的一种常见通信方法。一个能理解操作系统复杂度的人,基本上可以处理与其等价复杂度的软件工程问题,而软件工程的复杂度其实很难超越操作系统,所以与其在项目里试错,不如从这些基础知识里找答案。 所以如果你想在职业规划上更进一步,检查一下自己的基础是否牢固。如果你通用知识特别扎实,就可以快速学会行业基础知识,根据行业基础知识,你甚至可以独立创造任何一个新的框架,这些框架都会成为别人学习到的行业专用知识,如果另一位同学没有打基础,把时间都用在学习你做的框架上,那么他的职业发展一定程度会被你左右,而他如果只停留在用的阶段,而不了解实现原理,从长期来看,你的职业天花板一定会更高。 关于哪些是通用基础知识、行业基础知识、行业专用知识,这里不给出具体的建议,相信每个人都会有自己的判断。 ## 领域深耕 > 这段思考 **不适用于** 刚参加工作的前端同学。 前端有一句有名的鸡汤 “前端不是因为做交互界面,而是因为站在业务的最前端”,其实这句话是有问题的,我觉得每一位工作经验超过三年的前端同学都有一种在业务领域的无力感。 其实最核心的业务模型天然在后端,这是因为前端只是一个用户与业务系统交互的窗口,没有前端,用户也可以和接口直接交互,只是这么做成本很大,所以为了降低用户上手难度,或者带来更好的用户体验,才需要不断升级 UI 界面,所以 UI 界面和后端往往是多对一的关系,移动端、小程序、网页对应的接口都是一套,目的就是为了方便任何场景用户都能轻松触达业务,所以作为前端,首先要对前端存在的原因有正确的认识。 注意这里说的是业务模型,没有提到体验深度,如果讲究体验深度,自然只有前端能做到。然而前端本质还是锦上添花的部分,因为在任何行业耕耘久了,如果仅仅只考虑前端,那么目标永远是体验度量、研发提效的事情,很少触及到业务层,以至于前端在业务价值的体现不直接,比较难解释体验度量、研发提效与最终业务增长之间的关系。 所以对于有一定工作经验的前端同学,想要更进一步,一定要在业务领域深耕。 那么如何在业务领域深耕呢?首先你要抛开前端视角,用业务眼光看问题,否则还是会陷入无尽的交互细节。首先要了解你所在的领域,比如笔者在的数据领域,要知道行业的历史、现状和未来,有哪些产品,每种产品的商业模式是什么,产品之间有什么关联,现在的产品距离头部产品还有哪些差距,今年产品目标主要解决什么问题,三年目标是什么等等。每个同学首先都应该理解产品,其次再产生研发、产品经理的分工。 然后审视一下自己的工作,在产品核心能力里扮演者什么角色?比如做 BI 工具,其核心是数据分析能力与报表可视化分析能力,如果你总在做类似报表列表页、个人中心这种通用中后台的工作,你就要想想,这些工作是不是可以外包出去,如果不行,那就想办法做一些领域搭建,往通用领域转吧。 当你审视了自己工作,发现核心产品能力与你工作内容不相符,而你又不想转到前端中后台通用领域一直做研发提效的事情,这时候你就要想办法和老板沟通改变一下工作内容了,你可以找一些前端也能接触强业务模型的领域,比如 BI 分析,数据可视化等等。其实通用领域也有不少深水区,比如语雀背后的富文本编辑器、流程图、研发工作台、业务组件库等等都是可以做深的通用领域,当你想再上一层楼时,就要像玉伯一样成为语雀整个产品的引领者,这样你其实又进入了知识协作、生产力工具这个专业领域。 如果你既不想往通用技术领域发展,又无法改变工作内容,就尝试承担更多职责吧,如果可能的话,尝试参与后端业务逻辑的开发,这样可以帮助你深入、全面理解业务逻辑。其实前端 + 产品的路线也可以很好在专业领域做深,前端 + 后端路线也可以,你需要根据自己团队实际情况做出调整。 任何产品的研发团队都要有产品全局观,这就是刚才说的在技术之外,你对你所在业务领域的理解程度,理解程度越高,技术方向就越明确,但如果你的职业规划是再继续攀爬,就要成为整个产品负责人了。现在的年轻人非常上进,许多公司都在尝试采取活水政策,让想更进一步的年轻人尝试新方向开疆拓土,而不是留在一个成熟的团队里内卷。 ## 经济视角 做职业规划的另一个目的当然是升职加薪了,但是你的薪资并不能无限膨胀,其增长大致还是符合市场规律的。另外任何工作都是一笔经济账,我们要带着技术、产品和经济视角看业务,才能做出合理的判断。 因为去年疫情原因,全球远程办公得到了积极实践,并且在未来依然有增长潜力,因此作为用人单位方,必定会逐渐放眼全球去看人力成本问题,因为在哪都能办公。从全球软件开发数据来看,美国的工资水平最高,中国软件工程师的工资也紧随其后,所以在软件领域中国已经不存在劳动力成本低廉的优势了,尤其当你工作经验丰富后,要竞争中高级岗位,中国软件公司开的薪资放眼全球都不低。 然而国家之间技术发展阶段、教育水平仍然存在差距,如果同样的资深技术专家岗位,国内与国外开的薪资持平,但中国的软件工程师架构水平完全不及美国的软件工程师,那么长期来看,这种错配会造成企业用人成本浪费,企业会在一定程度想办法优化一下人员构成的。因此作为前端,或者软件工程师,你必须清楚长期而言,你要和全球的软件工程师竞争,所以你还要充分了解你的领域在全球范围的发展阶段,人才水平如何。 以上是个人的经济账,接下来谈谈业务的经济账。 首先你要了解自己的技术是怎样转化为收入,覆盖自己工资的。我们首先看市场竞争,市场竞争通过价格调节供需关系,我们做的产品成本、售价很清楚,是否值得做一目了然。然而对于复杂产品需要多人协作,如果人与人之间再通过市场化机制合作,往往容易产生低效的结果,比如我做的按钮按照 3 元一个的价格卖给后端,那为了提升我的价值,我会提价到 5 元一个,然而倾向于给产品加更多的按钮,这样都在看短期利益,谁也不会为产品长期发展负责。 所以公司是一个相对大锅饭的组织,谁也不要给自己工作定价,大家都尽可能的打磨产品,月底按照合同约定给固定薪酬。这样做确实解决了产品长期发展的问题,但这套机制成熟后,尤其在大公司,刚毕业就去拧螺丝钉的同学很可能永远没有机会了解何为成本,没有成本概念,就难以想清楚为什么做事要考虑投入产出比,或者觉得 ROI 这个词很高级,其实这个词一点不高级,只是公司将它屏蔽了,但如果这导致你做技术完全不考虑成本,只追求让你激动的技术细节,或者只做你感兴趣的技术方向,那其实是不成熟的表现,你做的事情可能也难以被业务认可。 如果你想往更高层次发展,成本意识是一定要培养的,可以了解一下人力成本、机器成本、以及接入二方、三方服务的外部成本,了解这些成本后,再算算产品年营收是否能覆盖这些成本,如果想继续加人,那明年产品营收相应要翻多少,现在市场空间允许产品翻这么多吗?如果想提供更好的服务,要加机器,那么你的业务方是否会因为服务变好变得更多?衡量业务方增多带来的价值一般从订单价格,MAU 来看,如果服务外部,直接看价格是否覆盖成本就行了,如果服务内部,就看 MAU 是否值得投入这些机器成本。 然而也不能只看钱,市场份额也很重要。如果 Chrome 对研发投入只看年营收,那现在 IE 估计还是主流浏览器。其实 Chrome 在确立霸主地位后,对谷歌产品生态的打通、W3C 的话语权、开发者吸引力有很大提升,这些看不见的影响面难以直接转化为金钱来统计,所以如果你认为产品市场份额的提升可以带来长线价值,那么也可以把市场份额作为目标之一。 最后经济视角也不仅仅让我们停留在算业务帐上,经济学的边际收益理论可以指导我们优先做边际收益更大的事。当前业务产品矩阵中,拓展哪些产品可以快速弥补不足,如果做技术优化,优化哪些模块带来产品收益、可维护性收益最大,如果时刻能想清楚这些问题,那每年的产品、技术方向就不会跑偏。 ## 总结 总结一下文中提到的三个思考方向,其实是职业生涯发展中可能遇到的三种问题。 工作时间久了就会发现,哪怕依然有学习的激情,但保持刚毕业那会的学习方式已经难有突破了,你会发现:工作实践用到的知识不会很多,反复读或者写入门技术文章,只会让自己停留在校招生的技术水平;自己所处的职业也限制了进一步发展,你需要思考怎么打破职业天花板;甚至只钻研技术领域都是不够的,大家都在谈成本,你在谈技术,天然就不在一个频道上。 本文也给出了对应的三个解决方案,**知识分类** 帮助你解决反复学习无用的、入门知识的问题;**领域深耕** 帮助你解决职业天花板的问题;**经济视角** 帮助你解决技术单一视角的问题。 其实职业有天花板很正常,没有哪个职业上升通道是一路无阻的,但人是活的,你可以逐渐改变自己,在适当的时候多看看业务、经济问题,学习知识也不要仅停留在表面,虽然这些你工作中可能根本用不到,但这其实是悖论,因为你没掌握某些知识,所以也没机会接触那些工作,想打破悖论只能从痛苦的自我打破边界开始。 与一般前端职业规划不同,我并没有说很多前端领域专有名词,或者点名要学哪些框架,因为我觉得人之间智商差距并不大,必须掌握的知识工作几年都能学会,而真正能拉开人之间差距的,不是智商,而是学习方法,或者学习路线,如果你把时间用在错误的地方,或者错误的阶段,终将积累成巨大差距。 希望我的思考可以对你有帮助。 > 讨论地址是:[精读《前端职业规划 - 2021 年》· Issue #317 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/317) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/197.精读《低代码逻辑编排》.md ================================================ 逻辑编排是用可视化方式描述逻辑,在一般搭建场景中用于代替逻辑描述部分。 更进一步的逻辑编排是前后端逻辑混排,一般出现在一站式 paas 平台,今天就介绍一个全面实现了逻辑编排的 paas 工具 [node-red](https://github.com/node-red/node-red),本周精读的内容是其介绍视频:[How To Create Your First Flow In Node-RED](https://www.youtube.com/watch?v=cVWVr_T7kQ0),介绍了如果利用纯逻辑编排实现一个天气查询应用,以及部署与应用迁移。 ## 概述 想要在本地运行 Node-RED 很简单,只要下面两条命令: ```bash npm install -g --unsafe-perm node-red node-red ``` 之后你就可以看到这个逻辑编排界面了: 我们可以利用这些逻辑节点构建前端网站、后端服务,以及大部分开发工作。光这么说还比较抽象,我们接下来会详细介绍每个逻辑节点的作用,让你了解这些逻辑节点是如何规划设计的,以及逻辑编排到底是怎么控制研发规范来提高研发效率的。 Node-RED 截止目前共有 42 个逻辑节点,按照通用、功能、网络、序列、解析、存储分为六大类。 所有节点都可能有左右连接点,左连接点是输入,右连接点是输出,特殊节点可能有多个输入或多个输出,其实对应代码也不难理解,就是入参和出参。 下面依次介绍每个节点的功能。 ### 通用 通用节点处理通用逻辑,比如手动输入数据、调试、错误捕获、注释等。 ### inject 手动输入节点。可以定期产生一些输入,由下一个节点消费。 举个例子,比如可以定期产生一些固定值,如这样一个这个对象: ```javascript return { payload: new Date(), topic: "abc", }; ``` 当然这里是用 UI 表单配置的: 之后就是消费,几乎后面任何节点都可以消费,比如利用 `change` 节点来设置一些环境变量时,或者利用 `template` 节点设置 html 模版时,都可以拿到这里输入的变量。如果在模版里,变量通过 `{{msg.payload}}` 访问,如果是其它表单,甚至可以通过下拉框直接枚举选择。 然而这个节点往往用来设置静态变量,更多的输入情况是来自其它程序或者用户的,比如 `http in`,这个后面会讲到。其实通过这种组合关系,我们可以把任意节点的输入从生产节点替换为 `inject` 节点,从而实现一些 mock 效果,而 `inject` 节点也支持配置定时自动触发: ### debug 用来调试的,当任何输出节点连接到 debug 的输入后,将会在控制台打印出输出信息,方便调试。 比如我们将 `inject` 的输入连上 `debug` 的输入,就可以在触发数据后在控制台看到打印结果: 当然如果你把输入连接到 debug,那么原有逻辑就中断了,然而任何输出节点都可以无限制的输出给其它节点,你只要同时把输出连接到 debug 与功能节点就行了: ### complete 监听某些节点触发完成动作。通过这个节点,我们可以捕获任意节点触发的动作,可以接入 `debug` 节点打印日志,或者 `function` 节点处理一下逻辑。 可以监听全部节点,也可以用可视化方式选择要监听哪些节点: #### catch 错误捕获节点,当任何或指定节点触发错误时输出,输出的格式为: ```text error.message 字符串 错误消息。 error.source.id 字符串 引发错误的节点的ID。 error.source.type 字符串 引发错误的节点的类型。 error.source.name 字符串 引发错误的节点的名称。(如果已设置) ``` 其实每个节点都有固定输出格式,这些固定格式限制了开发灵活度,但熟练掌握后可以大大提升开发效率,因为所有同类型节点格式都是一样的,这是逻辑编排带来规则约束的好处。 #### status 监听节点状态变化。 #### link in 只能连接 `link out`。`link in`、`link out` 就像一个传送门,用来整理逻辑编排节点,使之看上去易于维护。 比如下面的例子,在一个天气 `http in` 服务后,穿插了许多逻辑处理节点,有处理响应 html 内容的 `template` 节点,也有处理请求查询城市天气的 `http request` 服务,整体逻辑虽然聚合,但比较杂乱: 较好的方式是分类,即类似代码开发中的模块化行为,将天气服务导出,其他任何用到的模块直接导入,这个导入动作就是通过 `link in` 实现的,`link out` -> `link in` 只是一个空间位置的变换,传输值是不会变的: 这样模块看起来清晰了许多,如果要知道各个 “传送门” 见连接关系,只要鼠标点击其中一个就可以给出提示,看起来十分方便: #### link out 和 `link in` 成对出现,用来导出输入值,后面对接 `link out` 可以像传送门一样将值传送过去,在视觉上不会形成连接线。 #### comment 注释,配合 `link` 系列使用,可以让逻辑编排 UI 更易于维护。 结合原视频的例子,对于天气服务,有创建环境变量逻辑,有查询逻辑,其中查询天气还分为查询当前天气、连续 5 天天气、查询国家信息,我们可以在 UI 上讲每块逻辑分组,并利用 `comment` 组件标记好注释,方便阅读: ### 功能 功能型节点,一般用于处理业务逻辑,所以包含了基础的 if else、js 代码、模版处理等等功能模块。 #### function 最核心的 js 函数模块,你可以用它做任何事: 其输入会传导到 `msg` 对象,可以通过代码修改 `msg` 对象后再通过输出节点传导出去。 当然也可以访问和修改节点、流程、全局变量,这个在 `change` 节点里介绍。 #### switch 对应代码的 switch,只是用起来更加方便,因为我们可以根据不同 case 导出不同的节点: 注意看上图,因为有三条分支,所以节点的导出项也变成了三个,我们可以根据不同逻辑走不同的连接: #### change 用来改变环境变量。环境变量分为三种,分别是当前节点、流程(画布)、全局(跨应用)。也就是说,变量可以存储在某个节点上,也可以存储在整个画布上,也可以跨画布存储在全局。 访问参数分别为 `msg.`、`flow.`、`global.`,设置这些参数后,就像全局变量一样,任何节点都可以在任何地方使用,比较方便。 比如应用固定了一些 URL 地址,直接把一串字符串写死在某个 `http in` 节点里并不明智,因为后面的 html 或者其它节点里可能会访问它,一旦你进行修改,影响面会非常广,因此最好将其设置为全局变量,在节点中通过变量方式访问: 其实在控制台,可以看到这三种变量的值: 当我们利用 `change` 节点赋值后,可以通过调试面板查看不同作用域全局变量的值: #### range 区间映射,将一个范围的值映射到另一个范围。其实通过 `function` 模块也能完成,只是因为比较常用所以封装了一个特殊节点。其实用户也可以自己封装节点,具体方式可以参考 [官方文档](https://nodered.org/docs/creating-nodes/)。 上图很容易理解,比如数据分析中归一化就可以用这个节点实现。 #### template 以模版方式生成字符串或 json。 其实本质上也可以被 `function` 代替,只是用来写模版的话有高亮,维护起来比较方便。 内置了 [mustache](https://github.com/janl/mustache.js) 模版语法,通过 `{{}}` 方式使用变量。 比如我们通过 `inject` 注入一个变量给 `template`,并通过 `debug` 打印,流程是这样的: 其中 `inject` 是这么配置的: 可以看到,将 `msg.name` 设置为一个字符串,然后通过 `template` 访问 `name`: #### delay 延迟发消息,一个快捷的工具,可以放在任何输入与输出中间,比如让上面的例子中,`inject` 触发后 5s 再打印结果,可以这么配置: #### trigger 一个消息触发器,相比 `inject`,可以更灵活的设置何时重新触发。 从配置可以看出,首先和 `inject` 一样发送一条消息,然后可以等待,或者等待被重置,或者周期性触发(这样就和 `inject` 一样),其中 “发送第二条消息到单独的输出” 和 `switch` 一样会多一个输出口。 然后有重置条件,即 `payload` 为什么值时重置。 通过这个组件可以看出来,其实每个节点都可以用 `function` 节点实现,只不过通过定制一个节点,可以用 UI 而非代码的方式配置,使用起来更方便。 #### exec 执行系统命令,比如 `ls` 等,这个在系统后台执行而非前端,所以是一个相当危险的节点。 我们可以在配置中写入任何命令: #### rbe 异常报告节点(Report by Exception),比如说当输入变化时进行阻塞。 ### 网络 用于创建网络服务,比如 http、socket、tcp、udp 等等,因为其它都不常用,这次仅介绍 http 服务。 #### http in 创建一个 http 服务,可以是任何接口或者 web 服务。 当你把 Method 设置为 `post`,连接到 `http response` 就创建了后端接口;当设置为 `get` 请求,并连接 `template` 写上 html 模版,并连接到 `http response` 就创建了 web 服务。 虽然这种方式创建 web 服务难以使用 react 或 vue 框架,不过自定义节点还是为其创造了可能性,或许真的可以把前端模块化文件定义为节点相互串联。 #### http response http 返回,只能对接 `http in` 的输出,总是与 `http in` 成对使用。 如果只用了 `http in` 但没有用 `http response`,就相当于后端代码里处理了请求,但没有调用类似: ```typescript res.send("hello word"); ``` 来向客户端发送内容。 #### http request 与 `http in` 创建一个 http 服务不同,`http request` 直接发送一个网络请求并将返回值导入到输出节点。 视频中获取天气的例子,就用了 `http request` 发起请求获取天气信息: 不难看出,发送请求后,又使用了 `function` 节点处理返回结果。不过在逻辑编排中还是期望少使用 `function` 节点,因为除非有很好的命名,否则难以看出来节点含义,如果 `function` 处理内容过多或者 `function` 区块过多,就失去了逻辑编排的意义。 ### 序列 序列是对数组进行处理的节点。 #### split 对应代码的 `split`,将字符串变为数组。 #### join 对应代码的 `join`,一般与 `split` 配合使用,方便处理字符串。 #### sort 对应代码 `sort`,只能根据 `key` 做简单的升序降序处理,对于简单场景比较方便,但对于复杂场景可能还会使用 `function` 节点代替。 #### batch 批量接收输入流后,根据数量进行打包后统一输出,等于批量打包,可以按照数量或者时间间隔进行分组: ### 解析 很容易理解,专门处理上述格式的数据,并按照数据特征输出,比如 csv 数据,可以每行一条消息的方式输出,或者打包为一个大数组以一条消息输出。 当然也可以被 `function` 节点代替,那么解析方式与输出方式都可以自定义。 ### 存储 持久化存储,一般存储为文件。 #### file 输出为文件。 #### file in 以文件作为输入,并将文件结果作为输出。 #### watch 监听目录或文件的修改。 ## 精读 看了上面 node-red 功能后,相信你对逻辑编排已经有较为体系化的认识了。 逻辑编排的目的是为了让非研发人群也可以快速上手研发工作,因此注定是为 paas 工具服务的,而逻辑编排到底好不好用,取决于节点功能是否完备,以及各节点之间通信是否顺畅,像 node-red 逻辑编排方案,在完备性上做的较为成熟,可以说只要熟练掌握了几个核心节点规则,使用起来还是非常提效的。 逻辑编排也有天然缺点,就是当所有节点都退化为 `function` 节点后,会存在两个问题: - 所有节点都是 `function` 节点,即便有说明,但内部实现逻辑非常自由,导致逻辑编排无法起到约束输入输出的作用。 - 退化到代码函数式调用,本质上与写代码无异。逻辑编排之所以提效,很大程度上是我要的业务逻辑刚好与节点功能匹配,以低成本 UI 配置的方式实现效率才高。 然而这也是有解决方法的,如果你的业务无法被现有的逻辑编排节点满足,你可以尝试抽象一下,自己梳理出业务常用的节点,并用合理的配置封装,只要常用业务逻辑可以被封装为逻辑节点,逻辑编排就还有为业务提效的空间。 ## 总结 逻辑编排是一种极端,即用 UI 方式描述通用业务逻辑,降低非专业开发人员的上手门槛。通过对 [node-red](https://github.com/node-red/node-red) 的分析可以发现,一个较为完备的逻辑编排系统还是能带来价值的。 然而针对非专业开发人员降本提效还有一种极端,就是完全代码化,但是把代码模块化、函数库、工具链甚至低代码平台建设的非常完备,以至于写代码的效率根本不低,这条路走到极致也不错,因为既然要深入开发系统,同样是投入时间学习,为什么学习写代码就一定比学习拖拽 UI 效率低呢?如果有高度封装的函数与工具辅助,效率不见得比 UI 拖拽来的低。 然而 node-red 在创建前端 UI 的模版上还可以再增强一下,把 `template` 从节点升级为 UI 搭建画布,逻辑编排仅用来处理逻辑,这样对大型全栈项目的前端开发体验会更好。 > 讨论地址是:[精读《低代码逻辑编排》· Issue #319 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/319) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/2.精读《模态框的最佳实践》.md ================================================ 本期精读的文章是:[best practices for modals overlays dialog windows](https://uxplanet.org/best-practices-for-modals-overlays-dialog-windows-c00c66cddd8c)。 # 1 引言 logo 我为什么要选这篇文章呢? 1. 前端工程师今天在外界是怎么定位的。很多人以为前端都应该讨论架构层面的问题,其实不仅仅在此,我们不应该忽视交互体验这件事。 2. 对于用户体验的追求前端工程师从来没有停止过,而模态框在产品中的出现出现过很多争议,我想知道我们是怎么思考这件事的。 # 2 内容概要 来自 Wikipedia 的定义:模态框是一个定位于应用视窗顶层的元素。它创造了一种模式让自身保持在一个最外层的子视察下显示,并让主视窗失效。用户必须在回到主视窗前在它上面做交互动作。 **模态框用处** - 抓住用户的吸引力 - 需要用户输入 - 在上下文下显示额外的信息 - 不在上下文下显示额外的信息 不要用模态框显示错误、成功或警告的信息。保持它们在页面上。 **模态框的组成** - 退出的方式。可以是模态框上的一个按钮,可以是键盘上的一个按键,也可以是模态框外的区域。 - 描述性的标题。标题其实给了用户一个上下文信息。让用户知道他现在在哪个位置作操作。 - 按钮的内容。它一定要是可行动的,可以理解的。不要试图让按钮的内容让用户迷惑,如果你尝试做一个取消动作,但框内有一个取消的按钮,那么我是要取消一个取消呢,还是继续我的取消。 - 大小与位置。模态框的大小不要太大或太小,不应该。模态框的位置建议在视窗中间偏上的位置,因为在移动端如果太低的话会失去很多信息。 - 焦点。模态框的出现一定要吸引你的注意力,建议键盘的焦点也切换到框内。 - 用户发起。不要对用户造成惊吓。用用户的动作,比如一个按钮的点击来触发模态框的出现。 **模态框在移动端** 模态框在移动端总是不是玩转得很好。其中一个原因是一般来说模态框都太大了,占用了太多空间。建议增加设备的按键或内置的滚动条来操作,用户可以左移或放大缩小来抓住模态框。 **无障碍访问** 1. 快捷键。我们应该考虑在打开,移动,管理焦点和关闭时增加对模态框的快捷键。 2. ARIA。在前端代码层面加上 aria 的标识,如 Role = “dialog” , aria-hidden, aria-label # 3 精读 ### 模态框定位 首先,Modal 与 Toast、Notification、Message 以及 Popover 都会在某个时间点被触发弹出一个浮层,但与 Modal(模态框)还是有所不同的。定义上看,上述组件都不属于模态框,因为模态框有一个重要的特性,即阻塞原来主视窗下的操作,只能在框内作后续动作。也就是说模态框从界面上彻底打断了用户心流。 当然,这也是我们需要讨论的问题,如果只是一般的消息提醒,可以用信息条、小红点等交互形式,至少是不阻塞用户操作的。在原文末引用的 10 Guidelines to Consider when using Overlays 一文中,第 8 条强调了模态框不到万不得以不应该使用。这时我们应该思考什么情况下你非常希望他不要离开页面,来读框内的信息或作操作呢? 反过来说,模态框有什么优点呢?要知道比起页面跳转来说,模态框的体验还是要轻量的多。例如,用户在淘宝上看中了一款商品,想登陆购买,此时弹出登陆模态框的体验就要远远好于跳转到登陆页面,因为用户在模态框中登陆后,就可以直接购买了。其次,模态框的内容对于当前页面来说是一种衍生或补充,可以让用户更为专注去阅读或者填写一些内容。 也就是说,当我们设计好模态框出现的时机,流畅的弹出体验,必要的上下文信息,以及友好的退出反馈,还是完全可以提升体验的。模态框的目的在于吸引注意,但一定需要提供额外的信息,或是一个重要的用户操作,或是一份重要的协议确认。在本页面即可完成流程或信息告知。 ### 合理的使用模态框 我们也总结了一些经验,更好地使用模态框。 - 内容是否相关。模态框是作为当前页面的一种衍生或补充,如果其内容与当前内容毫不相干,那么可以使用其他操作(如新页面跳转)来替代模态框; - 模态框内部应该避免有过多的操作。模态框应该给用户一种看完即走,而且走的流畅潇洒的感觉,而不是利用繁杂的交互留住或牵制住用户; - 避免出现一个以上的模态框。出现多个模态框会加深了产品的垂直深度,提高了视觉复杂度,而且会让用户烦躁起来; - 不要突然打开或自动打开模态框,这个操作应该是用户主动触发的; 还有两种根据实际情况来定义: - 大小。对于模态框的大小应该要有相对严格的限制,如果内容过多导致模态框或页面出现滚动条,一般来说这种体验很糟糕,但如果用于展示一些明细内容,我们可能还是会考虑使用滚动条来做; - 开启或关闭动画。现在有非常多的设计倾向于用动画完成流畅的过渡,让 Modal 变得不再突兀,[dribble 上有很多相关例子](https://dribbble.com/shots/3206370-Coverage-Modal-Motion-Study)。但在一些围绕数据来做复杂处理的应用中,如 ERP、CRM 产品中用户通常关注点都在一个表单和围绕表单做的一系列操作,页面来回切换或复杂的看似酷炫的动画可能都会影响效率。用户需要的是直截了当的完成操作,这时候可能就不需要动画,用户想要的就是快捷的响应。 举两个例子,Facebook 在这方面给我们很好的 demo,它的分享模态框与主视窗是在同一个位置,给人非常流畅的体验。还看到一个细节,从主视窗到模态框焦点上的字体会变大。对比微博,它就把照片等分享形式直接展示出来,焦点在输入框上时也没有变化。 第二个例子是 Quora,Quora 主页呈现的是 Feed 流,点击标题就会打开一个模态框展示它回答的具体内容,内容里面是带有滚动条的,按 ESC 键就可以关闭。非常流畅的体验。相比较之下知乎首页想要快速看内容得来回切换。 ### 可访问性的反思 Accessibility 翻译过来是『无障碍访问』,是对不同终端用户的体验完善。每一个模态框,都要有通过键盘关闭的功能,通常使用 ESC 键。似乎我们程序员多少总会把我们自我的惯性思维带进实现的产品,尤其是当我们敲着外置的键盘,用着 PC 的时候。 下面的这些问题都是对可访问性的反思: - 用户可能没有鼠标,或者没有键盘,甚至可能既没有鼠标也没有键盘,只使用的是语音控制?你让这些用户如何退出 - 很多的 Windows PC 都已经获得了很好的触屏支持,而你的网页依旧只支持了键盘跟鼠标? - 在没有苹果触摸板的地方,横向滚动条是不是一个逆天的设计? - 在网页里,使用 Command(Ctrl) and +/- 和使用触摸板的缩放事件是两个不同的表现? - 如果你的终端用户没有好用的触摸板,但是他的确看不清你的网页上的内容。如果他用了前者,你能不能保证你的网页依然能够正常展示内容? 可访问性一直都是产品极其忽视的,在文章的最佳实践最后特别强调了它是怎么做的,对我们这些开发者是很好的督促。 ### 模态框代码实现层面 前端开发还是少不了代码层面的实现,**业务代码对于有状态或无状态模态框的使用方式存在普遍问题**。 对有状态模态框来说,很多库会支持 `.show` 直接调用的方式,那么模态框内部渲染逻辑,会在此方法执行时执行,没有什么问题。不过现在流行无状态模态框(Stateless Modal),模态框的显示与否交由父级组件控制,我们只要将模态框代码预先写好,由外部控制是否显示。 这种无状态模态框的方式,在模态框需要显示复杂逻辑的场景中,会自然将初始化逻辑写在父级,当模态框出现在循环列表中,往往会引发首屏触发 2-30 次模态框初始化运算,而这些运算最佳状态是模态框显示时执行一次,由于模态框同一时间只会出现一个,最次也是首屏初始化一次,但下面看似没问题的代码往往会引发性能危机: ```js const TdElement = data.map(item => { return ( ) }); ``` 上面代码初始化执行了 N 个模态框初始化代码,显然不合适。对于 table 操作列中触发的模态框,所有行都复用同一个模态框,通过父级中一个状态变量来控制展示的内容: ```js class Table extends Component { static state = { activeItem: null, }; render() { const { activeItem } = this.state; return (
); } } ``` 这种方案减少了节点数,但是可能会带来的问题是,每次模态框被展示的时候,触发是会是模态框的更新 (componentDidUpdate) 而不是新增。当然结合 table 中操作的特点,我们可以这样优化: ```js {activeItem ? : null} ``` ### 补充阅读 # 总结 这篇讲的是最佳实践,而且是 UX 层面的。但我们还是看到一些同学提出了相反的意见,我总结下就是不同的产品或不同的用户带给我们不同的认识。这时候是不是要死守着『最佳实践』呢?这时候,对于产品而言,我们可以采集用户研究的方法去判断,用数据结论代替感官上的结论。 另外,可访问性在这两年时不时会在一些文章中看到,但非常少。这是典型的长尾需求,很多研发在做产品只考虑 90% 的用户,不清楚我们放弃的一部分用户的需求。这是从产品到研发整体的思考的缺失。 **如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,每周五发布。** ================================================ FILE: 前沿技术/20.精读《Nestjs》文档.md ================================================ # 精读 《Nestjs 文档》 本期精读的文章是:[Nestjs 文档](https://docs.nestjs.com/) 体验一下 nodejs mvc 框架的优雅设计。 # 1 引言 logo Nestjs 是我见过的,将 Typescript 与 Nodejs Framework 结合的最好的例子。 # 2 内容概要 Nestjs 不是一个新轮子,它是基于 Express、socket.io 封装的 nodejs 后端开发框架,对 Typescript 开发者提供类型支持,也能优雅降级供 Js 使用,拥有诸多特性,像中间件等就不展开了,本文重点列举其亮点特性。 ## 2.1 Modules, Controllers, Providers Nestjs 开发围绕着这三个单词,Modules 是最大粒度的拆分,表示应用或者模块。Controllers 是传统意义的控制器,一个 Module 拥有多个 Controller。Providers 一般用于做 Services,比如将数据库 CRUD 封装在 Services 中,每个 Service 就是一个 Provider。 ## 2.2 装饰器路由 装饰器路由是个好东西,路由直接标志在函数头上,做到了路由去中心化: ```typescript @Controller() export class UsersController { @Get('users') getAllUsers() {} @Get('users/:id') getUser() {} @Post('users') addUser() {} } ``` 以前用过 Go 语言框架 [Beego](https://beego.me/docs/mvc/controller/router.md),就是采用了中心化路由管理方式,虽然引入了 `namespace` 概念,但当协作者多、模块体量巨大时,路由管理成本直线上升。Nestjs 类似 namespace 的概念通过装饰器实现: ```typescript @Controller('users') export class UsersController { @Get() getAllUsers(req: Request, res: Response, next: NextFunction) {} } ``` 访问 `/users` 时会进入 `getAllUsers` 函数。可以看到其 `namespace` 也是去中心化的。 ## 2.3 模块间依赖注入 Modules, Controllers, Providers 之间通过依赖注入相互关联,它们通过同名的 `@Module` `@Controller` `@Injectable` 装饰器申明,如: ```typescript @Controller() export class UsersController { @Get('users') getAllUsers() {} } ``` ```typescript @Injectable() export class UsersService { getAllUsers() { return [] } } ``` ```typescript @Module({ controllers: [ UsersController ], providers: [ UsersService ], }) export class ApplicationModule {} ``` 在 `ApplicationModule` 申明其内部 Controllers 与 Providers 后,就可以在 Controllers 中注入 Providers 了: ```typescript @Controller() export class UsersController { constructor(private usersService: UsersService) {} @Get('users') getAllUsers() { return this.usersService.getAllUsers() } } ``` ## 2.4 装饰器参数 与大部分框架从 `this.req` 或 `this.context` 等取请求参数不同,Nestjs 通过装饰器获取请求参数: ```typescript @Get('/:id') public async getUser( @Response() res, @Param('id') id, ) { const user = await this.usersService.getUser(id); res.status(HttpStatus.OK).json(user); } ``` `@Response` 获取 res,`@Param` 获取路由参数,`@Query` 获取 url query 参数,`@Body` 获取 Http body 参数。 # 3 精读 由于临近双十一,项目工期很紧张,本期精读由我独自完成 :p。 ## 3.1 Typeorm 有了如此强大的后端框架,必须搭配上同等强大的 orm 才能发挥最大功力,[Typeorm](https://github.com/typeorm/typeorm) 就是最好的选择之一。它也完全使用 Typescript 编写,使用方式具有同样的艺术气息。 ### 3.1.1 定义实体 每个实体对应数据库的一张表,Typeorm 在每次启动都会同步表结构到数据库,我们完全不用使用数据库查看表结构,所有结构信息都定义在代码中: ```typescript @Entity() export class Card { @PrimaryGeneratedColumn({ comment: '主键', }) id: number; @Column({ comment: '名称', length: 30, unique: true, }) name: string = 'nick'; } ``` 通过 `@Entity` 将类定义为实体,每个成员变量对应表中的每一列,如上定义了 `id` `name` 两个列,同时列 `id` 通过 `@PrimaryGeneratedColumn` 定义为了主键列,列 `name` 通过参数定义了其最大长度、唯一的信息。 至于类型,Typeorm 通过反射,拿到了类型定义,自动识别 `id` 为数字类型、`name` 为字符串类型,当然也可以手动设置 `type` 参数。 对于初始值,使用 js 语法就好,比如将 `name` 初始值设置为 `nick`,在 `new Card()` 时已经带上了初始值。 ### 3.1.2 自动校验 光判断参数类型是不够的,我们可以使用 `class-validator` 做任何形式的校验: ```typescript @Column({ comment: '配置 JSON', length: 5000, }) @Validator.IsString({ message: '必须为字符串' }) @Validator.Length(0, 5000, { message: '长度在 0~5000' }) content: string; ``` 这里遇到一个问题:新增实体时,需要校验所有字段,但更新实体时,由于性能需要,我们一般不会一次查询所有字段,就需要指定更新时,不校验没有赋值的字段,我们通过 Typeorm 的 `EventSubscriber` 完成数据库操作前的代码校验,并控制新增时全字段校验,更新时只校验赋值的字段,删除时不做校验: ```typescript @EventSubscriber() export class EverythingSubscriber implements EntitySubscriberInterface { // 插入前校验 async beforeInsert(event: InsertEvent) { const validateErrors = await validate(event.entity); if (validateErrors.length > 0) { throw new HttpException(getErrorMessage(validateErrors), 404); } } // 更新前校验 async beforeUpdate(event: UpdateEvent) { const validateErrors = await validate(event.entity, { // 更新操作不会验证没有涉及的字段 skipMissingProperties: true, }); if (validateErrors.length > 0) { throw new HttpException(getErrorMessage(validateErrors), 404); } } } ``` `HttpException` 会在校验失败后,终止执行,并立即返回错误给客户端,这一步体现了 Nestjs 与 Typeorm 完美结合。这带来的好处就是,我们放心执行任何 CRUD 语句,完全不需要做错误处理,当校验失败或者数据库操作失败时,会自动终止执行后续代码,并返回给客户端友好的提示: ```typescript @Post() async add( @Res() res: Response, @Body('name') name: string, @Body('description') description: string, ) { const card = await this.cardService.add(name, description); // 如果传入参数实体校验失败,会立刻返回失败,并提示 `@Validator.IsString({ message: '必须为字符串' })` 注册时的提示信息 // 如果插入失败,也会立刻返回失败 // 所以只需要处理正确情况 res.status(HttpStatus.OK).json(card); } ``` ### 3.1.3 外键 外键也是 Typeorm 的特色之一,通过装饰器语义化解释实体之间的关系,常用的有 `@OneToOne` `@OneToMany` `@ManyToOne` `@ManyToMany` 四种,比如用户表到评论表,是一对多的关系,可以这样设置实体: ```typescript @Entity() export class User { @PrimaryGeneratedColumn({ comment: '主键', }) id: number; @OneToMany(type => Comment, comment => comment.user) comments?: Comment[]; } ``` ```typescript @Entity() export class Comment { @PrimaryGeneratedColumn({ comment: '主键', }) id: number; @ManyToOne(type => User, user => user.Comments) @JoinColumn() user: User; } ``` 对 `User` 来说,一个 `User` 对应多个 `Comment`,就使用 `OneToMany` 装饰器装饰 `Comments` 字段;对 `Comment` 来说,多个 `Comment` 对应一个 `User`,所以使用 `ManyToOne` 装饰 `User` 字段。 在使用 Typeorm 查询 `User` 时,会自动外键查询到其关联的评论,保存在 `user.comments` 中。查询 `Comment` 时,会自动查询到其关联的 `User`,保存在 `comment.user` 中。 ## 3.2 部署 可以使用 Docker 部署 Mysql + Nodejs,通过 `docker-compose` 将数据库与服务都跑在 docker 中,内部通信。 有一个问题,就是 nodejs 服务运行时,要等待数据库服务启动完毕,也就是有一个启动等待的需求。可以通过 `environment` 来拓展等待功能,以下是 `docker-compose.yml`: ```yml version: "2" services: app: build: ./ restart: always ports: - "5000:8000" links: - db - redis depends_on: - db - redis environment: WAIT_HOSTS: db:3306 redis:6379 ``` 通过 `WAIT_HOSTS` 指定要等待哪些服务的端口服务 ready。在 nodejs `Dockerfile` 启动的 `CMD` 加上一个 `wait-for.sh` 脚本,它会读取 `WAIT_HOSTS` 环境变量,等待端口 ready 后,再执行后面的启动脚本。 ```bash CMD ./scripts/docker/wait-for.sh && npm run deploy ``` 以下是 `wait.sh` 脚本内容: ```bash #!/bin/bash set -e timeout=${WAIT_HOSTS_TIMEOUT:-30} waitAfterHosts=${WAIT_AFTER_HOSTS:-0} waitBeforeHosts=${WAIT_BEFORE_HOSTS:-0} echo "Waiting for ${waitBeforeHosts} seconds." sleep $waitBeforeHosts # our target format is a comma separated list where each item is "host:ip" if [ -n "$WAIT_HOSTS" ]; then uris=$(echo $WAIT_HOSTS | sed -e 's/,/ /g' -e 's/\s+/\n/g' | uniq) fi # wait for each target if [ -z "$uris" ]; then echo "No wait targets found." >&2; else for uri in $uris do host=$(echo $uri | cut -d: -f1) port=$(echo $uri | cut -d: -f2) [ -n "${host}" ] [ -n "${port}" ] echo "Waiting for ${uri}." seconds=0 while [ "$seconds" -lt "$timeout" ] && ! nc -z -w1 $host $port do echo -n . seconds=$((seconds+1)) sleep 1 done if [ "$seconds" -lt "$timeout" ]; then echo "${uri} is up!" else echo " ERROR: unable to connect to ${uri}" >&2 exit 1 fi done echo "All hosts are up" fi echo "Waiting for ${waitAfterHosts} seconds." sleep $waitAfterHosts exit 0 ``` # 4 总结 Nestjs 中间件实现也很精妙,与 Modules 完美结合起来,由于篇幅限制就不展开了。 后端框架已经很成熟了,相反前端发展的就眼花缭乱了,如果前端可以舍弃 ie11 浏览器,我推荐纯 proxy 实现的 [dob](https://github.com/ascoders/dob),配合 react 效率非常高。 > 讨论地址是:[精读 《Nestjs 文档》 · Issue #30 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/30) > 如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,每周五发布。 ================================================ FILE: 前沿技术/202.精读《React 18》.md ================================================ React 18 带来了几个非常实用的新特性,同时也没有额外的升级成本,值得仔细看一看。 下面是几个关键信息: - [React 18 工作小组](https://github.com/reactwg/react-18)。利用社区讨论 React 18 发布节奏与新特性。 - [发布计划](https://reactjs.org/blog/2021/06/08/the-plan-for-react-18.html)。目前还没有正式发布,不过 `@alpha` 版已经可用了,[安装 alpha 版](https://github.com/reactwg/react-18/discussions/9)。 - [React 18 新特性介绍](https://github.com/reactwg/react-18/discussions/4)。虽然还未正式发布,但特性介绍可以先行,本周精读主要就是解读这篇文档。 ## 精读 总的来说,React 18 带来了 3 大新特性: - Automatic batching。 - Concurrent APIS。 - SSR for Suspense。 同时为了开启新的特性,需要进行简单的 `render` 函数升级。 ### Automatic batching batching 是指,React 可以将回调函数中多个 `setState` 事件合并为一次渲染。 也就是说,`setState` 并不是实时修改 State 的,而将多次 `setState` 调用合并起来仅触发一次渲染,既可以减少程序数据状态存在中间值导致的不稳定性,也可以提升渲染性能。可以理解为如下代码所示: ```typescript function handleClick() { setCount((c) => c + 1); setFlag((f) => !f); // 仅触发一次渲染 } ``` 但可惜的是,React 18 以前,如果在回调函数的异步调用中执行 `setState`,由于丢失了上下文,无法做合并处理,所以每次 `setState` 调用都会立即触发一次重渲染: ```typescript function handleClick() { // React 18 以前的版本 fetch(/*...*/).then(() => { setCount((c) => c + 1); // 立刻重渲染 setFlag((f) => !f); // 立刻重渲染 }); } ``` 而 React 18 带来的优化便是,任何情况都可以合并渲染了!即使在 `promise`、`timeout` 或者 `event` 回调中调用多次 `setState`,也都会合并为一次渲染: ```typescript function handleClick() { // React 18+ fetch(/*...*/).then(() => { setCount((c) => c + 1); setFlag((f) => !f); // 仅触发一次渲染 }); } ``` 当然如果你非要 `setState` 调用后立即重渲染也行,只需要用 `flushSync` 包裹: ```typescript function handleClick() { // React 18+ fetch(/*...*/).then(() => { ReactDOM.flushSync(() => { setCount((c) => c + 1); // 立刻重渲染 setFlag((f) => !f); // 立刻重渲染 }); }); } ``` 开启这个特性的前提是,将 `ReactDOM.render` 替换为 `ReactDOM.createRoot` 调用方式。 ### 新的 ReactDOM Render API 升级方式很简单: ```typescript const container = document.getElementById("app"); // 旧 render API ReactDOM.render(, container); // 新 createRoot API const root = ReactDOM.createRoot(container); root.render(); ``` API 修改的主要原因还是语义化,即当我们多次调用 `render` 时,不再需要重复传入 `container` 参数,因为在新的 API 中,`container` 已经提前绑定到 `root` 了。 `ReactDOM.hydrate` 也被 `ReactDOM.hydrateRoot` 代替: ```typescript const root = ReactDOM.hydrateRoot(container, ); // 注意这里不用调用 root.render() ``` 这样的好处是,后续如果再调用 `root.render()` 进行重渲染,我们不用关心这个 `root` 来自 `createRoot` 或者 `hydrateRoot`,因为后续 API 行为表现都一样,减少了理解成本。 ### Concurrent APIS 首先要了解 Concurrent Mode 是什么。 简单来说,Concurrent Mode 就是一种可中断渲染的设计架构。什么时候中断渲染呢?当一个更高优先级渲染到来时,通过放弃当前的渲染,立即执行更高优先级的渲染,换来视觉上更快的响应速度。 有人可能会说,不对啊,中断渲染后,之前渲染的 CPU 执行不就浪费了吗,换句话说,整体执行时长增加了。这句话是对的,但实际上用户对页面交互及时性的感知是分为两种的,第一种是即时输入反馈,第二种是这个输入带来的副作用反馈,比如更新列表。其中,即使输入反馈只要能优先满足,即便副作用反馈更慢一些,也会带来更好的体验,更不用说副作用反馈大部分情况会因为即使输入反馈的变化而作废。 由于 React 将渲染 DOM 树机制改为两个双向链表,并且渲染树指针只有一个,指向其中一个链表,因此可以在更新完全发生后再切换指针指向,而在指针切换之前,随时可以放弃对另一颗树的修改。 以上是背景输入。React 18 提供了三个新的 API 支持这一模式,分别是: - startTransition。 - useDeferredValue。 - <SuspenseList>。 后两个文档还未放出,所以本文只介绍第一个 API:startTransition。首先看一下用法: ```typescript import { startTransition } from "react"; // 紧急更新: setInputValue(input); // 标记回调函数内的更新为非紧急更新: startTransition(() => { setSearchQuery(input); }); ``` 简单来说,就是被 `startTransition` 回调包裹的 `setState` **触发的渲染** 被标记为不紧急的渲染,这些渲染可能被其他紧急渲染所抢占。 比如这个例子,当 `setSearchQuery` 更新的列表内容很多,导致渲染时 CPU 占用 100% 时,此时用户又进行了一个输入,即触发了由 `setInputValue` 引起的渲染,此时由 `setSearchQuery` 引发的渲染会立刻停止,转而对 `setInputValue` 渲染进行支持,这样用户的输入就能快速反映在 UI 上,代价是搜索列表响应稍慢了一些。而一个 `transition` 被打断的状态可以通过 `isPending` 访问到: ```typescript import { useTransition } from "react"; const [isPending, startTransition] = useTransition(); ``` 其实这比较符合操作系统的设计理念,我们知道在操作系统是通过中断响应底层硬件事件的,中断都非常紧急(因为硬件能存储的消息队列非常有限,操作系统不能即使响应,硬件的输入可能就丢失了),因此要支持抢占式内核,并在中断到来时立刻执行中断(可能把不太紧急的操作放到下半部执行)。 对前端交互来说,用户角度发出的 “中断” 一般来自键盘或鼠标的操作,但不幸的是,前端框架甚至是 JS 都过于上层,它们无法自动识别: 1. 哪些代码是紧急中断产生的。比如 `onClick` 就一定是用户鼠标点击产生的吗?不一定,可能是 `xxx.onClick` 主动触发的,而非用户触发。 2. 用户触发的就一定是紧急中断吗?不一定,比如键盘输入后,`setInputValue` 是紧急的,而更新查询列表的 `setSearchQuery` 就是非紧急的。 我们要理解到前端场景对用户操作感知的局限性,才能理解为什么必须手动指定更新的紧急程度,而不能像操作系统一样,上层程序无需感知中断的存在。 ### SSR for Suspense 完整名称是:Streaming SSR with selective hydration。 即像水流一样,打造一个从服务端到客户端持续不断的渲染管线,而不是 `renderToString` 那样一次性渲染机制。selective hydration 表示选择性水合,水合指的是后端内容打到前端后,JS 需要将事件绑定其上,才能响应用户交互或者 DOM 更新行为,而在 React 18 之前,这个操作必须是整体性的,而水合过程可能比较慢,会引起全局的卡顿,所以选择性水合可以按需优先进行水合。 所以这个特性其实是转为 SSR 准备的,而功能启用载体就是 Suspense(所以以后不要再认为 Suspense 只是一个 loading 作用)。其实在 Suspense 设计之初,就是为了解决服务端渲染问题,只是一开始只实装了客户端测的按需加载功能,后面你会逐渐发现 React 团地逐渐赋予了 Suspense 更多强大能力。 SSR for Suspense 解决三个主要问题: - SSR 模式下,如果不同模块取数效率不同,会因为最慢的一个模块拖慢整体 HTML 吞吐时间,这可能导致体验还不如非 SSR 来的好。举一个极端情况,假设报表中一个组件依赖了慢查询,需要五分钟数据才能出来,那么 SSR 的后果就是白屏时间拉长到 5 分钟。 - 即便 SSR 内容打到了页面上,由于 JS 没有加载完毕,所以根本无法进行 hydration,整个页面处于无法交互状态。 - 即便 JS 加载完了,由于 React 18 之前只能进行整体 hydration,可能导致卡顿,导致首次交互响应不及时。 在 React 18 的 server render 中,只要使用 `pipeToNodeWritable` 代替 `renderToString` 并配合 `Suspense` 就能解决上面三个问题。 使用 `pipeToNodeWriteable` 可以看 [这个例子](https://codesandbox.io/s/festive-star-9hfqt?file=/server/render.js:1043-1575)。 最大的区别在于,服务端渲染由简单的 `res.send` 改成了 `res.socket`,这样渲染就从单次行为变成了持续性的行为。 那么 React 18 的 SSR 到底有怎样的效果呢?[这篇介绍文档](https://github.com/reactwg/react-18/discussions/37) 的图建议看一看,非常直观,这里我简要描述一下: 1. 被 `` 包裹的区块,在服务端渲染时不会阻塞首次吞吐,而且在这个区块准备完毕后(包括异步取数)再实时打到页面中(以 HTML 模式,此时还没有 hydration),在此之前返回的是 `fallback` 的内容。 2. hydration 的过程也是逐步的,这样不会导致一下执行所有完整的 js 导致页面卡顿(hydration 其实就是 React 里写的回调注册、各类 Hooks,整个应用的量非常庞大)。 3. hydration 因为被拆成多部,React 还会提前监听鼠标点击,并提前对点击区域优先级进行 hydration,甚至能抢占已经在其他区域正在进行中的 hydration。 那么总结一下,新版 SSR 性能提高的秘诀在于两个字:按需。 而这个难点在于,SSR 需要后端到前端的配合,在 React 18 之前,后端到前端的过程完全没有优化,而现在将 SSR HTML 的吞吐改成多次,按需,并且水合过程中还支持抢占,因此性能得到进一步提升。 ## 总结 结合起来看,React 18 关注点在于更快的性能以及用户交互响应效率,其设计理念处处包含了中断与抢占概念。 以后提起前端性能优化,我们就多了一些应用侧的视角(而不仅仅是工程化视角),从以下两个应用优化视角有效提升交互反馈速度: 1. 随时中断的框架设计,第一优先级渲染用户最关注的 UI 交互模块。 2. 从后端到前端 “顺滑” 的管道式 SSR,并将 hydration 过程按需化,且支持被更高优先级用户交互行为打断,第一优先水合用户正在交互的部分。 > 讨论地址是:[精读《React 18》· Issue #336 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/336) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/204.精读《默认、命名导出的区别》.md ================================================ 从代码可维护性角度出发,命名导出比默认导出更好,因为它减少了因引用产生重命名情况的发生。 但命名导出与默认导出的区别不止如此,在逻辑上也有很大差异,为了减少开发时在这方面栽跟头,有必要提前了解它们的区别。 本周找来了这方面很好的的文章:[export-default-thing-vs-thing-as-default](https://jakearchibald.com/2021/export-default-thing-vs-thing-as-default/),先描述梗概,再谈谈我的理解。 ## 概述 一般我们认为,import 导入的是引用而不是值,也就是说,当导入对象在模块内值发生变化后,import 导入的对象值也应当同步变化。 ```javascript // module.js export let thing = 'initial'; setTimeout(() => { thing = 'changed'; }, 500); ``` 上面的例子,500ms 后修改导出对象的值。 ```javascript // main.js import { thing as importedThing } from './module.js'; const module = await import('./module.js'); let { thing } = await import('./module.js'); setTimeout(() => { console.log(importedThing); // "changed" console.log(module.thing); // "changed" console.log(thing); // "initial" }, 1000); ``` 1s 后输出发现,前两种输出结果变了,第三种没有变。也就是对命名导出来说,前两种是引用,第三种是值。 但默认导出又不一样: ```javascript // module.js let thing = 'initial'; export { thing }; export default thing; setTimeout(() => { thing = 'changed'; }, 500); ``` ```javascript // main.js import { thing, default as defaultThing } from './module.js'; import anotherDefaultThing from './module.js'; setTimeout(() => { console.log(thing); // "changed" console.log(defaultThing); // "initial" console.log(anotherDefaultThing); // "initial" }, 1000); ``` 为什么对默认导出的导入结果是值而不是引用? 原因是默认导出可以看作一种对 “default 赋值” 的特例,就像 `export default = thing` 这种旧语法表达的一样,本质上是一种赋值,所以拿到的是值而不是引用。 那么默认导出的另一种写法 `export { thing as default }` 也是如此吗?并不是: ```javascript // module.js let thing = 'initial'; export { thing, thing as default }; setTimeout(() => { thing = 'changed'; }, 500); ``` ```javascript // main.js import { thing, default as defaultThing } from './module.js'; import anotherDefaultThing from './module.js'; setTimeout(() => { console.log(thing); // "changed" console.log(defaultThing); // "changed" console.log(anotherDefaultThing); // "changed" }, 1000); ``` 可见,这种默认导出,导出的都是引用。所以导出是否是引用,不取决于是否是命名导出,**而是取决于写法**。不同的写法效果不同,哪怕相同含义的不同写法,效果也不同。 难道是写法的问题吗?是的,只要是 `export default` 导出的都是值而不是引用。但不幸的是,存在一个特例: ```javascript // module.js export default function thing() {} setTimeout(() => { thing = 'changed'; }, 500); ``` ```javascript // main.js import thing from './module.js'; setTimeout(() => { console.log(thing); // "changed" }, 1000); ``` 为什么 `export default function` 是引用呢?原因是 `export default function` 是一种特例,这种写法就会导致导出的是引用而不是值。如果我们用正常方式导出 Function,那依然遵循前面的规则: ```javascript // module.js function thing() {} export default thing; setTimeout(() => { thing = 'changed'; }, 500); ``` 只要没有写成 `export default function` 语法,哪怕导出的对象是个 Function,引用也不会变化。所以取决效果的是写法,而与导出对象类型无关。 对于循环引用也有时而生效,时而不生效的问题,其实也取决于写法。下面的循环引用是可以正常工作的: ```javascript // main.js import { foo } from './module.js'; foo(); export function hello() { console.log('hello'); } ``` ```javascript // module.js import { hello } from './main.js'; hello(); export function foo() { console.log('foo'); } ``` 为什么呢?因为 `export function` 是一种特例,JS 引擎对其做了全局引用提升,所以两个模块都能各自访问到。下面方式就不行了,原因是不会做全局提升: ```javascript // main.js import { foo } from './module.js'; foo(); export const hello = () => console.log('hello'); ``` ```javascript // module.js import { hello } from './main.js'; hello(); export const foo = () => console.log('foo'); ``` 所以是否生效取决于是否提升,而是否提升取决于写法。当然下面的写法也会循环引用失败,因为这种写法会被解析为导出值: ```javascript // main.js import foo from './module.js'; foo(); function hello() { console.log('hello'); } export default hello; ``` 作者的探索到这里就结束了,我们来整理一下思路,尝试理解其中的规律。 ## 精读 可以这么理解: 1. 导出与导入均为引用时,最终才是引用。 2. 导入时,除 `{} = await import()` 外均为引用。 3. 导出时,除 `export default thing` 与 `export default 123` 外均为引用。 对导入来说,`{} = await import()` 相当于重新赋值,所以具体对象的引用会丢失,也就是说异步的导入会重新赋值,而 `const module = await import()` 引用不变的原因是 `module` 本身是一个对象,`module.thing` 的引用还是不变的,即便 `module` 是被重新赋值的。 对导出来说,默认导出可以理解为 `export default = thing` 的语法糖,所以 `default` 本身就是一个新的变量被赋值,所以基础类型的引用无法被导出也很合理。甚至 `export default '123'` 是合法的,而 `export { '123' as thing }` 是非法的也证明了这一点,因为命名导出本质是赋值到 `default` 变量,你可以用已有变量赋值,也可以直接用一个值,但命名导出不存在赋值,所以你不能用一个字面量作命名导出。 而导出存在一个特例,`export default function`,这个我们尽量少写就行了,写了也无所谓,因为函数保持引用不变一般不会引发什么问题。 为了保证导入的总是引用,一方面尽量用命名导入,另一方面要注意命名导出。如果这两点都做不到,可以尽量把需要维持引用的变量使用 `Object` 封装,而不要使用简单变量。 最后对循环依赖而言,只有 `export default function` 存在声明提升的 Magic,可以保证循环依赖正常 Work,但其他情况都不支持。要避免这种问题,最好的办法是不要写出循环依赖,遇到循环依赖时使用第三个模块作中间人。 ## 总结 一般我们都希望 import 到的是引用而不是瞬时值,但因为语义与特殊语法糖的原因,导致并不是所有写法效果都是一致的。 我也认为不需要背下来这些导入导出细枝末节的差异,只要写模块时都用规范的命名导入导出,少用默认导出,就可以在语义与实际表现上规避掉这些问题啦。 > 讨论地址是:[精读《export 默认/命名导出的区别》· Issue #342 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/342) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/205.精读《JS with 语法》.md ================================================ with 是一个不推荐使用的语法,因为它的作用是改变上下文,而上下文环境对开发者影响很大。 本周通过 [JavaScript's Forgotten Keyword (with)](https://dev.to/mistval/javascript-s-forgotten-keyword-with-48id) 这篇文章介绍一下 with 的功能。 ## 概述 下面是一种使用 with 的例子: ```javascript with (console) { log('I dont need the "console." part anymore!'); } ``` 我们往上下文注入了 `console` 对象,而 `console.log` 这个属性就被注册到了这个 Scope 里。 再比如: ```javascript with (console) { with (['a', 'b', 'c']) { log(join('')); // writes "abc" to the console. } } ``` 通过嵌套,我们可以追加注入上下文。其中 `with (['a', 'b', 'c'])` 其实是把 `['a', 'b', 'c']` 的返回值对象注入到了上下文,而数组对象具有 `.join` 成员函数,所以可以直接调用 `join('')` 输出 `"abc"`。 为了不让结果这么 Magic,建议以枚举方式申明要注入的 key: ```javascript with ({ myProperty: 'Hello world!' }) { console.log(myProperty); // Logs "Hello world!" } ``` 那为什么不推荐使用 with 呢?比如下面的情况: ```javascript function getAverage(min, max) { with (Math) { return round((min + max) / 2); } } getAverage(1, 5); ``` 注入的上下文可能与已有上下文产生冲突,导致输出结果为 `NaN`。 所以业务代码中不推荐使用 with,而且实际上在 **严格模式** 下 with 也是被禁用的。 ## 精读 由于 with 定义的上下文会优先查找,因此在前端沙盒领域是一种解决方案,具体做法是: ```javascript const sandboxCode = `with(scope) { ${code} }` new Function('scope', sandboxCode) ``` 这样就把所有 scope 定义的对象限定住了。但如果访问 scope 外的对象还是会向上冒泡查找,我们可以结合 Proxy 来限制查找范围,这样就能完成一个可用性尚可的沙盒。 第二种 with 的用法是前端模版引擎。 我们经常看到模版引擎里会有一些 `forEach`、`map` 等特殊用法,这些语法完全可以通过 with 注入。当然并不是所有模版引擎都是这么实现的,还有另一种方案是,现将模版引擎解析为 AST,再根据 AST 构造并执行,如果把这个过程放到编译时,那么 JSX 就是一个例子。 最后关于 with 注入上下文,还有一个误区,那就是认为下面的代码仅仅注入了 `run` 属性: ```javascript with ({ run: () => {} }) { run() } ``` 其实不然,因为 with 会在整个原型链上查找,而 `{}` 的原型链是 `Object.prototype`,这就导致挂在了许多非预期的属性。 如果想要挂载一个纯净的对象,可以使用 `Object.create()` 创建对象挂载到 with 上。 ## 总结 with 的使用场景很少,一般情况下不推荐使用。 如果你还有其他正经的 with 使用场景,可以告知我,或者给出评论。 > 讨论地址是:[精读《JS with 语法》· Issue #343 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/343) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/206.精读《一种 Hooks 数据流管理方案》.md ================================================ 维护大型项目 OR UI 组件模块时,一定会遇到全局数据传递问题。 维护项目时,像全局用户信息、全局项目配置、全局功能配置等等,都是跨模块复用的全局数据。 维护 UI 组件时,调用组件的入口只有一个,但组件内部会继续拆模块,分文件,对于这些组件内模块而言,入口文件的参数也就是全局数据。 这时一般有三种方案: 1. props 透传。 2. 上下文。 3. 全局数据流。 props 透传方案,因为任何一个节点掉链子都会导致参数传递失败,因此带来的维护成本与心智负担都特别大。 上下文即 `useContext` 利用上下文共享全局数据,带来的问题是更新粒度太粗,同上下文中任何值的改变都会导致重渲染。有一种较为 Hack 的解决方案 [use-context-selector](https://github.com/dai-shi/use-context-selector),不过这个和下面说到的全局数据流很像。 全局数据流即利用 `react-redux` 等工具,绕过 React 更新机制进行全局数据传递的方案,这种方案较好解决了项目问题,但很少有组件会使用。以前也有过不少利用 Redux 做局部数据流的方案,但本质上还是全局数据流。现在 `react-redux` 支持了局部作用域方案: ```javascript import { shallowEqual, createSelectorHook, createStoreHook } from 'react-redux' const context = React.createContext(null) const useStore = createStoreHook(context) const useSelector = createSelectorHook(context) const useDispatch = createDispatchHook(context) ``` 因此是机会好好梳理一下数据流管理方案,做一个项目、组件通用的数据流管理方案。 ## 精读 对项目、组件来说,数据流包含两种数据: 1. 可变数据。 2. 不可变数据。 对项目来说,可变数据的来源有: 1. 全局外部参数。 2. 全局项目自定义变量。 不可变数据来源有: 1. 操作数据或行为的函数方法。 > 全局外部参数指不受项目代码控制的,比如登陆用户信息数据。全局项目自定义变量是由项目代码控制的,比如定义了一些模型数据、状态数据。 对组件来说,可变数据的来源有: 1. 组件被调用时的传参。 2. 全局组件自定义变量。 不可变数据来源有: 1. 组件被调用时的传参。 2. 操作数据或行为的函数方法。 对组件来说,被调用时的传参既可能是可变数据,也可能是不可变数据。比如传入的 `props.color` 可能就是可变数据,而 `props.defaultValue`、`props.onChange` 就是不可变数据。 当梳理清楚项目与组件到底有哪些全局数据后,我们就可以按照注册与调用这两步来设计数据流管理规范了。 ### 数据流调用 首先来看调用。为了同时保证使用的便捷与应用程序的性能,我们希望使用一个统一的 API `useXXX` 来访问所有全局数据与方法,并满足: 1. `{} = useXXX()` 只能引用到不可变数据,包括变量与方法。 2. `{ value } = useXXX(state => ({ value: state.value }))` 可以引用到可变数据,但必须通过选择器来调用。 比如一个应用叫 `gaea`,那么 `useGaea` 就是对这个应用全局数据的唯一调用入口,我可以在组件里这么调用数据与方法: ```typescript const Panel = () => { // appId 是应用不可变数据,所以即使是变量也可以直接获取,因为它不会变化,也不会导致重渲染 // fetchData 是取数函数,内置发送了 appId,所以绑定了一定上下文,也属于不可变数据 const { appId, fetchData } = useGaea() // 主题色可能在运行时修改,只能通过选择器获取 // 此时这个组件会额外在 color 变化时重渲染 const { color } = useGaea(state => ({ color: state.theme?.color })) } ``` 比如一个组件叫 `Menu`,那么 `useMenu` 就是这个组件的全局数据调用入口,可以这么使用: ```typescript // SubMenu 是 Menu 组件的子组件,可以直接使用 useMenu const SubMenu = () => { // defaultValue 是一次性值,所以处理时做了不可变处理,这里已经是不可变数据了 // onMenuClick 是回调函数,不管传参引用如何变化,这里都处理成不可变的引用 const { defaultValue, onMenuClick } = useMenu() // disabled 是 menu 的参数,需要在变化时立即响应,所以是可变数据 const { disabled } = useMenu(state => ({ disabled: state.disabled })) // selectedMenu 是 Menu 组件的内部状态,也作为可变数据调用 const { selectedMenu } = useMenu(state => ({ selectedMenu: state.selectedMenu })) } ``` 可以发现,在整个应用或者组件的使用 Scope 中,已经做了一层抽象,即不关心数据是怎么来的,只关心数据是否可变。这样对于组件或应用,随时可以将内部状态开放到 API 层,而内部代码完全不用修改。 ### 数据流注册 数据流注册的时候,我们只要定义三种参数: 1. `dynamicValue`: 动态参数,通过 `useInput(state => state.xxx)` 才能访问到。 2. `staticValue`: 静态参数,引用永远不会改变,可以直接通过 `useInput().xxx` 访问到。 3. 自定义 hooks,入参是 `staticValue` `getState` `setState`,这里可以封装自定义方法,并且定义的方法都必须是静态的,可以直接通过 `useInput().xxx` 访问到。 ```typescript const { useState: useInput, Provider } = createHookStore<{ dynamicValue: { fontSize: number } staticValue: { onChange: (value: number) => void } }>(({ staticValue }) => { const onCustomChange = React.useCallback((value: number) => { staticValue.onChange(value + 1) }, [staticValue]) return React.useMemo(() => ({ onCustomChange }), [onCustomChange]) }) ``` 上面的方法暴露了 `Provider` 与 `useInput` 两个对象,我们首先需要在组件里给它传输数据。比如我写的是组件 `Input`,就可以这么调用: ```jsx function Input({ onChange, fontSize }) { return ( ) } ``` 如果对于某些动态数据,我们只想赋初值,可以使用 `defaultDynamicValue`: ```jsx function Input({ onChange, fontSize }) { return ( ) } ``` 这样 `count` 就是一个动态值,必须通过 `useInput(state => ({ count: state.count }))` 才能取到,但又不会因为外层组件 Rerender 而被重新赋值为 `1`。所有动态值都可以通过 `setState` 来修改,这个后面再说。 这样所有 Input 下的子组件就可以通过 `useInput` 访问到全局数据流的数据啦,我们有三种访问数据的场景。 一:访问传给 `Input` 组件的 `onChange`。 因为 `onChange` 是不可变对象,因此可以通过如下方式访问: ```typescript function InputComponent() { const { onChange } = useInput() } ``` 二:访问我们自定义的全局 Hooks 函数 `onCustomChange`: ```typescript function InputComponent() { const { onCustomChange } = useInput() } ``` 三:访问可能变化的数据 `fontSize`。由于我们需要在 `fontSize` 变化时让组件重渲染,又不想让上面两种调用方式受到 `fontSize` 的影响,需要通过如下方式访问: ```typescript function InputComponent() { const { fontSize } = useInput(state => ({ fontSize: state.fontSize })) } ``` 最后在自定义方法中,如果我们想修改可变数据,都要通过 `updateStore` 封装好并暴露给外部,而不能直接调用。具体方式是这样的,举个例子,假设我们需要定义一个应用状态 `status`,其可选值为 `edit` 与 `preview`,那么可以这么去定义: ```jsx const { useState: useInput, Provider } = createHookStore<{ dynamicValue: { isAdmin: boolean status: 'edit' | 'preview' } }>(({ getState, setState }) => { const toggleStatus = React.useCallback(() => { // 管理员才能切换应用状态 if (!getState().isAdmin) { return } setState(state => ({ ...state, status: state.status === 'edit' ? 'preview' : 'edit' })) }, [getState, setState]) return React.useMemo(() => ({ toggleStatus }), [toggleStatus]) }) ``` 下面是调用: ```jsx function InputComponent() { const { toggleStatus } = useInput() return (
); } ``` 这个例子可以在浏览器运行时做类似 babel 的事情,无论是低代码平台还是在线 coding 平台都可以用它做运行时编译。 #### @swc/jest `@swc/jest` 提供了 Rust 版本的 jest 实现,让 jest 跑得更快。使用方式也很简单,首先安装: ```bash npm i @swc/jest ``` 然后在 `jest.config.js` 配置文件中,将 ts 文件 compile 指向 `@swc/jest` 即可: ```javascript module.exports = { transform: { "^.+\\.(t|j)sx?$": ["@swc/jest"], }, }; ``` #### swc-loader `swc-loader` 是针对 webpack 的 loader 插件,代替 `babel-loader`: ```javascript module: { rules: [ { test: /\.m?js$/, exclude: /(node_modules)/, use: { // `.swcrc` can be used to configure swc loader: "swc-loader" } } ]; } ``` #### swcpack 增强了多文件 bundle 成一个文件的功能,基本可以认为是 swc 版本的 webpack,当然性能也会比 `swc-loader` 方案有进一步提升。 截至目前,该功能还在测试阶段,只要安装了 `@swc/cli` 就可使用,通过创建 `spack.config.js` 后执行 `npx spack` 即可运行,和 webpack 的使用方式一样。 ### Deno [Deno](https://deno.land/) 的 linter、code formatter、文档生成器采用 swc 构建,因此也算属于 Rust 阵营。 Deno 是一种新的 js/ts 运行时,所以我们总喜欢与 node 进行类比。[quickjs](https://bellard.org/quickjs/) 也一样,这三个都是一种对 js 语言的运行器,作为开发者,需求永远是更好的性能、兼容性与生态,三者几乎缺一不可,所以当下虽然不能完全代替 Nodejs,但作为高性能替代方案是很香的,可以基于他们做一些跨端跨平台的解析器,比如 [kraken](https://github.com/openkraken/kraken) 就是基于 quickjs + flutter 实现的一种高性能 web 渲染引擎,是 web 浏览器的替代方案,作为一种跨端方案。 ### esbuild [esbuild](https://esbuild.github.io/) 是较早被广泛使用的新一代 JS 基建,是 JS 打包与压缩工具。虽然采用 Go 编写,但性能与 Rust 不相上下,可以与 Rust 风潮放在一起看。 esbuild 目前有两个功能:编译和压缩,理论上分别可代替 babel 与 terser。 编译功能的基本用法: ```js require('esbuild').transformSync('let x: number = 1', { loader: 'ts', }) // 'let x = 1;\n' ``` 压缩功能的基本用法: ```js require('esbuild').transformSync('fn = obj => { return obj.x }', { minify: true, }) // 'fn=n=>n.x;\n' ``` 压缩功能比较稳定,适合用在生产环境,而编译功能要考虑兼容 webpack 的地方太多,在成熟稳定后才考虑能在生产环境使用,目前其实已经有不少新项目已经在生产环境使用 esbuild 的编译功能了。 编译功能与 `@swc` 类似,但因为 Rust 支持编译到 wasm,所以 `@swc` 提供了 web 运行时编译能力,而 esbuild 目前还没有看到这种特性。 ### Rome [Rome](https://rome.tools/blog/2020/08/08/introducing-rome) 是 Babel 作者做的基于 Nodejs 的前端基建全家桶,包含但不限于 Babel, ESLint, webpack, Prettier, Jest。目前 [计划使用 Rust 重构](https://rome.tools/blog/2021/09/21/rome-will-be-rewritten-in-rust),虽然还没有实现,但我们姑且可以把 Rome 当作 Rust 的一员。 `rome` 是个全家桶 API,所以你只需要 `yarn add rome` 就完成了所有环境准备工作。 - `rome bundle` 打包项目。 - `rome compile` 编译单个文件。 - `rome develop` 调试项目。 - `rome parse` 解析文件抽象语法树。 - `rome analyzeDependencies` 分析依赖。 Rome 还将文件格式化与 Lint 合并为了 `rome check` 命令,并提供了[友好 UI 终端提示](https://rome.tools/#command-usage)。 其实我并不太看好 Rome,因为它负担太重了,测试、编译、Lint、格式化、压缩、打包的琐碎事情太多,把每一块交给社区可能会做得更好,这不现在还在重构中,牵一发而动全身。 ### NAPI-RS [NAPI-RS](https://napi.rs/) 提供了高性能的 Rust 到 Node 的衔接层,可以将 Rust 代码编译后成为 Node 可调用文件。下面是官网的例子: ```rust #[js_function(1)] fn fibonacci(ctx: CallContext) -> Result { let n = ctx.get::(0)?.try_into()?; ctx.env.create_int64(fibonacci_native(n)) } ``` 上面写了一个斐波那契数列函数,直接调用了 `fibonacci_native` 函数实现。为了让这个方法被 Node 调用,首先安装 CLI:`npm i @napi-rs/cli`。 由于环境比较麻烦,因此需要利用这个脚手架初始化一个工作台,我们在里面写 Rust,然后再利用固定的脚本发布 npm 包。执行 `napi new` 创建一个项目,我们发现入口文件肯定是个 js,毕竟要被 node 引用,大概长这样(我创建了一个 `myLib` 包): ```js const { loadBinding } = require('@node-rs/helper') /** * __dirname means load native addon from current dir * 'myLib' is the name of native addon * the second arguments was decided by `napi.name` field in `package.json` * the third arguments was decided by `name` field in `package.json` * `loadBinding` helper will load `myLib.[PLATFORM].node` from `__dirname` first * If failed to load addon, it will fallback to load from `myLib-[PLATFORM]` */ module.exports = loadBinding(__dirname, 'myLib', 'myLib') ``` 所以 loadBinding 才是入口,同时项目文件夹下存在三个系统环境包,分别供不同系统环境调用: - `@cool/core-darwin-x64` macOS x64 平台。 - `@cool/core-win32-x64` Windows x64 平台。 - `@cool/core-linux-arm64-gnu` Linux aarch64 平台。 `@node-rs/helper` 这个包的作用是引导 node 执行预编译的二进制文件,`loadBinding` 函数会尝试加载当前平台识别的二进制包。 将 `src/lib.rs` 的代码改成上面斐波那契数列的代码后,执行 `npm run build` 编译。注意在编译前需要安装 rust 开发环境,只要一行脚本即可安装,具体看 [rustup.rs](https://rustup.rs/)。然后把当前项目整体当作 node 包发布即可。 发布后,就可以在 node 代码中引用啦: ```javascript import { fibonacci } from 'myLib' function hello() { let result = fibonacci(10000) console.log(result) return result } ``` NAPI-RS 作为 Rust 与 Node 的桥梁,很好的解决了 Rust 渐进式替换现有 JS 工具链的问题。 ### Rust + WebAssembly [Rust + WebAssembly](https://www.rust-lang.org/what/wasm) 说明 Rust 具备编译到 wasm 的能力,虽然编译后代码性能会变得稍慢,但还是比 js 快很多,同时由于 wasm 的可移植性,让 Rust 也变得可移植了。 其实 Rust 支持编译到 WebAssembly 也不奇怪,因为本来 WebAssembly 的定位之一就是作为其他语言的目标编译产物,然后它本身支持跨平台,这样它就很好的完成了传播的使命。 WebAssembly 是一个基于栈的虚拟机 ([stack machine](https://webassembly.github.io/spec/core/exec/index.html)),所以跨平台能力一流。 想要将 Rust 编译为 wasm,除了安装 Rust 开发环境外,还要安装 [wasm-pack](https://rustwasm.github.io/wasm-pack/installer/)。 安装后编译只需执行 `wasm-pack build` 即可。更多用法可以查看 [API 文档](https://rustwasm.github.io/wasm-pack/book/commands/build.html)。 ### dprint [dprint](https://github.com/dprint/dprint) 是用 rust 编写的 js/ts 格式化工具,并提供了 [dprint-node](https://github.com/devongovett/dprint-node) 版本,可以直接作为 node 包,通过 npm 安装使用,从 [源码](https://github.com/devongovett/dprint-node/blob/main/src/lib.rs) 可以看到,使用 [NAPI-RS](https://napi.rs/) 实现。 `dprint-node` 可以直接在 Node 中使用: ```js const dprint = require('dprint-node'); dprint.format(filePath, code, options); ``` [参数文档](https://dprint.dev/plugins/typescript/config/)。 ### Parcel [Parcel](https://parceljs.org/) 严格来说算是上一代 JS 基建,它出现在 Webpack 之后,Rust 风潮之前。不过由于它已经[采用 SWC 重写](https://github.com/parcel-bundler/parcel/pull/6230),所以姑且算是跟上了时髦。 ## 总结 前端全家桶已经有了一整套 Rust 实现,只是对于存量项目的编译准确性需要大量验证,我们还需要时间等待这些库的成熟度。 但毫无疑问的是,Rust 语言对 JS 基建支持已经较为完备了,剩下的只是工具层逻辑覆盖率的问题,都可以随时间而解决。而用 Rust 语言重写后的逻辑带来的巨幅性能提升将为社区注入巨大活力,就像原文说的,前端社区可以为了巨大性能提升而引入 Rust 语言,即便这可能导致为社区贡献门槛的提高。 > 讨论地址是:[精读《Rust 是 JS 基建的未来》· Issue #371 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/371) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/219.精读《深入了解现代浏览器一》.md ================================================ [Inside look at modern web browser](https://developers.google.com/web/updates/2018/09/inside-browser-part1) 是介绍浏览器实现原理的系列文章,共 4 篇,本次精读介绍第一篇。 虽然本文写于 2018 年,但如今依然值得学习,因为浏览器实现非常复杂,从细节开始学习很容易迷失方向,缺乏整体感,而这篇文章从宏观层面开始介绍,几乎没有涉及代码实现,全都是思路性的描述,非常适合培养对浏览器整体框架性思维。 原文有非常多形象的插图与动图,便于加深对知识的理解,所以也推荐直接阅读原文。 ## 概述 文章先从 CPU、GPU、操作系统开始介绍,因为这些是浏览器运行的基座。 ### CPU、GPU、操作系统、应用的关系 CPU 即中央处理器,可以处理几乎所有计算。以前的 CPU 是单核的,现在大部分笔记电脑都是多核的,专业服务器甚至有高达 100 多核的。CPU 计算能力很强,但只能一件件事处理, GPU 一开始是为图像处理设计的,即主要处理像素点,所以拥有大量并行的处理简单事物的能力,非常适合用来做矩阵运算,而矩阵运算又是计算机图形学的基础,所以大量用在可视化领域。 CPU、GPU 都是计算机硬件,这些硬件各自都提供了一些接口供汇编语言调用;而操作系统则基于它们之上用 C 语言(如 linux)将硬件管理了起来,包括进程调度、内存分配、用户内核态切换等等;运行在操作系统之上的则是应用程序了,所以应用程序不直接和硬件打交道,而是通过操作系统间接操作硬件。 > 为什么应用程序不能直接操作硬件呢?这样做有巨大的安全隐患,因为硬件是没有任何抽象与安全措施的,这意味着理论上一个网页可以通过 js 程序,在你打开网页时直接访问你的任意内存地址,读取你的聊天记录,甚至读取历史输入的银行卡密码进行转账操作。 显然,浏览器作为一个应用程序,运行在操作系统之上。 ### 进程与线程 为了让程序运行的更安全,操作系统创造了进程与线程的概念(linux 对进程与线程的实现是同一套),进程可以分配独立的内存空间,进程内可以创建多个线程进行工作,这些线程共享内存空间。 因为线程间共享内存空间,因此不需通信就能交流,但内存地址相互隔离的进程间也有通信需求,需通过 IPC(Inter Process Communication)进行通信。 进程之间相互独立,即一个进程挂了不会影响到其它进程,而在一个进程中可以创建一个新进程,并与之通信,所以浏览器就采用了这种策略,将 UI、网络、渲染、插件、存储等模块进程独立,并且任意挂掉后都可以被重新唤起。 ### 浏览器架构 浏览器可以拆分为许多独立的模块,比如: - 浏览器模块(Browser):负责整个浏览器内行为协调,调用各个模块。 - 网络模块(Network):负责网络 I/O。 - 存储模块(Storage):负责本地 I/O。 - 用户界面模块(UI):负责浏览器提供给用户的界面模块。 - GPU 模块:负责绘图。 - 渲染模块(Renderer):负责渲染网页。 - 设备模块(Device):负责与各种本地设备交互。 - 插件模块(Plugin):负责处理各类浏览器插件。 基于这些模块,浏览器有两种可用的架构设计,一种是少进程,一种是多进程。 少进程是指将这些模块放在一个或有限的几个进程里,也就是每个模块一个线程,这样做的好处是最大程度共享了内存空间,对设备要求较低,但问题是只要一个线程挂了都会导致整个浏览器挂掉,因此稳定性较差。 多进程是指为每个模块(尽量)开辟一个进程,模块间通过 IPC 通信,因此任何模块挂掉都不会影响其它模块,但坏处是内存占用较大,比如浏览器 js 解析与执行引擎 V8 就要在这套架构下拷贝多份实例运行在每个进程中。 ### Chrome 多进程架构的优势 Chrome 尽量为每个 tab 单独创建一个进程,所以我们才能在某个 tab 未响应时,从容的关闭它,而其它 tab 不会受到影响。不仅是 tab 间,一个 tab 内的 iframe 间也会创建独立的进程,这样做是为了保护网站的安全性。 ### 服务化 - 单/多进程弹性架构 Chrome 并不满足于采用一种架构,而是在不同环境下切换不同的架构。Chrome 将各功能模块化后,就可以自由决定当前将哪些模块放在一个进程中,将哪些模块启动独立进程,即可以在运行时决定采用哪套进程架构。 这样做的好处是,可以在资源受限的机器上开启单进程模式,以尽量节约内存开销,实际上在手机应用上就是这么做的;而在资源丰富、内核数量充足的机器上采用独立进程模式,虽然消耗了更多资源,但获得了更好的稳定性。 ### Iframe 独占进程 [site-isolation](https://developers.google.com/web/updates/2018/07/site-isolation) 将同一个 tab 内不同 iframe 包裹在不同的进程内运行,以确保 iframe 间资源的独占性,以及安全性。该功能直到 2018.7 才更新,是因为背后有许多复杂的工作要处理,比如开发者工具的调试、网页的全局搜索功能,都不能因为进程的隔离而受到影响,Chrome 必须让每个进程单独响应这些操作,并最终聚合在一起,让用户感受不到进程间的阻隔。 ## 精读 本文从浏览器如何基于操作系统提供的进程、线程概念构建自己的应用程序开始,从硬件、操作系统、软件的分层开始,介绍到浏览器是如何划分模块的,并且分配进程或线程给这些模块运行,这背后的思考非常有价值。 从宏观角度看,要设计一个安全稳定、高性能、具有拓展性的浏览器,首先要把各功能模块划分清楚,并定义好各模块的通信关系,在各业务场景下制定一套模块协作的流程。 ### 浏览器的主从架构 类似应用程序的主从模式,浏览器的 Browser 模块可以看作主模块,它本身用于协调其它模块的运行,并维持其它各模块的正常工作,在其它模块失去响应时等待或重新唤起,或者在模块销毁时进行内存回收。 各从模块也分工明确,比如在浏览器敲击 URL 地址时,会先通过 UI 模块响应用户的输入,并判断输入是否为 URL 地址,因为输入的可能是其它非法参数,或一些查询或设置命令。若输入的确实是 URL 地址,则校验通过后,会通知 Network 网络模块发送请求,UI 模块就不再关心请求是如何处理了。Network 模块也是相对独立的,仅处理请求的发送与接收,如果接收到的是 HTML 网页,则交给 Renderer 模块进行渲染。 有了这些相对独立且分工明确的模块划分后,将这些模块作为线程或进程管理就都不会影响它们的业务逻辑了,唯一影响的就是内存是否共享,以及某个模块 crash 后是否会影响到其它模块了,所以基于这个架构,判断设备类型,以采用单进程或多进程模式就变得简单了很多,且这个进程弹性架构本身也不需要入侵各模块业务逻辑,本身就是一套独立的机制。 浏览器作为非常复杂的应用程序,想要持续维护,就必须对每个功能点都进行合理的设计,让模块间高内聚、低耦合,这样才不至于让任何修改牵一发而动全身。 ### tab、iframe 进程隔离 微前端的沙箱隔离方案也比较火,这里可以和浏览器 tab/iframe 隔离做个对比。 基于 js 运行时的沙箱方案大多都因为吐槽 iframe 慢而诞生的,一般会基于 `with` 改变沙箱代码的上下文,修改访问的全局对象引用,但基于 js 原型链特征,为了阻断向原型链追溯到主应用代码,一般会采用 `proxy` 对 `with` mock 的变量进行访问阻断。 还有一些方案利用创建空 iframe 获取到 document 变量传递给沙箱,一定程度做到了访问隔离,且对 document 添加的监听会随 iframe 销毁而销毁,便于控制。 还有一些更加彻底的尝试,将 js 代码扔到 web worker 运行,并通过 mock 模拟了 worker 运行时缺失的 dom API。 对比这些方案可以发现,只有最后 worker 的方案是最彻底的,因为浏览器创建的 worker 进程是完全资源隔离的,想要和浏览器主线程通信只能利用 `postMessage`,虽然有一些基于 ArrayBuffer 的内存共享方案,但因为支持的数据类型具有针对性,也不会存在安全问题。 回到浏览器开发者的视角,为什么 iframe 隔离要花费九牛二虎之力拆分多进程,最后再费很大功夫拼接回来,还原出一个相对无缝的体验?浏览器厂商其实完全可以利用上面提到的 js 运行时能力,对 API 语法进行改造,创建一个逻辑上的沙盒环境。 我认为本质原因是浏览器要实现的沙盒必须是进程层面的,也就是对内存访问权限的绝对隔离,因为逻辑层面的隔离可能随着各浏览器厂商实现差异,或 API 本身存在的逻辑漏洞而导致越权情况的出现,所以如果需要构造一个完全安全的沙盒,最好利用浏览器提供的 API 创建新的进程处理沙盒代码。 ## 总结 本文介绍了浏览器是如何基于操作系统做宏观架构设计的,主要就说了一件事,即对进程,线程模型的弹性使用。同时在 tab、iframe 的设计中也要考虑到安全性要求,在必要的时候采用进程,在浏览器自身模块间因为没有安全性问题,所以可对进程模型进行灵活切换。 > 讨论地址是:[精读《深入了解现代浏览器一》· Issue #374 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/374) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/22.精读《V8 引擎特性带来的的 JS 性能变化》.md ================================================ 本期精读的文章是:[V8 引擎特性带来的的 JS 性能变化](https://www.nearform.com/blog/node-js-is-getting-a-new-v8-with-turbofan/) # 1 引言 logo 定时刷新一下对 js 的三观,防止经验变成坑。(对 IE 等浏览器的三观需保持不变) # 2 内容概要 ## try catch 对性能的影响忽略不计 try catch 非常有用,特别在 node 端更是一个常规操作。前端框架也越来越多采用了异常捕获的方式,结合 async await 让代码组织得更加优雅,详细可以看我的这篇博文 [统一异常捕获](https://github.com/ascoders/blog/issues/14)。react mixins 也喜欢 try 住 render 方法,包括 16 版本自动 try 住了所有 render,try catch 可谓无处不在。 node 8 版本之后 try 内部函数性能损耗可以忽略不计。 但是当前版本仍然存在安全隐患,将 [这里的代码](https://gist.github.com/mcollina/a40c6c3820287a474e9943406385c738) 拷贝到 chrome 控制台,当前页面会进入无限死循环。 此例子对 try catch 块做了大量循环,官方说法是在某些代码组合情况下陷入无限优化循环。 ## 解决 delete 性能问题 js 正在变得越来越简单,该 delete 的地方也不会犹豫是否写成 undefined,以提升性能为代价降低代码可读性了。 ## arguments 转数组性能已不是问题 在 node8.3 版本及以上,该使用拓展运算符获取参数,不但没有性能问题,可读性也大大提高,结合 ts 时也能得到类型支持。 ## bind 对性能影响可以忽略 但是在 react 中副作用仍需警惕。由于 ui 组件复用次数在大部分场景及其有限,强烈推荐使用箭头函数书写成员函数(在我的另一篇精读 [This 带来的困惑](https://github.com/dt-fe/weekly/blob/master/13.This%20%E5%B8%A6%E6%9D%A5%E7%9A%84%E5%9B%B0%E6%83%91.md#4-总结) 有详细介绍),而且在 node8 中,箭头函数的性能是最好的。 ## 函数调用对性能影响越来越小 对函数调用优化的越来越好,不需要过于担心注释与空白、函数间调用对性能的影响. ## 32 64 位数字计算性能 node8 对超长数字计算性能还是较低,大概是 32 位数字性能的 2/3,所以尽量用字符串处理大数。 ## 遍历 object 基本用法有 `for in` `Object.keys` `Object.values`. 在 node8 中,`for in` 将变得更慢,但任然比其他两种方法快,所以,尽早取消不必要的优化。 ## 创建对象 创建对象速度在 node8 得到极大提升,似乎是面向对象编程的福音。 ## 多态函数的性能问题 当函数或者对象存在多种类型参数时,在 node8 中性能没什么优化,但单态函数性能大幅提升。所以尽量让对象内部属性单态是比较有用的,比如尽量不要对字符串数组 `push` 一个数字。 # 3 精读 ## try catch 的问题 在 v8 优化之前,前端 try catch 存在挺大的性能问题,导致许多老旧的项目很少有使用异常的场景,而经验丰富的程序员也会极力避免使用 try catch,在必须使用 try catch 的地方,将代码逻辑封装在函数中,try 住函数而不是代码块,以降低性能损失。 现在是推翻这些经验的时候了,合理的异常处理还能够优化用户体验。 前端代码最容易出错的逻辑在于对后端数据的处理,一旦后端数据出错,前端整条数据处理链路难免报错或者抛出异常。这种场景最适合将异常 try 住,显示提示文案,同时也避免代码内部对数据格式过多的兼容处理。 ## 语句数量对性能的影响 由于语句数量对性能影响已经忽略不计了,以前推崇的写法可以说再见了: ```javascript // 提倡 var i = 1; var j = "hello"; var arr = [1,2,3]; var now = new Date(); // 避免 var i = 1, j = "hello", arr = [1,2,3], now = new Date(); ``` # 4 总结 这波 v8 优化带来了一些 js 性能上的改变,但在 js 性能优化中只解决了很小一块问题,而 js 在前端性能优化又只是冰山一角,dom 与 样式 的优化对性能影响也非常重大,我们仍然应该重视代码质量,提高代码性能。 > 讨论地址是:[精读《V8 引擎特性带来的的 JS 性能变化》 · Issue #33 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/33) > 如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,每周五发布。 ================================================ FILE: 前沿技术/220.精读《深入了解现代浏览器二》.md ================================================ [Inside look at modern web browser](https://developers.google.com/web/updates/2018/09/inside-browser-part2) 是介绍浏览器实现原理的系列文章,共 4 篇,本次精读介绍第二篇。 ## 概述 本篇重点介绍了 **浏览器路由跳转后发生了什么**,下一篇会介绍浏览器的渲染进程是如何渲染网页的,环环相扣。 在上一篇介绍了,browser process 包含 UI thread、network thread 和 storage thread,当我们在浏览器菜单栏输入网址并敲击回车时,这套动作均由 browser process 的 UI thread 响应。 接下来,按照几种不同的路由跳转场景,分别介绍了内部流程。 ### 普通的跳转 第一步,UI thread 响应输入,并判断是否为一个合法的网址,当然输入的也可能是个搜索协议,这就会导致分发到另外的服务处理。 第二步,如果第一步输入的是合法网址,则 UI thread 会通知 network thread 获取网页内容,network thread 会寻找合适的协议处理网络请求,一般会通过 [DNS 协议](https://en.wikipedia.org/wiki/Domain_Name_System) 寻址,通过 [TLS 协议](https://en.wikipedia.org/wiki/Transport_Layer_Security) 建立安全链接。如果服务器返回了比如 301 重定向信息,network thread 会通知 UI thread 这个信息,再启动一遍第二步。 第三步,读取响应内容,在这一步 network thread 会首先读取首部一些字节,即我们常说的响应头,其中包含 [Content-Type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) 告知返回内容是什么。如果返回内容是 HTML,则 network thread 会将数据传送给 renderer process。这一步还会校验安全性,比如 [CORB](https://www.chromium.org/Home/chromium-security/corb-for-developers) 或 [cross-site](https://en.wikipedia.org/wiki/Cross-site_scripting) 问题。 第四步,寻找 renderer process。一旦所有检查都完成,network thread 会通知 UI thread 已经准备好跳转了(注意此时并没有加载完所有数据,第三步只是检查了首字节),UI thread 会通知 renderer process 进行渲染。为了提升性能,UI thread 在通知 network thread 的同时就会实例化一个 renderer process 等着,一旦 network thread 完毕后就可以立即进入渲染阶段,如果检查失败则丢弃提前实例化的 renderer process。 第五步,确认导航。第四步后,browser process 通过 IPC 向 renderer process 传送 stream([精读《web streams》](https://github.com/ascoders/weekly/blob/master/%E5%89%8D%E6%B2%BF%E6%8A%80%E6%9C%AF/214.%E7%B2%BE%E8%AF%BB%E3%80%8Aweb%20streams%E3%80%8B.md))数据。此时导航会被确认,浏览器的各个状态(比如导航状态、前进后退历史)将会被修改,同时为了方便 tab 关闭后快速恢复,会话记录会被存储在硬盘。 额外步骤,加载完成。当 renderer process 加载完成后(具体做了什么下一篇会说明),会通知 browser process `onLoad` 事件,此时浏览器完成最终加载完毕状态,loading 圆圈也会消失,各类 onLoad 的回调触发。注意此时 js 可能会继续加载远程资源,但这都是加载状态完成后的事了。 ### 跳转到别的网站 当你准备跳转到别的网站时,在执行普通跳转流程前,还会响应 [beforeunload](https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event) 事件,这个事件注册在 renderer process,所以 browser process 需要检查 renderer process 是否注册了这个响应。注册 `beforeunload` 无论如何都会拖慢关闭 tab 的速度,所以如无必要请勿注册。 如果跳转是 js 发出的,那么执行跳转就由 renderer process 触发,browser process 来执行,后续流程就是普通的跳转流程。要注意的是,当执行跳转时,会触发原网站 `unload` 等事件([网页生命周期](https://developers.google.com/web/updates/2018/07/page-lifecycle-api#overview_of_page_lifecycle_states_and_events)),所以这个由旧的 renderer process 响应,而新网站会创建一个新的 renderer process 处理,当旧网页全部关闭时,才会销毁旧的 renderer process。 也就是说,即便只有一个 tab,在跳转时,也可能会在短时间内存在多个 renderer process。 ### Service Worker [Service Worker](https://developers.google.com/web/fundamentals/primers/service-workers) 可以在页面加载前执行一些逻辑,甚至改变网页内容,但浏览器仍然把 Service Worker 实现在了 renderer process 中。 当 Service Worker 被注册后,会被丢到一个作用域中,当 UI thread 执行时会检查这个作用域是否注册了 Service Worker,如果有,则 network thread 会创建一个 renderer process 执行 Service Worker(因为是 js 代码)。然后网络响应会被 Service Worker 接管。 但这样会慢一步,所以 UI thread 往往会在注册 Service Worker 的同时告诉 network thread 发送请求,这就是 [Navigation Preload](https://developers.google.com/web/updates/2017/02/navigation-preload) 机制。 本文介绍了网页跳转时发生的步骤,涉及 browser process、UI thread、network thread、renderer process 的协同。 ## 精读 也许你会有疑问,为什么是 renderer process 而不是 renderer thread?因为相比 process(进程)相比 thread(线程),之间数据是被操作系统隔离的,为了网页间无法相互读取数据(mysite.com 读取你 baidu.com 正在输入的账号密码),浏览器必须为每个 tab 创建一个独立的进程,甚至每个 iframe 都必须是独立进程。 读完第二篇,应该能更深切的感受到模块间合理分工的重要性。 UI thread 处理浏览器 UI 的展现与用户交互,比如当前加载的状态变化,历史前进后退,浏览器地址栏的输入、校验与监听按下 Enter 等事件,但不会涉及诸如发送请求、解析网页内容、渲染等内容。 network thread 也仅处理网络相关的事情,它主要关心通信协议、安全协议,目标就是快速准确的找到网站服务器,并读取其内容。network thread 会读取内容头做一些前置判断,读取内容和 renderer process 做的事情是有一定重合的,但 network thread 读取内容头仅为了判断内容类型,以便交给渲染引擎还是下载管理器(比如一个 zip 文件),所以为了不让渲染引擎知道下载管理器的存在,读取内容头必须由 network thread 来做。 与 renderer process 的通信也是由 browser process 来做的,也就是 UI thread、network thread 一旦要创建或与 renderer process 通信,都会交由它们所在的 browser process 处理。 renderer process 仅处理渲染逻辑,它不关心是从哪来的,比如是网络请求过来的,还是 Service Worker 拦截后修改的,也不关心当前浏览器状态是什么,它只管按照约定的接口规范,在指定的节点抛出回调,而修改应用状态由其它关心的模块负责,比如 `onLoad` 回调触发后,browser process 处理浏览器的状态就是一个例子。 再比如 renderer process 里点击了一个新的跳转链接,这个事情发生在 renderer process,但会交给 browser process 处理,因为每个模块解耦的非常彻底,所以任何复杂工作都能找到一个能响应它的模块,而这个模块也只要处理这个复杂工作的一部分,其余部分交给其它模块就好了,这就是大型应用维护的秘诀。 所以在浏览器运行周期里,有着非常清晰的逻辑链路,这些模块必须事先规划设计好,很难想象这些模块分工是在开发中逐渐形成的。 最后提到加速优化,Chrome 惯用技巧就是,用资源换时间。即宁可浪费潜在资源,也要让事物尽可能的并发,这些从提前创建 renderer process、提前发起 network process 都能看出来。 ## 总结 深入了解现代浏览器二介绍了网页跳转时发生的,browser process 与 renderer process 是如何协同的。 也许这篇文章可以帮助你回答 “聊聊在浏览器地址栏输入 www.baidu.com 并回车后发生了什么事儿吧!” > 讨论地址是:[精读《深入了解现代浏览器二》· Issue #375 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/375) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/221.精读《深入了解现代浏览器三》.md ================================================ [Inside look at modern web browser](https://developers.google.com/web/updates/2018/09/inside-browser-part3) 是介绍浏览器实现原理的系列文章,共 4 篇,本次精读介绍第三篇。 ## 概述 本篇宏观的介绍 renderer process 做了哪些事情。 浏览器 tab 内 html、css、javascript 内容基本上都由 renderer process 的主线程处理,除了一些 js 代码会放在 web worker 或 service worker 内,所以浏览器主线程核心工作就是解析 web 三剑客并生成可交互的用户界面。 ### 解析阶段 首先 renderer process 主线程会解析 HTML 文本为 DOM(Document Object Model),直译为中文就是文档对象模型,所以首先要把文本结构化才能继续处理。不仅是浏览器,代码的解析也得首先经历 Parse 阶段。 对于 HTML 的 link、img、script 标签需要加载远程资源的,浏览器会调用 network thread 优先并行处理,但遇到 script 标签就必须停下来优先执行,因为 js 代码可能会改变任何 dom 对象,这可能导致浏览器要重新解析。所以如果你的代码没有修改 dom 的副作用,可以添加 async、defer 标签,或 JS 模块的方式使浏览器不必等待 js 的执行。 ### 样式计算 只有 DOM 是不够的,style 标签申明的样式需要作用在 DOM 上,所以基于 DOM,浏览器要生成 CSSOM,这个 CSSOM 主要是基于 css 选择器(selector)确定作用节点的。 ### 布局 有了 DOM、CSSOM 仍然不足以绘制网页,因为我们仅知道结构和样式,但不知道元素的位置,这就需要生成 LayoutTree 以描述布局的结构。 LayoutTree 和 DOM 结构很像了,但比如 `display: none` 的元素不会出现在 LayoutTree 上,所以 LayoutTree 仅考虑渲染结构,而 DOM 是一个综合描述结构,它不适合直接用来渲染。 原文特别提到,LayoutTree 有个很大的技术难点,即排版,Chrome 专门有一整个团队在攻克这个技术难题。为什么排版这么难?可以从这几个例子中体会冰山一角:盒模型间碰撞、字体撑开内容导致换行,引发更大区域的重新排版、一个盒模型撑开挤压另一个盒模型,但另一个盒模型大小变化后内容排版也随之变化,导致盒模型再次变化,这个变化又导致了外部其它盒模型的布局变化。 布局最难的地方在于,需要对所有奇奇怪怪的布局定式做一个尽量合理的处理,而很多时候布局定式间规则是相互冲突的。而且这还不考虑布局引擎的修改在数亿网页上引发未知 BUG 的风险。 ### 绘图 有了 DOM、CSSOM、LayoutTree 就够了吗?还不行,还缺少最后一环 PaintRecord,这个指绘图记录,它会记录元素的层级关系,以决定元素绘制的顺序。因为 LayoutTree 仅决定了物理结构,但不决定元素的上下空间结构。 有了 DOM、CSSOM、LayoutTree、PaintRecord 之后,终于可以绘图了。然而当 HTML 变化时,重绘的代价是巨大的,因为上面任何一步的计算结果都依赖前面一步,HTML 改变时,需要对 DOM、CSSOM、LayoutTree、PaintRecord 进行重新计算。 大部分时候浏览器都可以在 16ms 内完成,使 FPS 保持在 60 左右,但当页面结构过于复杂,这些计算本身超过了 16ms,或其中遇到 js 代码的阻塞,都会导致用户感觉到卡顿。当然对于 js 卡顿问题可以通过 `requestAnimationFrame` 把逻辑运算分散在各帧空闲时进行,也可以独立到 web worker 里。 ### 合成 绘图的步骤称为 rasterizing(光栅化)。在 Chrome 最早发布时,采用了一种较为简单的光栅化方案,即仅渲染可视区域内的像素点,当滚动后,再补充渲染当前滚动位置的像素点。这样做会导致渲染永远滞后于滚动。 现在一般采用较为成熟的合成技术(compositing),即将渲染内容分层绘制与渲染,这可以大大提升性能,并可通过 CSS 属性 `will-change` 手动申明为一个新层(不要滥用)。 浏览器会根据 LayoutTree 分析后得到 LayerTree(层树),并根据它逐层渲染。 合成层会将绘图内容切分为多个栅格并交由 GPU 渲染,因此性能会非常好。 ## 精读 ### 从渲染分层看性能优化 本篇提到了浏览器渲染的 5 个重要环节:解析、样式、布局、绘图、合成,是前端开发者日常工作中对浏览器体感最深的部分,也是优化最常发生在的部分。 其实从性能优化角度来看,解析环节可以被替代为 JS 环节,因为现代 JS 框架往往没有什么 HTML 模版内容要解析,几乎全是 JS 操作 DOM,所以可以看作 5 个新环节:JS、样式、布局、绘图、合成。 值得注意的是,几乎每层的计算都依赖上层的结果,但并不是每层都一定会重复计算,我们需要尤其注意以下几种情况: 1. 修改元素几何属性(位置、宽高等)会触发所有层的重新计算,因为这是一个非常重量级的修改。 2. 修改某个元素绘图属性(比如颜色和背景色),并不影响位置,则会跳过布局层。 3. 修改比如 transform 属性会跳过布局与绘图层,这看上去很不可思议。 对于第三点,由于 transform 的内容会提升到合成层并交由 GPU 渲染,因此并不会与浏览器主线程的布局、绘图放在一起处理,所以视觉上这个元素的确产生了位移,但它和修改 `left`、`top` 的位移在实现上却有本质的不同。 所以站在浏览器开发者的角度,可以轻松理解为什么这种优化不是奇技淫巧了,因为本身浏览器的实现就把布局、绘图与合成层的行为分离开了,不同的代码底层方案不同,性能肯定会不同。你可以通过 [csstriggers](https://csstriggers.com/) 查看不同 css 属性会引发哪些层的重计算。 当然作为开发者还是可以吐槽,为什么浏览器不能 “自动把 `left` `top` 与 `transform` 的实现细节屏蔽,并自动进行合理的分层”,然而如果浏览器厂商做不到这一点,开发者还是主动去了解实现原理吧。 ### 隐式合成层、层爆炸、层自动合并 除了 `transform`、`will-change` 属性外,还有很多种情况元素会提升到合成层,比如 `video`、`canvas`、`iframe`,或 `fixed` 元素,但这些都有明确的规则,所以属于显示合成。 而隐式合成是指元素没有被特别标记,但也被提升到合成层的情况,这种情况常见发生在 `z-index` 元素产生重叠时,下方的元素显示申明提升到合成层,则浏览器为了保证 `z-index` 覆盖关系,就要隐式把上方的元素提升到合成层。 层爆炸是指隐式合成的原因,当 css 出现一些复杂行为时(比如轨迹动画),浏览器无法实时捕捉哪些元素位于当前元素上方,所以只好把所有元素都提升到合成层,当合成层数量过多,主线程与 GPU 的通信可能会成为瓶颈,反而影响性能。 浏览器也会支持层自动合并,比如隐式提升到合成层时,多个元素会自动合并在一个合成层里。但这种方式也并不总是靠谱,自动处理毕竟猜不到开发者的意图,所以最好的优化方式是开发者主动干预。 我们只要注意将所有显示提升到合成层的元素放在 `z-index` 的上方,这样浏览器就有了判断依据,不用再担惊受怕会不会这个元素突然移动到某个元素的位置,导致压住了那个元素,于是又不得不把这个元素给隐式提升到合成层以保证它们之间顺序的正确性,因为这个元素本来就位于其它元素的最上方。 ## 总结 读完这篇文章,希望你能根据浏览器在渲染进程的实现原理,总结出更多代码级别的性能优化经验。 最后想要吐槽的是,浏览器规范由于是逐步迭代的,因此看似都在描述位置的 css 属性其实背后实现原理是不同的,虽然这个规则体现在 W3C 规范上,但如果仅从属性名是很难看出来端倪的,因此想要做极致性能优化就必须了解浏览器实现原理。 > 讨论地址是:[精读《深入了解现代浏览器三》· Issue #379 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/379) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/222.精读《深入了解现代浏览器四》.md ================================================ [Inside look at modern web browser](https://developers.google.com/web/updates/2018/09/inside-browser-part4) 是介绍浏览器实现原理的系列文章,共 4 篇,本次精读介绍第四篇。 ## 概述 前几章介绍了浏览器的基础进程、线程以及它们之间协同的关系,并重点说到了渲染进程是如何处理页面绘制的,那么最后一章也就深入到了浏览器是如何处理页面中事件的。 全篇站在浏览器实现的视角思考问题,非常有趣。 ### 输入进入合成器 这是第一小节的标题。乍一看可能不明白在说什么,但这句话就是本文的核心知识点。为了更好的理解这句话,先要解释输入与合成器是什么: - 输入:不仅包括输入框的输入,其实所有用户操作在浏览器眼中都是输入,比如滚动、点击、鼠标移动等等。 - 合成器:第三节说过的,渲染的最后一步,这一步在 GPU 进行光栅化绘图,如果与浏览器主线程解耦的化效率会非常高。 所以输入进入合成器的意思是指,在浏览器实际运行的环境中,合成器不得不响应输入,这可能会导致合成器本身渲染被阻塞,导致页面卡顿。 ### "non-fast" 滚动区域 由于 js 代码可以绑定事件监听,而且事件监听中存在一种 `preventDefault()` 的 API 可以阻止事件的原生效果比如滚动,所以在一个页面中,浏览器会对所有创建了此监听的区块标记为 "non-fast" 滚动区域。 注意,只要创建了 `onwheel` 事件监听就会标记,而不是说调用了 `preventDefault()` 才会标记,因为浏览器不可能知道业务什么时候调用,所以只能一刀切。 为什么这种区域被称为 "non-fast"?因为在这个区域触发事件时,合成器必须与渲染进程通信,让渲染进程执行 js 事件监听代码并获得用户指令,比如是否调用了 `preventDefault()` 来阻止滚动?如果阻止了就终止滚动,如果没有阻止才会继续滚动,如果最终结果是不阻止,但这个等待时间消耗是巨大的,在低性能设备比如手机上,滚动延迟甚至有 10~100ms。 然而这并不是设备性能差导致的,因为滚动是在合成器发生的,如果它可以不与渲染进程通信,那么即便是 500 元的安卓机也可以流畅的滚动。 ### 注意事件委托 更有意思的是,浏览器支持一种事件委托的 API,它可以将事件委托到其父节点一并监听。 这本是一个非常方便的 API,但对浏览器实现可能是一个灾难: ```js document.body.addEventListener('touchstart', event => { if (event.target === area) { event.preventDefault(); } }); ``` 如果浏览器解析到上面的代码,只能用无语来形容。因为这意味着必须对全页面都进行 "non-fast" 标记,因为代码委托的是整个 document!这会导致滚动非常慢,因为在页面任何地方滚动都要发生一次合成器与渲染进程的通信。 所以最好的办法就是不要写这种监听。但还有一种方案是,告诉浏览器你不会 `preventDefault()`,这是因为 chrome 通过对应用源码统计后发现,大约 80% 的事件监听没有 `preventDefault()`,而仅仅是做别的事情,所以合成器应该可以与渲染进程的事件处理并行进行,这样既不卡顿,逻辑也不会丢失。所以添加了一种 `passive: true` 的标记,标识当前事件可以并行处理: ```js document.body.addEventListener('touchstart', event => { if (event.target === area) { event.preventDefault() } }, {passive: true}); ``` 这样就不会卡顿了,但 `preventDefault()` 也会失效。 ### 检查事件是否可取消 对于 `passive: true` 的情况,事件就实际上变得不可取消了,所以我们最好在代码里做一层判断: ```js document.body.addEventListener('touchstart', event => { if (event.cancelable && event.target === area) { event.preventDefault() } }, {passive: true}); ``` 然而这仅仅是阻止执行没有意义的 `preventDefault()`,并不能阻止滚动。这种情况下,最好的办法是通过 css 申明来阻止横向移动,因为这个判断不会发生在渲染进程,所以不会导致合成器与渲染进程的通信: ```css #area { touch-action: none; } ``` ### 事件合并 由于事件触发频率可能比浏览器帧率还要高(1 秒 120 次),如果浏览器坚持对每个事件都进行响应,而一次事件都必须在 js 里响应一次的话,会导致大量事件阻塞,因为当 FPS 为 60 时,一秒也仅能执行 60 次事件响应,所以事件积压是无法避免的。 为了解决这个问题,浏览器在针对可能导致积压的事件,比如滚动事件时,将多个事件合并到一次 js 中,仅保留最终状态。 如果不希望丢掉事件中间过程,可以使用 `getCoalescedEvents` 从合并事件中找回每一步事件的状态: ```js window.addEventListener('pointermove', event => { const events = event.getCoalescedEvents(); for (let event of events) { const x = event.pageX; const y = event.pageY; // draw a line using x and y coordinates. } }); ``` ## 精读 只要我们认识到事件监听必须运行在渲染进程,而现代浏览器许多高性能 “渲染” 其实都在合成层采用 GPU 做,所以看上去方便的事件监听肯定会拖慢页面流畅度。 但就这件事在 React 17 中有过一次讨论 [Touch/Wheel Event Passiveness in React 17](https://github.com/facebook/react/issues/19651)(实际上在即将到来的 18 该问题还在讨论中 [React 18 not passive wheel / touch event listeners support](https://github.com/facebook/react/issues/22794)),因为 React 可以直接在元素上监听 Touch、Wheel 事件,但其实框架采用了委托的方式在 document(后在 app 根节点)统一监听,这就导致了用户根本无从决定事件是否为 `passive`,如果框架默认 `passive`,会导致 `preventDefault()` 失效,否则性能得不到优化。 就结论而言,React 目前还是对几个受影响的事件 `touchstart` `touchmove` `wheel` 采用 `passive` 模式,即: ```tsx const Test = () => (
event.preventDefault()} > ...
) ``` 虽然结论如此而且对性能友好,但并不是一个让所有人都能满意的方案,我们看看当时 Dan 是如何思考,并给了哪些解决方案的。 首先背景是,React 16 事件委托绑定在 document 上,React 17 事件委托绑定在 App 根节点上,而根据 chrome 的优化,绑定在 document 的事件委托默认是 `passive` 的,而其它节点的不会,因此对 React 17 来说,如果什么都不做,仅改变绑定节点位置,就会存在一个 Break Change。 1. 第一种方案是坚持 Chrome 性能优化的精神,委托时依然 pasive 处理。这样处理至少和 React 16 一样,`preventDefault()` 都是失效的,虽然不正确,但至少不是 BreakChange。 2. 第二种方案即什么都不做,这导致原本默认 `passive` 的因为绑定到非 document 节点上而 `non-passive` 了,这样做不仅有性能问题,而且 API 会存在 BreackChange,虽然这种做法更 “原生”。 3. touch/wheel 不再采用委托,意味着浏览器可以有更少的 "non-fast" 区域,而 `preventDefault()` 也可以生效了。 最终选择了第一个方案,因为暂时不希望在 React API 层面出现行为不一致的 BreakChange。 然而 React 18 是一次 BreakChange 的时机,目前还没有进一步定论。 ## 总结 从浏览器角度看待问题会让你具备上帝视角而不是开发者视角,你不会再觉得一些奇奇怪怪的优化逻辑是 Hack 了,因为你了解浏览器背后是如何理解与实现的。 不过我们也会看到一些和实现强绑定的无奈,在前端开发框架实现时造成了不可避免的困扰。毕竟作为一个不了解浏览器实现的开发者,自然会认为 `preventDefault()` 绑定在滚动事件时,一定可以阻止默认滚动行为呀,但为什么因为: - 浏览器分为合成层和渲染进程,通信成本较高导致滚动事件监听会引发滚动卡顿。 - 为了避免通信,浏览器默认为 document 绑定开启 `passive` 策略减少 "non-fast" 区域。 - 开启了 `passive` 的事件监听 `preventDefault()` 会失效,因为这层实现在 js 里而不是 GPU。 - React16 采用事件代理,把元素 `onWheel` 代理到 document 节点而非当前节点。 - React17 将 document 节点绑定下移到了 App 根节点,因此浏览器优化后的 `passive` 失效了。 - React 为了保持 API 不发生 BreakChange,因此将 App 根节点绑定的事件委托默认补上了 `passive`,使其表现与绑定在 document 一样。 总之就是 React 与浏览器实现背后的纠纷,导致滚动行为阻止失效,而这个结果链条传导到了开发者身上,而且有明显感知。但了解背后原因后,你应该能理解一下 React 团队的痛苦吧,因为已有 API 确实没有办法描述是否 `passive` 这个行为,所以这是个暂时无法解决的问题。 > 讨论地址是:[精读《深入了解现代浏览器四》· Issue #381 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/381) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/223.精读《Records & Tuples 提案》.md ================================================ immutablejs、immer 等库已经让 js 具备了 immutable 编程的可能性,但还存在一些无解的问题,即 “怎么保证一个对象真的不可变”。 如果不是拍胸脯担保,现在还真没别的办法。或许你觉得 `frozen` 是个 good idea,但它内部仍然可以增加非 `frozen` 的 key。 另一个问题是,当我们 debug 调试应用数据的时候,看到状态发生 `[]` -> `[]` 变化时,无论在控制台、断点、redux devtools 还是 `.toString()` 都看不出来引用有没有变化,除非把变量值分别拿到进行 `===` 运行时判断。但引用变与没变可是一个大问题,它甚至能决定业务逻辑的正确与否。 但现阶段我们没有任何处理办法,如果不能接受完全使用 Immutablejs 定义对象,就只能摆胸脯保证自己的变更一定是 immutable 的,这就是 js 不可变编程被许多聪明人吐槽的原因,觉得在不支持 immutable 的编程语言下强行应用不可变思维是一种很别扭的事。 [proposal-record-tuple](https://github.com/tc39/proposal-record-tuple) 解决的就是这个问题,它让 js 原生支持了 **不可变数据类型**(高亮、加粗)。 ## 概述 & 精读 JS 有 7 种原始类型:string, number, bigint, boolean, undefined, symbol, null. 而 Records & Tuples 提案一下就增加了三种原始类型!这三种原始类型完全是为 immutable 编程环境服务的,也就是说,可以让 js 开出一条原生 immutable 赛道。 这三种原始类型分别是 Record, Tuple, Box: - Record: 类对象结构的深度不可变基础类型,如 `#{ x: 1, y: 2 }`。 - Tuple: 类数组结构的深度不可变基础类型,如 `#[1, 2, 3, 4]`。 - Box: 可以定义在上面两个类型中,存储对象,如 `#{ prop: Box(object) }`。 核心思想可以总结为一句话:因为这三个类型为基础类型,所以在比较时采用值对比(而非引用对比),因此 `#{ x: 1, y: 2} === #{ x: 1, y: 2 }`。这真的解决了大问题!如果你还不了解 js 不支持 immutable 之痛,请不要跳过下一节。 ### js 不支持 immutable 之痛 虽然很多人都喜欢 mvvm 的 reactive 特征(包括我也写了不少 mvvm 轮子和框架),但不可变数据永远是开发大型应用最好的思想,它可以非常可靠的保障应用数据的可预测性,同时不需要牺牲性能与内存,它使用起来没有 mutable 模式方便,但它永远不会出现预料外的情况,这对打造稳定的复杂应用至关重要,甚至比便捷性更加重要。当然可测试也是个非常重要的点,这里不详细展开。 然而 js 并不原生支持 immutable,这非常令人头痛,也造成了许多困扰,下面我试图解释一下这个困扰。 如果你觉得非原始类型按照引用对比很棒,那你一定一眼能看出下面的结果是正确的: ```js assert({ a: 1 } !== { a: 1 }) ``` 但如果是下面的情况呢? ```js console.log(window.a) // { a: 1 } console.log(window.b) // { a: 1 } assert(window.a === window.b) // ??? ``` **结果是不确定**,虽然这两个对象长得一样,但我们拿到的 scope 无法推断其是否来自同一个引用,如果来自于相同的引用,则断言通过,否则即便看上去值一样,也会 throw error。 更大的麻烦是,即便这两个对象长得完全不一样,我们也不敢轻易下结论: ```js console.log(window.a) // { a: 1 } // do some change.. console.log(window.b) // { b: 1 } assert(window.a === window.b) // ??? ``` 因为 b 的值可能在中途被修改,但确实与 a 来自同一个引用,我们无法断定结果到底是什么。 另一个问题则是应用状态变更的扑朔迷离。试想我们开发了一个树形菜单,结构如下: ```json { "id": "1", "label": "root", "children": [{ "id": "2", "label": "apple", }, { "id": "3", "label": "orange", }] } ``` 如果我们调用 `updateTreeNode('3', { id: '3', title: 'banana' })`,在 immutable 场景下我们仅更新 id 为 "1", "3" 组件的引用,而 id 为 "2" 的引用不变,那么这棵树节点 "2" 就不会重渲染,这是血统纯正的 immutable 思维逻辑。 但当我们保存下这个新状态后,要进行 “状态回放”,会发现其实应用状态进行了一次变更,整个描述 json 变成了: ```json { "id": "1", "label": "root", "children": [{ "id": "2", "label": "apple", }, { "id": "3", "label": "banana", }] } ``` 但如果我们拷贝上面的文本,把应用状态直接设置为这个结果,会发现与 “应用回放按钮” 的效果不同,这时 id "2" 也重渲染了,因为它的引用变化了。 问题就是我们无法根据肉眼观察出引用是否变化了,即便两个结构一模一样,也无法保证引用是否相同,进而导致无法推断应用的行为是否一致。如果没有人为的代码质量管控,出现非预期的引用更新几乎是难以避免的。 这就是 Records & Tuples 提案要解决问题的背景,我们带着这个理解去看它的定义,就更好学习了。 ### Records & Tuples 在用法上与对象、数组保持一致 Records & Tuples 提案说明,不可变数据结构除了定义时需要用 `#` 符号申明外,使用时与普通对象、数组无异。 Record 用法与普通 object 几乎一样: ```js const proposal = #{ id: 1234, title: "Record & Tuple proposal", contents: `...`, // tuples are primitive types so you can put them in records: keywords: #["ecma", "tc39", "proposal", "record", "tuple"], }; // Accessing keys like you would with objects! console.log(proposal.title); // Record & Tuple proposal console.log(proposal.keywords[1]); // tc39 // Spread like objects! const proposal2 = #{ ...proposal, title: "Stage 2: Record & Tuple", }; console.log(proposal2.title); // Stage 2: Record & Tuple console.log(proposal2.keywords[1]); // tc39 // Object functions work on Records: console.log(Object.keys(proposal)); // ["contents", "id", "keywords", "title"] ``` 下面的例子说明,Records 与 object 在函数内处理时并没有什么不同,这个在 FAQ 里提到是一个非常重要的特性,可以让 immutable 完全融入现在的 js 生态: ```js const ship1 = #{ x: 1, y: 2 }; // ship2 is an ordinary object: const ship2 = { x: -1, y: 3 }; function move(start, deltaX, deltaY) { // we always return a record after moving return #{ x: start.x + deltaX, y: start.y + deltaY, }; } const ship1Moved = move(ship1, 1, 0); // passing an ordinary object to move() still works: const ship2Moved = move(ship2, 3, -1); console.log(ship1Moved === ship2Moved); // true // ship1 and ship2 have the same coordinates after moving ``` Tuple 用法与普通数组几乎一样: ```js const measures = #[42, 12, 67, "measure error: foo happened"]; // Accessing indices like you would with arrays! console.log(measures[0]); // 42 console.log(measures[3]); // measure error: foo happened // Slice and spread like arrays! const correctedMeasures = #[ ...measures.slice(0, measures.length - 1), -1 ]; console.log(correctedMeasures[0]); // 42 console.log(correctedMeasures[3]); // -1 // or use the .with() shorthand for the same result: const correctedMeasures2 = measures.with(3, -1); console.log(correctedMeasures2[0]); // 42 console.log(correctedMeasures2[3]); // -1 // Tuples support methods similar to Arrays console.log(correctedMeasures2.map(x => x + 1)); // #[43, 13, 68, 0] ``` 在函数内处理时,拿到一个数组或 Tuple 并没有什么需要特别注意的区别: ```js const ship1 = #[1, 2]; // ship2 is an array: const ship2 = [-1, 3]; function move(start, deltaX, deltaY) { // we always return a tuple after moving return #[ start[0] + deltaX, start[1] + deltaY, ]; } const ship1Moved = move(ship1, 1, 0); // passing an array to move() still works: const ship2Moved = move(ship2, 3, -1); console.log(ship1Moved === ship2Moved); // true // ship1 and ship2 have the same coordinates after moving ``` 由于 Record 内不能定义普通对象(比如定义为 # 标记的不可变对象),如果非要使用普通对象,只能包裹在 Box 里,并且在获取值时需要调用 `.unbox()` 拆箱,并且就算修改了对象值,在 Record 或 Tuple 层面也不会认为发生了变化: ```js const myObject = { x: 2 }; const record = #{ name: "rec", data: Box(myObject) }; console.log(record.data.unbox().x); // 2 // The box contents are classic mutable objects: record.data.unbox().x = 3; console.log(myObject.x); // 3 console.log(record === #{ name: "rec", data: Box(myObject) }); // true ``` 另外不能在 Records & Tuples 内使用任何普通对象或 new 对象实例,除非已经用转化为了普通对象: ```js const instance = new MyClass(); const constContainer = #{ instance: instance }; // TypeError: Record literals may only contain primitives, Records and Tuples const tuple = #[1, 2, 3]; tuple.map(x => new MyClass(x)); // TypeError: Callback to Tuple.prototype.map may only return primitives, Records or Tuples // The following should work: Array.from(tuple).map(x => new MyClass(x)) ``` ### 语法 Records & Tuples 内只能使用 Record、Tuple、Box: ```js #{} #{ a: 1, b: 2 } #{ a: 1, b: #[2, 3, #{ c: 4 }] } #[] #[1, 2] #[1, 2, #{ a: 3 }] ``` 不支持空数组项: ```js const x = #[,]; // SyntaxError, holes are disallowed by syntax ``` 为了防止引用追溯到上层,破坏不可变性质,不支持定义原型链: ```js const x = #{ __proto__: foo }; // SyntaxError, __proto__ identifier prevented by syntax const y = #{ ["__proto__"]: foo }; // valid, creates a record with a "__proto__" property. ``` 也不能在里面定义方法: ```js #{ method() { } } // SyntaxError ``` 同时,一些破坏不可变稳定结构的特性也是非法的,比如 key 不可以是 Symbol: ```js const record = #{ [Symbol()]: #{} }; // TypeError: Record may only have string as keys ``` 不能直接使用对象作为 value,除非用 Box 包裹: ```js const obj = {}; const record = #{ prop: obj }; // TypeError: Record may only contain primitive values const record2 = #{ prop: Box(obj) }; // ok ``` ### 判等 判等是最核心的地方,Records & Tuples 提案要求 == 与 === 原生支持 immutable 判等,是 js 原生支持 immutable 的一个重要表现,所以其判等逻辑与普通的对象判等大相径庭: 首先看上去值相等,就真的相等,因为基础类型仅做值对比: ```js assert(#{ a: 1 } === #{ a: 1 }); assert(#[1, 2] === #[1, 2]); ``` 这与对象判等完全不同,而且把 Record 转换为对象后,判等就遵循对象的规则了: ```js assert({ a: 1 } !== { a: 1 }); assert(Object(#{ a: 1 }) !== Object(#{ a: 1 })); assert(Object(#[1, 2]) !== Object(#[1, 2])); ``` 另外 Records 的判等与 key 的顺序无关,因为有个隐式 key 排序规则: ```js assert(#{ a: 1, b: 2 } === #{ b: 2, a: 1 }); Object.keys(#{ a: 1, b: 2 }) // ["a", "b"] Object.keys(#{ b: 2, a: 1 }) // ["a", "b"] ``` Box 是否相等取决于内部对象引用是否相等: ```js const obj = {}; assert(Box(obj) === Box(obj)); assert(Box({}) !== Box({})); ``` 对于 `+0` `-0` 之间,`NaN` 与 `NaN` 对比,都可以安全判定为相等,但 `Object.is` 因为是对普通对象的判断逻辑,所以会认为 `#{ a: -0 }` 不等于 `#{ a: +0 }`,因为认为 `-0` 不等于 `+0`,这里需要特别注意。另外 Records & Tulpes 也可以作为 Map、Set 的 key,并且按照值相等来查找: ```js assert(#{ a: 1 } === #{ a: 1 }); assert(#[1] === #[1]); assert(#{ a: -0 } === #{ a: +0 }); assert(#[-0] === #[+0]); assert(#{ a: NaN } === #{ a: NaN }); assert(#[NaN] === #[NaN]); assert(#{ a: -0 } == #{ a: +0 }); assert(#[-0] == #[+0]); assert(#{ a: NaN } == #{ a: NaN }); assert(#[NaN] == #[NaN]); assert(#[1] != #["1"]); assert(!Object.is(#{ a: -0 }, #{ a: +0 })); assert(!Object.is(#[-0], #[+0])); assert(Object.is(#{ a: NaN }, #{ a: NaN })); assert(Object.is(#[NaN], #[NaN])); // Map keys are compared with the SameValueZero algorithm assert(new Map().set(#{ a: 1 }, true).get(#{ a: 1 })); assert(new Map().set(#[1], true).get(#[1])); assert(new Map().set(#[-0], true).get(#[0])); ``` ### 对象模型如何处理 Records & Tuples 对象模型是指 `Object` 模型,大部分情况下,所有能应用于普通对象的方法都可无缝应用于 Record,比如 `Object.key` 或 `in` 都可与处理普通对象无异: ```js const keysArr = Object.keys(#{ a: 1, b: 2 }); // returns the array ["a", "b"] assert(keysArr[0] === "a"); assert(keysArr[1] === "b"); assert(keysArr !== #["a", "b"]); assert("a" in #{ a: 1, b: 2 }); ``` 值得一提的是如果 wrapper 了 `Object` 在 Record 或 Tuple,提案还准备了一套完备的实现方案,即 `Object(record)` 或 `Object(tuple)` 会冻结所有属性,并将原型链最高指向 `Tuple.prototype`,对于数组跨界访问也只能返回 undefined 而不是沿着原型链追溯。 ### Records & Tuples 的标准库支持 对 Record 与 Tuple 进行原生数组或对象操作后,返回值也是 immutable 类型的: ```js assert(Object.keys(#{ a: 1, b: 2 }) !== #["a", "b"]); assert(#[1, 2, 3].map(x => x * 2), #[2, 4, 6]); ``` 还可通过 `Record.fromEntries` 和 `Tuple.from` 方法把普通对象或数组转成 Record, Tuple: ```js const record = Record({ a: 1, b: 2, c: 3 }); const record2 = Record.fromEntries([#["a", 1], #["b", 2], #["c", 3]]); // note that an iterable will also work const tuple = Tuple(...[1, 2, 3]); const tuple2 = Tuple.from([1, 2, 3]); // note that an iterable will also work assert(record === #{ a: 1, b: 2, c: 3 }); assert(tuple === #[1, 2, 3]); Record.from({ a: {} }); // TypeError: Can't convert Object with a non-const value to Record Tuple.from([{}, {} , {}]); // TypeError: Can't convert Iterable with a non-const value to Tuple ``` 此方法不支持嵌套,因为标准 API 仅考虑一层,递归一般交给业务或库函数实现,就像 `Object.assign` 一样。 Record 与 Tuple 也都是可迭代的: ```js const tuple = #[1, 2]; // output is: // 1 // 2 for (const o of tuple) { console.log(o); } const record = #{ a: 1, b: 2 }; // TypeError: record is not iterable for (const o of record) { console.log(o); } // Object.entries can be used to iterate over Records, just like for Objects // output is: // a // b for (const [key, value] of Object.entries(record)) { console.log(key) } ``` `JSON.stringify` 会把 Record & Tuple 转化为普通对象: ```js JSON.stringify(#{ a: #[1, 2, 3] }); // '{"a":[1,2,3]}' JSON.stringify(#[true, #{ a: #[1, 2, 3] }]); // '[true,{"a":[1,2,3]}]' ``` 但同时建议实现 `JSON.parseImmutable` 将一个 JSON 直接转化为 Record & Tuple 类型,其 API 与 `JSON.parse` 无异。 Tuple.prototype 方法与 Array 很像,但也有些不同之处,主要区别是不会修改引用值,而是创建新的引用,具体可看 [appendix](https://github.com/tc39/proposal-record-tuple/blob/main/NS-Proto-Appendix.md#tuple-prototype)。 由于新增了三种原始类型,所以 typeof 也会新增三种返回结果: ```js assert(typeof #{ a: 1 } === "record"); assert(typeof #[1, 2] === "tuple"); assert(typeof Box({}) === "box"); ``` Record, Tuple, Box 都支持作为 Map、Set 的 key,并按照其自身规则进行判等,即 ```js const record1 = #{ a: 1, b: 2 }; const record2 = #{ a: 1, b: 2 }; const map = new Map(); map.set(record1, true); assert(map.get(record2)); ``` ```js const record1 = #{ a: 1, b: 2 }; const record2 = #{ a: 1, b: 2 }; const set = new Set(); set.add(record1); set.add(record2); assert(set.size === 1); ``` 但不支持 WeakMap、WeakSet: ```js const record = #{ a: 1, b: 2 }; const weakMap = new WeakMap(); // TypeError: Can't use a Record as the key in a WeakMap weakMap.set(record, true); ``` ```js const record = #{ a: 1, b: 2 }; const weakSet = new WeakSet(); // TypeError: Can't add a Record to a WeakSet weakSet.add(record); ``` 原因是不可变数据没有一个可预测的垃圾回收时机,这样如果用在 Weak 系列反而会导致无法及时释放,所以 API 不匹配。 最后提案还附赠了理论基础与 FAQ 章节,下面也简单介绍一下。 ### 理论基础 #### 为什么要创建新的原始类型,而不是像其他库一样在上层处理? 一句话说就是让 js 原生支持 immutable 就必须作为原始类型。假如不作为原始类型,就不可能让 ==, === 操作符原生支持这个类型的特定判等,也就会导致 immutable 语法与其他 js 代码仿佛处于两套逻辑体系下,妨碍生态的统一。 #### 开发者会熟悉这套语法吗? 由于最大程度保证了与普通对象与数组处理、API 的一致性,所以开发者上手应该会比较容易。 #### 为什么不像 Immutablejs 一样使用 `.get` `.set` 方法操作? 这会导致生态割裂,代码需要关注对象到底是不是 immutable 的。一个最形象的例子就是,当 Immutablejs 与普通 js 操作库配合时,需要写出类似如下代码: ```js state.jobResult = Immutable.fromJS( ExternalLib.processJob( state.jobDescription.toJS() ) ); ``` 这有非常强的割裂感。 #### 为什么不使用全局 Record, Tuple 方法代替 `#` 申明? 下面给了两个对比: ```js // with the proposed syntax const record = #{ a: #{ foo: "string", }, b: #{ bar: 123, }, c: #{ baz: #{ hello: #[ 1, 2, 3, ], }, }, }; // with only the Record/Tuple globals const record = Record({ a: Record({ foo: "string", }), b: Record({ bar: 123, }), c: Record({ baz: Record({ hello: Tuple( 1, 2, 3, ), }), }), }); ``` 很明显后者没有前者简洁,而且也打破了开发者对对象、数组 Like 的认知。 #### 为什么采用 #[]/#{} 语法? 采用已有关键字可能导致歧义或者兼容性问题,另外其实还有 `{| |}` `[| |]` 的 [提案](https://github.com/tc39/proposal-record-tuple/issues/10),但目前 `#` 的赢面比较大。 #### 为什么是深度不可变? 这个提案喷了一下 `Object.freeze`: ```js const object = { a: { foo: "bar", }, }; Object.freeze(object); func(object); ``` 由于只保障了一层,所以 `object.a` 依然是可变的,既然要 js 原生支持 immutable,希望的肯定是深度不可变,而不是只有一层。 另外由于这个语法会在语言层面支持不可变校验,而深度不可变校验是非常重要的。 ### FAQ #### 如何基于已有不可变对象创建一个新不可变对象? 大部分语法都是可以使用的,比如解构: ```js // Add a Record field let rec = #{ a: 1, x: 5 } #{ ...rec, b: 2 } // #{ a: 1, b: 2, x: 5 } // Change a Record field #{ ...rec, x: 6 } // #{ a: 1, x: 6 } // Append to a Tuple let tup = #[1, 2, 3]; #[...tup, 4] // #[1, 2, 3, 4] // Prepend to a Tuple #[0, ...tup] // #[0, 1, 2, 3] // Prepend and append to a Tuple #[0, ...tup, 4] // #[0, 1, 2, 3, 4] ``` 对于类数组的 Tuple,可以使用 `with` 语法替换新建一个对象: ```js // Change a Tuple index let tup = #[1, 2, 3]; tup.with(1, 500) // #[1, 500, 3] ``` 但在深度修改时也遇到了绕不过去的问题,目前有一个 [提案](https://github.com/rickbutton/proposal-deep-path-properties-for-record) 在讨论这件事,这里提到一个有意思的语法: ```js const state1 = #{ counters: #[ #{ name: "Counter 1", value: 1 }, #{ name: "Counter 2", value: 0 }, #{ name: "Counter 3", value: 123 }, ], metadata: #{ lastUpdate: 1584382969000, }, }; const state2 = #{ ...state1, counters[0].value: 2, counters[1].value: 1, metadata.lastUpdate: 1584383011300, }; assert(state2.counters[0].value === 2); assert(state2.counters[1].value === 1); assert(state2.metadata.lastUpdate === 1584383011300); // As expected, the unmodified values from "spreading" state1 remain in state2. assert(state2.counters[2].value === 123); ``` `counters[0].value: 2` 看上去还是蛮新颖的。 #### 与 [Readonly Collections](https://github.com/tc39/proposal-readonly-collections) 的关系? 互补。 #### 可以基于 Class 创建 Record 实例吗? 目前不考虑。 #### TS 也有 Record 与 Tuple 关键字,之间的关系是? 熟悉 TS 的同学都知道只是名字一样而已。 #### 性能预期是? 这个问题挺关键的,如果这个提案性能不好,那也无法用于实际生产。 当前阶段没有对性能提出要求,但在 Stage4 之前会给出厂商优化的最佳实践。 ## 总结 如果这个提案与嵌套更新提案一起通过,在 js 使用 immutable 就得到了语言层面的保障,包括 Immutablejs、immerjs 在内的库是真的可以下岗啦。 > 讨论地址是:[精读《Records & Tuples 提案》· Issue #384 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/384) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/224.精读《Records & Tuples for React》.md ================================================ 继前一篇 [精读《Records & Tuples 提案》](https://github.com/ascoders/weekly/blob/master/%E5%89%8D%E6%B2%BF%E6%8A%80%E6%9C%AF/223.%E7%B2%BE%E8%AF%BB%E3%80%8ARecords%20%26%20Tuples%20%E6%8F%90%E6%A1%88%E3%80%8B.md),已经有人在思考这个提案可以帮助 React 解决哪些问题了,比如这篇 [Records & Tuples for React](https://sebastienlorber.com/records-and-tuples-for-react),就提到了许多 React 痛点可以被解决。 其实我比较担忧浏览器是否能将 Records & Tuples 性能优化得足够好,这将是它能否大规模应用,或者说我们是否放心把问题交给它解决的最关键因素。本文基于浏览器可以完美优化其性能的前提,一切看起来都挺美好,我们不妨基于这个假设,看看 Records & Tuples 提案能解决哪些问题吧! ## 概述 [Records & Tuples Proposal](https://github.com/tc39/proposal-record-tuple) 提案在上一篇精读已经介绍过了,不熟悉可以先去看一下提案语法。 ### 保证不可变性 虽然现在 React 也能用 Immutable 思想开发,但大部分情况无法保证安全性,比如: ```tsx const Hello = ({ profile }) => { // prop mutation: throws TypeError profile.name = 'Sebastien updated'; return

Hello {profile.name}

; }; function App() { const [profile, setProfile] = React.useState(#{ name: 'Sebastien', }); // state mutation: throws TypeError profile.name = 'Sebastien updated'; return ; } ``` 归根结底,我们不会总使用 `freeze` 来冻结对象,大部分情况下需要人为保证引用不被修改,其中的潜在风险依然存在。但使用 Record 表示状态,无论 TS 还是 JS 都会报错,立刻阻止问题扩散。 ### 部分代替 useMemo 比如下面的例子,为了保障 `apiFilters` 引用不变,需要对其 `useMemo`: ```tsx const apiFilters = useMemo( () => ({ userFilter, companyFilter }), [userFilter, companyFilter], ); const { apiData, loading } = useApiData(apiFilters); ``` 但 Record 模式不需要 memo,因为 js 引擎会帮你做类似的事情: ```tsx const {apiData,loading} = useApiData(#{ userFilter, companyFilter }) ``` ### 用在 useEffect 这段写的很啰嗦,其实和代替 useMemo 差不多,即: ```tsx const apiFilters = #{ userFilter, companyFilter }; useEffect(() => { fetchApiData(apiFilters).then(setApiDataInState); }, [apiFilters]); ``` 你可以把 `apiFilters` 当做一个引用稳定的原始对象看待,如果它确实变化了,那一定是值改变了,所以才会引发取数。如果把上面的 `#` 号去掉,每次组件刷新都会取数,而实际上都是多余的。 ### 用在 props 属性 可以更方便定义不可变 props 了,而不需要提前 useMemo: ```tsx ; ``` ### 将取数结果转化为 Record 这个目前还真做不到,除非用性能非常差的 `JSON.stringify` 或 `deepEqual`,用法如下: ```tsx const fetchUserAndCompany = async () => { const response = await fetch( `https://myBackend.com/userAndCompany`, ); return JSON.parseImmutable(await response.text()); }; ``` 即利用 Record 提案的 `JSON.parseImmutable` 将后端返回值也转化为 Record,这样即便重新查询,但如果返回结果完全不变,也不会导致重渲染,或者局部变化也只会导致局部重渲染,而目前我们只能放任这种情况下全量重渲染。 然而这对浏览器实现 Record 的新能优化提出了非常严苛的要求,因为假设后端返回的数据有几十 MB,我们不知道这种内置 API 会导致多少的额外开销。 假设浏览器使用非常 Magic 的办法做到了几乎零开销,那么我们应该在任何时候都用 `JSON.parseImmutable` 解析而不是 `JSON.parse`。 ### 生成查询参数 也是利用了 `parseImmutable` 方法,让前端可以精确发送请求,而不是每次 `qs.parse` 生成一个新引用就发一次请求: ```tsx // This is a non-performant, but working solution. // Lib authors should provide a method such as qs.parseRecord(search) const parseQueryStringAsRecord = (search) => { const queryStringObject = qs.parse(search); // Note: the Record(obj) conversion function is not recursive // There's a recursive conversion method here: // https://tc39.es/proposal-record-tuple/cookbook/index.html return JSON.parseImmutable( JSON.stringify(queryStringObject), ); }; const useQueryStringRecord = () => { const { search } = useLocation(); return useMemo(() => parseQueryStringAsRecord(search), [ search, ]); }; ``` 还提到一个有趣的点,即到时候配套工具库可能提供类似 `qs.parseRecord(search)` 的方法把 `JSON.parseImmutable` 包装掉,也就是这些生态库想要 “无缝” 接入 Record 提案其实需要做一些 API 改造。 ### 避免循环产生的新引用 即便原始对象引用不变,但我们写几行代码随便 `.filter` 一下引用就变了,而且无论返回结果是否变化,引用都一定会改变: ```tsx const AllUsers = [ { id: 1, name: 'Sebastien' }, { id: 2, name: 'John' }, ]; const Parent = () => { const userIdsToHide = useUserIdsToHide(); const users = AllUsers.filter( (user) => !userIdsToHide.includes(user.id), ); return ; }; const UserList = React.memo(({ users }) => (
    {users.map((user) => (
  • {user.name}
  • ))}
)); ``` 要避免这个问题就必须 `useMemo`,但在 Record 提案下不需要: ```tsx const AllUsers = #[ #{ id: 1, name: 'Sebastien' }, #{ id: 2, name: 'John' }, ]; const filteredUsers = AllUsers.filter(() => true); AllUsers === filteredUsers; // true ``` ### 作为 React key 这个想法更有趣,如果 Record 提案保证了引用严格不可变,那我们完全可以拿 `item` 本身作为 `key`,而不需要任何其他手段,这样维护成本会大大降低。 ```tsx const list = #[ #{ country: 'FR', localPhoneNumber: '111111' }, #{ country: 'FR', localPhoneNumber: '222222' }, #{ country: 'US', localPhoneNumber: '111111' }, ]; <> {list.map((item) => ( ))} ``` 当然这依然建立在浏览器非常高效实现 Record 的前提,假设浏览器采用 `deepEqual` 作为初稿实现这个规范,那么上面这坨代码可能导致本来不卡的页面直接崩溃退出。 ### TS 支持 也许到时候 ts 会支持如下方式定义不可变变量: ```tsx const UsersPageContent = ({ usersFilters, }: { usersFilters: #{nameFilter: string, ageFilter: string} }) => { const [users, setUsers] = useState([]); // poor-man's fetch useEffect(() => { fetchUsers(usersFilters).then(setUsers); }, [usersFilters]); return ; }; ``` 那我们就可以真的保证 `usersFilters` 是不可变的了。因为在目前阶段,编译时 ts 是完全无法保障变量引用是否会变化。 ### 优化 css-in-js 采用 Record 与普通 object 作为 css 属性,对 css-in-js 的区别是什么? ```tsx const Component = () => (
This has a hotpink background.
); ``` 由于 css-in-js 框架对新的引用会生成新 className,所以如果不主动保障引用不可变,会导致渲染时 className 一直变化,不仅影响调试也影响性能,而 Record 可以避免这个担忧。 ## 精读 总结下来,其实 Record 提案并不是解决之前无法解决的问题,而是用更简洁的原生语法解决了复杂逻辑才能解决的问题。这带来的优势主要在于 “不容易写出问题代码了”,或者让 Immutable 在 js 语言的上手成本更低了。 现在看下来这个规范有个严重担忧点就是性能,而 stage2 并没有对浏览器实现性能提出要求,而是给了一些建议,并在 stage4 之前给出具体性能优化建议方案。 其中还是提到了一些具体做法,包括快速判断真假,即对数据结构操作时的优化。 快速判真可以采用类似 hash-cons 快速判断结构相等,可能是将一些关键判断信息存在 hash 表中,进而不需要真的对结构进行递归判断。 快速判假可以通过维护散列表快速判断,或者我觉得也可以用上数据结构一些经典算法,比如布隆过滤器,就是用在高效快速判否场景的。 ### Record 降低了哪些心智负担 其实如果应用开发都是 hello world 复杂度,那其实 React 也可以很好的契合 immutable,比如我们给 React 组件传递的 props 都是 boolean、string 或 number: ```tsx ; ``` 比如上面的例子,完全不用关心引用会变化,因为我们用的原始类型本身引用就不可能变化,比如 `18` 不可能突变成 `19`,如果子组件真的想要 `19`,那一定只能创建一个新的,总之就是没办法改变我们传递的原始类型。 如果我们永远在这种环境下开发,那 React 结合 immutable 会非常美妙。但好景不长,我们总是要面对对象、数组的场景,然而这些类型在 js 语法里不属于原始类型,我们了解到还有 “引用” 这样一种说法,两个值不一样对象可能是 `===` 全等的。 可以认为,Record 就是把这个顾虑从语法层面消除了,即 `#{ a: 1 }` 也可以看作像 `18`,`19` 一样的数字,不可能有人改变它,所以从语法层面你就会像对 `19` 这个数字一样放心 `#{ a: 1 }` 不会被改变。 当然这个提案面临的最大问题就是 “如何将拥有子结构的类型看作原始类型”,也许 JS 引擎将它看作一种特别的字符串更贴合其原理,但难点是这又违背了整个语言体系对子结构的默认认知,Box 装箱语法尤其别扭。 ## 总结 看了这篇文章的畅想,React 与 Records & Tulpes 结合的一定会很好,但前提是浏览器对其性能优化必须与 “引用对比” 大致相同才可以,这也是较为少见,对性能要求如此苛刻的特性,因为如果没有性能的加持,其便捷性将毫无意义。 > 讨论地址是:[精读《Records & Tuples for React》· Issue #385 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/385) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/225.精读《Excel JS API》.md ================================================ Excel 现在可利用 js 根据单元格数据生成图表、表格,或通过 js 拓展自定义函数拓展内置 Excel 表达式。 我们来学习一下 Excel js API 开放是如何设计的,从中学习到一些开放 API 设计经验。 API 文档:[Excel JavaScript API overview](https://docs.microsoft.com/en-us/office/dev/add-ins/reference/overview/excel-add-ins-reference-overview) ## 精读 Excel 将利用 JS API 开放了大量能力,包括用户能通过界面轻松做到的,也包括无法通过界面操作做到的。 ### 为什么需要开放 JS API Excel 已经具备了良好的易用性,以及 [formula](https://support.microsoft.com/en-us/office/overview-of-formulas-in-excel-ecfdc708-9162-49e8-b993-c311f47ca173) 这个强大的公式。在之前 [精读《Microsoft Power Fx》](https://github.com/ascoders/weekly/blob/master/%E5%89%8D%E6%B2%BF%E6%8A%80%E6%9C%AF/211.%E7%B2%BE%E8%AF%BB%E3%80%8AMicrosoft%20Power%20Fx%E3%80%8B.md) 提到过,formula 就是 Excel 里的 Power FX,属于画布低代码语言,不过在 Excel 里叫做 “公式” 更合适。 已经具备这么多能力,为何还需要 JS API 呢?一句话概括就是,在 JS API 内可以使用 formula,即 JS API 是公式能力的超集,它包含了对 Excel 工作簿的增删改查、数据的限制、RangeAreas 操作、图表、透视表,甚至可以自定义 formula 函数。 也就是说,JS API 让 Excel “可编程化”,即以开发者视角对 Excel 进行二次拓展,包括对公式进行二次拓展,使 Excel 覆盖更多场景。 ### JS API 可以用在哪些地方 从 Excel 流程中最开始的工作薄、工作表环节,到最细节的单元格数据校验都可通过 JS API 支持,目前看来 Excel JS API 并没有设置能力边界,而且还会不断完善,将 Excel 全生命周期中一切可编程的地方开放出来。 首先是对工作薄、工作表的操作,以及对工作表用户操作的监听,或者对工作表进行只读设置。这一类 API 的目的是对 Excel 这个整体进行编程操作。 第二步就是对单元格级别进行操作,比如对单元格进行区域选中,获取选中区域,或者设置单元格属性、颜色,或者对单元格数据进行校验。自定义公式也在这个环节,因为单元格的值可以是公式,而公式可以利用 JS API 拓展。 最后一步是拓展行为,即在单元格基础上引入图表、透视表拓展。虽然这些功能在 UI 按钮上也可以操作出来,但 JS API 可以实现 UI 界面配置不出来的逻辑,对于非常复杂的逻辑行为,即便 UI 可以配置出来,可读性也远没有代码高。除了表格透视表外、还可以创建一些自定义形状,基本的几何图形、图片和 SVG 都支持。 ### JS API 设计 比较有趣的是,Excel 并没有抽象 “单元格” 对象,即便我们所有人都认为单元格就是 Excel 的代表。 这么做是出于 API 设计的合理性,因为 Excel 使用 Range 概念表示连续单元格。比如: ```js Excel.run(function (context) { var sheet = context.workbook.worksheets.getActiveWorksheet(); var headers = [ ["Product", "Quantity", "Unit Price", "Totals"] ]; var headerRange = sheet.getRange("B2:E2"); headerRange.values = headers; headerRange.format.fill.color = "#4472C4"; headerRange.format.font.color = "white"; return context.sync(); }); ``` 可以发现,Range 让 Excel 聚焦在批量单元格 API,即把单元格看做一个范围,整体 API 都可以围绕一个范围去设计。这种设计理念的好处是,把范围局限在单格单元格,就可以覆盖 Cell 概念,而聚焦在多个单元格时,可以很方便的基于二维数据结构创建表格、折线图等分析图形,因为二维结构的数据才是结构化数据。 或者可以说,结构化数据是 Excel 最核心的概念,而单元格无法体现结构化。结构化数据的好处是,一张工作表就是一个可以用来分析的数据集,在其之上无论是基于单元格的条件格式,还是创建分析图表,都是一种数据二次分析行为,这都得益于结构化数据,所以 Excel JS API 必然围绕结构化数据进行抽象。 再从 API 语法来看,除了工作薄这个级别的 API 采用了 `Excel.createWorkbook();` 之外,其他大部分 API 都是以下形式: ```js Excel.run(function (context) { // var sheet = context.workbook.worksheets.getItem("Sample"); // 对 sheet 操作 .. return context.sync(); }); ``` 最外层的函数 `Excel.run` 是注入 `context` 用的,而且也可以保证执行的时候 Excel context 已经准备好了。而 `context.sync()` 是同步操作,即使当前对 context 的操作生效。所以 Excel JS API 是命令式的,也不会做类似 MVVM 的双向绑定,所以在操作过程中数据和 Excel 状态不会发生变化,直到执行 `context.sync()`。 注意到这点后,就可以理解为什么要把某些代码写在 `context.sync().then` 里了,比如: ```js Excel.run(function (ctx) { var pivotTable = context.workbook.worksheets.getActiveWorksheet().pivotTables.getItem("Farm Sales"); // Get the totals for each data hierarchy from the layout. var range = pivotTable.layout.getDataBodyRange(); var grandTotalRange = range.getLastRow(); grandTotalRange.load("address"); return context.sync().then(function () { // Sum the totals from the PivotTable data hierarchies and place them in a new range, outside of the PivotTable. var masterTotalRange = context.workbook.worksheets.getActiveWorksheet().getRange("E30"); masterTotalRange.formulas = [["=SUM(" + grandTotalRange.address + ")"]]; }); }).catch(errorHandlerFunction); ``` 这个从透视表获取数据的例子,只有执行 `context.sync()` 后才能拿到 `grandTotalRange.address`。 ## 总结 微软还在 Office 套件 Excel、Outlook、Word 中推出了 [ScriptLab](https://docs.microsoft.com/zh-cn/office/dev/add-ins/overview/explore-with-script-lab) 功能,就可以在 Excel 的 ScriptLab 里编写 Excel JS API。 在 Excel JS API 之上,还有一个 [通用 API](https://docs.microsoft.com/zh-cn/javascript/api/office?view=common-js-preview),定义为跨应用的通用 API,这样 Excel JS API 就可以把精力聚焦在 Excel 产品本身能力上。 > 讨论地址是:[精读《Excel JS API》· Issue #387 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/387) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/226.精读《2021 前端新秀回顾》.md ================================================ [2021 JavaScript Rising Stars](https://risingstars.js.org/2021/en) 每年都会对前端开源项目进行点评,其依据是去年 Star 的增幅。Star 虽然只是一个维度,但至少反应了流行度,根据这个排行榜可以大体分析出前端社区的趋势。 ## 精读 该榜单包含整体榜单、前端框架、Node 框架、构建工具、Vue 生态、React 生态、CSS-In-JS、测试、移动端、桌面、静态建站、状态管理、GraphQL 共 13 个子榜单,都是前端开源最活跃的几个领域,下面分别介绍。 ### 整体榜单 第一名 [zx](https://github.com/google/zx) 是一个命令行工具,它基于 Node 语法拓展了 Bash 支持,可以非常方便的进行 Node 与 Bash 之间的输入输出,就像 Node 原生就支持 Bash 一样。它解决了离不开 Bash,但 Bash 写起大段逻辑不如 Node 自然的痛点。 第二名 [vite](https://github.com/vitejs/vite) 是去年最闪耀的星,它是一个 bundless 概念的前端构建工具,最初服务于 vue,后来进行框架无关升级后,在 react、angular 生态都大受欢迎。它解决了 webpack 编译太慢,其他 bundless 方案不够开箱即用且存在大量兼容问题的痛点。 第三名 [next.js](https://github.com/vercel/next.js) 2016 年开始的项目,是一个大而全的 React 全家桶,定位就是各大厂都会自己做一套的前端一体化框架,但它更时髦,不断加入许多流行功能比如 Server Component。这和 next.js 所在的明星公司 Vercel 有关,这家公司挖了大量开源知名人物,包括 Svelte 作者与 React 团队核心成员,所以也许未来社区的新玩具会先用在 next.js 再独立开源。它给出了前端最佳实践,并解决了没有精力持续给项目进行全方位优化,或追逐不上潮流的问题,因为 next.js 本身正在成为前端潮流的发源地。 第四名 [react](https://github.com/facebook/react) 不用多说了,数据驱动、响应式编程、函数式的领军框架,它改变了前端开发效率。 第五名 [tauri](https://github.com/tauri-apps/tauri) 比 electron 更轻量的桌面应用开发框架,基于任何前端框架。它解决了前端开发者遇到桌面应用开发场景时各平台巨大的原生开发学习成本的痛点。 第六名 [Tailwind CSS](https://github.com/tailwindlabs/tailwindcss) 是 css 框架,它提供了大量语义化 className,提供了许多最佳实践,让你有机会把 css 打理的井然有序。它解决了前端项目 css 杂乱无章又没有人真的在意的痛点。 第七名 [vscode](https://github.com/microsoft/vscode) 宇宙级 IDE,它解决了程序员没有真正趁手软件写代码的痛点。 第八名 [Slidev](https://github.com/slidevjs/slidev) 是一个把 markdown 渲染成 PPT 的框架,基于 vite + vue 等技术栈开发。用它开发的 PPT 非常简洁美观,非常适合在公开场合分享时使用,不仅看起来赏心悦目,还可以不经意间切换到 Markdown 源码 hotfix 一下小错误,展示出你的极客精神。它解决了你真的只想展示几句话,但又要以 PPT 方式 show 出来的痛点。 第九名 [NocoDB](https://github.com/nocodb/nocodb) 是一个支持多种数据源的数据库 UI 管理工具。但其实它有更大的格局,即对标 [airtable](https://www.airtable.com/),即用 NocoDB 连接数据库后,一切数据可视化的操作与功能都成为了可能,且提供了大量工作常用的甘特图、电子表格等视图,并可互相转换,最终其实数据存储到连接的数据库,但你无需关心细节。它解决了基于二维表格数据开发各类生产工具需投入大量研发资源的痛点。 第十名 [Vue](https://github.com/vuejs/vue) 和 React 一样不多说了。 ### 前端框架 第一名 [react](https://github.com/facebook/react) 在整体榜单里了。 第二名 [Vue](https://github.com/vuejs/vue) 也在整体榜单里了。 第三名 [svelte](https://github.com/sveltejs/svelte) 是一个类似 vue 的框架,但特色是极度重视编译时,而忽略运行时,即运行时除了必要逻辑外是完全不引入任何 runtime 框架的。说实话我觉得和 vue、react 相比在正儿八经项目中并没有核心优势,因为它并没有那种魔法能力,可以极大的减少大型项目体积与提升性能,反而会受制于其语法与编译时的特性产生副作用。但唯一一个好处是框架无关,即利用 svelte 编译的组件几乎没有额外运行时框架代码,可以最低成本,最大隔离性的与其他项目结合。 第四名 [angular](https://github.com/angular/angular) 笔者已经很久没有关注 angular 框架了,无法给出什么点评。但从 svelte 新增热度超过 angular 来看,可能大部分开发者对 angular 的态度和我一样。 第五名 [solid](https://github.com/solidjs/solid) 类似 svelte,提前编译,按需打包,重要的是,其类似 React `useEffect` 的 API `createEffect` 在依赖变化后,仅该函数会重新执行,而不会导致整个组件重新执行,在点对点更新上做得更极致。 前端框架的亮点是 svelte 与 solid 的概念,即重编译时,轻运行时,更加原子化的更新粒度,与更直接的调用原生浏览器方法带来性能提升。很难不让人觉得这是一个前端框架新趋势,但我翻了不少资料发现,这种创新带来的收益在正常项目里微乎其微,所以实际上 2021 年前端框架还是没能跳出三巨头创造新的概念,而以 svelte 与 solid 为代表的 “静态化” 框架只能算微创新。 ### Node 框架 第一名 [next.js](https://github.com/vercel/next.js) 在整体榜单里了,在 Node 框架一骑绝尘。 第二名 [nest](https://github.com/nestjs/nest) 是一个 node 版 server 框架,支持传统的 Controller、Module、Service,支持用装饰器申明路由、控制器等,语法上比较时髦。 第三名 [Strapi](https://github.com/strapi/strapi) 专门为 API 场景服务,提供了一个 API 管理后台,解决了只需要一个便捷 API 管理,而不希望了解一个大而全的后端框架的痛点。 第四名 [remix](https://github.com/remix-run/remix) 其实和 next.js 定位差不多,由 react-router 作者开发,才开源不久,需要进一步观察。 第五名 [nuxt.js](https://github.com/nuxt/nuxt.js) 是 vue 领域的 next.js。 值得一提的是,svelte 也有自己的专属框架 [sveltekit](https://kit.svelte.dev/),所以 Node 后端框架之争大部分其实在打全栈的牌,毕竟 Node 的优势就是支持 js 语言,而当前端应用基于某个框架编写时,如果有一个 Node 框架可以无缝集成这个前端框架,它就比非 Node 框架更优。 不过大厂几乎都是前后端分离的,所以这种全栈优势框架在国内没有太多出场机会,如果你是一个个人博主,还是首推使用全栈框架建站。 ### 构建工具 第一名 [vite](https://github.com/vitejs/vite) 在整体榜单里了,在构建工具里也是一骑绝尘。 第二名 [esbuild](https://github.com/evanw/esbuild) 是用 go 编写的构建工具,适用使用范围更广,其压缩模块在 bundless 还未成熟时就被各大构建全家桶提前集成了,而 vite 也是基于 esbuild 进行编译的,但 vite 的火热度更高,说明了整体 bundless 方案已在 2021 年成熟了。 第三名 [swc](https://github.com/swc-project/swc) 因采用 rust 编写而知名,类似 esbuild,但因为依托 rust 编译到 wasm 的特性,支持了在线编译器,非常方便。swc 还被大量新生代构建工具作为基建,这在 [精读《Rust 是 JS 基建的未来》](https://github.com/ascoders/weekly/blob/master/%E5%89%8D%E6%B2%BF%E6%8A%80%E6%9C%AF/218.%E7%B2%BE%E8%AF%BB%E3%80%8ARust%20%E6%98%AF%20JS%20%E5%9F%BA%E5%BB%BA%E7%9A%84%E6%9C%AA%E6%9D%A5%E3%80%8B.md) 时提到过。 第四名 [turborepo](https://github.com/vercel/turborepo) 是用 go 写的 monorepo 项目管理工具,是 lerna 的替代品。 第五名 [nx](https://github.com/nrwl/nx) 也是一个 monorepo 管理工具。 与框架不同,构建工具往往呈现套娃结构,不是你中有我,就是我中有你,每个热门库都重点解决某一块关键问题,不断套娃套娃,最后套成一个很棒的全家桶。 ### Vue 生态 第一名 [Slidev](https://github.com/slidevjs/slidev) 在整体榜单里了。 第二名 [Vue Element Admin](https://github.com/PanJiaChen/vue-element-admin) 基于 vue 的管理后台,在权限验证有一些最佳实践,使用 vuex 管理状态。 第三名 [Headless UI](https://github.com/tailwindlabs/headlessui) 是一个完全无样式的基础组件库,支持 React 与 Vue,官网的例子都是利用 [Tailwind CSS](https://github.com/tailwindlabs/tailwindcss) 内置样式组合而成的。它解决了 UI 组件库绑定样式后,自定义样式 “实际上非常恶心” 的痛点。 第四名 [Naive UI](https://github.com/TuSimple/naive-ui) 是一个 Vue 组件库,没有太多特别之处,但竟然上了排行榜。看了一下 star 趋势,在 2021.6 月份 star 涨幅是之后的十倍,估计刚开源推广了一波,后续涨幅很慢了,不出意外明年会跌出这个榜单。 第五名 [vue-next](https://github.com/vuejs/vue-next) 即 vue3,star 数量只有 vue2 的 13%,但今年 star 增幅有 vue2 的一半。 vue3 还自带了状态管理库 [pinia](https://github.com/vuejs/pinia),其生态已经非常完备。 ### React 生态 第一名 [next.js](https://github.com/vercel/next.js) 在整体榜单里了。 第二名 [Ant Design](https://github.com/ant-design/ant-design) 虽然立志成为西湖区最好的 React 组件库,但事实上已经成为了全球最好的 React 组件库。 第三名 [MUI](https://github.com/mui-org/material-ui) 就是大名鼎鼎的 material design UI 组件库,我对它影响最深的是按钮点击后出现的水波纹,这是 material design 的一大特色。早在 2014 年就创建了,在 Ant Design 没火的时候,是开源组件库首选。 第四名 [remix](https://github.com/remix-run/remix) 在 Node 框架榜单里了,和 next.js 一样,是绑定了 React 生态的 Node 框架,所以也出现在 React 生态中。 第五名 [react-use](https://github.com/streamich/react-use) 是很小巧的 React Hook 库,提供了如 `usePrevious`、`useDebounce` 等常用的 Hook。 看完整个 React 生态榜单,无论是优质生态库数量,还是去年增长的 Star 数,都比 Vue 生态更胜一筹。这背后是无副作用的纯函数与自动依赖收集的响应式视图之争,甚至在 React 生态里也有比如 mobx-react 等优质 MVVM 库,这两种编程范式都会长期并存。 ### CSS-In-JS 第一名 [vanilla-extract](https://github.com/seek-oss/vanilla-extract) 作为 2021 年的黑马,主打零运行时与 TS 支持。零运行时是通过 @vanilla-extract/webpack-plugin 插件在编译时就完成内容输出。 第二名 [styled-components](https://github.com/styled-components/styled-components) 是推出最早,也最成熟的一个 CSS-In-JS 框架,虽然版本间出现过运行时不兼容让我放弃过,但不得不说是这个方向的鼻祖。 第三名 [stitches](https://github.com/modulz/stitches) 和第一名很像,也主打零运行时,不过没有提对 TS 是否友好。 第四名 [Twin](https://github.com/ben-rogerson/twin.macro) 基于 [Tailwind CSS](https://github.com/tailwindlabs/tailwindcss) 实现了 CSS-In-JS 版的语法,可以认为是内置了一套最佳实践的 CSS-In-JS 库,也没解决太大的痛点,只是如果你同时喜欢 Tailwind CSS 与 CSS-In-JS,可能会爱屋及乌的选择 Twin。 第五名 [Emotion](https://github.com/emotion-js/emotion) 也是一个相对完备的库,基本上 CSS-In-JS 各类语法都能支持。 相比传统 CSS-In-JS 库,第一名 vanilla-extract 的零运行时是一大亮点,是这个方向的新趋势。 ### 测试 第一名 [Playwright](https://github.com/microsoft/playwright) 是一个跨浏览器跨平台的测试框架,可以利用 js 代码打开任意 url 地址截图或者对比,解决了搭建自动化测试平台需要从零开始编写底层框架的痛点。 第二名 [Storybook](https://github.com/storybookjs/storybook) 是非常有名的文档工具,很多开源组件、项目的文档都基于 Storybook 创建。神奇的是它还支持[单元测试](https://storybook.js.org/docs/react/writing-tests/introduction),在你访问 UI 组件时进行测试并打印出测试结果。Storybook 已经变成了一个 all-in-one 的组件开发工具。 第三名 [Cypress](https://github.com/cypress-io/cypress) 与 Playwright 且诞生比较早,但由于不支持多 tab 页面,且仅支持 js,所以仅在前端流行,在测试工程师角度却不如支持多语言的 Playwright 好用。 第四名 [Puppeteer](https://github.com/puppeteer/puppeteer) 是 2017 年谷歌推出基于 Chrome 无头浏览器的测试工具,但 2020 年微软的 Playwright 具有跨浏览器特性还是更胜一筹。 第五名 [Jest](https://github.com/facebook/jest) 是代码级别单测工具的佼佼者,覆盖了全框架,只要你想对代码进行单元测试,选 Jest 是不会错的。 测试框架围绕单测与浏览器测试这两个子领域,2021 年在浏览器测试领域出现了跨浏览器这个特色方向,在单测领域没有太大变化,顶多出了一个 [Vitest](https://github.com/vitest-dev/vitest) 让单测跑得更快,这个库在 2022 年稳定后可能会大放异彩,甚至可能因为 Vite 流行的原因取代 Jest。 ### 移动端 第一名 [ReactNative](https://github.com/facebook/react-native) 是基于 React 的 Mobile Native 开发框架,笔者用过一段时间,只能说不能抱有太大期待,因为极大的局限了 web 语法,如果你觉得仅掌握前端知识就可以轻松使用,那么一定会让你失望,不要一开始就抱着这种期待。另外跨端真是非常痛,比如 `SwitchAndroid`、`SwitchIOS` 让你感受不到 Write Once, Run everywhere(虽然官方也没这么说)。 第二名 [Ionic](https://github.com/ionic-team/ionic-framework) 是一个跨前端框架的跨平台构建工具,解决了 ReactNative 无法 Run everywhere 的痛点,但也带来了不够灵活的问题,即无法使用平台特定特性。 第三名 [Expo](https://github.com/expo/expo) 是基于 ReactNative 的一站式跨端开发工具,它的 App 使用非常傻瓜化,并且内置了调试能力,可以说是把 ReactNative 要踩的坑帮你踩完了。 第四名 [Quasar](https://github.com/quasarframework/quasar) 可以认为是 Vue 版的 ReactNative。 第五名 [Flipper](https://github.com/facebook/flipper) 是一个 Native 应用调试工具,可以认为是手机应用版本的 Chrome DevTools,支持连接远程终端,解决了手机应用难以用电脑调试的痛点。 其实还少了 [Flutter](https://github.com/flutter/flutter) 这个优秀框架,虽然不属于前端方向,但就像前端脚手架越来越多用 Rust、Go 写一样,Native 用 Dart 也是可以接受的。 从前端角度看移动端,唯一需求就是 Write Once,Run Anywhere,然后再把调试体验做好一些,Native 的兼容性、拓展性做强一些,就是一个完美方案了。 说到跨端,基于 Flutter 的 [kraken](https://github.com/openkraken/kraken) 也绝对值得一提,它利用 Flutter 高一执行渲染层能力,并解决了 Dart 生态对前端不友好的问题,做了一个 html+css+js 到 dart 的桥接层,如果明年可以在手淘稳定覆盖大量场景,那一定是个值得考虑的方案。 ## 总结 还有更多榜单就不一一总结了,如果觉得不过瘾,可以去 [2021 JavaScript Rising Stars](https://risingstars.js.org/2021/en) 翻翻这些 top star 项目的介绍和源码深入了解一下。 最后总结一下 2021 前端领域的几个关键特征: - 编程语言全面开花。以后 JS 开发者不等于前端开发者了,因为 Go、Rust、Dart、C++ 语言都可以为前端服务,并且 2021 年是真的有不少场景做到了生产环境可用,不论我们接不接受,前端不止有 JS 一种语言了。 - 前端开发全家桶逐渐产生技术壁垒。在前几年,抄一个前端全家桶很容易,在过程中还可以学到很多底层知识,但现在前端全家桶的积累越来越多,涉及的领域越来越广,甚至 next.js 引入的特性会超越你自己调制的全家桶,这说明全家桶的知识量已经逐渐达到个人知识广度的极限,如果你没有足够精力持续学习,跟进时代步伐的最好方式是使用一个成熟的全家桶。 > 讨论地址是:[精读《2021 前端新秀回顾》· Issue #390 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/390) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/228.精读《pipe operator for JavaScript》.md ================================================ [Pipe Operator (|>) for JavaScript](https://github.com/tc39/proposal-pipeline-operator#tacit-unary-function-application-syntax) 提案给 js 增加了 Pipe 语法,这次结合 [A pipe operator for JavaScript: introduction and use cases](https://2ality.com/2022/01/pipe-operator.html) 文章一起深入了解这个提案。 ## 概述 Pipe 语法可以将函数调用按顺序打平。如下方函数,存在三层嵌套,但我们解读时需要由内而外阅读,因为调用顺序是由内而外的: ```js const y = h(g(f(x))) ``` Pipe 可以将其转化为正常顺序: ```js const y = x |> f(%) |> g(%) |> h(%) ``` Pipe 语法有两种风格,分别来自 Microsoft 的 [F#](https://en.wikipedia.org/wiki/F_Sharp_(programming_language)) 与 Facebook 的 [Hack](https://en.wikipedia.org/wiki/Hack_(programming_language))。 之所以介绍这两个,是因为 js 提案首先要决定 “借鉴” 哪种风格。js 提案最终采用了 Hack 风格,因此我们最好把 F# 与 Hack 的风格都了解一下,并对其优劣做一个对比,才能知其所以然。 ### Hack Pipe 语法 Hack 语法相对冗余,在 Pipe 时使用 `%` 传递结果: ```js '123.45' |> Number(%) ``` 这个 `%` 可以用在任何地方,基本上原生 js 语法都支持: ```js value |> someFunction(1, %, 3) // function calls value |> %.someMethod() // method call value |> % + 1 // operator value |> [%, 'b', 'c'] // Array literal value |> {someProp: %} // object literal value |> await % // awaiting a Promise value |> (yield %) // yielding a generator value ``` ### F# Pipe 语法 F# 语法相对精简,默认不使用额外符号: ```js '123.45' |> Number ``` 但在需要显式声明参数时,为了解决上一个 Pipe 结果符号从哪来的问题,写起来反而更为复杂: ```js 2 |> $ => add2(1, $) ``` ### await 关键字 - Hack 优 F# 在 `await` `yield` 时需要特殊语法支持,而 Hack 可以自然的使用 js 内置关键字。 ```js // Hack value |> await % // F# value |> await ``` F# 代码看上去很精简,但实际上付出了高昂的代价 - `await` 是一个仅在 Pipe 语法存在的关键字,而非普通 `await` 关键字。如果不作为关键字处理,执行逻辑就变成了 `await(value)` 而不是 `await value`。 ### 解构 - F# 优 正因为 F# 繁琐的变量声明,反而使得在应对解构场景时得心应手: ```js // F# value |> ({ a, b }) => someFunction(a, b) // Hack value |> someFunction(%.a, %.b) ``` Hack 也不是没有解构手段,只是比较繁琐。要么使用立即调用函数表达式 IIFE: ```js value |> (({ a, b }) => someFunction(a, b))(%) ``` 要么使用 `do` 关键字: ```js value |> do { const { a, b } = %; someFunction(a, b) } ``` 但 Hack 虽败犹荣,因为解决方法都使用了 js 原生提供的语法,所以反而体现出与 js 已有生态亲和性更强,而 F# 之所以能优雅解决,全都归功于自创的语法,这些语法虽然甜,但割裂了 js 生态,这是 F# like 提案被放弃的重要原因之一。 ### 潜在改进方案 虽然选择了 Hack 风格,但 F# 与 Hack 各有优劣,所以列了几点优化方案。 #### 利用 Partial Application Syntax 提案降低 F# 传参复杂度 F# 被诟病的一个原因是传参不如 Hack 简单: ```js // Hack 2 |> add2(1, %) // F# 2 |> $ => add2(1, $) ``` 但如果利用处于 stage1 的提案 [Partial Application Syntax](https://github.com/tc39/proposal-partial-application) 可以很好的解决问题。 这里就要做一个小插曲了。js 对柯里化没有原生支持,但 [Partial Application Syntax](https://github.com/tc39/proposal-partial-application) 提案解决了这个问题,语法如下: ```js const add = (x, y) => x + y; const addOne = add~(1, ?); addOne(2); // 3 ``` 即利用 `fn~(?, arg)` 的语法,将任意函数柯里化。这个特性解决 F# 传参复杂问题简直绝配,因为 F# 的每一个 Pipe 都要求是一个函数,我们可以将要传参的地方记为 `?`,这样返回值还是一个函数,完美符合 F# 的语法: ```js // F# 2 |> add~(1, ?) ``` 上面的例子拆开看就是: ```js const addOne = add~(1, ?) 2 |> addOne ``` 想法很美好,但 [Partial Application Syntax](https://github.com/tc39/proposal-partial-application) 得先落地。 #### 融合 F# 与 Hack 语法 在简单情况下使用 F#,需要利用 `%` 传参时使用 Hack 语法,两者混合在一起写就是: ```js const resultArray = inputArray |> filter(%, str => str.length >= 0) // Hack |> map(%, str => '['+str+']') // Hack |> console.log // F# ``` 不过这个 [提案](https://github.com/tc39/proposal-smart-pipelines) 被废弃了。 #### 创造一个新的操作符 如果用 `|>` 表示 Hack 语法,用 `|>>` 表示 F# 语法呢? ```js const resultArray = inputArray |> filter(%, str => str.length >= 0) // Hack |> map(%, str => '['+str+']') // Hack |>> console.log // F# ``` 也是看上去很美好,但这个特性连提案都还没有。 ### 如何用现有语法模拟 Pipe 即便没有 [Pipe Operator (|>) for JavaScript](https://github.com/tc39/proposal-pipeline-operator#tacit-unary-function-application-syntax) 提案,也可以利用 js 现有语法模拟 Pipe 效果,以下是几种方案。 #### Function.pipe() 利用自定义函数构造 pipe 方法,该语法与 F# 比较像: ```js const resultSet = Function.pipe( inputSet, $ => filter($, x => x >= 0) $ => map($, x => x * 2) $ => new Set($) ) ``` 缺点是不支持 `await`,且存在额外函数调用。 #### 使用中间变量 说白了就是把 Pipe 过程拆开,一步步来写: ```js const filtered = filter(inputSet, x => x >= 0) const mapped = map(filtered, x => x * 2) const resultSet = new Set(mapped) ``` 没什么大问题,就是比较冗余,本来可能一行能解决的问题变成了三行,而且还声明了三个中间变量。 #### 复用变量 改造一下,将中间变量变成复用的: ```js let $ = inputSet $ = filter($, x => x >= 0) $ = map($, x => x * 2) const resultSet = new Set($) ``` 这样做可能存在变量污染,可使用 IIFE 解决。 ## 精读 Pipe Operator 语义价值非常明显,甚至可以改变编程的思维方式,在串行处理数据时非常重要,因此命令行场景非常常见,如: ```bash cat "somefile.txt" | echo ``` 因为命令行就是典型的输入输出场景,而且大部分都是单输入、单输出。 在普通代码场景,特别是处理数据时也需要这个特性,大部分具有抽象思维的代码都进行了各种类型的管道抽象,比如: ```js const newValue = pipe( value, doSomething1, doSomething2, doSomething3 ) ``` 如果 [Pipe Operator (|>) for JavaScript](https://github.com/tc39/proposal-pipeline-operator#tacit-unary-function-application-syntax) 提案通过,我们就不需要任何库实现 pipe 动作,可以直接写成: ```js const newValue = value |> doSomething1(%) |> doSomething2(%) |> doSomething3(%) ``` 这等价于: ```js const newValue = doSomething3(doSomething2(doSomething1(value))) ``` 显然,利用 pipe 特性书写处理流程更为直观,执行逻辑与阅读逻辑是一致的。 ### 实现 pipe 函数 即便没有 [Pipe Operator (|>) for JavaScript](https://github.com/tc39/proposal-pipeline-operator#tacit-unary-function-application-syntax) 提案,我们也可以一行实现 pipe 函数: ```js const pipe = (...args) => args.reduce((acc, el) => el(acc)) ``` 但要实现 Hack 参数风格是不可能的,顶多实现 F# 参数风格。 ### js 实现 pipe 语法的考虑 从 [提案](https://github.com/tc39/proposal-pipeline-operator#tc39-has-rejected-f-pipes-multiple-times) 记录来看,F# 失败有三个原因: - 内存性能问题。 - `await` 特殊语法。 - 割裂 js 生态。 其中割裂 js 生态是指因 F# 语法的特殊性,如果有太多库按照其语法实现功能,可能导致无法被非 Pipe 语法场景所复用。 甚至还有部分成员反对 [隐性编程(Tacit programming)](https://en.wikipedia.org/wiki/Tacit_programming),以及柯里化提案 [Partial Application Syntax](https://github.com/tc39/proposal-partial-application),这些会使 js 支持的编程风格与现在差异过大。 看来处于鄙视链顶端的编程风格在 js 是否支持不是能不能的问题,而是想不想的问题。 ### pipe 语法的弊端 下面是普通 `setState` 语法: ```ts setState(state => ({ ...state, value: 123 })) ``` 如果改为 `immer` 写法如下: ```ts setState(produce(draft => draft.value = 123)) ``` 得益于 ts 类型自动推导,在内层 `produce` 里就已经知道 `value` 是数值类型,此时如果输入字符串会报错,而如果其在另一个上下文的 `setState` 内,类型也会随着上下文的变化而变化。 但如果写成 pipe 模式: ```ts produce(draft => draft.value = 123) |> setState ``` 因为先考虑的是如何修改数据,此时还不知道后面的 pipe 流程是什么,所以 `draft` 的类型无法确定。所以 pipe 语法仅适用于固定类型的数据处理流程。 ## 总结 pipe 直译为管道,潜在含义是 “数据像流水线一样被处理”,也可以形象理解为每个函数就是一个不同的管道,显然下一个管道要处理上一个管道的数据,并将结果输出到下一个管道作为输入。 合适的管道数量与体积决定了一条生产线是否高效,过多的管道类型反而会使流水线零散而杂乱,过少的管道会让流水线笨重不易拓展,这是工作中最大的考验。 > 讨论地址是:[精读《pipe operator for JavaScript》· Issue #395 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/395) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/23.精读《API 设计原则》.md ================================================ 本期精读的文章是:[API 设计原则](https://coolshell.cn/articles/18024.html) # 1 引言 logo 优秀的 API 之于代码,就如良好内涵对于每个人。好的 API 不但利于使用者理解,开发时也会事半功倍,后期维护更是顺风顺水。 一个骨灰级资深的同事跟我说过,任何在成长的代码库,至少半年到一年就要重构一次,否则失去的不仅是活力,更失去了可维护性与可用性。 # 2 内容概要 由于本文已经是翻译后的文章,概要只列出不涉及 c++ 概念的思路框架,细节请移步[译文](https://coolshell.cn/articles/18024.html)。 ## 好 API 的 6 个特质 极简且完备、语义清晰简单、符合直觉、易于记忆和引导 API 使用者写出可读代码。 ## 静态多态 尽量减少继承,让相似的类具备相似的 API,而不是统一继承一个父类。因为统一继承会带来 API public 数量过多,父级无意义的方法对用户产生误导。 ## 基于属性的 API 属性指的是对象状态,通过属性为粒度的 API,有利于使用者理解 API 的含义,但需注意关联属性的顺序性。 ## API 语义和文档 比如传值 -1 的含义是什么?如果 API 文档不像 [http status codes](http://www.restapitutorial.com/httpstatuscodes.html) 一样健全,建议通过枚举的方式增加可读性。 ## 命名的艺术 不要使用缩写,保持一致性。类命名以功能分组作为后缀,比零散命名更易懂。 函数命名要体现出是否包含副作用,参数过多时以对象作为传参,布尔参数改为枚举类型,或者分解为两个语义化 API。 # 3 精读 以下精读是对原文观点的补充。 ## Const 入参 eslint 有一条规则,不要直接改变入参的值。这个规则的初衷是解决函数副作用问题,禁止可能产生副作用代码的产生。但却可以通过如下方式避免: ```javascript function (num) { let scopeNum = num scopeNum = 5 } ``` 这是从包含指针类型编程语言学习过来的,因为当 `*num` 表示指针时,代表代码可能产生副作用(修改入参的风险)。而 js 并不总是这样的,不但没有指针申明,基本类型也总是通过拷贝进入传参,非基本类型通过引用传递,也就是会发生通过如上代码绕过检测,却依然产生副作用(改变函数入参)的情况。 为了避免副作用,建议引入 `flow` 或 `typescript`,通过 `const` 关键字与约定约束入参行为: ```typescript function (const num) { ... } ``` 将没有副作用函数的所有入参定义为 `const` 类型,静态检查阶段就禁止了对值的直接修改,同时因为有这个关键字的约束,在函数体内也约定不要通过引用浅拷贝修改它的值。 但这也无法彻底避免,仍然可以通过如下写法绕过检测,修改入参: ```typescript function (const num) { const scopeNum = { ...num } scopeNum.a.b = 'c' } ``` 在 js 中没有完美的方式避免对入参的修改,但通过对入参修饰 `const` 关键字,可以对使用者明确这是纯函数,对开发者提示不要写有副作用的代码。 > c++ 的 `const` 定义从编译开始就完全杜绝了修改的可能性,虽然有 `const_cast` “去” `const` 行为,但仍然不会改变入参的值(虽然可以后续对值修改,指针指向保持不变,但用 `const` 修饰的入参值永远不会改变)。 ## 统一关键字库 所有 api 定义之前,先抽离业务和功能语义的关键字,统一关键字库; 可以更好的让多人协作看起来如出一辙, 而且关键字库 更能够让调用者感觉到 符合直觉、语义清晰; 关键字库也是项目组新同学 PREDO 的内容之一, 很有带入感; ## 单一职责 接口设计尽量要做到 单一职责,最细粒度化; 可以使用组合的方式把多个解耦的单个接口组合在一起作为一个大的功能项接口; 接口设计的单一职责,也更方便多人协作时候的扩展和组合; ## 面向未来的多态 对于接口参数的扩展,我们要做到面向扩展开放,面向修改关闭; 升级做到要兼容,否则会导致大批量的下游不可用。 同时也要避免过度设计,当抽象功能只有一处使用时,尽量不要过早抽象。 ## 不要重复局部命名 ```typescript class User { // good setName() {} // bad setUserName() {} } ``` 在有上下文环境的调用中,减少不必要的描述可以提高 API 的精简和清晰度。 同时要避免过度使用解构,因为解构会丢失上下文,让我们对变量来源一无所知: ```typescript const { setName } = this.props.store.user const { setVisible } = this.props.store.article ``` 上述 `setName` `setVisible` 脱离了 `user` `article` 作用域,当隔着几百行调用时,早已不知所云。 # 4 总结 参考优秀类库是设计 API 很好的方法之一,比如本文 c++ 参考的 Qt、js 可以参考 jQuery。 当 API 稳定后,需要花时间整理文档,因为写文档的思考过程可能推动着你重构和优化代码。 最后,如果有精力,最好每半年重构一次(然后完整跑一遍测试)! > 讨论地址是:[精读《API 设计原则》 · Issue #34 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/34) > 如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,每周五发布。 ================================================ FILE: 前沿技术/230.精读《对 Markdown 的思考》.md ================================================ Markdown 即便在 2022 年也非常常用,比如这篇文章依然采用 Markdown 编写。 但 Markdown 是否应该成为文本编辑领域的默认技术选型呢?答案是否定的。我找到了一篇批判无脑使用 Markdown 作为技术选型的好文 [Thoughts On Markdown](https://www.smashingmagazine.com/2022/02/thoughts-on-markdown/),它提到 Markdown 在标准化、结构化、组件化都存在硬伤,如果你真的想做一个现代化的文本结构编辑器,不要采用 Markdown。 ## 概述 Markdown 流传甚广,甚至已成为我们的第二语言。Markdown 最早的解析器由 John Gruber 在 2004 年基于 Perl 编写发布,那时候 Markdown 只有一个目的,即为了方便网络写作。 网络写作必须基于 HTML 规范,而 HTML 规范对大部分人上手成本太高,因此 Markdown 就是基于文本创建的更易理解,或者说上手成本更低,甚至傻瓜化的一种语法,而要解析这个语法需要配套一个解析器,将这种语法文本最终转化为 HTML。 而数字化发展到今天,Markdown 已不再适合当下的写作场景了,主要原因有二: 1. Markdown 不再适合当下富交互、内容形态的编写。 2. Markdown 纯文本的开发体验不再满足当代开发者日益提高的体验需求。 首先还是从 Markdown 思想开始介绍。 ### Markdown 的核心思想 Markdown 最大优势就是好上手,不需要接触 HTML 这种复杂的嵌套语句(虽然对程序员来说 HTML 也简单到处于鄙视链底端)。原文抽象了三个优势: 1. 基于文本的合适抽象。虽然 HTML 甚至代码都是文本,但 “合适” 这个词很重要,即任何文本都可以是 Markdown,只要加一点点小标记就能描述专业结构,学习成本极低。 2. 有大量生态工具。比如语法解析器、高亮、格式转换、格式化、渲染等工具完备。 3. 编辑内容便于维护。比如 Markdown 很方便作为源码存储,而其他格式的富文本可能并不方便在源码里维护。 如果把 Markdown 与数据库表结构做比较,那数据库的理解成本真是太高了。 但是在如今后端即服务的时代,数据库访问越来越轻松,甚至出现大量如 AirTable 等 SAAS 产品将结构化数据快速转化为应用,其实接触了这些后才真正发现,结构化数据对开发者有多重要。Markdown 用来写写文章还是不错的,但用来表达逻辑结构最后一定会引发灾难后果,原文作者的团队就深受 Markdown 技术选型的困扰,被迫解决大量远超预期的难题。 如果真的要在 Markdown 的坑越走越深,就必须使用语法拓展来满足自定义诉求。 ### Markdown 语法拓展 最初 Markdown 语法是不支持表格的,如果想用 Markdown 绘制一张表格,只能使用原生 HTML 标签:`
`,当然,这也说明了 Markdown 本质就是给 HTML 加强了便捷的语法,我们完全可以将其当 HTML 使用。 然而并不是所有创作平台都支持 `
` 语法的,笔者自己就经常受到困扰,比如有些平台会屏蔽原生 HTML 语法,已保障所谓的 “安全性” 或者内容体验的 “一致性”,而这些平台为了弥补缺失的绘制表格能力,往往会支持一些自定义语法,更糟糕的是不支持,这就说到了 Markdown 的语法拓展。 Markdown 有哪些拓展呢?比如:[multiMarkdown](https://fletcherpenney.net/multimarkdown/)、[commonMark](https://commonmark.org/)、[Github Flavored Markdown](https://docs.github.com/en/get-started/writing-on-github) 等等。 这里随便举个例子,比如标准 MD 格式,其实第一行最后要加两个空格才能换行,但 GFM 取消了这个限制。这虽然更方便了,但暴露出平台间规范的不一致性,导致 Markdown 跨平台基本一定被坑。 而各平台拓展的语法,我们是否有足够的精力学习和记忆呢?先不说能不能记得下来,首先值不值得学习就是个问题,为什么一个网络写作平台需要占用写手学习与认知成本,而不是想办法去简化写作流程呢?所以语法拓展看似很美好,但放在写手角度,或者整个互联网各平台林立的角度来看,这种非标准的做法一定不靠谱,没有用户觉得你的平台有资格 “教他语法”,除非你是微信,钉钉或者飞书。 原文提到的观点是: 1. 作为写手,你不知道 Markdown 哪些语法可用,哪些语法不可用。 2. 标准规范存在一些 [模糊地带](https://johnmacfarlane.net/babelmark2/faq.html#what-is-this-for) 导致开发者实现时也会遇到各种纠结。 原文还提到一个语法拓展导致理解成本增高的例子:slack 平台自定义的 [mrkdown](https://api.slack.com/reference/surfaces/formatting#basics) 就不支持 `[link](https://slack.com)` 方式描述链接,而使用了 `` 语法。 总结来说,Markdown 语法拓展本应该是件好事,但实际无标准导致了标准的百花齐放,使 Markdown 成为了实际上没有标准的状态,整体来看弊端更多。 ### Markdown 面向的用户群 Markdown 的对自己的定位其实很不清晰,这也导致了一直不想确定标准化语法。 最初 Markdown 是服务给熟悉 HTML 的人提供的标记语言,而后来面向用户群实质上转向了开发者,因为开发者才会想到拓展语法以满足更复杂的使用场景,Markdown 原生语法无法适应越来越复杂的视觉展示形态。 如今 Markdown 的主要用户已经是开发人员与对代码感兴趣的人了,这倒不是说开发者有多喜欢它,而是在说 Markdown 的受众变窄了。如今任何一款面向非开发者群体的文档编辑器都不会采用 Markdown 了,而是所见即所得的 WYSIWYG(what you see is what you want)模式。 这个转变的过程是痛苦的,但现在来看,富文本编辑器不应用用 Markdown 语法,而是 WYSIWYG 模式已经是共识了。 ### 从段落到区块、从文章到应用 简单来说,即 Markdown 已经不适应当前 HTML 丰富的生态了,能轻松描述段落的标记语言,遇到富有交互的组件区块时,不得不引入例如 [MDX](https://mdxjs.com/) 等方案,但这样的方案根本只适合程序员群体,完全无法移植。 网络浏览形态也从简单的文章发展到具有整体性的应用,这些应用拥有复杂的布局、样式与交互,如果你尝试基于 Markdown 拓展语法来支持,最后可能发现还不如直接用原生 HTML。 ### 对结构化内容的诉求 从编程角度理解就是 “组件复用”。Markdown 原生语法无法实现内容的复用,如果必须要复用内容,只能将其重复写在每一处,势必造成巨大同步成本。 比如 Jekyll 就提出了 [FrontMatter](https://jekyllrb.com/docs/front-matter/) 概念用来创建复用的变量: ```yml --- food: Pizza ---

{{ page.food }}

``` ### WYSIWYG 编辑器不应将 HTML 作为底层数据结构 虽然浏览器真正将 HTML 作为底层数据结构,但这并不代表所见即所得的编辑器也可以如此,这也是为什么浏览器只能提供从源码到 UI 的输出,而不能提供从 UI 编辑到源码的反向输入。 因为用户的输入与 HTML 并不是一一对应关系,其中存在大量模糊地带,比如当前光标处在粗体与细体文字中间,那下一个输入到底算加粗还是不加粗呢?从 UI 上看不到加粗标签。再有,如果 HTML 存在冗余,其实当前光标所在位置已经被加粗标签包裹了好几层,但因为光标所在区域又被另一个样式标签覆盖成非加粗模式,当再次输入时可能就跳出了覆盖范围,重新变成了加粗,这个过程符合用户预期吗?从技术上,这种复杂标签结构也几乎无法被处理,因为组合花样实在太多。 现代大多数编辑器都以 JSON 格式存储数据结构,就因为其结构化且易于检索。 结构化最重要的体现是,其生成的 HTML 结构可以是稳定的,即对于一个既加粗又标红的文字,一定包裹在一个 `` 标签里,而不是 `
`,也就是这种模式根本没把 HTML 作为结构化数据去看待,自然就不会出现歧义。 Markdown 也是一样,其本身也会出现类似 HTML 标签的二义性,不适合作为底层数据结构存储。 ## 精读 批判 Markdown 的文章不多见,笔者也是看了之后才恍然发现 Markdown 竟然有这么多缺点。笔者结合自己的经验谈谈 Markdown 的缺点吧。 ### 不支持富交互的无奈 Markdown 仅能支持简单的 HTML 结构,而无法描述逻辑区块。Github 上大部分 Readme 都采用图片来实现这些功能,包括状态卡片、构建结果、个人信息名片等,可惜交互能力还是太弱,我觉得有朝一日 Github 应该会推出比如 Block 小区块的概念,让这些区块可以直接插入 Markdown 成为一个可交互的元素。 ### MDX 解决了 Markdown 的痛点吗? 看似完美兼容 JSX 与 Markdown 的 MDX 曾经也是笔者写作的救命稻草,但该方案移植性是一大痛点,组件只能在自己部署的网站用,如果你想把文章发布到另一个平台,完全不可能。 这还仅是笔者的视角,如果从 Markdown 生态来看,MDX 面向用户仅是程序员群体,根本没有解决其使命 “方便网络写作”,而程序员最终也会抛弃 MDX 而转向开发所见即所得编辑器解决问题。 ### Markdown 到 HTML 的转换存在逻辑问题 Markdown 本质上还是一种脱离 HTML 的文本表示结构,看上去解耦很优雅,实际上会遇到不少不一致的问题。 比如说连续敲击多个空格会出现什么情况呢?在 Markdown 会变成一个引用区块,那如何才能展示多个空格呢?谁也不知道,可能需要查阅具体平台提供的额外语法才可以做到。 这种大体上用起来方便,但细节无法定制,甚至用户无法控制的情况会大大伤害已经深度使用 Markdown 的用户,此时用户要么硬着头皮发明新语法解决这些漏洞,要么就完全放弃 Markdown 了。 ### 结构化能力不足 看上去 Markdown 的语法挺具有结构化的,但实际上 Markdown 的结构化不具有强约束力。 拿 JSON 作对比,比如我们可以用 JSON 拓展出 [https://json-schema.org/](jsonSchema) 结构,这个结构甚至可以反推出一个完整的表单应用,其原因是 JSON 可以针对每一个 Key、层级下定义,首先有结构,其次才有内容。 而 Markdown 正好反过来,是先有内容,再有结构。比如我们可以在 Markdown 任何地方写任何 HTML 标签,或者任意段落的问题,这些内容是无法被序列化的,即便我们按照浏览器解析 HTML 的规则解析成 JSON,也无法从中方便的提取信息。 背后的根本原因是,Markdown 本身定位就是 “近乎于 UI 渲染结果” 的,而实际上浏览器渲染 UI 背后是需要一套严谨的 HTML 语法,因为 UI 与背后语法并不能一一建立映射,一个稳定的渲染逻辑只能是从源码推导到渲染,而不能从渲染反推出源码。Markdown 本身定位就近乎于渲染结果,所以结构化能力不足是天然的问题。 ## 总结 记得语雀早期内部试用时,编辑态还是采用 Markdown 的,但后来很快就把 Markdown 的编辑入口下掉了,这件事还引发了不少开发者的不满,甚至还有一些 Markdown 编辑的插件被开发出来,一度很受欢迎。但渐渐的我们都习惯用所见即所得方式编辑了,Markdown 唯一留给我们的印象就是快捷键,比如 `###` 后敲入空格可以生成 `h3` 标题段落,而语雀编辑器也在富交互组件区块上越走越远,要是当年被 Markdown 锁定住了技术,也不可能有今天这么高级的编辑体验。 所以技术前瞻性真的很重要,Markdown 所有程序员都爱,但提前看到它在当前互联网发展阶段的局限性,并设计一套结构化数据代替 Markdown 结构不是所有人都能想到的,我们需要以动态的眼光看待技术,也要放下技术人的偏见,把偏爱让位于产品定位。 > 讨论地址是:[精读《对 Markdown 的思考》· Issue #397 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/397) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/237.精读《Typescript 4.5-4.6 新特性》.md ================================================ ## 新增 Awaited 类型 Awaited 可以将 Promise 实际返回类型抽出来,按照名字可以理解为:等待 Promise resolve 了拿到的类型。下面是官方文档提供的 Demo: ```ts // A = string type A = Awaited>; // B = number type B = Awaited>>; // C = boolean | number type C = Awaited>; ``` ## 捆绑的 dom lib 类型可以被替换 TS 因开箱即用的特性,捆绑了所有 dom 内置类型,比如我们可以直接使用 Document 类型,而这个类型就是 TS 内置提供的。 也许有时不想随着 TS 版本升级而升级连带的 dom 内置类型,所以 TS 提供了一种指定 dom lib 类型的方案,在 `package.json` 申明 `@typescript/lib-dom` 即可: ```json { "dependencies": { "@typescript/lib-dom": "npm:@types/web" } } ``` 这个特性提升了 TS 的环境兼容性,但一般情况还是建议开箱即用,省去繁琐的配置,项目更好维护。 ## 模版字符串类型也支持类型收窄 ```ts export interface Success { type: `${string}Success`; body: string; } export interface Error { type: `${string}Error`; message: string; } export function handler(r: Success | Error) { if (r.type === "HttpSuccess") { // 'r' has type 'Success' let token = r.body; } } ``` 模版字符串类型早就支持了,但现在才支持按照模版字符串在分支条件时,做类型收窄。 ## 增加新的 --module es2022 虽然可以使用 --module esnext 保持最新特性,但如果你想使用稳定的版本号,又要支持顶级 await 特性的话,可以使用 es2022。 ## 尾递归优化 TS 类型系统支持尾递归优化了,拿下面这个例子就好理解: ```ts type TrimLeft = T extends ` ${infer Rest}` ? TrimLeft : T; // error: Type instantiation is excessively deep and possibly infinite. type Test = TrimLeft<" oops">; ``` 在没有做尾递归优化前,TS 会因为堆栈过深而报错,但现在可以正确返回执行结果了,因为尾递归优化后,不会形成逐渐加深的调用,而是执行完后立即退出当前函数,堆栈数量始终保持不变。 JS 目前还没有做到自动尾递归优化,但可以通过自定义函数 TCO 模拟实现,下面放出这个函数的实现: ```js function tco(f) { var value; var active = false; var accumulated = []; return function accumulator(...rest) { accumulated.push(rest); if (!active) { active = true; while (accumulated.length) { value = f.apply(this, accumulated.shift()); } active = false; return value; } }; } ``` 核心是把递归变成 while 循环,这样就不会产生堆栈。 ## 强制保留 import TS 编译时会把没用到的 import 干掉,但这次提供了 `--preserveValueImports` 参数禁用这一特性,原因是以下情况会导致误移除 import: ```ts import { Animal } from "./animal.js"; eval("console.log(new Animal().isDangerous())"); ``` 因为 TS 无法分辨 eval 里的引用,类似的还有 vue 的 `setup` 语法: ```html ``` ## 支持变量 import type 声明 之前支持了如下语法标记引用的变量是类型: ```ts import type { BaseType } from "./some-module.js"; ``` 现在支持了变量级别的 type 声明: ```ts import { someFunc, type BaseType } from "./some-module.js"; ``` 这样方便在独立模块构建时,安全的抹去 `BaseType`,因为单模块构建时,无法感知 `some-module.js` 文件内容,所以如果不特别指定 `type BaseType`,TS 编译器将无法识别其为类型变量。 ## 类私有变量检查 包含两个特性,第一是 TS 支持了类私有变量的检查: ```ts class Person { #name: string; } ``` 第二是支持了 `#name in obj` 的判断,如: ```ts class Person { #name: string; constructor(name: string) { this.#name = name; } equals(other: unknown) { return other && typeof other === "object" && #name in other && // <- this is new! this.#name === other.#name; } } ``` 该判断隐式要求了 `#name in other` 的 `other` 是 Person 实例化的对象,因为该语法仅可能存在于类中,而且还能进一步类型缩窄为 Person 类。 ## Import 断言 支持了导入断言提案: ```ts import obj from "./something.json" assert { type: "json" }; ``` 以及动态 import 的断言: ```ts const obj = await import("./something.json", { assert: { type: "json" } }) ``` TS 该特性支持了任意类型的断言,而不关心浏览器是否识别。所以该断言如果要生效,需要以下两种支持的任意一种: - 浏览器支持。 - 构建脚本支持。 不过目前来看,构建脚本支持的语法并不统一,比如 Vite 对导入类型的断言有如下两种方式: ```ts import obj from "./something?raw" // 或者自创的语法 blob 加载模式 const modules = import.meta.glob( './**/index.tsx', { assert: { type: 'raw' }, }, ); ``` 所以该导入断言至少在未来可以统一构建工具的语法,甚至让浏览器原生支持后,就不需要构建工具处理 import 断言了。 其实完全靠浏览器解析要走的路还有很远,因为一个复杂的前端工程至少有 3000~5000 个资源文件,目前生产环境不可能使用 bundless 一个个加载这些资源,因为速度太慢了。 ## const 只读断言 ```ts const obj = { a: 1 } as const obj.a = 2 // error ``` 通过该语法指定对象所有属性为 `readonly`。 ## 利用 realpathSync.native 实现更快加载速度 对开发者没什么感知,就是利用 `realpathSync.native` 提升了 TS 加载速度。 ## 片段自动补全增强 在 Class 成员函数与 JSX 属性的自动补全功能做了增强,在使用了最新版 TS 之后应该早已有了体感,比如 JSX 书写标签输入回车后,会自动根据类型补全内容,如: ```tsx // ↑回车↓ // // ↑光标自动移到这里 ``` ## 代码可以写在 super() 前了 JS 对 `super()` 的限制是此前不可以调用 this,但 TS 限制的更严格,在 `super()` 前写任何代码都会报错,这显然过于严格了。 现在 TS 放宽了校验策略,仅在 `super()` 前调用 this 会报错,而执行其他代码是被允许的。 这点其实早就该改了,这么严格的校验策略让我一度以为 JS 就是不允许 `super()` 前调用任何函数,但想想也觉得不合理,因为 `super()` 表示调用父类的 `constructor` 函数,之所以不自动调用,而需要手动调用 `super()` 就是为了开发者可以灵活决定哪些逻辑在父类构造函数前执行,所以 TS 之前一刀切的行为实际上导致 `super()` 失去了存在的意义,成为一个没有意义的模版代码。 ## 类型收窄对解构也生效了 这个特性真的很厉害,即解构后类型收窄依然生效。 此前,TS 的类型收窄已经很强大了,可以做到如下判断: ```ts function foo(bar: Bar) { if (bar.a === '1') { bar.b // string 类型 } else { bar.b // number 类型 } } ``` 但如果提前把 a、b 从 bar 中解构出来就无法自动收窄了。现在该问题也得到了解决,以下代码也可以正常生效了: ```ts function foo(bar: Bar) { const { a, b } = bar if (a === '1') { b // string 类型 } else { b // number 类型 } } ``` ## 深度递归类型检查优化 下面的赋值语句会产生异常,原因是属性 prop 的类型不匹配: ```ts interface Source { prop: string; } interface Target { prop: number; } function check(source: Source, target: Target) { target = source; // error! // Type 'Source' is not assignable to type 'Target'. // Types of property 'prop' are incompatible. // Type 'string' is not assignable to type 'number'. } ``` 这很好理解,从报错来看,TS 也会根据递归检测的方式查找到 prop 类型不匹配。但由于 TS 支持泛型,如下写法就是一种无限递归的例子: ```ts interface Source { prop: Source>; } interface Target { prop: Target>; } function check(source: Source, target: Target) { target = source; } ``` 实际上不需要像官方说明写的这么复杂,哪怕是 `props: Source` 也足以让该例子无限递归下去。TS 为了确保该情况不会出错,做了递归深度判断,过深的递归会终止判断,但这会带来一个问题,即无法识别下面的错误: ```ts interface Foo { prop: T; } declare let x: Foo>>>>>; declare let y: Foo>>>>; x = y; ``` 为了解决这一问题,TS 做了一个判断:递归保护仅对递归写法的场景生效,而上面这个例子,虽然也是很深层次的递归,但因为是一个个人肉写出来的,TS 也会不厌其烦的一个个递归下去,所以该场景可以正确 Work。 这个优化的核心在于,TS 可以根据代码结构解析哪些是 “非常抽象/启发式” 写法导致的递归,哪些是一个个枚举产生的递归,并对后者的递归深度检查进行豁免。 ## 增强的索引推导 下面的官方文档给出的例子,一眼看上去比较复杂,我们来拆解分析一下: ```ts interface TypeMap { "number": number; "string": string; "boolean": boolean; } type UnionRecord

= { [K in P]: { kind: K; v: TypeMap[K]; f: (p: TypeMap[K]) => void; } }[P]; function processRecord(record: UnionRecord) { record.f(record.v); } // This call used to have issues - now works! processRecord({ kind: "string", v: "hello!", // 'val' used to implicitly have the type 'string | number | boolean', // but now is correctly inferred to just 'string'. f: val => { console.log(val.toUpperCase()); } }) ``` 该例子的目的是实现 `processRecord` 函数,该函数通过识别传入参数 `kind` 来自动推导回调函数 `f` 中 `value` 的类型。 比如 `kind: "string"`,那么 `val` 就是字符串类型,`kind: "number"`,那么 `val` 就是数字类型。 因为 TS 这次更新解决了之前无法识别 `val` 类型的问题,我们不需要关心 TS 是怎么解决的,只要记住 TS 可以正确识别该场景(有点像围棋的定式,对于经典例子最好逐一学习),并且理解该场景是如何构造的。 如何做到呢?首先定义一个类型映射: ```ts interface TypeMap { "number": number; "string": string; "boolean": boolean; } ``` 之后定义最终要的函数 `processRecord`: ```ts function processRecord(record: UnionRecord) { record.f(record.v); } ``` 这里定义了一个泛型 K,`K extends keyof TypeMap` 等价于 `K extends 'number' | 'string' | 'boolean'`,所以这里是限定了以下泛型 K 的取值范围,值为这三个字符串之一。 重点来了,参数 `record` 需要根据传入的 `kind` 决定 `f` 回调函数参数类型。我们先想象以下 `UnionRecord` 类型怎么写: ```ts type UnionRecord = { kind: K; v: TypeMap[K]; f: (p: TypeMap[K]) => void; } ``` 如上,自然的想法是定义一个泛型 K,这样 `kind` 与 `f`, `p` 类型都可以表示出来,这样 `processRecord(record: UnionRecord)` 的 `UnionRecord` 就表示了将当前接收到的实际类型 K 传入 `UnionRecord`,这样 `UnionRecord` 就知道实际处理什么类型了。 本来到这里该功能就已经结束了,但官方给的 `UnionRecord` 定义稍有些不同: ```ts type UnionRecord

= { [K in P]: { kind: K; v: TypeMap[K]; f: (p: TypeMap[K]) => void; } }[P]; ``` 这个例子特意提升了一个复杂度,用索引的方式绕了一下,可能之前 TS 就无法解析这种形式吧,总之现在这个写法也被支持了。我们看一下为什么这个写法与上面是等价的,上面的写法简化一下如下: ```ts type UnionRecord

= { [K in P]: X }[P]; ``` 可以解读为,`UnionRecord` 定义了一个泛型 P,该函数从对象 `{ [K in P]: X }` 中按照索引(或理解为下标) `[P]` 取得类型。而 `[K in P]` 这种描述对象 Key 值的类型定义,等价于定义了复数个类型,由于正好 `P extends keyof TypeMap`,你可以理解为类型展开后是这样的: ```ts type UnionRecord

= { 'number': X, 'string': X, 'boolean': X }[P]; ``` 而 P 是泛型,由于 `[K in P]` 的定义,所以必定能命中上面其中的一项,所以实际上等价于下面这个简单的写法: ```ts type UnionRecord = { kind: K; v: TypeMap[K]; f: (p: TypeMap[K]) => void; } ``` ## 参数控制流分析 这个特性字面意思翻译挺奇怪的,还是从代码来理解吧: ```ts type Func = (...args: ["a", number] | ["b", string]) => void; const f1: Func = (kind, payload) => { if (kind === "a") { payload.toFixed(); // 'payload' narrowed to 'number' } if (kind === "b") { payload.toUpperCase(); // 'payload' narrowed to 'string' } }; f1("a", 42); f1("b", "hello"); ``` 如果把参数定义为元组且使用或并列枚举时,其实就潜在包含了一个运行时的类型收窄。比如当第一个参数值为 `a` 时,第二个参数类型就确定为 `number`,第一个参数值为 `b` 时,第二个参数类型就确定为 `string`。 值得注意的是,这种类型推导是从前到后的,因为参数是自左向右传递的,所以是前面推导出后面,而不能是后面推导出前面(比如不能理解为,第二个参数为 `number` 类型,那第一个参数的值就必须为 `a`)。 ## 移除 JSX 编译时产生的非必要代码 JSX 编译时干掉了最后一个没有意义的 `void 0`,减少了代码体积: ```js - export const el = _jsx("div", { children: "foo" }, void 0); + export const el = _jsx("div", { children: "foo" }); ``` 由于改动很小,所以可以借机学习一下 TS 源码是怎么修改的,这是 [PR DIFF 地址](https://github.com/microsoft/TypeScript/pull/47467/files#)。 可以看到,修改位置是 `src/compiler/transformers/jsx.ts` 文件,改动逻辑为移除了 `factory.createVoidZero()` 函数,该函数正如其名,会创建末尾的 `void 0`,除此之外就是大量的 tests 文件修改,其实理解了源码上下文,这种修改并不难。 ## JSDoc 校验提示 JSDoc 注释由于与代码是分离的,随着不断迭代很容易与实际代码产生分叉: ```ts /** * @param x {number} The first operand * @param y {number} The second operand */ function add(a, b) { return a + b; } ``` 现在 TS 可以对命名、类型等不一致给出提示了。顺便说一句,用了 TS 就尽量不要用 JSDoc,毕竟代码和类型分离随时有不一致的风险产生。 ## 总结 从这两个更新来看,TS 已经进入成熟期,但 TS 在泛型类的问题上依然还处于早期阶段,有大量复杂的场景无法支持,或者没有优雅的兼容方案,希望未来可以不断完善复杂场景的类型支持。 > 讨论地址是:[精读《Typescript 4.5-4.6 新特性》· Issue #408 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/408) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/238.精读《不再需要 JS 做的 5 件事》.md ================================================ 关注 JS 太久,会养成任何功能都用 JS 实现的习惯,而忘记了 HTML 与 CSS 也具备一定的功能特征。其实有些功能用 JS 实现吃力不讨好,我们要综合使用技术工具,而不是只依赖 JS。 [5 things you don't need Javascript for](https://lexoral.com/blog/you-dont-need-js/) 这篇文章就从 5 个例子出发,告诉我们哪些功能不一定非要用 JS 做。 ## 概述 ### 使用 css 控制 svg 动画 原文绘制了一个放烟花的 [例子](https://lexoral.com/blog/you-dont-need-js/),本质上是用 css 控制 svg 产生动画效果,核心代码: ```css .trail { stroke-width: 2; stroke-dasharray: 1 10 5 10 10 5 30 150; animation-name: trail; animation-timing-function: ease-out; } @keyframes trail { from, 20% { stroke-width: 3; stroke-dashoffset: 80; } 100%, to { stroke-width: 0.5; stroke-dashoffset: -150; } } ``` 可以看到,主要使用 `stroke-dasharray` 控制线条实虚线的样式,再利用动画效果对 `stroke-dashoffset` 产生变化,从而实现对线条起始点进行位移,实现线条 “绘图” 的效果,且该 css 样式对 svg 绘制的路径是生效的。 ### sidebar 可以完全使用 css 实现 hover 时才出现的侧边栏: ```css nav { position: 'absolute'; right: 100%; transition: 0.2s transform; } nav:hover, nav:focus-within { transform: translateX(100%); } ``` 核心在于 `hover` 时设置 `transform` 属性可以让元素偏移,且 `translateX(100%)` 可以位移当前元素宽度的身位。 另一个有意思的是,如果使用 TABS 按键聚焦到 sidebar 内元素也要让 sidebar 出来,可以直接用 `:focus-within` 实现。如果需要 hover 后延迟展示可以使用 `transition-delay` 属性。 ### sticky position 使用 `position: sticky` 来黏住一个元素: ```css .square { position: sticky; top: 2em; } ``` 这样该元素会始终展示在其父容器内,但一旦其出现在视窗时,当 top 超过 `2em` 后就会变为 `fixed` 定位并保持原位。 使用 JS 判断还是挺复杂的,你得设法监听父元素滚动,并且在定位切换时可能产生一些抖动,因为 JS 的执行与 CSS 之间是异步关系。但当我们只用 CSS 描述这个行为时,浏览器就有办法解决转换时的抖动问题。 ### 手风琴菜单 使用 `

` 标签可以实现类似一个简易的折叠手风琴效果: ```html
title

1

2

``` 在 `
` 标签内的 `` 标签内容总是会展示,且点击后会切换 `
` 内其他元素的显隐藏。虽然这做不了特殊动画效果,但如果只为了做一个普通的展开折叠功能,用 HTML 标签就够了。 ### 暗色主题 虽然直觉上暗色主题好像是一种定制业务逻辑,但其实因为暗色主题太过于普遍,以至于操作系统和浏览器都内置实现了,而 CSS 也实现了对应的方法判断当前系统的主题到底是亮色还是暗色:[prefers-color-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme)。 所以如果系统要实现暗色系主题,最好可以和操作系统设置保持一致,这样用户体验也会更好: ```css @media (prefers-color-scheme: light) { /** ... */ } @media (prefers-color-scheme: dark) { /** ... */ } @media (prefers-color-scheme: no-preference) { /** ... */ } ``` 如果使用 Checkbox 勾选是否开启暗色主题,也可以仅用 CSS 变量判断,核心代码是: ```css #checkboxId:checked ~ .container { background-color: black; } ``` `~` 这个符号表示,`selector1 ~ selector2` 时,为选择器 `selector1` 之后满足 `selector2` 条件的兄弟节点设置样式。 ## 精读 除了上面例子外,笔者再追加几个例子。 ### 幻灯片滚动 幻灯片滚动即每次滚动有固定的步长,把子元素完整的展示在可视区域,不可能出现上下或者左右两个子元素各出现一部分的 “割裂” 情况。 该场景除了用浏览器实现幻灯片外,在许多网站首页也被频繁使用,比如将首页切割为 5 个纵向滚动的区块,每个区块展示一个产品特性,此时滚动不再是连续的,而是从一个区块到另一个区块的完整切换。 其实这种效果无需 JS 实现: ```css html { scroll-snap-type: y mandatory; } .child { scroll-snap-align: start; } ``` 这样便将页面设置为精准捕捉子元素滚动位置,在滚轮触发、鼠标点击滚动条松手或者键盘上下按键时,`scroll-snap-type: y mandatory` 可以精准捕捉这一垂直滚动行为,并将子元素完全滚动到可视区域。 ### 颜色选择器 使用 HTML 原生就能实现颜色选择器: ```html ``` 该选择器的好处是性能、可维护性都非常非常的好,甚至可以捕捉桌面的颜色,不好的地方是无法对拾色器进行定制。 ## 总结 关于 CSS 可以实现哪些原本需要 JS 做的事,有很多很好的文章,比如: - [youmightnotneedjs](http://youmightnotneedjs.com/)。 - [You-Dont-Need-JavaScript](https://github.com/you-dont-need/You-Dont-Need-JavaScript)。 - 以及本文简介里介绍的 [5 things you don't need Javascript for](https://lexoral.com/blog/you-dont-need-js/)。 但并不是读了这些文章,我们就要尽量用 CSS 实现所有能做的事,那样也没有必要。CSS 因为是描述性语言,它可以精确控制样式,但却难以精确控制交互过程,对于标准交互行为比如幻灯片滑动、动画可以使用 CSS,对于非标准交互行为,比如自定义位置弹出 Modal、用 svg 绘制完全自定义路径动画尽量还是用 JS。 另外对于交互过程中的状态,如果需要传递给其他元素响应,还是尽量使用 JS 实现。虽然 CSS 伪类可以帮我们实现大部分这种能力,但如果我们要监听状态变化发一个请求什么的,CSS 就无能为力了,或者我们需要非常 trick 的利用 CSS 实现,这也违背了 CSS 技术选型的初衷。 最后,能否在合适的场景选择 CSS 方案,也是技术选型能力的一种,不要忘了 CSS 适用的领域,不要什么功能都用 JS 实现。 > 讨论地址是:[精读《不再需要 JS 做的 5 件事》· Issue #413 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/413) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/239.精读《JS 数组的内部实现》.md ================================================ 每个 JS 执行引擎都有自己的实现,我们这次关注 [V8](https://v8.dev/) 引擎是如何实现数组的。 本周主要精读的文章是 [How JavaScript Array Works Internally?](https://blog.gauravthakur.in/how-javascript-array-works-internally),比较简略的介绍了 V8 引擎的数组实现机制,笔者也会参考部分其他文章与源码结合进行讲解。 ## 概述 JS 数组的内部类型有很多模式,如: - PACKED_SMI_ELEMENTS - PACKED_DOUBLE_ELEMENTS - PACKED_ELEMENTS - HOLEY_SMI_ELEMENTS - HOLEY_DOUBLE_ELEMENTS - HOLEY_ELEMENTS PACKED 翻译为打包,实际意思是 “连续有值的数组”;HOLEY 翻译为孔洞,表示这个数组有很多孔洞一样的无效项,实际意思是 “中间有孔洞的数组”,这两个名词是互斥的。 SMI 表示数据类型为 32 位整型,DOUBLE 表示浮点类型,而什么类型都不写,表示数组的类型还杂糅了字符串、函数等,这个位置上的描述也是互斥的。 所以可以这么去看数组的内部类型:`[PACKED, HOLEY]_[SMI, DOUBLE, '']_ELEMENTS`。 ### 最高效的类型 PACKED_SMI_ELEMENTS 一个最简单的空数组类型默认为 PACKED_SMI_ELEMENTS: ```js const arr = [] // PACKED_SMI_ELEMENTS ``` PACKED_SMI_ELEMENTS 类型是性能最好的模式,存储的类型默认是连续的整型。当我们插入整型时,V8 会给数组自动扩容,此时类型还是 PACKED_SMI_ELEMENTS: ```js const arr = [] // PACKED_SMI_ELEMENTS arr.push(1) // PACKED_SMI_ELEMENTS ``` 或者直接创建有内容的数组,也是这个类型: ```js const arr = [1, 2, 3] // PACKED_SMI_ELEMENTS ``` ### 自动降级 当我们对数组使用骚操作时,V8 会默默的进行类型降级。比如突然访问到第 100 项: ```js const arr = [1, 2, 3] // PACKED_SMI_ELEMENTS arr[100] = 4 // HOLEY_SMI_ELEMENTS ``` 如果突然插入一个浮点类型,会降级到 DOUBLE: ```js const arr = [1, 2, 3] // PACKED_SMI_ELEMENTS arr.push(4.1) // PACKED_DOUBLE_ELEMENTS ``` 当然如果两个骚操作一结合,HOLEY_DOUBLE_ELEMENTS 就成功被你造出来了: ```js const arr = [1, 2, 3] // PACKED_SMI_ELEMENTS arr[100] = 4.1 // HOLEY_DOUBLE_ELEMENTS ``` 再狠一点,插入个字符串或者函数,那就到了最最兜底类型,HOLEY_ELEMENTS: ```js const arr = [1, 2, 3] // PACKED_SMI_ELEMENTS arr[100] = '4' // HOLEY_ELEMENTS ``` 从是否有 Empty 情况来看,PACKED > HOLEY 的性能,Benchmark 测试结果大概快 23%。 从类型来看,SMI > DOUBLE > 空类型。原因是类型决定了数组每项的长度,DOUBLE 类型是指每一项可能为 SMI 也可能为 DOUBLE,而空类型的每一项类型完全不可确认,在长度确认上会花费额外开销。 因此,HOLEY_ELEMENTS 是性能最差的兜底类型。 ### 降级的不可逆性 文中提到一个重点,表示降级是不可逆的,具体可以看下图: 其实要表达的规律很简单,即 PACKED 只会变成更糟的 HOLEY,SMI 只会往更糟的 DOUBLE 和空类型变,且这两种变化都不可逆。 ## 精读 为了验证文章的猜想,笔者使用 v8-debug 调试了一番。 ### 使用 v8-debug 调试 先介绍一下 v8-debug,它是一个 v8 引擎调试工具,首先执行下面的命令行安装 `jsvu`: ```bash npm i -g jsvu ``` 然后执行 `jsvu`,根据引导选择自己的系统类型,第二步选择要安装的 js 引擎,选择 `v8` 和 `v8-debug`: ```bash jsvu // 选择 macos // 选择 v8,v8-debug ``` 然后随便创建一个 js 文件,比如 `test.js`,再通过 `~/.jsvu/v8-debug ./test.js` 就可以执行调试了。默认是不输出任何调试内容的,我们根据需求添加参数来输出要调试的信息,比如: ```bash ~/.jsvu/v8-debug ./test.js --print-ast ``` 这样就会把 `test.js` 文件的语法树打印出来。 ### 使用 v8-debug 调试数组的内部实现 为了观察数组的内部实现,使用 `console.log(arr)` 显然不行,我们需要用 `%DebugPrint(arr)` 以 debug 模式打印数组,而这个 `%DebugPrint` 函数式 V8 提供的 Native API,在普通 js 脚本是不识别的,因此我们要在执行时添加参数 `--allow-natives-syntax`: ```bash ~/.jsvu/v8-debug ./test.js --allow-natives-syntax ``` 同时,在 `test.js` 里使用 `%DebugPrint` 打印我们要调试的数组,如: ```js const arr = [] %DebugPrint(arr) ``` 输出结果为: ```test DebugPrint: 0x120d000ca0b9: [JSArray] - map: 0x120d00283a71 [FastProperties] ``` 也就是说,`arr = []` 创建的数组的内部类型为 `PACKED_SMI_ELEMENTS`,符合预期。 ### 验证不可逆转换 不看源码的话,姑且相信原文说的类型转换不可逆,那么我们做一个测试: ```js const arr = [1, 2, 3] arr.push(4.1) console.log(arr); %DebugPrint(arr) arr.pop() console.log(arr); %DebugPrint(arr) ``` 打印核心结果为: ```text 1,2,3,4.1 DebugPrint: 0xf91000ca195: [JSArray] - map: 0x0f9100283b11 [FastProperties] 1,2,3 DebugPrint: 0xf91000ca195: [JSArray] - map: 0x0f9100283b11 [FastProperties] ``` 可以看到,即便 `pop` 后将原数组回退到完全整型的情况,DOUBLE 也不会优化为 SMI。 再看下长度的测试: ```js const arr = [1, 2, 3] arr[4] = 4 console.log(arr); %DebugPrint(arr) arr.pop() arr.pop() console.log(arr); %DebugPrint(arr) ``` 打印核心结果为: ```text 1,2,3,,4 DebugPrint: 0x338b000ca175: [JSArray] - map: 0x338b00283ae9 [FastProperties] 1,2,3 DebugPrint: 0x338b000ca175: [JSArray] - map: 0x338b00283ae9 [FastProperties] ``` 也证明了 PACKED 到 HOLEY 的不可逆。 ### 字典模式 数组还有一种内部实现是 Dictionary Elements,它用 HashTable 作为底层结构模拟数组的操作。 这种模式用于数组长度非常大的时候,不需要连续开辟内存空间,而是用一个个零散的内存空间通过一个 HashTable 寻址来处理数据的存储,这种模式在数据量大时节省了存储空间,但带来了额外的查询开销。 当对数组的赋值远大于当前数组大小时,V8 会考虑将数组转化为 Dictionary Elements 存储以节省存储空间。 做一个测试: ```js const arr = [1, 2, 3]; %DebugPrint(arr); arr[3000] = 4; %DebugPrint(arr); ``` 主要输出结果为: ```text DebugPrint: 0x209d000ca115: [JSArray] - map: 0x209d00283a71 [FastProperties] DebugPrint: 0x209d000ca115: [JSArray] - map: 0x209d00287d29 [FastProperties] ``` 可以看到,占用了太多空间会导致数组的内部实现切换为 DICTIONARY_ELEMENTS 模式。 实际上这两种模式是根据固定规则相互转化的,具体查了下 V8 源码: 字典模式在 V8 代码里叫 SlowElements,反之则叫 FastElements,所以要看转化规则,主要就看两个函数:`ShouldConvertToSlowElements` 和 `ShouldConvertToFastElements`。 下面是 `ShouldConvertToSlowElements` 代码,即什么时候转化为字典模式: ```c++ static inline bool ShouldConvertToSlowElements( uint32_t used_elements, uint32_t new_capacity ) { uint32_t size_threshold = NumberDictionary::kPreferFastElementsSizeFactor * NumberDictionary::ComputeCapacity(used_elements) * NumberDictionary::kEntrySize; return size_threshold <= new_capacity; } static inline bool ShouldConvertToSlowElements( JSObject object, uint32_t capacity, uint32_t index, uint32_t* new_capacity ) { STATIC_ASSERT(JSObject::kMaxUncheckedOldFastElementsLength <= JSObject::kMaxUncheckedFastElementsLength); if (index < capacity) { *new_capacity = capacity; return false; } if (index - capacity >= JSObject::kMaxGap) return true; *new_capacity = JSObject::NewElementsCapacity(index + 1); DCHECK_LT(index, *new_capacity); if (*new_capacity <= JSObject::kMaxUncheckedOldFastElementsLength || (*new_capacity <= JSObject::kMaxUncheckedFastElementsLength && ObjectInYoungGeneration(object))) { return false; } return ShouldConvertToSlowElements(object.GetFastElementsUsage(), *new_capacity); } ``` `ShouldConvertToSlowElements` 函数被重载了两次,所以有两个判断逻辑。第一处 `new_capacity > size_threshold` 则变成字典模式,new_capacity 表示新尺寸,而 size_threshold 是根据 3 * 已有尺寸 * 2 计算出来的。 第二处 `index - capacity >= JSObject::kMaxGap` 时变成字典模式,其中 kMaxGap 是常量 1024,也就是新加入的 HOLEY(孔洞) 大于 1024,则转化为字典模式。 而由字典模式转化为普通模式的函数是 `ShouldConvertToFastElements`: ```c++ static bool ShouldConvertToFastElements( JSObject object, NumberDictionary dictionary, uint32_t index, uint32_t* new_capacity ) { // If properties with non-standard attributes or accessors were added, we // cannot go back to fast elements. if (dictionary.requires_slow_elements()) return false; // Adding a property with this index will require slow elements. if (index >= static_cast(Smi::kMaxValue)) return false; if (object.IsJSArray()) { Object length = JSArray::cast(object).length(); if (!length.IsSmi()) return false; *new_capacity = static_cast(Smi::ToInt(length)); } else if (object.IsJSArgumentsObject()) { return false; } else { *new_capacity = dictionary.max_number_key() + 1; } *new_capacity = std::max(index + 1, *new_capacity); uint32_t dictionary_size = static_cast(dictionary.Capacity()) * NumberDictionary::kEntrySize; // Turn fast if the dictionary only saves 50% space. return 2 * dictionary_size >= *new_capacity; } ``` 重点是最后一行 `return 2 * dictionary_size >= *new_capacity` 表示字典模式仅节省了 50% 空间时,不如切换为普通模式(fast mode)。 具体就不测试了,感兴趣同学可以用上面介绍的方法使用 v8-debug 测试一下。 ## 总结 JS 数组使用方法非常灵活,但 V8 使用 C++ 实现时,必须转化为更底层的类型,所以为了兼顾性能,就做了快慢模式,而快模式又分了 SMI、DOUBLE;PACKED、HOLEY 模式分别处理来尽可能提升速度。 也就是说,我们在随意创建数组的时候,V8 会分析数组的元素构成与长度变化,自动分发到各种不同的子模式处理,以最大化提升性能。 这种模式使 JS 开发者获得了更好的开发者体验,而实际上执行性能也和 C++ 原生优化相差无几,所以从这个角度来看,JS 是一种更高封装层次的语言,极大降低了开发者学习门槛。 当然 JS 还提供了一些相对原生的语法比如 ArrayBuffer,或者 WASM 让开发者直接操作更底层的特性,这可以使性能控制更精确,但带来了更大的学习和维护成本,需要开发者根据实际情况权衡。 > 讨论地址是:[精读《JS 数组的内部实现》· Issue #414 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/414) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/24.精读《现代 JavaScript 概览》.md ================================================ 本期精读的文章是: [Glossary of Modern JavaScript Concepts: Part 1](https://auth0.com/blog/glossary-of-modern-javascript-concepts/) [Glossary of Modern JavaScript Concepts: Part 2](https://auth0.com/blog/glossary-of-modern-javascript-concepts-part-2/) # 1 引言 我为什么要选这篇文章呢? 之所以选这篇文章, 是因为非常认同作者写这两篇文章的原因. 作者在文中说, 现代 JavaScript 的很多概念和思想在快速被传播和扩展, 很多新概念出现在前端相关的博客和文档中, 这些概念对于很多前端开发人员来说, 仍然很陌生. 因此我们有必要来学习一下现代的这些 JavaScript 的概念, 看这些概念在现在 JavaScript 的库或应用中是怎么被使用的. # 2 内容概要 文章讲了很多现代 JavaScript 中的概念, 罗列如下: ## 纯函数和副作用 在了解纯函数之前, 首先要了解副作用. 副作用是指改变了其作用域外的状态. 副作用的举例有调用了一个 API, 操作了一个 DOM 节点, 弹出了一个弹窗, 或者改变了一条数据等. 而纯函数则是指 函数的返回值仅仅由参数决定, 当给同样的参数时, 返回值是固定的. ## Stateful 和 Stateless (有状态和无状态) Stateless 无状态, 有点像纯函数, 不管理自己的数据或状态, 结果取决于参数. 而 Stateful, 有状态, 指的是函数自己有自己的运行状态, 可以修改自己的状态. 在现代 JavaScript 开发中, 处理状态, 显得很重要. ## 可变对象与不可变对象 可变对象与不可变对象概念很清楚, 可变对象指的是在创建后值仍可以被改变, 不可变对象指的是创建后值无法被改变. 相比于其他语言, 可变对象与不可变对象在 JavaScript 中更加模糊, 当你了解函数式编程时, 你会听到很多不可变对象的好处. 在 JavaScript 中, 你可以通过 Object.freeze(obj), 让一个对象变得不可变, 但是注意这是浅层的冻结对象, 如果有一个属性的值是个对象, 那这个对象中的属性是可以被修改的. 现在 JavaScript 也出现了 npm deep-freeze , Immutable.js 这些库来帮助你在 JavaScript 中实现不可变对象. ## Imperative and Declarative Programming(命令式和声明式编程) 命令式编程, 描述一段代码的逻辑怎么被显式调用去改变程序的状态. 声明式编程, 描述一段代码的逻辑, 而不需要描述如何完成这段逻辑. JavaScript 可以同时被写为命令式和声明式编程方式, 但是随着函数式编程的兴起, 声明式编程将变得更加普遍. ## 高阶函数 函数作为 JavaScript 的一等公民, 可以跟普通数据类型一样, 被存储, 或者被作为值传参. 而高阶函数就是一种函数 可以接收另外一个函数作为入参, 或者返回一个函数作为结果. ## 函数式编程 FP 上面我们了解的 纯函数, 无状态, 不可变对象, 命令式编程, 和高阶函数, 都是很重要的函数式编程组成. 函数式编程通过以下方式包含上述概念: - 关键函数实现使用纯函数, 没有副作用. - 数据不可变 - 函数 无状态 - 声明式代码去管理副作用和执行命令式编程 ## Hot and Cold Observables Observables 和数组类似, 只不过数组是被保存在内存中, 而 Observables 的每一个元素则是异步加入进来. 我们可以订阅这些 observables. Hot Observables 容易会被执行, 即使我们没有订阅它们. 比如说 用户的操作界面的 按钮点击事件, 鼠标移动, 窗口大小改变, 这些都是 Hot Observables. 而 cold observable 则是需要我们去订阅, 并且会在我们订阅的时候开始执行. ## 响应式编程 RP 响应式编程, 可以看作是面向异步事件流的编程, 声明式的, 表述去做什么, 而不是怎么做. ## 函数式响应型编程 FRP 函数式响应型编程简而言之,就是对事件或者行为给予声明式的反馈. FRP 具有两个很明显的特点: - 函数或者类型有明确的定义 - 操作的是连续变化的值 ## 作用域和闭包 闭包作为最常见的面试题经常被提及, 但是很多资深的前端开发都解释不清楚闭包, 即使他们理解闭包. 作者首先介绍了全局作用域和局部作用域, 作用域作为许多 JS 开发人员最开始学习的知识, 理解作用域对于编写优秀的代码至关重要. 闭包的形成在于, 当一个在函数内声明的函数可以引用外部函数的局部变量. 就形成了闭包. ## 单向数据流和双向数据流 随着现在各种 SPA 框架的兴起, 理解数据流概念, 对于现在 JS 开发者越来越重要, React 被认为是单向数据流的典范, 使用 Model 作为唯一的数据来源, 控制 View 的渲染. 在 View 层用事件的方式通知 Model 更新, 在反应到 View 层的变化上. 数据沿着一个方向流动, UI 永远不会更新 Model, 而是通过事件或者 setState 方法. 在双向数据绑定中, 数据是在两个方向上流动的, JS 可以更新 Model 数据, View 层 也可以更新 Model 数据. AngularJs 的 1.x 版本是双向数据流的典型实现. 早在 2009 年, 双向绑定是 Angualr 最受欢迎的特性之一, 但是 Angular 把这一特性抛弃了. 现在很多流行的框架和库都使用了单向数据流(React,Angular,Inferno,Redux 等). 单向数据流倡导的是清晰的架构, 数据流动更加清晰和易管理. 对于单向数据流来说说了点 View 自动更新数据的便利, 但也得到了清晰的数据流. ## JS 框架中的变化侦测: 脏检查, getter 和 setter, 虚拟 DOM 变化侦测对于现代 SPA 应用来说很重要. 当用户更新一些内容时, 应用必须以一种方法知道这种变化, 并做出反应更新. AngularJS 1.x 使用的是脏检查的方式, 具体做法是对 View 中涉及到的 Model 进行深度比较. 脏检查的优点在于它的简单和可预测, 不涉及到 API 和对象的变更. 但是正因为涉及到大量比较, 也很低效. Ember 和 Backbone 是使用 getters 和 setters 来做变化侦测, 这样涉及到数据修改时, 都会触发变更事件. 而 React 是使用了虚拟 Dom 来做变化侦测, React 通过 setState 方法来通知变更, 使用虚拟 Dom 来比较是否发生了数据变化. ## Web Components 组件 Web 组件是 Web 平台上可复用的基础组件, 而 Web Components 则定义了一些规范来实现这些可复用组件. 规范包括: - 自定义元素 - HTML Template - Shadow Dom - HTML imports 引入 Web Components 本身并不能代替 SPA 框架的功能, 但是它的想法和核心概念, 在很多 SPA 框架中都有体现. ## Smart 和 Dumb 组件 现在 Web 的开发严重依赖组件, 而很多时候我们把组件分成 Smart 组件和 Dumb 组件. Smart 组件, 又叫容器组件, 在组件内处理各种业务逻辑, 通常也管理 Dumb 组件,响应 Dumb 组件的事件. Dumb 组件, 又叫展示组件, 通常被写成纯函数, 依赖于外部的数据和方法, 专注于展现数据. ## JIT 编译 Just-In-time(JIT)编译指的是代码的运行时, 被编译成机器代码的过程. 在 JavaScript 运行时, JIT 能够找到代码的特定模式, 而这些模式可以让 JavaScript 更快的被执行. ## AOT 编译 Ahead-Of-Time(AOT), 指的是编写的代码在运行之前, 被翻译成机器代码的过程. AOT 给 tree shaking 带来了可能, 使用 AOT 预编译, 对于生产环境下的代码有以下好处: - 更少的异步请求, 模板和样式内联在 JS 内 - 更小的体积 - 更早的检查到模板错误 - 更好的安全性 ## Tree Shaking Tree Shaking 是指打包 JS 模块时, 通过对代码的静态分析, 排除掉不用的代码的机制. Tree Shaking 技术建立在 ES2015 模块的, import 和 export 上, 支持我们导入特定的内容,而不是整个库. ```plain import { BehaviorSubject } from 'rxjs/BehaviorSubject'; ``` 这样我们只导入了 BehaviorSubject, 而没有导入整个 Rxjs 库. # 3 精读 文中讲到的现代 JavaScript 已经很多了, 再对理解的现代 JavaScript 补充几条: ## Dependent injection(依赖注入) 通过控制反转,父级不需要关心子实现细节,将子类可能用到的实例都初始化好,由子类决定引入哪些依赖。还有一个好处是维持了单实例,这一点在数据流中尤为重要,如果 store 不是单例的,那数据流必然乱了套,既希望传给子类使用,又要维持单例,依赖注入是很好的解决方案。 ## Symbol Reflect Proxy Symbol 是 ES6 中加入的一种新的数据类型, 每一个 Symbol 都是独一无二的, 不与其它 Symbol 重复. ES6 中的 Proxy , 则是通过 Proxy 方法, 实现对于对象的一层拦截. 提供一种机制, 代理对象的操作. 而 Reflect 是一个内置的对象,它提供可拦截 JavaScript 操作的方法。方法与代理处理程序的方法相同。 这三篇文章非常详细介绍了这三位 API:[symbol](https://www.keithcirkel.co.uk/metaprogramming-in-es6-symbols/) [reflect](https://www.keithcirkel.co.uk/metaprogramming-in-es6-part-2-reflect/) [proxy](https://www.keithcirkel.co.uk/metaprogramming-in-es6-part-3-proxies/) ## Server rendering 前端对后端渲染的热度降了很多,主要是盲目跟风的氛围消停了,真正需要的团队已经稳定的用起来了。后端渲染的理念很新颖,一定程度帮助了 html 认识到自己的不足,就像 Angular, React, Vue 对 webComponents 的冲击一样,或许未来十年可以用上 ECMAScript 标准提供的功能,但业务不能等待技术,现在唯有不断折腾,直到被消灭或者招安。 # 4 总结 伴随着各种框架的热度, 理解这些现代 JavaScript 概念变得越来越重要, 大家可以以这个作为概览, 详细去学习和了解现代 JavaScript 的概念. > 讨论地址是:[精读《现代 JavaScript 概览》 · Issue #35 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/35) > 如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,每周五发布。 ================================================ FILE: 前沿技术/240.精读《React useEvent RFC》.md ================================================ useEvent 要解决一个问题:如何同时保持函数引用不变与访问到最新状态。 本周我们结合 [RFC](https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md) 原文与解读文章 [What the useEvent React hook is (and isn't)](https://typeofnan.dev/what-the-useevent-react-hook-is-and-isnt/) 一起了解下这个提案。 借用提案里的代码,一下就能说清楚 `useEvent` 是个什么东西: ```ts function Chat() { const [text, setText] = useState(''); // ✅ Always the same function (even if `text` changes) const onClick = useEvent(() => { sendMessage(text); }); return ; } ``` `onClick` 既保持引用不变,又能在每次触发时访问到最新的 `text` 值。 为什么要提供这个函数,它解决了什么问题,在概述里慢慢道来。 ## 概述 定义一个访问到最新 state 的函数不是什么难事: ```ts function App() { const [count, setCount] = useState(0) const sayCount = () => { console.log(count) } return } ``` 但 `sayCount` 函数引用每次都会变化,这会直接破坏 `Child` 组件 memo 效果,甚至会引发其更严重的连锁反应(`Child` 组件将 `onClick` 回调用在 `useEffect` 里时)。 想要保证 `sayCount` 引用不变,我们就需要用 `useCallback` 包裹: ```ts function App() { const [count, setCount] = useState(0) const sayCount = useCallback(() => { console.log(count) }, [count]) return } ``` 但即便如此,我们仅能保证在 `count` 不变时,`sayCount` 引用不变。如果想保持 `sayCount` 引用稳定,就要把依赖 `[count]` 移除,这会导致访问到的 `count` 总是初始值,逻辑上引发了更大问题。 一种无奈的办法是,维护一个 countRef,使其值与 count 保持同步,在 `sayCount` 中访问 `countRef`: ```ts function App() { const [count, setCount] = useState(0) const countRef = React.useRef() countRef.current = count const sayCount = useCallback(() => { console.log(countRef.current) }, []) return } ``` 这种代码能解决问题,但绝对不推荐,原因有二: 1. 每个值都要加一个配套 Ref,非常冗余。 2. 在函数内直接同步更新 ref 不是一个好主意,但写在 `useEffect` 里又太麻烦。 另一种办法就是自创 hook,如 `useStableCallback`,这本质上就是这次提案的主角 - `useEvent`: ```ts function App() { const [count, setCount] = useState(0) const sayCount = useEvent(() => { console.log(count) }) return } ``` 所以 `useEvent` 的内部实现很可能类似于自定义 hook `useStableCallback`。在提案内也给出了可能的实现思路: ```ts // (!) Approximate behavior function useEvent(handler) { const handlerRef = useRef(null); // In a real implementation, this would run before layout effects useLayoutEffect(() => { handlerRef.current = handler; }); return useCallback((...args) => { // In a real implementation, this would throw if called during render const fn = handlerRef.current; return fn(...args); }, []); } ``` 其实很好理解,我们将需求一分为二看: 1. 既然要返回一个稳定引用,那最后返回的函数一定使用 `useCallback` 并将依赖数组置为 `[]`。 2. 又要在函数执行时访问到最新值,那么每次都要拿最新函数来执行,所以在 Hook 里使用 Ref 存储每次接收到的最新函数引用,在执行函数时,实际上执行的是最新的函数引用。 注意两段注释,第一个是 `useLayoutEffect` 部分实际上要比 `layoutEffect` 执行时机更提前,这是为了保证函数在一个事件循环中被直接消费时,不可能访问到旧的 Ref 值;第二个是在渲染时被调用时要抛出异常,这是为了避免 `useEvent` 函数被渲染时使用,因为这样就无法数据驱动了。 ## 精读 其实 `useEvent` 概念和实现都很简单,下面我们聊聊提案里一些有意思的细节吧。 ### 为什么命名为 useEvent 提案里提到,如果不考虑名称长短,完全用功能来命名的话,`useStableCallback` 或 `useCommittedCallback` 会更加合适,都表示拿到一个稳定的回调函数。但 `useEvent` 是从使用者角度来命名的,即其生成的函数一般都被用于组件的回调函数,而这些回调函数一般都有 “事件特性”,比如 `onClick`、`onScroll`,所以当开发者看到 `useEvent` 时,可以下意识提醒自己在写一个事件回调,还算比较直观。(当然我觉得主要原因还是为了缩短名称,好记) ### 值并不是真正意义上的实时 虽然 `useEvent` 可以拿到最新值,但和 `useCallback` 拿 `ref` 还是有区别的,这个差异体现在: ```ts function App() { const [count, setCount] = useState(0) const sayCount = useEvent(async () => { console.log(count) await wait(1000) console.log(count) }) return } ``` `await` 前后输出值一定是一样的,在实现上,`count` 值仅是调用时的快照,所以函数内异步等待时,即便外部又把 `count` 改了,当前这次函数调用还是拿不到最新的 `count`,而 `ref` 方法是可以的。在理解上,为了避免夜长梦多,回调函数尽量不要写成异步的。 ### useEvent 也救不了手残 如果你坚持写出 `onSomething={cond ? handler1 : handler2}` 这样的代码,那么 `cond` 变化后,传下去的函数引用也一定会变化,这是 `useEvent` 无论如何也避免不了的,也许解救方案是 Lint and throw error。 其实将 `cond ? handler1 : handler2` 作为一个整体包裹在 `useEvent` 就能解决引用变化的问题,但除了 Lint,没有人能防止你绕过它。 ### 可以用自定义 hook 代替 useEvent 实现吗? 不能。虽然提案里给了一个近似解决方案,但实际上存在两个问题: 1. 在赋值 ref 时,`useLayoutEffect` 时机依然不够提前,如果值变化后立即访问函数,拿到的会是旧值。 2. 子组件 layout effect 在父组件之前执行,拿到的也是旧值。 3. 生成的函数被用在渲染并不会给出错误提示。 ## 总结 `useEvent` 显然又给 React 增加了一个官方概念,在结结实实增加了理解成本的同时,也补齐了 React Hooks 在实践中缺失的重要一环,无论你喜不喜欢,问题就在那,解法也给了,挺好。 > 讨论地址是:[精读《React useEvent RFC》· Issue #415 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/415) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/242.精读《web reflow》.md ================================================ 网页重排(回流)是阻碍流畅性的重要原因之一,结合 [What forces layout / reflow](https://gist.github.com/paulirish/5d52fb081b3570c81e3a) 这篇文章与引用,整理一下回流的起因与优化思考。 借用这张经典图: 网页渲染会经历 DOM -> CSSOM -> Layout(重排 or reflow) -> Paint(重绘) -> Composite(合成),其中 Composite 在 [精读《深入了解现代浏览器四》](https://github.com/ascoders/weekly/blob/master/%E5%89%8D%E6%B2%BF%E6%8A%80%E6%9C%AF/222.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%B7%B1%E5%85%A5%E4%BA%86%E8%A7%A3%E7%8E%B0%E4%BB%A3%E6%B5%8F%E8%A7%88%E5%99%A8%E5%9B%9B%E3%80%8B.md) 详细介绍过,是在 GPU 进行光栅化。 那么排除 JS、DOM、CSSOM、Composite 可能导致的性能问题外,剩下的就是我们这次关注的重点,reflow 了。从顺序上可以看出来,重排后一定重绘,而重绘不一定触发重排。 ## 概述 什么时候会触发 Layout(reflow) 呢?一般来说,当元素位置发生变化时就会。但也不尽然,因为浏览器会自动合并更改,在达到某个数量或时间后,会合并为一次 reflow,而 reflow 是渲染页面的重要一步,打开浏览器就一定会至少 reflow 一次,所以我们不可能避免 reflow。 那为什么要注意 reflow 导致的性能问题呢?这是因为某些代码可能导致浏览器优化失效,即明明能合并 reflow 时没有合并,这一般出现在我们用 js API 访问某个元素尺寸时,为了保证拿到的是精确值,不得不提前触发一次 reflow,即便写在 for 循环里。 当然也不是每次访问元素位置都会触发 reflow,在浏览器触发 reflow 后,所有已有元素位置都会记录快照,只要不再触发位置等变化,第二次开始访问位置就不会触发 reflow,关于这一点会在后面详细展开。现在要解释的是,这个 ”触发位置等变化“,到底有哪些? 根据 [What forces layout / reflow](https://gist.github.com/paulirish/5d52fb081b3570c81e3a) 文档的总结,一共有这么几类: ### 获得盒子模型信息 - `elem.offsetLeft`, `elem.offsetTop`, `elem.offsetWidth`, `elem.offsetHeight`, `elem.offsetParent` - `elem.clientLeft`, `elem.clientTop`, `elem.clientWidth`, `elem.clientHeight` - `elem.getClientRects()`, `elem.getBoundingClientRect()` 获取元素位置、宽高的一些手段都会导致 reflow,不存在绕过一说,因为只要获取这些信息,都必须 reflow 才能给出准确的值。 ### 滚动 - `elem.scrollBy()`, `elem.scrollTo()` - `elem.scrollIntoView()`, `elem.scrollIntoViewIfNeeded()` - `elem.scrollWidth`, `elem.scrollHeight` - `elem.scrollLeft`, `elem.scrollTop` 访问及赋值 对 `scrollLeft` 赋值等价于触发 `scrollTo`,所有导致滚动产生的行为都会触发 reflow,笔者查了一些资料,目前主要推测是滚动条出现会导致可视区域变窄,所以需要 reflow。 ### focus() - `elem.focus()` ([源码](https://source.chromium.org/chromium/chromium/src/+/master:third_party/blink/renderer/core/dom/element.cc;l=4206-4225;drc=d685ea3c9ffcb18c781bc3a0bdbb92eb88842b1b)) 可以根据源码看一下注释,主要是这一段: ```c++ // Ensure we have clean style (including forced display locks). GetDocument().UpdateStyleAndLayoutTreeForNode(this) ``` 即在聚焦元素时,虽然没有拿元素位置信息的诉求,但指不定要被聚焦的元素被隐藏或者移除了,此时必须调用 `UpdateStyleAndLayoutTreeForNode` 重排重绘函数,确保元素状态更新后才能继续操作。 还有一些其他 element API: - `elem.computedRole`, `elem.computedName` - `elem.innerText` ([源码](https://source.chromium.org/chromium/chromium/src/+/master:third_party/blink/renderer/core/editing/element_inner_text.cc;l=462-468;drc=d685ea3c9ffcb18c781bc3a0bdbb92eb88842b1b)) `innerText` 也需要重排后才能拿到正确内容。 ### 获取 window 信息 - `window.scrollX`, `window.scrollY` - `window.innerHeight`, `window.innerWidth` - `window.visualViewport.height` / `width` / `offsetTop` / `offsetLeft` ([源码](https://source.chromium.org/chromium/chromium/src/+/master:third_party/blink/renderer/core/frame/visual_viewport.cc;l=435-461;drc=a3c165458e524bdc55db15d2a5714bb9a0c69c70?originalUrl=https:%2F%2Fcs.chromium.org%2F)) 和元素级别一样,为了拿到正确宽高和位置信息,必须重排。 ### document 相关 - `document.scrollingElement` 仅重绘 - `document.elementFromPoint` `elementFromPoint` 因为要拿到精确位置的元素,必须重排。 ### Form 相关 - `inputElem.focus()` - `inputElem.select()`, `textareaElem.select()` `focus`、`select` 触发重排的原因和 `elem.focus` 类似。 ### 鼠标事件相关 - `mouseEvt.layerX`, `mouseEvt.layerY`, `mouseEvt.offsetX`, `mouseEvt.offsetY` ([源码](https://source.chromium.org/chromium/chromium/src/+/master:third_party/blink/renderer/core/events/mouse_event.cc;l=476-487;drc=52fd700fb07a43b740d24595d42d8a6a57a43f81)) 鼠标相关位置计算,必须依赖一个正确的排布,所以必须触发 reflow。 ### getComputedStyle `getComputedStyle` 通常会导致重排和重绘,是否触发重排取决于是否访问了位置相关的 key 等因素。 ### Range 相关 - `range.getClientRects()`, `range.getBoundingClientRect()` 获取选中区域的大小,必须 reflow 才能保障精确性。 ### SVG 大量 SVG 方法会引发重排,就不一一枚举了,总之使用 SVG 操作时也要像操作 dom 一样谨慎。 ### contenteditable 被设置为 `contenteditable` 的元素内,包括将图像复制到剪贴板在内,大量操作都会导致重排。([源码](https://source.chromium.org/search?q=UpdateStyleAndLayout%20-f:test&ss=chromium%2Fchromium%2Fsrc:third_party%2Fblink%2Frenderer%2Fcore%2Fediting%2F)) ## 精读 [What forces layout / reflow](https://gist.github.com/paulirish/5d52fb081b3570c81e3a) 下面引用了几篇关于 reflow 的相关文章,笔者挑几个重要的总结一下。 ### repaint-reflow-restyle [repaint-reflow-restyle](http://www.phpied.com/rendering-repaint-reflowrelayout-restyle/) 提到现代浏览器会将多次 dom 操作合并,但像 IE 等其他内核浏览器就不保证有这样的实现了,因此给出了一个安全写法: ```js // bad var left = 10, top = 10; el.style.left = left + "px"; el.style.top = top + "px"; // better el.className += " theclassname"; // or when top and left are calculated dynamically... // better el.style.cssText += "; left: " + left + "px; top: " + top + "px;"; ``` 比如用一次 className 的修改,或一次 `cssText` 的修改保证浏览器一定触发一次重排。但这样可维护性会降低很多,不太推荐。 ### avoid large complex layouts [avoid large complex layouts](https://web.dev/avoid-large-complex-layouts-and-layout-thrashing/) 重点强调了读写分离,首先看下面的 bad case: ```js function resizeAllParagraphsToMatchBlockWidth() { // Puts the browser into a read-write-read-write cycle. for (var i = 0; i < paragraphs.length; i++) { paragraphs[i].style.width = box.offsetWidth + 'px'; } } ``` 在 for 循环中不断访问元素宽度,并修改其宽度,会导致浏览器执行 N 次 reflow。 虽然当 JavaScript 运行时,前一帧中的所有旧布局值都是已知的,但当你对布局做了修改后,前一帧所有布局值缓存都会作废,因此当下次获取值时,不得不重新触发一次 reflow。 而读写分离的话,就代表了集中读,虽然读的次数还是那么多,但从第二次开始就可以从布局缓存中拿数据,不用触发 reflow 了。 另外还提到 flex 布局比传统 float 重排速度快很多(3ms vs 16ms),所以能用 flex 做的布局就尽量不要用 float 做。 ### really fixing layout thrashing [really fixing layout thrashing](https://mattandre.ws/2014/05/really-fixing-layout-thrashing/) 提到了用 [fastdom](https://github.com/wilsonpage/fastdom) 实践读写分离: ```js ids.forEach(id => { fastdom.measure(() => { const top = elements[id].offsetTop fastdom.mutate(() => { elements[id].setLeft(top) }) }) }) ``` `fastdom` 是一个可以在不分离代码的情况下,分离读写执行的库,尤其适合用在 reflow 性能优化场景。每一个 `measure`、`mutate` 都会推入执行队列,并在 [window.requestAnimationFrame](https://developer.mozilla.org/en-US/docs/web/api/window/requestanimationframe) 时机执行。 ## 总结 回流无法避免,但需要控制在正常频率范围内。 我们需要学习访问哪些属性或方法会导致回流,能不使用就不要用,尽量做到读写分离。在定义要频繁触发回流的元素时,尽量使其脱离文档流,减少回流产生的影响。 > 讨论地址是:[精读《web reflow》· Issue #420 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/420) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/25.精读《null >= 0?》.md ================================================ 本期精读的文章是:[null >= 0?](https://blog.campvanilla.com/javascript-the-curious-case-of-null-0-7b131644e274) # 1 引言 logo 你是如何看待 null >= 0 为 `true` 这个结果的呢?要么选择勉强接受,要么跟着我一探究竟吧。 # 2 内容概要 ## 大于判断 javascript 在判断 `a > b` 时,记住下面 21 步判断法: 1. 调用 b 的 ToPrimitive(hit Number) 方法. 2. 调用 a 的 ToPrimitive(hit Number) 方法. 3. 如果此时 Result(1) 与 Result(2) 都是字符串,跳到步骤 16. 4. 调用 ToNumber(Result(1)). 5. 调用 ToNumber(Result(2)). 6. 如果 Result(4) 为 NaN, return undefined. 7. 如果 Result(5) 为 NaN, return undefined. 8. 如果 Result(4) 和 Result(5) 是相同的数字,return false. 9. 如果 Result(4) 为 +0, Result(5) 为 -0, return false. 10. 如果 Result(4) 为 -0, Result(5) 为 +0, return false. 11. 如果 Result(4) 为 +∞, return false. 12. 如果 Result(5) 为 +∞, return true. 13. 如果 Result(5) 为 -∞, return false. 14. 如果 Result(4) 为 -∞, return true. 15. 如果 Result(4) 的数值大小小于 Result(5),return true,否则 return false. 16. 如果 Result(2) 是 Result(1) 的前缀 return false. (比如 "ab" 是 "abc" 的前缀) 17. 如果 Result(1) 是 Result(2) 的前缀, return true. 18. 找到一个位置 k,使得 a[k] 与 b[k] 不相等. 19. 取 m 为 a[k] 字符的数值. 20. 取 n 为 b[k] 字符的数值. 21. 如果 m < n, return true,否则 return false. > ToPrimitive 会按照顺序优先使用存在的值:valueOf()、toString(),如果都没有,会抛出异常。 > ToPrimitive(hit Number) 表示隐转数值类型 所以 null > 0 结果为 `false`。 ## 等于判断 现在看看 `a == b` 时的表现(三等号会严格判断类型,两等号反而是最复杂的情况)。 1. 如果 a 与 b 的类型相同,则: - 如果 Type(b) 为 undefined,return true. - 如果 Type(b) 为 null,return true. - 如果 Type(b) 为 number,则: - 如果 b 为 NaN,return false. - 如果 a 为 NaN,return false. - 如果 a 与 b 数值相同,return true. - 如果 a 为 +0,b 为 -0,return true. - 如果 a 为 -0,b 为 +0,return true. - 否则 return false. - 如果 Type(b) 为 string,且 a 与 b 是完全相同的字符串,return true,否则 return false. - 如果 Type(b) 是 boolean,如果都是 true 或 false,return true,否则 return false. - 如果 a 与 b 是同一个对象引用,return true,否则 return false. 2. 如果 a 为 null,b 为 undefined,return true. 3. 如果 a 为 undefined,b 为 null,return true. 4. 如果 Type(a) 为 number,Type(b) 为 string,返回 a == ToNumber(b) 的结果. 5. 如果 Type(a) 为 string,Type(b) 为 number,返回 ToNumber(a) == b 的结果. 6. 如果 Type(a) 为 boolean,返回 ToNumber(a) == b 的结果. 7. 如果 Type(b) 为 boolean,返回 a == ToNumber(b) 的结果. 8. 如果 Type(a) 是 string 或 number,且 Type(b) 是对象类型,返回 a == ToPrimitive(b) 的结果. 9. 如果 Type(a) 是对象类型,且 Type(b) 是 string 或 number,返回 ToPrimitive(a) == b 的结果. 10. 否则 return false. 所以 null == 0 走到了第 10 步,返回了默认的 `false`。 ## 大于等于判断 javascript 是这么定义大于等于判断的: > 如果 a < b 为 `false`,则 a >= b 为 `true` 所以 null >= 0 为 `true`,因为 null < 0 是 `false`. # 3 精读 ### 关于 toPrimitive 拓展一下,我们可以通过 `Symbol.toPrimitive` 定义某个 class 的 ToPrimitive 行为,比如: ```javascript class AnswerToLifeAndUniverseAndEverything { [Symbol.toPrimitive](hint) { if (hint === 'string') { return 'Like, 42, man'; } else if (hint === 'number') { return 42; } else { // when pushed, most classes (except Date) // default to returning a number primitive return 42; } } } ``` ### 还有不按套路出牌的情况? 按上面的道理,我们可以举一反三: ```javascript {} >= {} // true ``` 可是这是为何呢? ```javascript null >= {} // false ``` 仔细读过上文应该不难发现,如果 ToPrimitive(hit Number) 出现了 NaN,将直接 return undefined,也就是打印出 false,而下面是隐式转换表,{} 的结果是 NaN,因此结果是 false。 ![primitive](https://camo.githubusercontent.com/c8ccc486bd441453d9c3529ed6d3b26661541787/68747470733a2f2f692e6c6f6c692e6e65742f323031372f30392f32322f353963346362316238336434632e706e67) # 4 总结 NaN 在 javascript 是个特殊存在,只有 `isNaN` 可以准确判断到它,而且使用它进行比较判断时,会直接 return false. javascript 隐式转换有一套优先级规则,而且不同值的隐式转换还需要对照表记忆,还存在 `ToPrimitive(hint Number)` `ToPrimitive(hint String)` `ToPrimitive(hint Boolean)` 三份表,记忆起来确实有点复杂。 因此推荐比较判断时,尽量使用 `===`,通过 `Typescript` `Flow` 等强类型语言约束变量类型,尽量不要做不同类型变量间的比较。 > 讨论地址是:[精读《null >= 0?》 · Issue #36 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/36) > 如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,每周五发布。 ================================================ FILE: 前沿技术/253.精读《pnpm》.md ================================================ [pnpm](https://pnpm.io/) 全称是 “Performant NPM”,即高性能的 npm。它结合软硬链接与新的依赖组织方式,大大提升了包管理的效率,也同时解决了 “幻影依赖” 的问题,让包管理更加规范,减少潜在风险发生的可能性。 使用 `pnpm` 很容易,可以使用 `npm` 安装: ``` npm i pnpm -g ``` 之后便可用 `pnpm` 代替 `npm` 命令了,比如最重要的安装包步骤,可以使用 `pnpm i` 代替 `npm i`,这样就算把 `pnpm` 使用起来了。 ## pnpm 的优势 用一个比较好记的词描述 `pnpm` 的优势那就是 “快、准、狠”: - 快:安装速度快。 - 准:安装过的依赖会准确复用缓存,甚至包版本升级带来的变化都只 diff,绝不浪费一点空间,逻辑上也严丝合缝。 - 狠:直接废掉了幻影依赖,在逻辑合理性与含糊的便捷性上,毫不留情的选择了逻辑合理性。 而带来这些优势的点子,全在官网上的这张图上: ![](https://s1.ax1x.com/2022/08/14/vUsEM4.png) - 所有 npm 包都安装在全局目录 `~/.pnpm-store/v3/files` 下,同一版本的包仅存储一份内容,甚至不同版本的包也仅存储 diff 内容。 - 每个项目的 `node_modules` 下有 `.pnpm` 目录以打平结构管理每个版本包的源码内容,以硬链接方式指向 pnpm-store 中的文件地址。 - 每个项目 `node_modules` 下安装的包结构为树状,符合 node 就近查找规则,以软链接方式将内容指向 `node_modules/.pnpm` 中的包。 所以每个包的寻找都要经过三层结构:`node_modules/package-a` > 软链接 `node_modules/.pnpm/package-a@1.0.0/node_modules/package-a` > 硬链接 `~/.pnpm-store/v3/files/00/xxxxxx`。 经过这三层寻址带来了什么好处呢?为什么是三层,而不是两层或者四层呢? ## 依赖文件三层寻址的目的 ### 第一层 接着上面的例子思考,第一层寻找依赖是 `nodejs` 或 `webpack` 等运行环境/打包工具进行的,他们的在 `node_modules` 文件夹寻找依赖,并遵循就近原则,所以第一层依赖文件势必要写在 `node_modules/package-a` 下,一方面遵循依赖寻找路径,一方面没有将依赖都拎到上级目录,也没有将依赖打平,目的就是还原最语义化的 `package.json` 定义:即定义了什么包就能依赖什么包,反之则不行,同时每个包的子依赖也从该包内寻找,解决了多版本管理的问题,同时也使 `node_modules` 拥有一个稳定的结构,即该目录组织算法仅与 `package.json` 定义有关,而与包安装顺序无关。 如果止步于此,这就是 `npm@2.x` 的包管理方案,但正因为 `npm@2.x` 包管理方案最没有歧义,所以第一层沿用了该方案的设计。 ### 第二层 从第二层开始,就要解决 `npm@2.x` 设计带来的问题了,主要是包复用的问题。所以第二层的 `node_modules/package-a` > 软链接 `node_modules/.pnpm/package-a@1.0.0/node_modules/package-a` 寻址利用软链接解决了代码重复引用的问题。相比 `npm@3` 将包打平的设计,软链接可以保持包结构的稳定,同时用文件指针解决重复占用硬盘空间的问题。 若止步于此,也已经解决了一个项目内的包管理问题,但项目不止一个,多个项目对于同一个包的多份拷贝还是太浪费,因此要进行第三步映射。 ### 第三层 第三层映射 `node_modules/.pnpm/package-a@1.0.0/node_modules/package-a` > 硬链接 `~/.pnpm-store/v3/files/00/xxxxxx` 已经脱离当前项目路径,指向一个全局统一管理路径了,这正是跨项目复用的必然选择,然而 `pnpm` 更进一步,没有将包的源码直接存储在 pnpm-store,而是将其拆分为一个个文件块,这在后面详细讲解。 ## 幻影依赖 幻影依赖是指,项目代码引用的某个包没有直接定义在 `package.json` 中,而是作为子依赖被某个包顺带安装了。代码里依赖幻影依赖的最大隐患是,对包的语义化控制不能穿透到其子包,也就是包 `a@patch` 的改动可能意味着其子依赖包 `b@major` 级别的 Break Change。 正因为这三层寻址的设计,使得第一层可以仅包含 `package.json` 定义的包,使 node_modules 不可能寻址到未定义在 `package.json` 中的包,自然就解决了幻影依赖的问题。 但还有一种更难以解决的幻影依赖问题,即用户在 Monorepo 项目根目录安装了某个包,这个包可能被某个子 Package 内的代码寻址到,要彻底解决这个问题,需要配合使用 Rush,在工程上通过依赖问题检测来彻底解决。 ## peer-dependences 安装规则 `pnpm` 对 `peer-dependences` 有一套严格的安装规则。对于定义了 `peer-dependences` 的包来说,意味着为 `peer-dependences` 内容是敏感的,潜台词是说,对于不同的 `peer-dependences`,这个包可能拥有不同的表现,因此 `pnpm` 针对不同的 `peer-dependences` 环境,可能对同一个包创建多份拷贝。 比如包 `bar` `peer-dependences` 依赖了 `baz^1.0.0` 与 `foo^1.0.0`,那我们在 Monorepo 环境两个 Packages 下分别安装不同版本的包会如何呢? ``` - foo-parent-1 - bar@1.0.0 - baz@1.0.0 - foo@1.0.0 - foo-parent-2 - bar@1.0.0 - baz@1.1.0 - foo@1.0.0 ``` 结果是这样(引用官网文档例子): ``` node_modules └── .pnpm ├── foo@1.0.0_bar@1.0.0+baz@1.0.0 │ └── node_modules │ ├── foo │ ├── bar -> ../../bar@1.0.0/node_modules/bar │ ├── baz -> ../../baz@1.0.0/node_modules/baz │ ├── qux -> ../../qux@1.0.0/node_modules/qux │ └── plugh -> ../../plugh@1.0.0/node_modules/plugh ├── foo@1.0.0_bar@1.0.0+baz@1.1.0 │ └── node_modules │ ├── foo │ ├── bar -> ../../bar@1.0.0/node_modules/bar │ ├── baz -> ../../baz@1.1.0/node_modules/baz │ ├── qux -> ../../qux@1.0.0/node_modules/qux │ └── plugh -> ../../plugh@1.0.0/node_modules/plugh ├── bar@1.0.0 ├── baz@1.0.0 ├── baz@1.1.0 ├── qux@1.0.0 ├── plugh@1.0.0 ``` 可以看到,安装了两个相同版本的 `foo`,虽然内容完全一样,但却分别拥有不同的名称:`foo@1.0.0_bar@1.0.0+baz@1.0.0`、`foo@1.0.0_bar@1.0.0+baz@1.1.0`。这也是 `pnpm` 规则严格的体现,任何包都不应该有全局副作用,或者考虑好单例实现,否则可能会被 `pnpm` 装多次。 ## 硬连接与软链接的原理 要理解 `pnpm` 软硬链接的设计,首先要复习一下操作系统文件子系统对软硬链接的实现。 硬链接通过 `ln originFilePath newFilePath` 创建,如 `ln ./my.txt ./hard.txt`,这样创建出来的 `hard.txt` 文件与 `my.txt` 都指向同一个文件存储地址,因此无论修改哪个文件,都因为直接修改了原始地址的内容,导致这两个文件内容同时变化。进一步说,通过硬链接创建的 N 个文件都是等效的,通过 `ls -li ./` 查看文件属性时,可以看到通过硬链接创建的两个文件拥有相同的 inode 索引: ``` ls -li ./ 84976912 -rw-r--r-- 2 author staff 489 Jun 9 15:41 my.txt 84976912 -rw-r--r-- 2 author staff 489 Jun 9 15:41 hard.txt ``` 其中第三个参数 2 表示该文件指向的存储地址有两个硬链接引用。硬链接如果要指向目录就麻烦多了,第一个问题是这样会导致文件的父目录有歧义,同时还要将所有子文件都创建硬链接,实现复杂度较高,因此 Linux 并没有提供这种能力。 软链接通过 `ln -s originFilePath newFilePath` 创建,可以认为是指向文件地址指针的指针,即它本身拥有一个新的 inode 索引,但文件内容仅包含指向的文件路径,如: ``` 84976913 -rw-r--r-- 2 author staff 489 Jun 9 15:41 soft.txt -> my.txt ``` 源文件被删除时,软链接也会失效,但硬链接不会,软链接可以对文件夹生效。因此 `pnpm` 虽然采用了软硬结合的方式实现代码复用,但软链接本身也几乎不会占用多少额外的存储空间,硬链接模式更是零额外内存空间占用,所以对于相同的包,`pnpm` 额外占用的存储空间可以约等于零。 ## 全局安装目录 pnpm-store 的组织方式 `pnpm` 在第三层寻址时采用了硬链接方式,但同时还留下了一个问题没有讲,即这个硬链接目标文件并不是普通的 NPM 包源码,而是一个哈希文件,这种文件组织方式叫做 content-addressable(基于内容的寻址)。 简单来说,基于内容的寻址比基于文件名寻址的好处是,即便包版本升级了,也仅需存储改动 Diff,而不需要存储新版本的完整文件内容,在版本管理上进一步节约了存储空间。 pnpm-store 的组织方式大概是这样的: ``` ~/.pnpm-store - v3 - files - 00 - e4e13870602ad2922bfc7.. - e99f6ffa679b846dfcbb1.. .. - 01 .. - .. .. - ff .. ``` 也就是采用文件内容寻址,而非文件位置寻址的存储方式。之所以能采用这种存储方式,是因为 NPM 包一经发布内容就不会再改变,因此适合内容寻址这种内容固定的场景,同时内容寻址也忽略了包的结构关系,当一个新包下载下来解压后,遇到相同文件 Hash 值时就可以抛弃,仅存储 Hash 值不存在的文件,这样就自然实现了开头说的,`pnpm` 对于同一个包不同的版本也仅存储其增量改动的能力。 ## 总结 `pnpm` 通过三层寻址,既贴合了 `node_modules` 默认寻址方式,又解决了重复文件安装的问题,顺便解决了幻影依赖问题,可以说是包管理的目前最好的创新,没有之一。 但其苛刻的包管理逻辑,使我们单独使用 `pnpm` 管理大型 Monorepo 时容易遇到一些符合逻辑但又觉得别扭的地方,比如如果每个 Package 对于同一个包的引用版本产生了分化,可能会导致 Peer Deps 了这些包的包产生多份实例,而这些包版本的分化可能是不小心导致的,我们可能需要使用 Rush 等 Monorepo 管理工具来保证版本的一致性。 > 讨论地址是:[精读《pnpm》· Issue #435 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/435) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/254.精读《对前端架构的理解 - 分层与抽象》.md ================================================ 可能一些同学会认为前端比较简单而不需要架构,或者因为前端交互细节杂而乱难以统一抽象,所以没办法进行架构设计。这个理解是片面的,虽然一些前端项目是没有仔细考虑架构就堆起来的,但这不代表不需要架构设计。任何业务程序都可以通过代码堆砌的方式实现功能,但背后的可维护性、可拓展性自然也就千差万别了。 为什么前端项目也要考虑架构设计?有如下几点原因: - **从必要性看**,前后端应用都跑在计算机上,计算机从硬件到操作系统,再到上层库都是有清晰架构设计与分层的,应用程序作为最上层的一环也是嵌入在整个大架构图里的。 - **从可行性看**,交互虽然多而杂,但这不构成不需要架构设计的理由。对计算机基础设计来说,也面临着多种多样的输入设备与输出设备,进而产生的标准输入输出的抽象,那么前端也应当如此。 - **从广义角度看**,大部分通用的约定与模型早已沉淀下来了,如编程语言,前端框架本身就是业务架构的一部分,用 React 哪怕写个 “Hello World” 也使用了数据驱动的设计理念。 **从必要性看**,虽然操作系统和各类基础库屏蔽了底层实现,让业务可以仅关心业务逻辑,大大解放了生产力,但一款应用必然是底层操作系统与业务层代码协同才能运行的,从应用程序往下有一套逻辑井然的架构分层设计,如果到了业务层没有很好的架构设计,技术抽象是一团乱麻,很难想象这样形成的整体运行环境是健康的。 业务模块的架构设计应当类似计算机基础的架构设计,从需求分析出发,设计有哪些业务子模块,并定义这些子模块的职责与子模块之间的关系。子模块的设计取决于业务的特性,子模块间的分层取决于业务的拓展能力。 比如一个绘图软件设计时只要需要组件子系统与布局子系统,它们之间互相独立,也能无缝结合。对于 BI 软件来说,就增加了筛选联动与通用数据查询的概念,因此对应的也会增加筛选联动模型、数据模型、图形语法这几个子模块,并按照其作用关系上下分层: 如果分层清晰而准确,可以看出这两个业务上层具有相同的抽象,即最上层都是组件与布局的结合,而筛选联动与数据查询,以及从数据模型映射到图元关系的映射功能都属于附加项,这些项移除了也不影响系统的运行。如果不这么设计,可能就理不清系统之间的相似点与差异点,导致功能耦合,要维护一个大系统可能要时刻关系各模块之间的相互影响,这样的系统即不清晰,也不够可拓展,关键是要维护它的理解成本也高。 **从可行性看**,前端的特点在于用户输入的触点非常多,但这不妨碍我们抽象标准输入接口,比如用户点击按钮或者输入框是输入,那键盘快捷键也是一种输入方式,URL 参数也是一种输入方式,在业务前置的表单配置也是一种输入方式,如果输入方式很多,对标准输入的抽象就变得重要,使业务代码的实际复杂度不至于真的膨胀到用户使用的复杂度那么高。 不止输入触点多,前端系统的功能组合也非常多,比如图形绘制软件,画布可以放任意数量的组件,每个组件有任意多的配置,组件之间还可以相互影响。这种系统属于开放式系统,用户很容易试出开发者都未曾想到过的功能组合,有些时候开发者都惊叹这些新的组合竟然能一起工作!用户会感叹软件能力的强大,但开发者不能真的把这些功能组合一一尝试来解决冲突,必须通过合理的分层抽象来保证功能组合的稳定性。 其实这种挑战也是计算机面临的问题,如何设计一个通用架构的计算机,使上面可以运行任何开发者软件,且软件之间可以相互独立,也可以相互调用,系统还不容易产生 BUG。从这个角度来看,计算机的底层架构设计对前端架构设计是有参考意义的,大体上来说,计算机通过硬件、操作系统、软件这个三个分层解决了要计算一切的难题。 冯·诺依曼体系就解决了硬件层面的问题。为了保证软件层的可拓展性,通过 CPU、存储、输入输出设备的抽象解决了计算、存储、拓展的三个基本能力。再细分来看,CPU 也仅仅支持了三个基本能力:数学计算、条件控制、子函数。这使得计算机底层设计既是稳定的,设计因素也是可枚举的,同时拥有了强大的拓展能力。 操作系统也一样,它不需要知道软件具体是怎么执行的,只需要给软件提供一个安全的运行环境,使软件不会受到其他软件的干扰;提供一些基本范式统一软件的行为,比如多窗口系统,防止软件同时在一块区域绘图而相互影响;提供一些基础的系统调用封装给上层的语言进行二次封装,而考虑到这些系统调用封装可能会随着需求而拓展,进而采用动态链接库的方式实现,等等。操作系统为了让自身功能稳定与可枚举,对自己与软件定义了清晰的边界,无论软件怎么拓展,操作系统不需要拓展。 回到前端业务,想要保障一个复杂的绘图软件代码清晰与好的可维护性,一样需要从最底层稳定的模块开始网上,一步步构建模块间依赖关系,只有这样,模块内逻辑才能可枚举,模块与模块间才敢大胆的组合,各自设计各自的拓展点,使整个系统最终拥有强大的拓展能力,但细看每个子模块又都是简单清晰、可枚举可测试的代码逻辑。 以 BI 系统举例,划分为组件、筛选、布局、数据模型四个子系统的话: - 对组件系统来说,任何组件实现都可接入,这就使这个 BI 系统不仅可以展示报表,也可以展示普通的按钮,甚至表单,可以搭建任意数据产品,或者可以搭建任意的网站,能力拓展到哪完全由业务决定。 - 对筛选系统来说,任何组件之间都能关联,不一定是筛选器到图表,也可以是图表到图表,这样就支持了图表联动。不仅是 BI 联动场景,即便是做一个表单联动都可以复用这个筛选能力,使整个系统实现统一而简单。 - 对布局系统来说,不关心布局内的组件是什么,有哪些关联能力,只要做好布局就行。这样画布系统容易拓展为任何场景,比如生产效率工具、仪表盘、ppt 或者大屏,而对其他系统无影响。 - 对数据模型系统来说,其承担了数据配置到 sql 查询,最后映射到图形通道展示的过程,它本身是对组件系统中,统计图表类型的抽象实现,因此虽然逻辑复杂,但也不影响其他子系统的设计。 **从广义角度看**,前端业务代码早就处于一系列架构分层中,也就是编程语言与前端框架。编程语言与前端框架会自带一些设计模式,以减少混用代码范式带来的沟通成本,其实架构设计本身也要解决代码一致性问题,所以这些内容都是架构设计的一环。 前端框架带来的数据驱动特性本身就很大程度上解决了前端代码在复杂应用下可维护问题,大大降低了过程代码带来的复杂度。React 或 Vue 框架本身也起到了类似操作系统的操作,即定义上层组件(软件规格)的规格,为组件渲染和事件响应抹平浏览器差异(硬件差异),并提供组件渲染调度功能(软件调度)。同时也提供了组件间变量传递(进程通信),让组件与组件间通信符合统一的接口。 但是没有必要把每个组件都类比到进程来设计,也就是说,组件与组件之间不用都通过通信方式工作。比较合适的类比粒度是模块,把一个大模块抽象为组件,模块与模块间互相不依赖,用数据通信来交流。小粒度组件就做成状态无关的元件,注意相似功能的组件接口尽量保持一致,这样就能体验到类似多态的好处。 所以话说回来,遵循前端框架的代码规范不是一件可有可无的事情,业务架构设计从编程语言和前端框架时就已经开始了,如果一个组件不遵循框架的最佳实践,就无法参与到更上层的业务架构规划里,最终可能导致项目混乱,或者无架构可言。所以重视架构设计从代码规范就要开始。 所以前端架构设计是必要的,那怎么做好前端架构设计呢?这个话题太过于庞大,本次就从操作系统借鉴一些灵感,先谈一谈对分层与抽象的理解。 ## 没有绝对的分层 分层是架构设计的重点,但一个模块在分层的位置可能会随着业务迭代而变化,类比到操作系统举两个例子: 语音输入现在由各个软件自行提供,背后的语音识别与 NLP 能力可能来自各大公司的 AI 中台,或者一些提供 AI 能力的云服务。但语音输入能力成熟后,很可能会成为操作系统内置能力,因为语音输入与键盘输入都属于标准输入,只是语音输入难度更大,操作系统短期难以内置,所以目前发展在各个上层应用里。 Go 语言的协程实现在编程语言层,但其对标的线程实现在操作系统层,协程运行在用户态,而线程运行在内核态。但如果哪天操作系统提供了更高效的线程,内存占用也采用动态递增的逻辑,说不定协程就不那么必要了。 按理说语音输入属于标准输入的一部分,应该实现在操作系统的通用输入层,协程也属于多任务处理的一部分,应该实现在操作系统多任务处理层,但它们都被是现在了更上层,有的在编程语言层,有的在业务服务层。之所以产生了这些意外,是因为通用输入输出层与多任务处理层的需求并没有想象中那么稳定,随着技术的迭代,需要对其拓展时,因为内置在底层不方便拓展,只能在更上层实现了。 当然我们也要注意到的是,即便这些拓展点实现在更上层,但对软件工程师来说并没有特别大的侵入性影响,比如 goroutine,程序员并不接触操作系统提供的 API,所以编程语言层对操作系统能力的拓展对程序员是透明的;语音输入就有一点影响了,如果由操作系统来实现,可能就变成与键盘输出保持一致的事件结构了,但由业务层实现就有无数种 API 格式了,业务流程可能也更加复杂,比如增加鉴权。 从计算机操作系统的例子我们可以学习到两点: 1. 站在分层合理性视角对输入做进一步的抽象与整合。比如将语音识别封装到标准的输入事件,让其逻辑上成为标准输入层。 2. 业务架构的设计必然也会遇到分层不满足业务拓展性的场景。 业务分层与硬件、操作系统不同的是,业务分层中,几乎所有层都方便修改与拓展,因此如果遇到分层不合理的设计,最好将其移动到应该归属的层。操作系统与硬件层不方便随意拓展的原因是版本更新的频率和软件更新的频率不匹配。 同时,也要意识到分层需要一个演进过程,等新模块稳定后再移动到其归属所在层可能更好,因为从上层挪到底层意味着更多被模块共享使用,就像我们不会轻易把软件层某个包提供的函数内置到编程语言一样,也不会随意把编程语言实现的函数内置到操作系统内置的系统调用。 在前端领域的一个例子是,如果一个搭建平台项目中已经有了一套组件元信息描述,最好先让其在业务代码里跑一段时间,观察一下元信息定义的属性哪些有缺失,哪些是不必要的,等业务稳定一段时间后,再把这套元信息运行时代码抽成一个通用包提供给本业务,甚至其他业务使用。但即便这个能力沉淀到了通用包,也不代表它就是永远不能被迭代的,操作系统的多任务管理都有协程来挑战,何况前端一个抽象包的能力呢?所以要慎重抽象,但抽象后也要敢于质疑挑战。 ## 没有绝对的抽象 抽象粒度永远是架构设计的难题。 计算机把一切都理解为数据。计算结果是数据,执行程序的代码也是数据,所以 CPU 只要专注于对数据的计算,再加上存储与输入输出,就可以完成一切工作。想一想这样抽象的伟大之处:所有程序最终对计算机来说都是这三个概念,CPU 在计算时无需关心任何业务含义,这也使得它可以计算任何业务。 另一个有争议的抽象是 Unix 一切皆文件的抽象,该抽象使文件、进程、线程、socket 等管理都抽象为文件的 API,且都拥有特定的 “文件路径”,比如你甚至可以通过 `/proc` 访问到进程文件夹,`ls` 可以看到所有运行的进程。当然进程不是文件,这只是说明了 Unix 的一种抽象哲学,即 “文件” 本身就是一种抽象,开发和可以用理解文件的方式理解一切事物,这带来了巨大的理解成本降低,也使许多代码模式可以不关心具体资源类型。但这样做的争议点在于,并不是一切资源都适合抽象成文件,比如输入输出中的显示器,它作为一个呈现五彩缤纷像素点的载体,实在难以用文件系统来统一描述。 计算机设计与操作系统设计已经给了我们很明显的启发,即一切能抽象的都要尽可能的抽象,如此才能提高系统各模块内的稳定性。但从如 Unix 一切皆文件的抽象来看,有时候的技术抽象难免被当时的业务需求所局限,当输入输出设备的种类增加后,这种极致的抽象未必能永远合适。但永远要相信抽象,因为假若所有资源都可以被文件抽象所描述,且使用起来没有不便捷的地方,为什么还要造其他的抽象概念呢?如无必要勿增实体。 比如 BI 场景的筛选、联动、下钻场景是否都能抽象为组件与组件间的联动关系呢?如果一套标准联动设计可以解决这三个场景,那自然不需要为某个具体场景单独引入概念。从原始场景来看,无论筛选、联动还是下钻场景都是修改组件的取数参数以改变查询条件,我们就可以抽象出一种组件间联动的规范,使其可以驱动取数参数的变化,但未来需求可能引入更多的可能性,如在筛选时触发一些额外的追加分析查询,此时之前的抽象就收到了挑战,我们需要权衡维持统一性的收益与通用接口不适用于特殊场景带来成本之间的平衡。 抽象的方式是无数的,哪种更好取决于业务如何变化,不用过于纠结完美的抽象,就连 Unix 一切皆文件的最基础抽象都备受争议,业务抽象的稳定性肯定会更差,也更需要随着需求变化而调整。 ## 总结 我们从计算机与操作系统的架构设计出发,探讨了前端架构设计的必要性,并从分层与抽象两个角度分析了架构设计时的考量,希望你在架构设计遇到拿捏不定的问题时,可以向下借助计算机的架构设计获得一些灵感或支持。 > 讨论地址是:[精读《对前端架构的理解 - 分层与抽象》· Issue #436 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/436) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/255.精读《SolidJS》.md ================================================ [SolidJS](https://github.com/solidjs/solid) 是一个语法像 React Function Component,内核像 Vue 的前端框架,本周我们通过阅读 [Introduction to SolidJS](https://www.loginradius.com/blog/engineering/guest-post/introduction-to-solidjs/) 这篇文章来理解理解其核心概念。 为什么要介绍 SolidJS 而不是其他前端框架?因为 SolidJS 在教 React 团队正确的实现 Hooks,这在唯 React 概念与虚拟 DOM 概念马首是瞻的年代非常难得,这也是开源技术的魅力:任何观点都可以被自由挑战,只要你是对,你就可能脱颖而出。 ## 概述 整篇文章以一个新人视角交代了 SolidJS 的用法,但本文假设读者已有 React 基础,那么只要交代核心差异就行了。 ### 渲染函数仅执行一次 SolidJS 仅支持 FunctionComponent 写法,无论内容是否拥有状态管理,也无论该组件是否接受来自父组件的 Props 透传,都仅触发一次渲染函数。 所以其状态更新机制与 React 存在根本的不同: - React 状态变化后,通过重新执行 Render 函数体响应状态的变化。 - Solid 状态变化后,通过重新执行用到该状态代码块响应状态的变化。 与 React 整个渲染函数重新执行相对比,Solid 状态响应粒度非常细,甚至一段 JSX 内调用多个变量,都不会重新执行整段 JSX 逻辑,而是仅更新变量部分: ```jsx const App = ({ var1, var2 }) => ( <> var1: {console.log("var1", var1)} var2: {console.log("var2", var2)} ); ``` 上面这段代码在 `var1` 单独变化时,仅打印 `var1`,而不会打印 `var2`,在 React 里是不可能做到的。 这一切都源于了 SolidJS 叫板 React 的核心理念:**面向状态驱动而不是面向视图驱动**。正因为这个差异,导致了渲染函数仅执行一次,也顺便衍生出变量更新粒度如此之细的结果,同时也是其高性能的基础,同时也解决了 React Hooks 不够直观的顽疾,一箭 N 雕。 ### 更完善的 Hooks 实现 SolidJS 用 `createSignal` 实现类似 React `useState` 的能力,虽然看上去长得差不多,但实现原理与使用时的心智却完全不一样: ```jsx const App = () => { const [count, setCount] = createSignal(0); return ; }; ``` 我们要完全以 SolidJS 心智理解这段代码,而不是 React 心智理解它,虽然它长得太像 Hooks 了。一个显著的不同是,将状态代码提到外层也完全能 Work: ```jsx const [count, setCount] = createSignal(0); const App = () => { return ; }; ``` 这是最快理解 SolidJS 理念的方式,即 SolidJS 根本没有理 React 那套概念,SolidJS 理解的数据驱动是纯粹的数据驱动视图,无论数据在哪定义,视图在哪,都可以建立绑定。 这个设计自然也不依赖渲染函数执行多次,同时因为使用了依赖收集,也不需要手动申明 deps 数组,也完全可以将 `createSignal` 写在条件分支之后,因为不存在执行顺序的概念。 ### 派生状态 用回调函数方式申明派生状态即可: ```jsx const App = () => { const [count, setCount] = createSignal(0); const doubleCount = () => count() * 2; return ; }; ``` 这是一个不如 React 方便的点,因为 React 付出了巨大的代价(在数据变更后重新执行整个函数体),所以可以用更简单的方式定义派生状态: ```jsx // React const App = () => { const [count, setCount] = useState(0); const doubleCount = count * 2; // 这块反而比 SolidJS 定义的简单 return ( ); }; ``` 当然笔者并不推崇 React 的衍生写法,因为其代价太大了。我们继续分析为什么 SolidJS 这样看似简单的衍生状态写法可以生效。原因在于,SolidJS 收集所有用到了 `count()` 的依赖,而 `doubleCount()` 用到了它,而渲染函数用到了 `doubleCount()`,仅此而已,所以自然挂上了依赖关系,这个实现过程简单而稳定,没有 Magic。 SolidJS 还支持衍生字段计算缓存,使用 `createMemo`: ```jsx const App = () => { const [count, setCount] = createSignal(0); const doubleCount = () => createMemo(() => count() * 2); return ; }; ``` 同样无需写 deps 依赖数组,SolidJS 通过依赖收集来驱动 `count` 变化影响到 `doubleCount` 这一步,这样访问 `doubleCount()` 时就不用总执行其回调的函数体,产生额外性能开销了。 ### 状态监听 对标 React 的 `useEffect`,SolidJS 提供的是 `createEffect`,但相比之下,不用写 deps,是真的监听数据,而非组件生命周期的一环: ```jsx const App = () => { const [count, setCount] = createSignal(0); createEffect(() => { console.log(count()); // 在 count 变化时重新执行 }); }; ``` 这再一次体现了为什么 SolidJS 有资格 “教” React 团队实现 Hooks: - 无 deps 申明。 - 将监听与生命周期分开,React 经常容易将其混为一谈。 在 SolidJS,生命周期函数有 `onMount`、`onCleanUp`,状态监听函数有 `createEffect`;而 React 的所有生命周期和状态监听函数都是 `useEffect`,虽然看上去更简洁,但即便是精通 React Hooks 的老手也不容易判断哪些是监听,哪些是生命周期。 ### 模板编译 为什么 SolidJS 可以这么神奇的把 React 那么多历史顽疾解决掉,而 React 却不可以呢?核心原因还是在 SolidJS 增加的模板编译过程上。 以官方 [Playground](https://playground.solidjs.com/) 提供的 Demo 为例: ```jsx function Counter() { const [count, setCount] = createSignal(0); const increment = () => setCount(count() + 1); return ( ); } ``` 被编译为: ```jsx const _tmpl$ = /*#__PURE__*/ template(``, 2); function Counter() { const [count, setCount] = createSignal(0); const increment = () => setCount(count() + 1); return (() => { const _el$ = _tmpl$.cloneNode(true); _el$.$$click = increment; insert(_el$, count); return _el$; })(); } ``` 首先把组件 JSX 部分提取到了全局模板。初始化逻辑:将变量插入模板;更新状态逻辑:由于 `insert(_el$, count)` 时已经将 `count` 与 `_el$` 绑定了,下次调用 `setCount()` 时,只需要把绑定的 `_el$` 更新一下就行了,而不用关心它在哪个位置。 为了更完整的实现该功能,必须将用到模板的 Node 彻底分离出来。我们可以测试一下稍微复杂些的场景,如: ```jsx ``` 这段代码编译后的模板结果是: ```jsx const _el$ = _tmpl$.cloneNode(true), _el$2 = _el$.firstChild, _el$4 = _el$2.nextSibling; _el$4.nextSibling; _el$.$$click = increment; insert(_el$, count, _el$4); insert(_el$, () => count() + 1, null); ``` 将模板分成了一个整体和三个子块,分别是字面量、变量、字面量。为什么最后一个变量没有加进去呢?因为最后一个变量插入直接放在 `_el$` 末尾就行了,而中间插入位置需要 `insert(_el$, count, _el$4)` 给出父节点与子节点实例。 ## 精读 SolidJS 的神秘面纱已经解开了,下面笔者自问自答一些问题。 ### 为什么 createSignal 没有类似 hooks 的顺序限制? React Hooks 使用 deps 收集依赖,在下次执行渲染函数体时,因为没有任何办法标识 “deps 是为哪个 Hook 申明的”,只能依靠顺序作为标识依据,所以需要稳定的顺序,因此不能出现条件分支在前面。 而 SolidJS 本身渲染函数仅执行一次,所以不存在 React 重新执行函数体的场景,而 `createSignal` 本身又只是创建一个变量,`createEffect` 也只是创建一个监听,逻辑都在回调函数内部处理,而与视图的绑定通过依赖收集完成,所以也不受条件分支的影响。 ### 为什么 createEffect 没有 useEffect 闭包问题? 因为 SolidJS 函数体仅执行一次,不会存在组件实例存在 N 个闭包的情况,所以不存在闭包问题。 ### 为什么说 React 是假的响应式? React 响应的是组件树的变化,通过组件树自上而下的渲染来响应式更新。而 SolidJS 响应的只有数据,甚至数据定义申明在渲染函数外部也可以。 所以 React 虽然说自己是响应式,但开发者真正响应的是 UI 树的一层层更新,在这个过程中会产生闭包问题,手动维护 deps,hooks 不能写在条件分支之后,以及有时候分不清当前更新是父组件 rerender 还是因为状态变化导致的。 这一切都在说明,React 并没有让开发者真正只关心数据的变化,如果只要关心数据变化,那为什么组件重渲染的原因可能因为 “父组件 rerender” 呢? ### 为什么 SolidJS 移除了虚拟 dom 依然很快? 虚拟 dom 虽然规避了 dom 整体刷新的性能损耗,但也带来了 diff 开销。对 SolidJS 来说,它问了一个问题:为什么要规避 dom 整体刷新,局部更新不行吗? 对啊,局部更新并不是做不到,通过模板渲染后,将 jsx 动态部分单独提取出来,配合依赖收集,就可以做到变量变化时点对点的更新,所以无需进行 dom diff。 ### 为什么 signal 变量使用 `count()` 不能写成 `count`? 笔者也没找到答案,理论上来说,Proxy 应该可以完成这种显式函数调用动作,除非是不想引入 Mutable 的开发习惯,让开发习惯变得更加 Immutable 一些。 ### props 的绑定不支持解构 由于响应式特性,解构会丢失代理的特性: ```jsx // ✅ const App = (props) =>
{props.userName}
; // ❎ const App = ({ userName }) =>
{userName}
; ``` 虽然也提供了 `splitProps` 解决该问题,但此函数还是不自然。该问题比较好的解法是通过 babel 插件来规避。 ### createEffect 不支持异步 没有 deps 虽然非常便捷,但在异步场景下还是无解: ```jsx const App = () => { const [count, setCount] = createSignal(0); createEffect(() => { async function run() { await wait(1000); console.log(count()); // 不会触发 } run(); }); }; ``` ## 总结 SolidJS 的核心设计只有一个,即让数据驱动真的回归到数据上,而非与 UI 树绑定,在这一点上,React 误入歧途了。 虽然 SolidJS 很棒,但相关组件生态还没有起来,巨大的迁移成本是它难以快速替换到生产环境的最大问题。前端生态想要无缝升级,看来第一步是想好 “代码范式”,以及代码范式间如何转换,确定了范式后再由社区竞争完成实现,就不会遇到生态难以迁移的问题了。 但以上假设是不成立的,技术迭代永远都以 BreakChange 为代价,而很多时候只能抛弃旧项目,在新项目实践新技术,就像 Jquery 时代一样。 > 讨论地址是:[精读《SolidJS》· Issue #438 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/438) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/256.精读《依赖注入简介》.md ================================================ 精读文章:[Dependency Injection in JS/TS – Part 1](https://blog.codeminer42.com/dependency-injection-in-js-ts-part-1/) ## 概述 **依赖注入是将函数内部实现抽象为参数,使我们更方便控制这些它们。** 原文按照 “如何解决无法做单测的问题、统一依赖注入的入口、如何自动保证依赖顺序正确、循环依赖怎么解决、自上而下 vs 自下而上编程思维” 的思路,将依赖注入从想法起点,到延伸出来的特性连贯的串了起来。 ### 如何解决无法做单测的问题 如果一个函数内容实现是随机函数,如何做测试? ```js export const randomNumber = (max: number): number => { return Math.floor(Math.random() * (max + 1)); }; ``` 因为结果不受控制,显然无法做单测,那将 `Math.random` 函数抽象到参数里问题不就解决了! ```js export type RandomGenerator = () => number; export const randomNumber = ( randomGenerator: RandomGenerator, max: number ): number => { return Math.floor(randomGenerator() * (max + 1)); }; ``` 但带来了一个新问题:这样破坏了 `randomNumber` 函数本身接口,而且参数变得复杂,不那么易用了。 ### 工厂函数 + 实例模式 为了方便业务代码调用,同时导出工厂函数和方便业务用的实例不就行了! ```js export type RandomGenerator = () => number; export const randomNumberImplementation = ({ randomGenerator }: Deps) => (max: number): number => { return Math.floor(randomGenerator() * (max + 1)); }; export const randomNumber = (max: number) => randomNumberImplementation(Math.random, max); ``` 这样乍一看是不错,单测代码引用 `randomNumberImplementation` 函数并将 `randomGenerator` mock 为固定返回值的函数;业务代码引用 `randomNumber`,因为内置了 `Math.random` 实现,用起来也是比较自然的。 只要每个文件都遵循这种双导出模式,且业务实现除了传递参数外不要有额外的逻辑,这种代码就能同时解决单测与业务问题。 但带来了一个新问题:代码中同时存在工厂函数与实例,即同时构建与使用,这样职责不清晰,而且因为每个文件都要提前引用依赖,依赖间容易形成循环引用,即便上从具体函数层面看,并没有发生函数间的循环引用。 ### 统一依赖注入的入口 用一个统一入口收集依赖就能解决该问题: ```js import { secureRandomNumber } from "secureRandomNumber"; import { makeFastRandomNumber } from "./fastRandomNumber"; import { makeRandomNumberList } from "./randomNumberList"; const randomGenerator = Math.random; const fastRandomNumber = makeFastRandomNumber(randomGenerator); const randomNumber = process.env.NODE_ENV === "production" ? secureRandomNumber : fastRandomNumber; const randomNumberList = makeRandomNumberList(randomNumber); export const container = { randomNumber, randomNumberList, }; export type Container = typeof container; ``` 上面的例子中,一个入口文件同时引用了所有构造函数文件,所以这些构造函数文件之间就不需要相互依赖了,这解决了循环引用的大问题。 然后我们依次实例化这些构造函数,传入它们需要的依赖,再用 `container` 统一导出即可使用,对使用者来说无需关心如何构建,开箱即用。 但带来了一个新问题:统一注入的入口代码要随着业务文件的变化而变化,同时,如果构造函数之间存在复杂的依赖链条,手动维护起顺序将是一件越来越复杂的事情:比如 A 依赖 B,B 依赖 C,那么想要初始化 C 的构造函数,就要先初始化 A 再初始化 B,最后初始化 C。 ### 如何自动保证依赖顺序正确 那有没有办法固定依赖注入的模板逻辑,让其被调用时自动根据依赖关系来初始化呢?答案是有的,而且非常的漂亮: ```js // container.ts import { makeFastRandomNumber } from "./fastRandomNumber"; import { makeRandomNumberList } from "./randomNumberList"; import { secureRandomNumber } from "secureRandomNumber"; const dependenciesFactories = { randomNumber: process.env.NODE_ENV !== "production" ? makeFastRandomNumber : () => secureRandomNumber, randomNumberList: makeRandomNumberList, randomGenerator: () => Math.random, }; type DependenciesFactories = typeof dependenciesFactories; export type Container = { [Key in DependenciesFactories]: ReturnValue; }; export const container = {} as Container; Object.entries(dependenciesFactories).forEach(([dependencyName, factory]) => { return Object.defineProperty(container, dependencyName, { get: () => factory(container), }); }); ``` 最核心的代码在 `Object.defineProperty(container)` 这部分,所有从 `container[name]` 访问的函数,都会在调用时才被初始化,它们会经历这样的处理链条: 1. 初始化 `container` 为空,不提供任何函数,也没有执行任何 `factory`。 2. 当业务代码调用 `container.randomNumber` 时,触发 `get()`,此时会执行 `randomNumber` 的 `factory` 并将 `container` 传入。 3. 如果 `randomNumber` 的 `factory` 没有用到任何依赖,那么 `container` 的子 key 并不会被访问,`randomNumber` 函数就成功创建了,流程结束。 4. 关键步骤来了,如果 `randomNumber` 的 `factory` 用到了任何依赖,假设依赖是它自己,那么会陷入死循环,这是代码逻辑错误,报错是应该的;如果依赖是别人,**假设调用了 `container.abc`,那么会触发 `abc` 所在的 `get()`,重复第 2 步,直到 `abc` 的 `factory` 被成功执行,这样就成功拿到了依赖** 很神奇,固定的代码逻辑竟然会根据访问链路自动嗅探依赖树,并用正确的顺序,从没有依赖的那个模块开始执行 `factory`,一层层往上,直到顶部包的依赖全部构建完成。其中每一条子模块的构建链路和主模块都是分型的,非常优美。 ### 循环依赖怎么解决 这倒不是说如何解决函数循环依赖问题,因为: - 如果函数 a 依赖了函数 b,而函数 b 又依赖了函数 a,这个相当于 a 依赖了自身,神仙都救不了,如果循环依赖能解决,就和声明发明了永动机一样夸张,所以该场景不用考虑解决。 - 依赖注入让模块之间不引用,所以不存在函数间循环依赖问题。 为什么说 a 依赖了自身连神仙都救不了呢? - a 的实现依赖 a,要知道 a 的逻辑,得先了解依赖项 a 的逻辑。 - 依赖项 a 的逻辑无从寻找,因为我们正在实现 a,这样递归下去会死循环。 那依赖注入还需要解决循环依赖问题吗?需要,比如下面代码: ```js const aFactory = ({ a }: Deps) => () => { return { value: 123, onClick: () => { console.log(a.value); }, }; }; ``` 这是循环依赖最极限的场景,自己依赖自己。但从逻辑上来看,并没有死循环,如果 `onClick` 触发在 `a` 实例化之后,那么它打印 `123` 是合乎情理的。 但逻辑容不得模糊,如果不经过特殊处理,`a.value` 还真就解析不出来。 这个问题的解法可以参考 spring 三级缓存思路,放到精读部分聊。 ### 自上而下 vs 自下而上编程思维 原文做了一下总结和升华,相当有思考价值:依赖注入的思维习惯是自上而下的编程思维,即先思考包之间的逻辑关系,而不需要真的先去实现它。 相比之下,自下而上的编程思维需要先实现最后一个无任何依赖的模块,再按照顺序实现其他模块,但这种实现顺序不一定符合业务抽象的顺序,也限制了实现过程。 ## 精读 我们讨论对象 `A` 与对象 `B` 相互引用时,spring 框架如何用三级缓存解决该问题。 无论用 spring 还是其他框架实现了依赖注入,当代码遇到这样的形式时,就碰到了 `A` `B` 循环引用的场景: ```js class A { @inject(B) b; value = "a"; hello() { console.log("a:", this.b.value); } } class B { @inject(A) a; value = "b"; hello() { console.log("b:", this.a.value); } } ``` 从代码执行角度来看,应该都可以正常执行 `a.hello()` 与 `b.hello()` 才对,因为虽然 `A` `B` 各自循环引用了,但他们的 `value` 并没有构成循环依赖,只要能提前拿到他们的值,输出自然不该有问题。 但依赖注入框架遇到了一个难题,初始化 `A` 依赖 `B`,初始化 `B` 依赖 `A`,让我们看看 spring 三级缓存的实现思路: spring 三级缓存的含义分别为: | 一级缓存 | 二级缓存 | 三级缓存 | | -------- | ---------- | -------- | | 实例 | 半成品实例 | 工厂类 | - 实例:实例化 + 完成依赖注入初始化的实例. - 半成品实例:仅完成了实例化。 - 工厂类:生成半成品实例的工厂。 先说流程,当 `A` `B` 循环依赖时,框架会按照随机顺序初始化,假设先初始化 `A` 时: 一:寻找实例 `A`,但一二三级缓存都没有,因此初始化 `A`,此时只有一个地址,添加到三级缓存。 堆栈:A。 | | 一级缓存 | 二级缓存 | 三级缓存 | | ------ | -------- | -------- | -------- | | 模块 A | | | ✓ | | 模块 B | | | | 二:发现实例 `A` 依赖实例 `B`,寻找实例 `B`,但一二三级缓存都没有,因此初始化 `B`,此时只有一个地址,添加到三级缓存。 堆栈:A->B。 | | 一级缓存 | 二级缓存 | 三级缓存 | | ------ | -------- | -------- | -------- | | 模块 A | | | ✓ | | 模块 B | | | ✓ | 三:发现实例 `B` 依赖实例 `A`,寻找实例 `A`,因为三级缓存找到,因此执行三级缓存生成二级缓存。 堆栈:A->B->A。 | | 一级缓存 | 二级缓存 | 三级缓存 | | ------ | -------- | -------- | -------- | | 模块 A | | ✓ | ✓ | | 模块 B | | | ✓ | 四:因为实例 `A` 的二级缓存已被找到,因此实例 `B` 完成了初始化(堆栈变为 A->B),压入一级缓存,并清空三级缓存。 堆栈:A。 | | 一级缓存 | 二级缓存 | 三级缓存 | | ------ | -------- | -------- | -------- | | 模块 A | | ✓ | ✓ | | 模块 B | ✓ | | | 五:因为实例 `A` 依赖实例 `B` 的一级缓存被找到,因此实例 `A` 完成了初始化,压入一级缓存,并清空三级缓存。 堆栈:空。 | | 一级缓存 | 二级缓存 | 三级缓存 | | ------ | -------- | -------- | -------- | | 模块 A | ✓ | | | | 模块 B | ✓ | | | ## 总结 依赖注入本质是将函数的内部实现抽象为参数,带来更好的测试性与可维护性,其中可维护性是 “只要申明依赖,而不需要关心如何实例化带来的”,同时自动初始化容器也降低了心智负担。但最大的贡献还是带来了自上而下的编程思维方式。 依赖注入因为其神奇的特性,需要解决循环依赖问题,这也是面试常问的点,需要牢记。 > 讨论地址是:[精读《依赖注入简介》· Issue #440 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/440) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 前沿技术/257.精读《State of CSS 2022》.md ================================================ 本周读一读 [State of CSS 2022](https://web.dev/state-of-css-2022/#color-spaces) 介绍的 CSS 特性。 ## 概述 ### 2022 已经支持的特性 #### @layer 解决业务代码的 !important 问题。为什么业务代码需要用 !important 解决问题?因为 css 优先级由文件申明顺序有关,而现在大量业务使用动态插入 css 的方案,插入的时机与 js 文件加载与执行时间有关,这就导致了样式优先级不固定。 `@layer` 允许业务定义样式优先级,层越靠后优先级越高,比如下面的例子,`override` 定义的样式优先级比 `framework` 高: ```css @layer framework, override; @layer override { .title { color: white; } } @layer framework { .title { color: red; } } ``` #### subgrid subgrid 解决 grid 嵌套 grid 时,子网格的位置、轨迹线不能完全对齐到父网格的问题。只要在子网格样式做如下定义: ```css .sub-grid { display: grid; grid-template-columns: subgrid; grid-template-rows: subgrid; } ``` #### @container @container 使元素可以响应某个特定容器大小。在 @container 出来之前,我们只能用 @media 响应设备整体的大小,而 @container 可以将相应局限在某个 DOM 容器内: ```scss // 将 .container 容器的 container-name 设置为 abc .container { container-name: abc; } ``` ```scss // 根据 abc 容器大小做出响应 @container abc (max-width: 200px) { .text { font-size: 14px; } } ``` 一个使用场景是:元素在不同的 `.container` 元素内的样式可以是不同的,取决于当前所在 `.container` 的样式。 #### hwb 支持 [hwb](https://en.wikipedia.org/wiki/HWB_color_model)(hue, whiteness, blackness) 定义颜色: ```css .text { color: hwb(30deg 0% 20%); } ``` 三个参数分别表示:角度 \[0-360\],混入白色比例、混入黑色比例。角度对应在色盘不同角度的位置,每个角度都有属于自己的颜色值,这个函数非常适合直观的从色盘某个位置取色。 #### lch, oklch, lab, oklab, display-p3 等 **lch**(lightness, chroma, hue),即亮度、饱和度和色相,语法如: ```css .text { color: lch(50%, 100, 100deg); } ``` chroma(饱和度) 指颜色的鲜艳程度,越高色彩越鲜艳,越低色彩越暗淡。hue(色相) 指色板对应角度的颜色值。 **oklch**(lightness, chroma, hue, alpha),即亮度、饱和度、色相和透明度。 ```css .text { color: oklch(59.69% 0.156 49.77 / 0.5); } ``` 更多的就不一一枚举说明了,总之就是颜色表达方式多种多样,他们之间也可以互相转换。 #### color-mix() css 语法支持的 mix,类似 sass 的 mix() 函数功能: ```css .text { color: color-mix(in srgb, #34c9eb 10%, white); } ``` 第一个参数是颜色类型,比如 hwb、lch、lab、srgb 等,第二个参数就是要基准颜色与百分比,第三个参数是要混入的颜色。 #### color-contrast() 让浏览器自动在不同背景下调整可访问颜色。换句话说,就是背景过深时自动用白色文字,背景过浅时自动用黑色文字: ```css .text { color: color-contrast(black); } ``` 还支持更多参数,详情见 [Manage Accessible Design System Themes With CSS Color-Contrast()](https://www.smashingmagazine.com/2022/05/accessible-design-system-themes-css-color-contrast/)。 #### 相对颜色语法 可以根据语法类型,基于某个语法将某个值进行一定程度的变化: ```css .text { color: hsl(from var(--color) h s calc(l - 20%)); } ``` 如上面的例子,我们将 `--color` 这个变量在 hsl 颜色模式下,将其 l(lightness) 亮度降低 20%。 #### 渐变色 namespace 现在渐变色也支持申明 namespace 了,比如: ```css .text { background-image: linear-gradient(to right in hsl, black, white); } ``` 这是为了解决一种叫 [灰色死区](https://css-tricks.com/the-gray-dead-zone-of-gradients/) 的问题,即渐变色如果在色盘穿过了饱和度为 0 的区域,中间就会出现一段灰色,而指定命名空间比如 hsl 后就可以绕过灰色死区。 因为 hsl 对应色盘,渐变的逻辑是在色盘上沿圆弧方向绕行,而非直接穿过圆心(圆心饱和度低,会呈现灰色)。 #### accent-color accent-color 主要对单选、多选、进度条这些原生输入组件生效,控制的是它们的主题色: ```css body { accent-color: red; } ``` 比如这样设置之后,单选与多选的背景色会随之变化,进度条表示进度的横向条与圆心颜色也会随之变化。 #### inert inert 是一个 attribute,可以让拥有该属性的 dom 与其子元素无法被访问,即无法被点击、选中、也无法通过快捷键选中: ```html
...
``` #### COLRv1 Fonts COLRv1 Fonts 是一种自定义颜色与样式的矢量字体方案,浏览器支持了这个功能,用法如下: ```css @import url(https://fonts.googleapis.com/css2?family=Bungee+Spice); @font-palette-values --colorized { font-family: "Bungee Spice"; base-palette: 0; override-colors: 0 hotpink, 1 cyan, 2 white; } .spicy { font-family: "Bungee Spice"; font-palette: --colorized; } ``` 上面的例子我们引入了矢量图字体文件,并通过 `@font-palette-values --colorized` 自定义了一个叫做 `colorized` 的调色板,这个调色板通过 `base-palette: 0` 定义了其继承第一个内置的调色板。 使用上除了 `font-family` 外,还需要定义 `font-palette` 指定使用哪个调色板,比如上面定义的 `--colorized`。 #### 视口单位 除了 `vh`、`vw` 等,还提供了 `dvh`、`lvh`、`svh`,主要是在移动设备下的区别: - `dvh`: dynamic vh, 表示内容高度,会自动忽略浏览器导航栏高度。 - `lvh`: large vh, 最大高度,包含浏览器导航栏高度。 - `svh`: small vh, 最小高度,不包含浏览器导航栏高度。 #### :has() 可以用来表示具有某些子元素的父元素: ```css .parent:has(.child) { } ``` 表示选中那些有用 `.child` 子节点的 `.parent` 节点。 ### 即将支持的特性 #### @scope 可以让某个作用域内样式与外界隔绝,不会继承外部样式: ```css @scope (.card) { header { color: var(--text); } } ``` 如上定义后,`.card` 内 `header` 元素样式就被确定了,不会受到外界影响。如果我们用 `.card { header {} }` 来定义样式,全局的 `header {}` 样式定义依然可能覆盖它。 #### 样式嵌套 @nest 提案时 css 内置支持了嵌套,就像 postcss 做的一样: ```scss .parent { &:hover { // ... } } ``` #### prefers-reduced-data @media 新增了 `prefers-reduced-data`,描述用户对资源占用的期望,比如 `prefers-reduced-data: reduce` 表示期望仅占用很少的网络带宽,那我们可以在这个情况下隐藏所有图片和视频: ```css @media (prefers-reduced-data: reduce) { picture, video { display: none; } } ``` 也可以针对 `reduce` 情况降低图片质量,至于要压缩多少效果取决于业务。 #### 自定义 media 名称 允许给 @media 自定义名称了,如下定义了很多自定义 @media: ```css @custom-media --OSdark (prefers-color-scheme: dark); @custom-media --OSlight (prefers-color-scheme: light); @custom-media --pointer (hover) and (pointer: coarse); @custom-media --mouse (hover) and (pointer: fine); @custom-media --xxs-and-above (width >= 240px); @custom-media --xxs-and-below (width <= 240px); ``` 我们就可以按照自定义名称使用了: ```css @media (--OSdark) { :root { … } } ``` #### media 范围描述支持表达式 以前只能用 `@media (min-width: 320px)` 描述宽度不小于某个值,现在可以用 `@media (width >= 320px)` 代替了。 #### @property @property 允许拓展 css 变量,描述一些修饰符: ```css @property --x { syntax: ""; initial-value: 0px; inherits: false; } ``` 上面的例子把变量 `x` 定义为长度类型,所以如果错误的赋值了字符串类型,将会沿用其 `initial-value`。 #### scroll-start `scroll-start` 允许定义某个容器从某个位置开始滚动: ```css .snap-scroll-y { scroll-start-y: var(--nav-height); } ``` #### :snapped :snapped 这个伪类可以选中当前滚动容器中正在被响应的元素: ```css .card:snapped { --shadow-distance: 30px; } ``` 这个特性有点像 IOS select 控件,上下滑动后就像个左轮枪一样转动元素,最后停留在某个元素上,这个元素就处于 `:snapped` 状态。同时 JS 也支持了 `snapchanging` 与 `snapchanged` 两种事件类型。 #### :toggle() 只有一些内置 html 元素拥有 `:checked` 状态,`:toggle` 提案是用它把这个状态拓展到每一个自定义元素: ```css button { toggle-trigger: lightswitch; } button::before { content: "🌚 "; } html:toggle(lightswitch) button::before { content: "🌝 "; } ``` 上面的例子把 `button` 定义为 `lightswitch` 的触发器,且定义当 `lightswitch` 触发或不触发时的 css 样式,这样就可以实现点击按钮后,黑脸与黄脸的切换。 #### anchor() anchor() 可以将没有父子级关系的元素建立相对位置关系,更详细的用法可以看 [CSS Anchored Positioning](https://github.com/MicrosoftEdge/MSEdgeExplainers/blob/main/CSSAnchoredPositioning/explainer.md)。 #### selectmenu selectmenu 允许将任何元素添加为 select 的 option: ```html ``` 还支持更复杂的语法,比如对下拉内容分组: ```html
Choose a plant

Flowers

Trees

``` ## 总结 CSS 2022 新特性很大一部分是将 HTML 原生能力暴露出来,赋能给业务自定义,不过如果这些状态完全由业务来实现,比如 Antd `` 作为子元素,自身的逻辑而发生变化的。 这就意味着,父元素不需要知道子元素的实例,比如 `Tabs`: ```tsx {insertPosition(`tabs-${this.state.selectedTab}`)} ``` 当然有些情况看似是例外,比如 `Tree` 的查询功能,就依赖子元素 `TreeNode` 的配合。但它依赖的是基于某个约定的子元素,而不是具体子元素的实例,父级只需要与子元素约定接口即可。真正需要关心物理结构的恰恰是子元素,比如插入到 `Tree` 子元素节点的 `TreeNode` 必须实现某些方法,如果不满足这个功能,就不要把组件放在 `Tree` 下面;而 `Tree` 的实现就无需顾及啦,只需要默认子元素有哪些约定即可。 > 举例:`gaea-editor`。 #### 2.1.4 分型插件化 代表 `egg`,特点是插件结构与项目结构分型,也就是组成大项目的小插件,自身结构与项目结构相同。 因为对于 `node server` 的插件来说,要实现的功能应该是项目功能的子集,而本身 `egg` 对功能是按照目录结构划分的,所以插件的目录结构与项目一致,看起来也很美观。 > 举例:`egg`。 当然不是所有插件都能写成目录分形的,这也恰好解释了 `egg` 与 `koa` 之间的关系:`koa` 是 node 框架,与项目结构无关,`egg` 是基于 `koa` 上层的框架,将项目结构转化成 server 功能,而插件需要拓展的也是 server 功能,恰好可以用项目结构的方式写插件。 ### 2.2 核心代码如何加载插件 一个支持插件化的框架,核心功能是整合插件以及定义生命周期,与功能相关的代码反而可以通过插件实现,下一小节再展开说明。 ### 2.2.1 确定插件加载形式 根据 2.1 节的描述,我们根据项目的功能,找到一个合适的插件使用方式,这会决定我们如何执行插件。 ### 2.2.2 确定插件注册方式 插件注册方式非常多样,这里举几个例子: **通过 npm 注册**:比如只要 `npm` 包符合某个前缀,就会自动注册为插件,这个很简单,不举例子了。 **通过文件名注册**:比如项目中存在 `xx.plugin.ts` 会自动做到插件引用,当然这一般作为辅助方案使用。 **通过代码注册**:这个很基础,就是通过代码 `require` 就行,比如 `babel-polyfill`,不过这个要求插件执行逻辑正好要在浏览器运行,场景比较受限。 **通过描述注册**:比如在 `package.json` 描述一个属性,表明了要加载的插件,比如 `.babelrc`: ```json { "presets": ["es2015"] } ``` **自动注册**:比较暴力,通过遍历可能存在的位置,只要满足插件约定的,会自动注册为插件。这个行为比较像 `require` 行为,会自动递归寻找 `node_modules`,当然别忘了像 `require` 一样提供 `paths` 让用户手动配置寻址起始路径。 ### 2.2.3 确定生命周期 确定插件注册方式后,一般第一件事就是加载插件,后面就是根据框架业务逻辑不同而不同的生命周期了,插件在这些生命周期中扮演不同的功能,我们需要通过一些方式,让插件能够影响这些过程。 ### 2.2.4 插件对生命周期的拦截 一般通过事件、回调函数的方式,支持插件对生命周期的拦截,最简单的例子比如: ```typescript document.on("click", callback); ``` 就是让插件拦截了 `click` 这个事件,当然这个事件与 dom 的生命周期相比微乎其微,但也算是一个微小的生命周期,我们也可以 `event.stopPropagation()` 阻止冒泡,来影响这个生命周期的逻辑。 ### 2.2.5 插件之间的依赖与通信 插件之间难免有依赖关系,目前有两种方式处理,分为:**依赖关系定义在业务项目中,与依赖关系定义在插件中**。 稍微解释下,依赖关系定义在业务项目中,比如 webpack 的配置,我们在业务项目里是这么配的: ```json { "use": ["babel-loader", "ts-loader"] } ``` 在 webpack 中,执行逻辑是 `ts-loader -> babel-loader`,当然这个规则由框架说了算,但总之插件加载执行肯定有个顺序,而且与配置写法有关,而且配置需要写在项目中(至少不在插件中)。 另一种行为,将插件依赖写在插件中,比如 `webpack-preload-plugin` 就是依赖 `html-webpack-plugin`。 这两种场景各不同,一个是业务有关的顺序,也就是插件无法做主的业务逻辑问题,需要把顺序交给业务项目配置;一种是插件内部顺序,也就是业务无需关心的顺序问题,由插件自己定义就好啦。注意框架核心一般可能要同时支持这两种配置方式,最终决定插件的加载顺序。 插件之间通信也可以通过 `hook` 或者 `context` 方式支持,`hook` 主要传递的是时机信息,而 `context` 主要传递的是数据信息,但最终是否能生效,取决于上面说到的插件加载顺序。 `context` 可以拿 react 做个类比,一般都有作用域的,而且与执行顺序严格相关。 `hook` 等于插件内部的一个事件机制,由一个插件注册。业界有个比较好的实现,叫 [tapable](https://github.com/webpack/tapable),这里简单介绍一下。 利用 `tapable` 在 A 插件注册新 `hook`: ```typescript const SyncWaterfallHook = require("tapable").SyncWaterfallHook; compilation.hooks.htmlWebpackPluginAlterChunks = new SyncWaterfallHook([ "chunks", "objectWithPluginRef" ]); ``` 在 A 插件某个地方使用此 `hook`,实现某个特定业务逻辑。 ```typescript const chunks = compilation.hooks.htmlWebpackPluginAlterChunks.call(chunks, { plugin: self }); ``` B 插件可以拓展此 `hook`,来改变 A 的行为: ```typescript compilation.hooks.htmlWebpackPluginAlterChunks.tap( "HtmlWebpackIncludeSiblingChunksPlugin", chunks => { const ids = [] .concat(...chunks.map(chunk => [...chunk.siblings, chunk.id])) .filter(onlyUnique); return ids.map(id => allChunks[id]); } ); ``` 这样,A 拿到的 `chunks` 就被 B 修改掉了。 ### 2.3 核心功能的插件化 2.2 开头说到,插件化框架的核心代码主要功能是对插件的加载、生命周期的梳理,以及实现 `hook` 让插件影响生命周期,最后补充上插件的加载顺序以及通信,就比较完备了。 那么写到这里,衡量代码质量的点就在于,是不是所有核心业务逻辑都可以由插件完成?因为只有用插件实现核心业务逻辑,才能检验插件的能力,进而推导出第三方插件是否拥有足够的拓展能力。 如果核心逻辑中有一部分代码没有通过插件机制编写,不仅让第三方插件也无法拓展此逻辑,而且还不利于框架的维护。 所以这主要是个思想,希望开发者首先明确哪些功能应该做成插件,以及将哪些插件固化为内置插件。 笔者认为应该提前思考清楚三点: #### 2.3.1 哪些插件需要内置 这个是业务相关的问题,但总体来看,**开源的,基础功能以及体现核心竞争力的可以内置**,可以开源与核心竞争力都比较好理解,主要说下基础功能: 基础功能就是一个业务的架子。因为插件机制的代码并不解决任何业务问题,一个没有内置插件的框架肯定什么都不是,所以选择基础功能就尤为重要。 举个例子,比如做构建工具,至少要有一个基本的配置作为模版,其他插件通过拓展这个配置来修改构建效果。那么这个基本配置就决定了其他插件可以如何修改它,也决定了这个框架的配置基调。 比如:`create-react-app` 对 dev 开发时的模版配置。如果没有这个模版,本地就无法开发,所以这个插件必须内置,而且需要考虑如何让其他插件对其拓展,这个在 2.3.2 节详细说明。 另一种情况就是非常基本,而又不需要再拓展加工的可以做成内置插件,比如 `babel` 对 js 模块的 `commonjs` 分析逻辑就不需要暴露出来,因为这个标准已经确定,既不需要拓展,又是 `babel` 运行的基础,所以肯定要内置。 #### 2.3.2 插件是依赖型还是完全正交的 功能完全正交的插件是最完美的,因为它既不会影响其他插件,也不需要依赖任何插件,自身也不需要被任何插件拓展。 在写非正交功能的插件时就要担心了,我们还是分为三个点去看: ##### 2.3.2.1 依赖其他插件的插件 举个例子,比如插件 X 需要拓展命令行,在执行 `npm start` 时统计当前用户信息并打点。那么这个插件就要知道当前登陆用户是谁。这个功能恰好是另一个 “用户登陆” 插件完成的,那么插件 X 就要依赖 “用户登陆” 插件了。 这种情况,根据 2.2.5 插件依赖小节经验,需要明确这个插件是插件级别依赖,还是项目级别依赖。 当然,这种情况是插件级别依赖,我们把依赖关系定义在插件 X 中即可,比如 `package.json`: ```json "plugin-dep": ["user-login"] ``` 另一种情况,比如我们写的是 `babel-loader` 插件,它在 ts 项目中依赖 `ts-loader`,那只能在项目中定义依赖了,此时需要补充一些文档说明 ts 场景的使用顺序。 ##### 2.3.2.2 依赖并拓展其他插件的插件 如果插件 X 在以来 “用户登陆” 插件的基础上,还要拓展登陆时获取的用户信息,比如要同时获取用户的手机号,而 “用户登陆” 插件默认并没有获取此信息,但可以通过扩展方式实现,插件 X 需要注意什么呢? 首先插件 X 最好不要减少另一个插件的功能(具体拓展方式,参考 2.2.5 节,这里假设插件都比较具有可拓展性),否则插件 X 可能破坏 “用户登录” 插件与其他插件之间的协作。 > 减少功能的情况非常普遍,为了加深理解,这里举一个例子:某个插件直接 pipeTemplate 拓展模版内容,但插件 X 直接返回了新内容,而没有 concat 原有内容,就是减少了功能。 但也不是所有情况都要保证不减少功能,比如当缺少必要的配置项时,可以直接抛出异常,提前终止程序。 其次,要确保增加的功能尽可能少的与其他插件产生可能的冲突。拿拓展 webpack 配置举例,现在要拓展对 `node_modules` js 文件的处理,让这些文件过一遍 `babel`。 不好的做法是直接修改原有对 js 的 rules,增加一项对 `node_modules` 的 include,以及 `babel-loader`。因为这样会破坏原先插件对项目内 js 文件的处理,可能项目的 js 文件不需要 `babel` 处理呢? 比较好的做法是,新增一个 rules,单独对 `node_modules` 的 js 文件处理,不要影响其他规则。 ##### 2.3.2.3 可能被其他插件拓展的插件 这点是最难的,难在如何设计拓展的粒度。 由于所有场景都类似,我们拿对模版的拓展举例子,其他场景可以类比:插件 X 定义了入口文件的基础内容,但还要提供一些 `hook` 供其他插件修改入口文件。 假设入口文件一般是这样的: ```typescript import * as React from "react"; import * as ReactDOM from "react-dom"; import { App } from "./app"; ReactDOM.render(, document.getELementById("root")); ``` 这种最简单的模版,其实内部要考虑以下几点潜在拓展需求: 1. 在某处需要插入其他代码,怎么支持? 2. 如何保证插入代码的顺序? 3. 用 `react-lite` 替换 `react`,怎么支持? 4. dev 模式需要用 `hot(App)` 替换 `App` 作为入口,怎么支持? 5. 模版入口 div 的 id 可能不是 `root`,怎么支持? 6. 模版入口 div 是自动生成的,怎么支持? 7. 用在 `reactNative`,没有 `document`,怎么支持? 8. 后端渲染时,需要用 `ReactDOM.hydrate` 而不是 `ReactDOM.render`,怎么支持? 9. 以上 8 种场景可能会不同组合,需要保证任意组合都能正确运行,所以无法全量模版替换,那怎么办? 笔者此处给出一种解决方案,供大家参考。另外要注意,这个方案随着考虑到的使用场景增多,是要不断调整变化的。 ```typescript get( "entry", ` ${get("importBefore", "")} ${get("importReact", `import * as React from "react"`)} ${get("importReactDOM", `import * as ReactDOM from "react-dom"`)} import { App } from "./app" ${get("importAfter", "")} ${get("renderMethod", `ReactDOM.render`)}(${get( "renderApp", "" )}, ${get("rootElement", `document.getELementById("root")`)}) ${get("renderAfter", "")} ` ); ``` 以上八种情况读者脑补一下,不详细说明了。 ### 2.3.3 内置插件如何与第三方插件相处 内置的插件与第三方插件的冲突点在于,内置插件如果拓展性很差,那还不如不要内置,内置了反而阻碍第三方插件的拓展。 所以参考 2.3.2.3 节,为内置插件考虑最大化的拓展机制,才能确保内置插件的功能不会变成拓展性瓶颈。 每新增一个内置的插件,都在消灭一部分拓展能力,因为由插件拓展后的区块拥有的拓展能力,应该是逐渐减弱的。这里比较拗口,可以比喻为,一条小溪流,插件就是层层的水处理站,每新增一个处理站就会改变下游水势变化,甚至可能将水拦住,下游一滴水也拿不到。 2.3.1 节说了哪些插件需要内置,而这一节想说明的是,谨慎增加内置插件数量,因为内置的越多,框架拓展能力就越弱。 ### 2.4 哪些场景可以插件化 最后梳理下插件化适用场景,笔者根据有限的经验列出一下一些场景。 #### 2.4.1 前后端框架 如果你要做一个前/后端开发框架,插件化是必然,比如 `react` 的生命周期,`koa` 的中间件,甚至业务代码用到的 `request` 处理,都是插件化的体现。 #### 2.4.2 脚手架 支持插件化的脚手架具有拓展性,社区方便提供插件,而且脚手架为了适配多种代码,功能可插拔是非常重要的。 #### 2.4.3 工具库 一些小的工具库,比如管理数据流的 `redux` 提供的中间件机制,就是让社区贡献插件,完善自身的功能。 #### 2.4.4 需要多人协同的复杂业务项目 如果业务项目很复杂,同时又有多人协作完成,最好按照功能划分来分工。但是分工如果只是简单的文件目录分配方式,必然导致功能的不均匀,也就是每个人开发的模块可能不能访问所有系统能力,或者涉及到与其他功能协同时,文件相互引用带来代码的耦合度提高,最终导致难以维护。 插件化给这种项目带来的最大优势就是,每一个人开发的插件都是一个拥有完整功能的个体,这样只需要关心功能的分配,不用担心局部代码功能不均衡;插件之间的调用框架层已经做掉了,所以协同不会发生耦合,只需要申明好依赖关系。 插件化机制良好的项目开发,和 git 功能分支开发的体验有相似之处,git 给每个功能或需求开一个分支,而插件化可以让每个功能作为一个插件,而 git 功能分支之间是无关联的,所以只有功能之间正交的需求才能开多个分支,而插件机制可以考虑到依赖情况,进行更复杂的功能协同。 ## 3 总结 现在还没有找到对插件化系统化思考的文章,所以这一篇算是抛砖引玉,大家一定有更多的框架开发心得值得分享。 同时也想借这篇文章提高大家对插件化必要性的重视,许多情况插件化并不是小题大做,因为它能带来更好的分工协作,而分工的重要性不言而喻。 ## 4 更多讨论 > 讨论地址是:[精读《插件化思维》 · Issue #75 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/75) **如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。** ================================================ FILE: 前沿技术/54.精读《在浏览器运行 serverRender》.md ================================================ 本周精读内容是 《在浏览器运行 serverRender》。 这里是效果页,先睹为快:[client-ssr](https://ascoders.github.io/client-ssr/)。 ## 1 引言 在服务端 ssr 成为常识的今天,前端 ssr 是我最近的新尝试,效果可以点击上面链接查看。说说前端 ssr 有哪些好处: 1. 不消耗服务器资源。对,不消耗服务器资源,完美的分布式运行!对于百万 UV 甚至更高的页面,服务器成本减少了几十万或者上百万。 2. 前后端分离,首先 ssr 不需要部署服务器,其次前端代码也不需要担心质量问题导致的内存泄露了,同时可以不必时刻注意使用同构的三方库,只需要考虑前端可运行! 3. 不需要后端缓存服务,对于千人千面的复杂页面,对后端 ssr 来说缓存规模庞大的无法计算。 4. 相比后端 ssr,在前端可以绕过复杂的权限系统,同时 http 请求的权限问题也无需关心。 5. 因为第一点,对于不支持后端服务的 github pages 也能做到 ssr。 相对的,缺点是: 1. 需要客户端支持 serviceWorker。 2. 第二次首屏才会生效。后端 ssr 可以做到访问前预缓存 ssr 结果。 3. 可能破坏前端页面状态,因为在同一个环境偷偷执行了一些页面逻辑。不过这个缺点可以通过 web worker 执行 ssr 解决,还在调研中。 4. service worker 拦截入口 html 风险很高,一旦代码有故障可能导致严重后果,需要提前考虑完备的回滚方案。 像缓存清空时机等问题,前后端 ssr 都会遇到,所以不列在优缺点中。 ## 2 精读 本篇精读分享的是前端 ssr 方案具体实现步骤。 我们先了解整体流程: ![](https://img.alicdn.com/imgextra/i4/O1CN01hOmtLc1VaRlyRsTRm_!!6000000002669-2-tps-1616-1598.png) ### service worker 拦截首页 service worker 可以在浏览器尝试请求首屏 html 之前的时机拦截,此时如果 caches 命中,直接将 response 扔给浏览器,那么服务端将完全不会收到请求,完成了最高效的缓存命中。 当然第一次没有缓存,所以在没有命中缓存时,会同步的做两件事: 1. 发送请求,拿到后端返回的 response,扔给浏览器。这是最普通的请求逻辑。 2. 当前端代码 ready 后,postMessage 给浏览器,索要 ssr 内容。 附上代码片段: ```javascript self.addEventListener("fetch", event => { if ( event.request.mode === "navigate" && event.request.method === "GET" && event.request.headers.get("accept").includes("text/html") ) { event.respondWith( caches.open(SSR_BUNDLE_VERSION).then(cache => { return cache.match(event.request).then(response => { // 命中缓存,直接返回结果。 if (response) { return response; } return fetch(event.request).then(response => { const newResponse = response.clone(); return newResponse .text() .then(text => { // 通知浏览器,执行 ssr 并且返回内容。 self.clients.matchAll().then(clients => { if (!clients || !clients.length) { return; } clients.forEach(client => { client.postMessage({ type: "getServerRenderContent", pathname: new URL(event.request.url, location).pathname }); }); }); return response; }) .catch(err => response); }); }); }) ); } }); ``` 当然还需要一个监听,用来拿浏览器的 ssr 内容,并缓存到 caches 中,比较简单就省略了。 ### 浏览器执行 ssr 监听就不说了,主要是如何利用 `react-router` 与 `react-loadable` 完成前端 ssr。 首先根据 service worker 告诉我们的 `pathname`,拿到对应 `loadable` 的实例,并通过 `loadable.preload()` 预先加载 chunk,当 chunk 加载完毕时,资源已经准备好了。 我们利用给 `StaticRouter` 传递当前的 `pathname`,让 `react-router` 模拟出需要 ssr 的页面内容,通过 `renderToString` 拿到 ssr 的结果。 附上代码片段: ```tsx if (navigator.serviceWorker) { navigator.serviceWorker.addEventListener("message", event => { if (event.data.type === "getServerRenderContent") { const baseHrefRegex = new RegExp( escapeRegExp("${projectConfig.baseHref}"), "g" ); const matchRouterPath = event.data.pathname.replace(baseHrefRegex, ""); const loadableMap = pageLoadableMap.get( matchRouterPath === "/" ? "/" : trimEnd(matchRouterPath, "/") ); if (loadableMap) { loadableMap.preload().then(() => { const ssrResult = renderToString( ); if (navigator.serviceWorker.controller) { navigator.serviceWorker.controller.postMessage({ type: "serverRenderContent", pathname: event.data.pathname, content: ssrResult }); } }); } } }); } ``` 这里需要优化,利用 web worker 执行 ssr 才可以用于生产环境。 最后,等待用户的下一次刷新,service worker 会帮我们把 ssr 内容作为首屏给用户一个惊喜的。 ## 3 总结 同样这次只是抛砖引玉,希望大家能提出建议一起帮助我们完善这个方案。 此方案正式用在生产环境后,会再写一篇文章介绍实践过程。 ## 4 更多讨论 > 讨论地址是:[精读《在浏览器运行 serverRender》 · Issue #80 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/80) **如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。** ================================================ FILE: 前沿技术/55.精读《async await 是把双刃剑》.md ================================================ 本周精读内容是 [《async/await 是把双刃剑》](https://medium.freecodecamp.org/avoiding-the-async-await-hell-c77a0fb71c4c)。 ## 1 引言 终于,async/await 也被吐槽了。Aditya Agarwal 认为 async/await 语法让我们陷入了新的麻烦之中。 其实,笔者也早就觉得哪儿不对劲了,终于有个人把实话说了出来,async/await 可能会带来麻烦。 ## 2 概述 下面是随处可见的现代化前端代码: ```typescript (async () => { const pizzaData = await getPizzaData(); // async call const drinkData = await getDrinkData(); // async call const chosenPizza = choosePizza(); // sync call const chosenDrink = chooseDrink(); // sync call await addPizzaToCart(chosenPizza); // async call await addDrinkToCart(chosenDrink); // async call orderItems(); // async call })(); ``` await 语法本身没有问题,有时候可能是使用者用错了。当 `pizzaData` 与 `drinkData` 之间没有依赖时,顺序的 await 会最多让执行时间增加一倍的 `getPizzaData` 函数时间,因为 `getPizzaData` 与 `getDrinkData` 应该并行执行。 回到我们吐槽的回调地狱,虽然代码比较丑,带起码两行回调代码并不会带来阻塞。 看来语法的简化,带来了性能问题,而且直接影响到用户体验,是不是值得我们反思一下? 正确的做法应该是先同时执行函数,再 await 返回值,这样可以并行执行异步函数: ```typescript (async () => { const pizzaPromise = selectPizza(); const drinkPromise = selectDrink(); await pizzaPromise; await drinkPromise; orderItems(); // async call })(); ``` 或者使用 `Promise.all` 可以让代码更可读: ```typescript (async () => { Promise.all([selectPizza(), selectDrink()]).then(orderItems); // async call })(); ``` 看来不要随意的 await,它很可能让你代码性能降低。 ## 3 精读 仔细思考为什么 async/await 会被滥用,笔者认为是它的功能比较反直觉导致的。 首先 async/await 真的是语法糖,功能也仅是让代码写的舒服一些。先不看它的语法或者特性,仅从语法糖三个字,就能看出它一定是局限了某些能力。 举个例子,我们利用 html 标签封装了一个组件,带来了便利性的同时,其功能一定是 html 的子集。又比如,某个轮子哥觉得某个组件 api 太复杂,于是基于它封装了一个语法糖,我们多半可以认为这个便捷性是牺牲了部分功能换来的。 功能完整度与使用便利度一直是相互博弈的,很多框架思想的不同开源版本,几乎都是把功能完整度与便利度按照不同比例混合的结果。 那么回到 async/await 它的解决的问题是回调地狱带来的灾难: ```typescript a(() => { b(() => { c(); }); }); ``` 为了减少嵌套结构太多对大脑造成的冲击,async/await 决定这么写: ```typescript await a(); await b(); await c(); ``` 虽然层级上一致了,但逻辑上还是嵌套关系,这不是另一个程度上增加了大脑负担吗?而且这个转换还是隐形的,所以许多时候,我们倾向于忽略它,所以造成了语法糖的滥用。 ### 理解语法糖 虽然要正确理解 async/await 的真实效果比较反人类,但为了清爽的代码结构,以及防止写出低性能的代码,还是挺有必要认真理解 async/await 带来的改变。 首先 async/await 只能实现一部分回调支持的功能,也就是仅能方便应对层层嵌套的场景。其他场景,就要动一些脑子了。 比如两对回调: ```typescript a(() => { b(); }); c(() => { d(); }); ``` 如果写成下面的方式,虽然一定能保证功能一致,但变成了最低效的执行方式: ```typescript await a(); await b(); await c(); await d(); ``` 因为翻译成回调,就变成了: ```typescript a(() => { b(() => { c(() => { d(); }); }); }); ``` 然而我们发现,原始代码中,函数 `c` 可以与 `a` 同时执行,但 async/await 语法会让我们倾向于在 `b` 执行完后,再执行 `c`。 所以当我们意识到这一点,可以优化一下性能: ```typescript const resA = a(); const resC = c(); await resA; b(); await resC; d(); ``` 但其实这个逻辑也无法达到回调的效果,虽然 `a` 与 `c` 同时执行了,但 `d` 原本只要等待 `c` 执行完,现在如果 `a` 执行时间比 `c` 长,就变成了: ```typescript a(() => { d(); }); ``` 看来只有完全隔离成两个函数: ```typescript (async () => { await a(); b(); })(); (async () => { await c(); d(); })(); ``` 或者利用 `Promise.all`: ```typescript async function ab() { await a(); b(); } async function cd() { await c(); d(); } Promise.all([ab(), cd()]); ``` 这就是我想表达的可怕之处。回调方式这么简单的过程式代码,换成 async/await 居然写完还要反思一下,再反推着去优化性能,这简直比回调地狱还要可怕。 而且大部分场景代码是非常复杂的,同步与 await 混杂在一起,想捋清楚其中的脉络,并正确优化性能往往是很困难的。但是我们为什么要自己挖坑再填坑呢?很多时候还会导致忘了填。 原文作者给出了 `Promise.all` 的方式简化逻辑,但笔者认为,不要一昧追求 async/await 语法,在必要情况下适当使用回调,是可以增加代码可读性的。 ## 4 总结 async/await 回调地狱提醒着我们,不要过度依赖新特性,否则可能带来的代码执行效率的下降,进而影响到用户体验。同时,笔者认为,也不要过度利用新特性修复新特性带来的问题,这样反而导致代码可读性下降。 当我翻开 redux 刚火起来那段时期的老代码,看到了许多过度抽象、为了用而用的代码,硬是把两行代码能写完的逻辑,拆到了 3 个文件,分散在 6 行不同位置,我只好用字符串搜索的方式查找线索,最后发现这个抽象代码整个项目仅用了一次。 写出这种代码的可能性只有一个,就是在精神麻木的情况下,一口气喝完了 redux 提供的全部鸡汤。 就像 async/await 地狱一样,看到这种 redux 代码,我觉得远不如所谓没跟上时代的老前端写出的 jquery 代码。 决定代码质量的是思维,而非框架或语法,async/await 虽好,但也要适度哦。 > PS: 经过讨论,笔者把原文 async/await 地狱标题改成了 async/await 是把双刃剑。因为 async/await 并没有回调地狱那么可怕,称它为地狱有误导的可能性。 ## 5 更多讨论 > 讨论地址是:[精读《async/await 是把双刃剑》 · Issue #82 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/82) **如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。** ================================================ FILE: 前沿技术/56.精读《重新思考 Redux》.md ================================================ 本周精读内容是 [《重新思考 Redux》](https://hackernoon.com/redesigning-redux-b2baee8b8a38)。 ## 1 引言 《重新思考 Redux》是 [rematch](https://github.com/rematch/rematch) 作者 [Shawn McKay](https://github.com/ShMcK) 写的一篇干货软文。 [dva](https://github.com/dvajs/dva) 之后,有许多基于 redux 的状态管理框架,但大部分都很局限,甚至是倒退。但直到看到了 rematch,总算觉得 redux 社区又进了一步。 这篇文章的宝贵之处在于,抛开 Mobx、RXjs 概念,仅针对 redux 做深入的重新思考,对大部分还在使用 redux 的工程场景非常有帮助。 ## 2 概述 比较新颖的是,作者给出一个公式,评价一个框架或工具的质量: `工具质量 = 工具节省的时间/使用工具消耗的时间` 如果这样评估原生的 redux,我们会发现,使用 redux 需要额外花费的时间可能超过了其节省下来的时间,从这个角度看,redux 是会降低工作效率的。 但 redux 的数据管理思想是正确的,复杂的前端项目也确实需要这种理念,为了更有效率的使用 redux,我们需要使用基于 redux 的框架。作者从 6 个角度阐述了基于 redux 的框架需要解决什么问题。 ### 简化初始化 redux 初始化代码涉及的概念比较多,比如 `compose` `thunk` 等等,同时将 `reducer`、`initialState`、`middlewares` 这三个重要概念拆分成了函数方式调用,而不是更容易接受的配置方式: ```typescript const store = preloadedState => { return createStore( rootReducer, preloadedState, compose(applyMiddleware(thunk, api), DevTools.instrument()) ); }; ``` 如果换成配置方式,理解成本会降低不少: ```typescript const store = new Redux.Store({ instialState: {}, reducers: { count }, middlewares: [api, devTools] }); ``` > 笔者注:redux 的初始化方式非常函数式,而下面的配置方式就更面向对象一些。相比之下,还是面向对象的方式更好理解,毕竟 store 是一个对象。`instialState` 也存在同样问题,相比显示申明,将 `preloadedState` 作为函数入参就比较抽象了,同时 redux 对初始 state 的赋值也比较隐蔽,`createStore` 时统一赋值比较别扭,因为 reducers 是分散的,如果在 reducers 中赋值,要利用 es 的默认参数特性,看起来更像业务思考,而不是 redux 提供的能力。 ### 简化 Reducers redux 的 reducer 粒度太大,不但导致函数内手动匹配 `type`,还带来了 `type`、`payload` 等理解成本: ```typescript const countReducer = (state, action) => { switch (action.type) { case INCREMENT: return state + action.payload; case DECREMENT: return state - action.payload; default: return state; } }; ``` 如果用配置的方式设置 reducers,就像定义一个对象一样,会更清晰: ```typescript const countReducer = { INCREMENT: (state, action) => state + action.payload, DECREMENT: (state, action) => state - action.payload }; ``` ### 支持 async/await redux 支持动态数据还是挺费劲的,需要理解高阶函数,理解中间件的使用方式,否则你不会知道为什么这样写是对的: ```typescript const incrementAsync = count => async dispatch => { await delay(); dispatch(increment(count)); }; ``` 为什么不抹掉理解成本,直接允许 `async` 类型的 action 呢? ```typescript const incrementAsync = async count => { await delay(); dispatch(increment(count)); }; ``` > 笔者注:我们发现 rematch 的方式,dispatch 是 import 进来的(全局变量),而 redux 的 dispatch 是注入进来的,乍一看似乎 redux 更合理,但其实我更推崇 rematch 的方案。经过长期实践,组件最好不要使用数据流,项目的数据流只用一个实例完全够用了,全局 dispatch 的设计其实更合理,而注入 dispatch 的设计看似追求技术极致,但忽略了业务使用场景,导致画蛇添足,增加了不必要的麻烦。 ### 将 action + reducer 改为两种 action redux 抽象的 action 与 reducer 的职责很清晰,action 负责改 store 以外所有事,而 reducer 负责改 store,偶尔用来做数据处理。这种概念其实比较模糊,因为往往不清楚数据处理放在 action 还是 reducer 里,同时过于简单的 reducer 又要写 action 与之匹配,感觉过于形式化,而且繁琐。 重新考虑这个问题,我们只有两类 action:`reducer action` 与 `effect action`。 * reducer action:改变 store。 * effect action:处理异步场景,能调用其他 action,不能修改 store。 同步的场景,一个 reducer 函数就能处理,只有异步场景需要 `effect action` 处理掉异步部分,同步部分依然交给 reducer 函数,这两种 action 职责更清晰。 ### 不再显示申明 action type 不要在用一个文件存储 Action 类型了,`const ACTION_ONE = 'ACTION_ONE'` 其实重复写了一遍字符串,直接用对象的 key 表示 action 的值,再加上 store 的 name 为前缀保证唯一性即可。 同时 redux 建议使用 `payload` key 来传值,那为什么不强制使用 `payload` 作为入参,而要通过 `action.payload` 取值呢?直接使用 `payload` 不但视觉上减少代码数量,容易理解,同时也强制约束了代码风格,让建议真正落地。 ### Reducer 直接作为 ActionCreator redux 调用 action 比较繁琐,使用 `dispatch` 或者将 reducer 经过 `ActionCreator` 函数包装。为什么不直接给 reducer 自动包装 `ActionCreator` 呢?减少样板代码,让每一行代码都有业务含义。 最后作者给出了一个 `rematch` 完整的例子: ```typescript import { init, dispatch } from "@rematch/core"; import delay from "./makeMeWait"; const count = { state: 0, reducers: { increment: (state, payload) => state + payload, decrement: (state, payload) => state - payload }, effects: { async incrementAsync(payload) { await delay(); this.increment(payload); } } }; const store = init({ models: { count } }); dispatch.count.incrementAsync(1); ``` ## 3 精读 我觉得本文基本上把 redux 存在的工程问题分析透彻了,同时还给出了一套非常好的实现。 ### 细节的极致优化 首先是直接使用 `payload` 而不是整个 `action` 作为入参,加强了约束同时简化代码复杂度: ```typescript increment: (state, payload) => state + payload; ``` 其次使用 `async` 在 effects 函数中,使用 `this.increment` 函数调用方式,取代 `put({type: "increment"})`(dva),在 typescript 中拥有了类型支持,不但可以用自动跳转代替字符串搜索,还能校验参数类型,在 redux 框架中非常难得。 最后在 `dispatch` 函数,也提供了两种调用方式: ```typescript dispatch({ type: "count/increment", payload: 1 }); dispatch.count.increment(1); ``` 如果为了更好的类型支持,或者屏蔽 `payload` 概念,可以使用第二种方案,再一次简化 redux 概念。 ### 内置了比较多的插件 rematch 将常用的 reselect、persist、immer 等都集成为了插件,相对比较强化插件生态的概念。数据流对数据缓存,性能优化,开发体验优化都有进一步施展的空间,拥抱插件生态是一个良好的发展方向。 比如 [rematch-immer](https://github.com/rematch/rematch/blob/master/plugins/immer/README.md) 插件,可以用 mutable 的方式修改 store: ```typescript const count = { state: 0, reducers: { add(state) { state += 1; return state; } } }; ``` 但是当 state 为非对象时,immer 将不起作用,所以最好能养成 `return state` 的习惯。 最后说一点瑕疵的地方,`reducers` 申明与调用参数不一致。 ### Reducers 申明与调用参数不一致 比如下面的 reducers: ```typescript const count = { state: 0, reducers: { increment: (state, payload) => state + payload, decrement: (state, payload) => state - payload }, effects: { async incrementAsync(payload) { await delay(); this.increment(payload); } } }; ``` 定义时 `increment` 是两个参数,而 `incrementAsync` 调用它时,只有一个参数,这样可能造成一些误导,笔者建议保持参数对应关系,将 `state` 放在 `this` 中: ```typescript const count = { state: 0, reducers: { increment: payload => this.state + payload, decrement: payload => this.state - payload }, effects: { async incrementAsync(payload) { await delay(); this.increment(payload); } } }; ``` 当然 rematch 的方式保持了函数的无副作性质,可以看出是做了一些取舍。 ## 4 总结 重复一下作者提出工具质量的公式: `工具质量 = 工具节省的时间/使用工具消耗的时间` 如果一个工具能节省开发时间,但本身带来了很大使用成本,在想清楚如何减少使用成本之前,不要急着用在项目中,这是我得到的最大启发。 最后感谢 rematch 作者精益求精的精神,给 redux 带来进一步的极致优化。 ## 5 更多讨论 > 讨论地址是:[精读《重新思考 Redux》 · Issue #83 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/83) **如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。** ================================================ FILE: 前沿技术/57.精读《现代 js 框架存在的根本原因》.md ================================================ ## 1 引言 深入思考为何前端需要框架,以及 web components 是否可以代替前端框架? [原文地址](https://medium.com/dailyjs/the-deepest-reason-why-modern-javascript-frameworks-exist-933b86ebc445),建议先阅读原文,或者阅读概述。 ## 2 概述 现在前端框架非常多了,如果让我们回答 “为什么要用前端框架” 这个问题,你觉得是下面这些原因吗? * 组件化。 * 拥有强大的开源社区。 * 拥有大量第三方库解决大部分问题。 * 拥有大量现成的第三方组件。 * 拥有浏览器拓展/工具帮助快速 debug。 * 友好的支持单页应用。 不,这些都不是根本原因,最多算前端框架的营销手段。作者给出的最根本原因是: **解决 UI 与状态同步的难题。** 作者假设了一个没有前端框架的项目,就像 Jquery 时代,我们需要手动同步状态与 UI。就像下面的代码: ```typescript addAddress(address) { // state logic const id = String(Dat.now()) this.state = this.state.concat({ address, id }) // UI logic this.updateHelp() const li = document.createElement('li') const span = document.createElement('span') const del = document.createElement('a') span.innerText = address del.innerText = 'delete' del.setAttribute('data-delete-id', id) this.ul.appendChild(li) li.appendChild(del) li.appendChild(span) this.items[id] = li } ``` 首先更新效率是个问题,最大问题还是同步问题。试想多次与服务器交互,在同步过程中漏执行了一步,会导致之后的 UI 与状态逐渐脱节。 因为我们只能一步步同步状态与 UI,却无法保证每个瞬间 UI 与状态是完全同步的,任何一个疏忽都会导致 UI 与状态脱节,而我们除了不断检查 UI 与数据是否对应,毫无办法。 所以现代框架最重要的帮助是保持 UI 与状态的同步。 ### 如何做到 有两种思路: 1. 组件级重渲染:比如 React,当状态改版后,映射出改变后的虚拟 DOM,最终改变当前组件映射的真实 DOM,这个过程被称为 reconciliation。 2. 监听修改:比如 Angluar 和 Vue.js,状态改变直接触发对应 DOM 节点中 value 值的变化。 这里稍微说明下,React 虽然是整体渲染,但在虚拟 DOM 作用下,效率不比 observable 低。observable 在值不能完整映射 UI 时,也需要做更大范围的 rerender。另外,Vue.js 与 Angluar 也早已采用了虚拟 DOM。 这三个框架已经融会贯通,作者提到的两种思路现在已经是一种混合技术了。 ### 那 web components 呢? 大家经常会拿 React, Angluar, Vue.js 与 [web components](https://www.webcomponents.org/) 做比较,可 web components 最大的问题就是,没有解决 UI 与状态同步。 web components 只提供了模版语法,自定义标签解决 html 的问题,并没有给出一套状态与 UI 同步的方法。 所以就算使用 web components,我们可能还需要一个框架做 UI 同步,比如 Vue.js 或者 [stenciljs](https://stenciljs.com/)。 作者还提供了一段简短的 UI 状态同步实例,这里略过。 最后给出了四点总结: * **现代 js 框架主要在解决 UI 与状态同步的问题。** * 仅使用原生 js 难以写出复杂、高效、又容易维护的 UI 代码。 * Web components 没有解决这个主要问题。 * 虽然使用虚拟 DOM 库很容易造一个解决问题的框架,但不建议你真的这么做! ## 3 精读 作者的核心观点是,现代前端框架主要解决 UI 与状态同步的问题,这是毫无疑问的,也提到了包括 web components 也依然没有解决这个问题。 这可能是 web 开发最核心的问题了。 最初开发者的精力都在前端标准化上,诞生了一系列解决标准化问题的库,最有知名度的是 jquery。当前端进入 react 时代后,可以看到精力从解决标准化到解决 web 规范与实践的冲突,这个冲突正是作者说的问题。 ### 前端三剑客 问题就出现在 html、js、css 三者分离上。 html、css、js 各是一套独立的体系,但 js 又能同时控制 html 与 css,**那为了解决同步问题,最好将控制权全部交给 js**。 这样 web components 的问题也就好理解了,web components 解决的是 html 问题,注定与 js 无关。 html 官方规范估计很难出现现代框架的设计了,因为官方设计中前端三剑客是相互分离的方案,为了解决现阶段前端框架的问题,html 必须由 js 完全接管,这几乎就是 jsx,或者支持 template 语法的 html,可这与最初网页设计思路是违背的。 html 是独立的,甚至可以不依赖 js 运行,这天然导致了 UI 与状态同步这个难题。 ### 为什么一定要用 js html 不依赖 js 的设计可能已经跟不上前端发展步伐了,也许 jsx 或者 template 才是真正的未来。 诚然,html 现在的设计可以在不支持 js 的浏览器执行,但就在最近,所有现代浏览器都支持了 service worker,它是凌驾于 html 执行时机之上的 js 脚本,甚至可以拦截 html 请求。一个不支持 js 的浏览器,可能也无法支持 service worker,禁用 js 的坚持可能只剩下安全性保护。 而实际上现代 web 页面都使用了 js 完全主导网页渲染,所以这已经从技术问题上升到了社会问题,如今禁用 js 的浏览器还有多少网页可以正常访问?除了某些超大型网站对禁用 js 状态做了特殊优化以外,现在几乎没有前端项目会考虑禁用 js 的情况了,因为我们不会假设 React、Angluar、Vue.js 框架代码无法运行。 ### 所以为什么不融合 html 与 js 呢? 既然事实上 UI 已经与 js 绑定了,那 w3c 为何不将 jsx 或者 template 列为标准呢?也许为了向前兼容,规范永远也迈不出这一步吧。 幸运的是,这并不妨碍现代前端框架的大量普及,而且势不可挡。 ## 4 总结 也许 UI 与状态同步的问题是前端发展的最大阻力,虽然现代化框架已经解决了这个问题,但 w3c 标准却一直无法往这个方向发力,导致 web 的下一个发展方向难以依靠标准规范来推动。前端日新月异的发展,很大一部分是规范的发展带来的,而现在我们进入了一个由工业化领导的时代,规范很可能永远也跟不上来,随之而来的是工业化社区也难以做进一步突破。 前端不仅是 web,或者也许下一个突破并不在 web,而是 ar/vr 或者下一个人机交互场景。同样,web 也不仅是前端三剑客,如果认为 React、Angluar、Vue.js 带来的工业化规范就是新的规范,前端才有动力向后发展,比如基于虚拟 DOM 的新框架、新语言。 所以笔者推导出现代前端开发的本质,是将 js、html 的平行关系变成了 js 包含 html 的关系,正如上面所说,这可能背离了 w3c 的初衷,但这就是现在的潮流。 最后总结一下观点: 1. 也是原作者的,**现代 js 框架主要在解决 UI 与状态同步的问题。** 2. 传统的前端三剑客正面临着进一步发展乏力的危机。 3. 现代前端框架正在告诉我们新的三剑客:js(虚拟 dom、虚拟 css)。 ## 5 更多讨论 > 讨论地址是:[精读《现代 js 框架存在的根本原因》 · Issue #84 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/84) **如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。** ================================================ FILE: 前沿技术/58.精读《Typescript2.0 - 2.9》.md ================================================ ## 1 引言 精读原文是 typescript 2.0-2.9 的文档: [2.0-2.8](http://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html),[2.9 草案](https://blogs.msdn.microsoft.com/typescript/2018/05/16/announcing-typescript-2-9-rc/). 我发现,许多写了一年以上 Typescript 开发者,对 Typescript 对理解和使用水平都停留在入门阶段。造成这个现象的原因是,Typescript 知识的积累需要 **刻意练习**,使用 Typescript 的时间与对它的了解程度几乎没有关系。 这篇文章精选了 TS 在 `2.0-2.9` 版本中最重要的功能,并配合实际案例解读,帮助你快速跟上 TS 的更新节奏。 对于 TS 内部优化的用户无感部分并不会罗列出来,因为这些优化都可在日常使用过程中感受到。 ## 2 精读 由于 Typescript 在严格模式下的许多表现都与非严格模式不同,为了避免不必要的记忆,建议只记严格模式就好了! ### 严格模式导致的大量边界检测代码,已经有解了 直接访问一个变量的属性时,如果这个变量是 `undefined`,不但属性访问不到,js 还会抛出异常,这几乎是业务开发中最高频的报错了(往往是后端数据异常导致的),而 typescript 的 `strict` 模式会检查这种情况,不允许不安全的代码出现。 在 `2.0` 版本,提供了 “非空断言标志符” `!.` 解决明确不会报错的情况,比如配置文件是静态的,那肯定不会抛出异常,但在 `2.0` 之前的版本,我们可能要这么调用对象: ```typescript const config = { port: 8000 }; if (config) { console.log(config.port); } ``` 有了 `2.0` 提供的 “非空断言标志符”,我们可以这么写了: ```typescript console.log(config!.port); ``` 在 `2.8` 版本,ts 支持了条件类型语法: ```typescript type TypeName = T extends string ? "string" ``` 当 T 的类型是 string 时,TypeName 的表达式类型为 "string"。 这这时可以构造一个自动 “非空断言” 的类型,把代码简化为: ```typescript console.log(config.port); ``` 前提是框架先把 `config` 指定为这个特殊类型,这个特殊类型的定义如下: ```typescript export type PowerPartial = { [U in keyof T]?: T[U] extends object ? PowerPartial : T[U] }; ``` 也就是 `2.8` 的条件类型允许我们在类型判断进行递归,把所有对象的 key 都包一层 “非空断言”! > 此处灵感来自 [egg-ts 总结](https://github.com/whxaxes/blog/issues/12) ### 增加了 `never` `object` 类型 当一个函数无法执行完,或者理解为中途中断时,TS `2.0` 认为它是 `never` 类型。 比如 `throw Error` 或者 `while(true)` 都会导致函数返回值类型时 `never`。 和 `null` `undefined` 特性一样,`never` 等于是函数返回值中的 `null` 或 `undefined`。**它们都是子类型**,比如类型 `number` 自带了 `null` 与 `undefined` 这两个子类型,是因为任何有类型的值都有可能是空(也就是执行期间可能没有值)。 这里涉及到很重要的概念,就是预定义了类型不代表类型一定如预期,就好比函数运行时可能因为 `throw Error` 而中断。所以 ts 为了处理这种情况,**将 `null` `undefined` 设定为了所有类型的子类型**,而从 `2.0` 开始,函数的返回值类型又多了一种子类型 `never`。 TS `2.2` 支持了 `object` 类型, 但许多时候我们总把 `object` 与 `any` 类型弄混淆,比如下面的代码: ```typescript const persion: object = { age: 5 }; console.log(persion.age); // Error: Property 'age' does not exist on type 'object'. ``` 这时候报错会出现,有时候闭个眼改成 `any` 就完事了。其实这时候只要把 `object` 删掉,换成 TS 的自动推导就搞定了。那么问题出在哪里? 首先 `object` 不是这么用的,它是 TS `2.3` 版本中加入的,用来描述一种非基础类型,所以一般用在类型校验上,比如作为参数类型。如果参数类型是 `object`,那么允许任何对象数据传入,但不允许 `3` `"abc"` 这种非对象类型: ```typescript declare function create(o: object | null): void; create({ prop: 0 }); // 正确 create(null); // 正确 create(42); // 错误 create("string"); // 错误 create(false); // 错误 create(undefined); // 错误 ``` 而一开始 `const persion: object` 这种用法,是将能精确推导的对象类型,扩大到了整体的,模糊的对象类型,TS 自然无法推断这个对象拥有哪些 `key`,因为对象类型仅表示它是一个对象类型,在将对象作为整体观察时是成立的,但是 `object` 类型是不承认任何具体的 `key` 的。 ### 增加了修饰类型 TS 在 `2.0` 版本支持了 `readonly` 修饰符,被它修饰的变量无法被修改。 在 TS `2.8` 版本,又增加了 `-` 与 `+` 修饰修饰符,有点像副词作用于形容词。举个例子,`readonly` 就是 `+readonly`,我们也可以使用 `-readonly` 移除只读的特性;也可以通过 `-?:` 的方式移除可选类型,因此可以延伸出一种新类型:`Required`,将对象所有可选修饰移除,自然就成为了必选类型: ```typescript type Required = { [P in keyof T]-?: T[P] }; ``` ### 可以定义函数的 this 类型 也是 TS `2.0` 版本中,我们可以定制 `this` 的类型,这个在 `vue` 框架中尤为有用: ```typescript function f(this: void) { // make sure `this` is unusable in this standalone function } ``` `this` 类型是一种假参数,所以并不会影响函数真正参数数量与位置,只不过它定义在参数位置上,而且永远会插队在第一个。 ### 引用、寻址支持通配符了 简单来说,就是模块名可以用 `*` 表示任何单词了: ```typescript declare module "*!text" { const content: string; export default content; } ``` 它的类型可以辐射到: ```typescript import fileContent from "./xyz.txt!text"; ``` 这个特性很强大的一个点是用在拓展模块上,因为包括 `tsconfig.json` 的模块查找也支持通配符了!举个例子一下就懂: 最近比较火的 `umi` 框架,它有一个 `locale` 插件,只要安装了这个插件,就可以从 `umi/locale` 获取国际化内容: ```typescript import { locale } from "umi/locale"; ``` 其实它的实现是创建了一个文件,通过 `webpack.alias` 将引用指了过去。这个做法非常棒,那么如何为它加上类型支持呢?只要这么配置 `tsconfig.json`: ```json { "compilerOptions": { "paths": { "umi/*": ["umi", ""] } } } ``` 将所有 `umi/*` 的类型都指向 ``,那么 `umi/locale` 就会指向 `/locale.ts` 这个文件,如果插件自动创建的文件名也恰好叫 `locale.ts`,那么类型就自动对应上了。 ### 跳过仓库类型报错 TS 在 `2.x` 支持了许多新 `compileOptions`,但 `skipLibCheck` 实在是太耀眼了,笔者必须单独提出来说。 `skipLibCheck` 这个属性不但可以忽略 npm 不规范带来的报错,还能最大限度的支持类型系统,可谓一举两得。 拿某 UI 库举例,某天发布的小版本 `d.ts` 文件出现一个漏洞,导致整个项目构建失败,你不再需要提 PR 催促作者修复了!`skipLibCheck` 可以忽略这种报错,同时还能保持类型的自动推导,也就是说这比 `declare module "ui-lib"` 将类型设置为 `any` 更强大。 ### 对类型修饰的增强 TS `2.1` 版本可谓是针对类型操作革命性的版本,我们可以通过 `keyof` 拿到对象 key 的类型: ```typescript interface Person { name: string; age: number; } type K1 = keyof Person; // "name" | "age" ``` 基于 `keyof`,我们可以增强对象的类型: ```typescript type NewObjType = { [P in keyof T]: T[P] }; ``` Tips:在 TS `2.8` 版本,我们可以以表达式作为 `keyof` 的参数,比如 `keyof (A & B)`。 Tips:在 TS `2.9` 版本,`keyof` 可能返回非 `string` 类型的值,因此从一开始就不要认为 `keyof` 的返回类型一定是 `string`。 `NewObjType` 原封不动的将对象类型重新描述了一遍,这看上去没什么意义。但实际上我们有三处拓展的地方: * 左边:比如可以通过 `readonly` 修饰,将对象的属性变成只读。 * 中间:比如将 `:` 改成 `?:`,将对象所有属性变成可选。 * 右边:比如套一层 `Promise`,将对象每个 `key` 的 `value` 类型覆盖。 基于这些能力,我们拓展出一系列上层很有用的 `interface`: * Readonly。把对象 key 全部设置为只读,或者利用 `2.8` 的条件类型语法,实现递归设置只读。 * Partial。把对象的 key 都设置为可选。 * Pick。从对象类型 T 挑选一些属性 K,比如对象拥有 10 个 key,只需要将 K 设置为 `"name" | "age"` 就可以生成仅支持这两个 key 的新对象类型。 * Extract。是 Pick 的底层 API,直到 `2.8` 版本才内置进来,可以认为 Pick 是挑选对象的某些 key,Extract 是挑选 key 中的 key。 * Record。将对象某些属性转换成另一个类型。比较常见用在回调场景,回调函数返回的类型会覆盖对象每一个 key 的类型,此时类型系统需要 `Record` 接口才能完成推导。 * Exclude。将 T 中的 U 类型排除,和 Extract 功能相反。 * Omit(未内置)。从对象 T 中排除 key 是 K 的属性。可以利用内置类型方便推导出来:`type Omit = Pick>` * NonNullable。排除 `T` 的 `null` 与 `undefined` 的可能性。 * ReturnType。获取函数 `T` 返回值的类型,这个类型意义很大。 * InstanceType。获取一个构造函数类型的实例类型。 > 以上类型都内置在 lib.d.ts 中,不需要定义就可直接使用,可以认为是 Typescript 的 utils 工具库。 单独拿 `ReturnType` 举个例子,体现出其重要性: Redux 的 Connect 第一个参数是 `mapStateToProps`,这些 Props 会自动与 React Props 聚合,我们可以利用 `ReturnType` 拿到当前 Connect 注入给 Props 的类型,就可以打通 Connect 与 React 组件的类型系统了。 ### 对 Generators 和 async/await 的类型定义 TS `2.3` 版本做了许多对 Generators 的增强,但实际上我们早已用 async/await 替代了它,所以 TS 对 Generators 的增强可以忽略。需要注意的一块是对 `for..of` 语法的异步迭代支持: ```typescript async function f() { for await (const x of fn1()) { console.log(x); } } ``` 这可以对每一步进行异步迭代。注意对比下面的写法: ```typescript async function f() { for (const x of await fn2()) { console.log(x); } } ``` 对于 `fn1`,它的返回值是可迭代的对象,并且每个 item 类型都是 Promise 或者 Generator。对于 `fn2`,它自身是个异步函数,返回值是可迭代的,而且每个 item 都不是异步的。举个例子: ```typescript function fn1() { return [Promise.resolve(1), Promise.resolve(2)]; } function fn2() { return [1, 2]; } ``` 在这里顺带一提,对 `Array.map` 的每一项进行异步等待的方法: ```typescript await Promise.all( arr.map(async item => { return await item.run(); }) ); ``` 如果为了执行顺序,可以换成 `for..of` 的语法,因为数组类型是一种可迭代类型。 ### 泛型默认参数 了解这个之前,先介绍一下 TS `2.0` 之前就支持的函数类型重载。 首先 JS 是不支持方法重载的,Java 是支持的,而 TS 类型系统一定程度在对标 Java,当然要支持这个功能。好在 JS 有一些偏方实现伪方法重载,典型的是 redux 的 `createStore`: ```typescript export default function createStore(reducer, preloadedState, enhancer) { if (typeof preloadedState === "function" && typeof enhancer === "undefined") { enhancer = preloadedState; preloadedState = undefined; } } ``` 既然 JS 有办法支持方法重载,那 TS 补充了函数类型重载,两者结合就等于 Java 方法重载: ```typescript declare function createStore( reducer: Reducer, preloadedState: PreloadedState, enhancer: Enhancer ); declare function createStore(reducer: Reducer, enhancer: Enhancer); ``` 可以清晰的看到,`createStore` 想表现的是对参数个数的重载,如果定义了函数类型重载,TS 会根据函数类型自动判断对应的是哪个定义。 而在 TS `2.3` 版本支持了泛型默认参数,**可以减少某些场景函数类型重载的代码量**,比如对于下面的代码: ```typescript declare function create(): Container; declare function create(element: T): Container; declare function create( element: T, children: U[] ): Container; ``` 通过枚举表达了泛型默认值,以及 U 与 T 之间可能存在的关系,这些都可以用泛型默认参数解决: ```typescript declare function create( element?: T, children?: U ): Container; ``` 尤其在 React 使用过程中,如果用泛型默认值定义了 `Component`: ```typescript .. Component .. ``` 就可以实现以下等价的效果: ```typescript class Component extends React.PureComponent { //... } // 等价于 class Component extends React.PureComponent { //... } ``` ### 动态 Import TS 从 `2.4` 版本开始支持了动态 Import,同时 Webpack4.0 也支持了这个语法(在 [精读《webpack4.0%20 升级指南》](https://github.com/dt-fe/weekly/blob/master/47.%E7%B2%BE%E8%AF%BB%E3%80%8Awebpack4.0%20%E5%8D%87%E7%BA%A7%E6%8C%87%E5%8D%97%E3%80%8B.md) 有详细介绍),这个语法就正式可以用于生产环境了: ```typescript const zipUtil = await import("./utils/create-zip-file"); ``` > 准确的说,动态 Import 实现于 webpack 2.1.0-beta.28,最终在 TS `2.4` 版本获得了语法支持。 在 TS `2.9` 版本开始,支持了 `import()` 类型定义: ```typescript const zipUtil: typeof import('./utils/create-zip-file') = await import('./utils/create-zip-file') ``` 也就是 `typeof` 可以作用于 `import()` 语法,而不真正引入 js 内容。不过要注意的是,这个 `import('./utils/create-zip-file')` 路径需要可被推导,比如要存在这个 npm 模块、相对路径、或者在 `tsconfig.json` 定义了 `paths`。 好在 `import` 语法本身限制了路径必须是字面量,使得自动推导的成功率非常高,只要是正确的代码几乎一定可以推导出来。好吧,所以这也从另一个角度推荐大家放弃 `require`。 ### Enum 类型支持字符串 从 Typescript `2.4` 开始,支持了枚举类型使用字符串做为 value: ```typescript enum Colors { Red = "RED", Green = "GREEN", Blue = "BLUE" } ``` 笔者在这提醒一句,这个功能在纯前端代码内可能没有用。因为在 TS 中所有 `enum` 的地方都建议使用 `enum` 接收,下面给出例子: ```typescript // 正确 { type: monaco.languages.types.Folder; } // 错误 { type: 75; } ``` 不仅是可读性,`enum` 对应的数字可能会改变,直接写 `75` 的做法存在风险。 但如果前后端存在交互,前端是不可能发送 `enum` 对象的,必须要转化成数字,这时使用字符串作为 value 会更安全: ```typescript enum types { Folder = "FOLDER" } fetch(`/api?type=${monaco.languages.types.Folder}`); ``` ### 数组类型可以明确长度 最典型的是 chart 图,经常是这样的二维数组数据类型: ```json [[1, 5.5], [2, 3.7], [3, 2.0], [4, 5.9], [5, 3.9]] ``` 一般我们会这么描述其数据结构: ```typescript const data: number[][] = [[1, 5.5], [2, 3.7], [3, 2.0], [4, 5.9], [5, 3.9]]; ``` 在 TS `2.7` 版本中,我们可以更精确的描述每一项的类型与数组总长度: ```typescript interface ChartData extends Array { 0: number; 1: number; length: 2; } ``` ### 自动类型推导 自动类型推导有两种,分别是 `typeof`: ```typescript function foo(x: string | number) { if (typeof x === "string") { return x; // string } return x; // number } ``` 和 `instanceof`: ```typescript function f1(x: B | C | D) { if (x instanceof B) { x; // B } else if (x instanceof C) { x; // C } else { x; // D } } ``` 在 TS `2.7` 版本中,新增了 `in` 的推导: ```typescript interface A { a: number; } interface B { b: string; } function foo(x: A | B) { if ("a" in x) { return x.a; } return x.b; } ``` 这个解决了 `object` 类型的自动推导问题,因为 `object` 既无法用 `keyof` 也无法用 `instanceof` 判定类型,因此找到对象的特征吧,再也不要用 `as` 了: ```typescript // Bad function foo(x: A | B) { // I know it's A, but i can't describe it. (x as A).keyofA; } // Good function foo(x: A | B) { // I know it's A, because it has property `keyofA` if ("keyofA" in x) { x.keyofA; } } ``` ## 4 总结 Typescript `2.0-2.9` 文档整体读下来,可以看出还是有较强连贯性的。但我们可能并不习惯一步步学习新语法,因为新语法需要时间消化、同时要连接到以往语法的上下文才能更好理解,所以本文从功能角度,而非版本角度梳理了 TS 的新特性,比较符合学习习惯。 另一个感悟是,我们也许要用追月刊漫画的思维去学习新语言,特别是 TS 这种正在发展中,并且迭代速度很快的语言。 ## 5 更多讨论 > 讨论地址是:[精读《Typescript2.0 - 2.9》 · Issue #85 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/85) **如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。** ================================================ FILE: 前沿技术/59.精读《如何利用 Nodejs 监听文件夹》.md ================================================ ## 1 引言 本期精读的文章是:[How to Watch for Files Changes in Node.js](http://thisdavej.com/how-to-watch-for-files-changes-in-node-js/),探讨如何监听文件的变化。 如果想使用现成的库,推荐 [chokidar](https://www.npmjs.com/package/chokidar) 或 [node-watch](https://www.npmjs.com/package/node-watch),如果想了解实现原理,请往下阅读。 ## 2 概述 ### 使用 fs.watchfile 使用 `fs` 内置函数 `watchfile` 似乎可以解决问题: ```typescript fs.watchFile(dir, (curr, prev) => {}); ``` 但你可能会发现这个回调执行有一定延迟,因为 `watchfile` 是通过轮询检测文件变化的,它并不能实时作出反馈,而且只能监听一个文件,存在效率问题。 ### 使用 fs.watch 使用 `fs` 的另一个内置函数 `watch` 是更好的选择: ```typescript fs.watch(dir, (event, filename) => {}); ``` `watch` 通过操作系统提供的文件更改通知机制,在 Linux 操作系统使用 inotify,在 macOS 系统使用 FSEvents,在 windows 系统使用 ReadDirectoryChangesW,而且可以用来监听目录的变化,在监听文件夹的场景中,比创建 N 个 `fs.watchfile` 效率高出很多。 ```bash $ node file-watcher.js [2018-05-21T00:55:52.588Z] Watching for file changes on ./button-presses.log [2018-05-21T00:56:00.773Z] button-presses.log file Changed [2018-05-21T00:56:00.793Z] button-presses.log file Changed [2018-05-21T00:56:00.802Z] button-presses.log file Changed [2018-05-21T00:56:00.813Z] button-presses.log file Changed ``` 但当我们修改一个文件时,回调却执行了 4 次!原因是文件被写入时,可能触发多次写操作,即使只保存了一次。但我们不需要这么敏感的回调,因为通常认为一次保存就是一次修改,系统底层写了几次文件我们并不关心。 因而可以进一步判断是否触发状态是 `change`: ```typescript fs.watch(dir, (event, filename) => { if (filename && event === "change") { console.log(`${filename} file Changed`); } }); ``` 这样做可以一定程度解决问题,但作者发现 Raspbian 系统不支持 `rename` 事件,如果归类为 `change`,会导致这样的判断毫无意义。 > 作者要表达的意思是,在不同平台下,`fs.watch` 的规则可能会不同,原因是 `fs.watch` 分别使用了各平台提供的 api,所以无法保证这些 api 实现规则的统一性。 ### 优化方案一:对比文件修改时间 基于 `fs.watch`,增加了对修改时间的判断: ```typescript let previousMTime = new Date(0); fs.watch(dir, (event, filename) => { if (filename) { const stats = fs.statSync(filename); if (stats.mtime.valueOf() === previousMTime.valueOf()) { return; } previousMTime = stats.mtime; console.log(`${filename} file Changed`); } }); ``` log 由 4 个变成了 3 个,但依然存在问题。我们认为文件内容变化才算有修改,但操作系统考虑的因素更多,所以我们再尝试对比文件内容是否变化。 > 笔者补充:另外一些开源编辑器可能先清空文件再写入,也会影响到触发回调的次数。 ### 优化方案二:校验文件 md5 只有文件内容变化了,才认为触发了改动,这下总可以了吧: ```typescript let md5Previous = null; fs.watch(dir, (event, filename) => { if (filename) { const md5Current = md5(fs.readFileSync(buttonPressesLogFile)); if (md5Current === md5Previous) { return; } md5Previous = md5Current; console.log(`${filename} file Changed`); } }); ``` log 终于由 3 个变成了 2 个,为什么多出一个?可能的原因是,在文件保存过程中,系统可能会触发多个回调事件,也许存在中间态。 ### 优化方案三:加入延迟机制 我们尝试延迟 100 毫秒进行判断,也许能避开中间状态: ```typescript let fsWait = false; fs.watch(dir, (event, filename) => { if (filename) { if (fsWait) return; fsWait = setTimeout(() => { fsWait = false; }, 100); console.log(`${filename} file Changed`); } }); ``` 这下 log 变成一个了。很多 npm 包在这里使用了 debounce 函数控制触发频率,才将触发频率修正。 而且我们需要结合 md5 与延迟机制共同作用,才能得到相对精准的结果: ```typescript let md5Previous = null; let fsWait = false; fs.watch(dir, (event, filename) => { if (filename) { if (fsWait) return; fsWait = setTimeout(() => { fsWait = false; }, 100); const md5Current = md5(fs.readFileSync(dir)); if (md5Current === md5Previous) { return; } md5Previous = md5Current; console.log(`${filename} file Changed`); } }); ``` ## 3 精读 作者讨论了一些实现文件夹监听的基本方式,可以看出,使用了各平台原生 API 的 `fs.watch` 并不那么靠谱,但这也我们监听文件的唯一手段,所以需要基于它进行一系列优化。 而实际场景中,还需要考虑区分文件夹与文件、软连接、读写权限等情况。 另外用在生产环境的库,也基本使用 50 到 100 毫秒解决重复触发的问题。 所以无论 [chokidar](https://www.npmjs.com/package/chokidar) 或 [node-watch](https://www.npmjs.com/package/node-watch),都大量使用了文中提及的技巧,再加上对边界条件的处理,对软连接、权限等情况处理,将所有可能情况都考虑到,才能提供较为准确的回调。 比如判断文件写入操作是否完毕,也需要通过轮询的方式: ```typescript function awaitWriteFinish() { // ...省略 fs.stat( fullPath, function(err, curStat) { // ...省略 if (prevStat && curStat.size != prevStat.size) { this._pendingWrites[path].lastChange = now; } if (now - this._pendingWrites[path].lastChange >= threshold) { delete this._pendingWrites[path]; awfEmit(null, curStat); } else { timeoutHandler = setTimeout( awaitWriteFinish.bind(this, curStat), this.options.awaitWriteFinish.pollInterval ); } }.bind(this) ); // ...省略 } ``` 可以看出,第三方 npm 库都采取不信任操作系统回调的方式,根据文件信息完全重写了判断逻辑。 可见,信任操作系统的回调,就无法抹平所有操作系统间的差异,唯有统一重写文件的 “写入”、“删除”、“修改” 等逻辑,才能保证在全平台的兼容性。 ## 4 总结 利用 nodejs 监听文件夹变化很容易,但提供准确的回调却很难,主要难在两点: 1. 抹平操作系统间的差异,这需要在结合 `fs.watch` 的同时,增加一些额外校验机制与延时机制。 2. 分清楚操作系统预期与用户预期,比如编辑器的额外操作、操作系统的多次读写都应该被忽略,用户的预期不会那么频繁,会忽略极小时间段内的连续触发。 另外还有兼容性、权限、软连接等其他因素要考虑,`fs.watch` 并不是一个开箱可用的工程级别 api。 ## 5 更多讨论 > 讨论地址是:[精读《如何利用 Nodejs 监听文件夹》 · Issue #87 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/87) **如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。** ================================================ FILE: 前沿技术/6.精读《JavaScript 错误堆栈处理》.md ================================================ 本期精读文章:[JavaScript-Errors-and-Stack-Traces](http://lucasfcosta.com/2017/02/17/JavaScript-Errors-and-Stack-Traces.html?utm_source=javascriptweekly&utm_medium=email) [中文版译文](https://zhuanlan.zhihu.com/p/25338849) # 1. 引言 logo 错误处理无论对那种语言来说,都至关重要。在 JavaScript 中主要是通过 Error 对象和 Stack Traces 提供有价值的错误堆栈,帮助开发者调试。在服务端开发中,开发者可以将有价值错误信息打印到服务器日志中,而对于客户端而言就很难重现用户环境下的报错,我们团队一直在做一个错误监控的应用,在这里也和大家一起讨论下 js 异常监控的常规方式。 # 2. 内容概要 ## 了解 Stack Stack 部分主要在阐明 js 中函数调用栈的概念,它符合栈的基本特性『当调用时,压入栈顶。当它执行完毕时,被弹出栈』,简单看下面的代码: ```plain function c() { try { var bar = baz; throw new Error() } catch (e) { console.log(e.stack); } } function b() { c(); } function a() { b(); } a(); ``` 上述代码中会在执行到 c 函数的时候跑错,调用栈为 `a -> b -> c`,如下图所示: ![](https://img.alicdn.com/tfs/TB1hqekQVXXXXa1XVXXXXXXXXXX-734-256.png) 很明显,错误堆栈可以帮助我们定位到报错的位置,在大型项目或者类库开发时,这很有意义。 ## 认知 Error 对象 紧接着,原作者讲到了 Error 对象,主要有两个重要属性 message 和 name 分别表示错误信息和错误名称。实际上,除了这两个属性还有一个未被标准化的 stack 属性,我们上面的代码也用到了 `e.stack`,这个属性包含了错误信息、错误名称以及错误栈信息。在 chrome 中测试打印出 `e.stack` 于 `e` 类似。感兴趣的可以了解下 Sentry 的 [stack traces](https://sentry.io/features/stacktrace/),它集成了 TraceKit,会对 Error 对象进行规范化处理。 ## 如何使用堆栈追踪 该部分以 NodeJS 环境为例,讲解了 `Error.captureStackTrace`,将 stack 信息作为属性存储在一个对象当中,同时可以过滤掉一些无用的堆栈信息。这样可以隐藏掉用户不需要了解的内部细节。作者也以 Chai 为例,内部使用该方法对代码的调用者屏蔽了不相关的实现细节。通过以 Assertion 对象为例,讲述了具体的内部实现,简单来说通过一个 addChainableMethod 链式调用工具方法,在运行一个 Assertion 时,将它设为标记,其后面的堆栈会被移除;如果 assertion 失败移除起后面所有内部堆栈;如果有内嵌 assertion,将当前 assertion 的方法放到 ssfi 中作为标记,移除后面堆栈帧; # 3. 精读 参与本次精读的同学有:[范洪春](https://www.zhihu.com/people/fanhc/activities)、[黄子毅](https://www.zhihu.com/people/huang-zi-yi-83/answers)、[杨森](https://www.zhihu.com/people/yangsen/answers)、[camsong](https://www.zhihu.com/people/camsong/answers),该部分由他们的观点总结而出。 ## captureStackTrace 方法优劣 captureStackTrace 方法通过截取有意义报错堆栈,并统计上报,有助于排查问题。常用的断言库 chai 就是通过此方式屏蔽了库自身的调用栈,仅保留了用户代码的调用栈,这样用户会清晰的看到自己代码的调用栈。不过 Chai 的断言方式过分语义化,代码不易读。而实际上,现在有另外一款更黑科技的断言库正在崛起,那就是 [power-assert](https://github.com/power-assert-js/power-assert)。 直观的看一下 Chai.js 和 power-assert 的用法及反馈效果(以下代码及截图来自[小菜荔枝](http://www.jianshu.com/p/41ced3207a0c): ```js const assert = require('power-assert'); const should = require('should'); // 别忘记 npm install should const obj = { arr: [1,2,3], number: 10 }; describe('should.js和power-assert的区别', () => { it('使用should.js的情况', () => { should(obj.arr[0]).be.equal(obj.number); // should api }); it('使用power-assert的情况', () => { assert(obj.arr[0] === obj.number); // 用assert就可以 }); }); ``` ![](https://cloud.githubusercontent.com/assets/1336484/25432441/0696cda2-2ab7-11e7-94a7-6719acdcb7af.png) ## 抛 Error 对象的正确姿势 在我们日常开发中一定要抛出标准的 Error 对象。否则,无法知道抛出的类型,很难对错误进行统一处理。正确的做法应该是使用 throw new Error(“error message here”),这里还引用了 Node.js 中推荐的异常[处理方式](https://www.joyent.com/node-js/production/design/errors): - 区分操作异常和程序员的失误。操作异常指可预测的不可避免的异常,如无法连接服务器 - 操作异常应该被处理。程序员的失误不需要处理,如果处理了反而会影响错误排查 - 操作异常有两种处理方式:同步 (try……catch) 和异步(callback, event - emitter)两种处理方式,但只能选择其中一种。 - 函数定义时应该用文档写清楚参数类型,及可能会发生的合理的失败。以及错误是同步还是异步传给调用者的 - 缺少参数或参数无效是程序员的错误,一旦发生就应该 throw。 传递错误时,使用标准的 Error 对象,并附件尽可能多的错误信息,可以使用标准的属性名 ## 异步(Promise)环境下错误处理方式 在 Promise 内部使用 reject 方法来处理错误,而不要直接调用 `throw Error`,这样你不会捕捉到任何的报错信息。 reject 如果使用 Error 对象,会导致捕获不到错误的情况,在我的博客中有讨论过这种情况:Callback Promise Generator Async-Await 和异常处理的演进,我们看以下代码: ```js function thirdFunction() { return new Promise((resolve, reject) => { setTimeout(() => { reject('我可以被捕获') // throw Error('永远无法被捕获') }) }) } Promise.resolve(true).then((resolve, reject) => { return thirdFunction() }).catch(error => { console.log('捕获异常', error) // 捕获异常 我可以被捕获 }); ``` 我们发现,在 macrotask 队列中,`reject` 行为是可以被 catch 到的,而此时 throw Error 就无法捕获异常,大家可以贴到浏览器运行试一试,第二次把 `reject('我可以被捕获')` 注释起来,取消 `throw Error('永远无法被捕获')` 的注释,会发现异常无法 catch 住。 这是因为 setTimeout 中 throw Error 无论如何都无法捕获到,而 reject 是 Promise 提供的关键字,自己当然可以 catch 住。 ## 监控客户端 Error 报错 文中提到的 `try...catch` 可以拿到出错的信息,堆栈,出错的文件、行号、列号等,但无法捕捉到语法错误,也没法去捕捉全局的异常事件。此外,在一些古老的浏览器下 `try...catch` 对 js 的性能也有一定的影响。 这里,想提一下另一个捕捉异常的方法,即 `window.onerror`,这也是我们在做错误监控中用到比较多的方案。它可以捕捉语法错误和运行时错误,并且拿到出错的信息,堆栈,出错的文件、行号、列号等。不过,由于是全局监测,就会统计到浏览器插件中的 js 异常。当然,还有一个问题就是浏览器跨域,页面和 js 代码在不同域上时,浏览器出于安全性的考虑,将异常内容隐藏,我们只能获取到一个简单的 `Script Error` 信息。不过这个解决方案也很成熟: - 给应用内所需的 ``` 上面定义了 `my-component` 与 `my-child` 组件,并将 `my-child` 作为 `my-component` 的默认子元素。 ```js import { defineComponent, reactive, html, onMounted, onUpdated, onUnmounted } from 'https://unpkg.com/@vue/lit' ``` `defineComponent` 定义 custom element,第一个参数是自定义 element 组件名,必须遵循原生 API [customElements.define](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements) 对组件名的规范,组件名必须包含中划线。 `reactive` 属于 [@vue/reactivity](https://github.com/vuejs/vue-next/tree/master/packages/reactivity) 提供的响应式 API,可以创建一个响应式对象,在渲染函数中调用时会自动进行依赖收集,这样在 Mutable 方式修改值时可以被捕获,并自动触发对应组件的重渲染。 `html` 是 [lit-html](https://github.com/lit/lit/blob/main/packages/lit-html/README.md) 提供的模版函数,通过它可以用 [Template strings](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals) 原生语法描述模版,是一个轻量模版引擎。 `onMounted`、`onUpdated`、`onUnmounted` 是基于 [web component lifecycle](https://developers.google.com/web/fundamentals/web-components/customelements#reactions) 创建的生命周期函数,可以监听组件创建、更新与销毁时机。 接下来看 `defineComponent` 的内容: ```js defineComponent('my-component', () => { const state = reactive({ text: 'hello', show: true }) const toggle = () => { state.show = !state.show } const onInput = e => { state.text = e.target.value } return () => html`

${state.text}

${state.show ? html`` : ``} ` }) ``` 借助模版引擎 [lit-html](https://github.com/lit/lit/blob/main/packages/lit-html/README.md) 的能力,可以同时在模版中传递变量与函数,再借助 [@vue/reactivity](https://github.com/vuejs/vue-next/tree/master/packages/reactivity) 能力,让变量变化时生成新的模版,更新组件 dom。 ## 精读 阅读源码可以发现,vue-lit 巧妙的融合了三种技术方案,它们配合方式是: 1. 使用 [@vue/reactivity](https://github.com/vuejs/vue-next/tree/master/packages/reactivity) 创建响应式变量。 2. 利用模版引擎 [lit-html](https://github.com/lit/lit/blob/main/packages/lit-html/README.md) 创建使用了这些响应式变量的 HTML 实例。 3. 利用 [web component](https://developers.google.com/web/fundamentals/web-components/customelements) 渲染模版引擎生成的 HTML 实例,这样创建的组件具备隔离能力。 其中响应式能力与模版能力分别是 [@vue/reactivity](https://github.com/vuejs/vue-next/tree/master/packages/reactivity)、[lit-html](https://github.com/lit/lit/blob/main/packages/lit-html/README.md) 这两个包提供的,我们只需要从源码中寻找剩下的两个功能:如何在修改值后触发模版刷新,以及如何构造生命周期函数的。 首先看如何在值修改后触发模版刷新。以下我把与重渲染相关代码摘出来了: ```js import { effect } from 'https://unpkg.com/@vue/reactivity/dist/reactivity.esm-browser.js' customElements.define( name, class extends HTMLElement { constructor() { super() const template = factory.call(this, props) const root = this.attachShadow({ mode: 'closed' }) effect(() => { render(template(), root) }) } } ) ``` 可以清晰的看到,首先 `customElements.define` 创建一个原生 web component,并利用其 API 在初始化时创建一个 `closed` 节点,该节点对外部 API 调用关闭,即创建的是一个不会受外部干扰的 web component。 然后在 `effect` 回调函数内调用 `html` 函数,即在使用文档里返回的模版函数,由于这个模版函数中使用的变量都采用 `reactive` 定义,所以 `effect` 可以精准捕获到其变化,并在其变化后重新调用 `effect` 回调函数,实现了 “值变化后重渲染” 的功能。 然后看生命周期是如何实现的,由于生命周期贯穿整个实现流程,因此必须结合全量源码看,下面贴出全量核心代码,上面介绍过的部分可以忽略不看,只看生命周期的实现: ```js let currentInstance export function defineComponent(name, propDefs, factory) { if (typeof propDefs === 'function') { factory = propDefs propDefs = [] } customElements.define( name, class extends HTMLElement { constructor() { super() const props = (this._props = shallowReactive({})) currentInstance = this const template = factory.call(this, props) currentInstance = null this._bm && this._bm.forEach((cb) => cb()) const root = this.attachShadow({ mode: 'closed' }) let isMounted = false effect(() => { if (isMounted) { this._bu && this._bu.forEach((cb) => cb()) } render(template(), root) if (isMounted) { this._u && this._u.forEach((cb) => cb()) } else { isMounted = true } }) } connectedCallback() { this._m && this._m.forEach((cb) => cb()) } disconnectedCallback() { this._um && this._um.forEach((cb) => cb()) } attributeChangedCallback(name, oldValue, newValue) { this._props[name] = newValue } } ) } function createLifecycleMethod(name) { return (cb) => { if (currentInstance) { ;(currentInstance[name] || (currentInstance[name] = [])).push(cb) } } } export const onBeforeMount = createLifecycleMethod('_bm') export const onMounted = createLifecycleMethod('_m') export const onBeforeUpdate = createLifecycleMethod('_bu') export const onUpdated = createLifecycleMethod('_u') export const onUnmounted = createLifecycleMethod('_um') ``` 生命周期实现形如 `this._bm && this._bm.forEach((cb) => cb())`,之所以是循环,是因为比如 `onMount(() => cb())` 可以注册多次,因此每个生命周期都可能注册多个回调函数,因此遍历将其依次执行。 而生命周期函数还有一个特点,即并不分组件实例,因此必须有一个 `currentInstance` 标记当前回调函数是在哪个组件实例注册的,而这个注册的同步过程就在 `defineComponent` 回调函数 `factory` 执行期间,因此才会有如下的代码: ```js currentInstance = this const template = factory.call(this, props) currentInstance = null ``` 这样,我们就将 `currentInstance` 始终指向当前正在执行的组件实例,而所有生命周期函数都是在这个过程中执行的,**因此当调用生命周期回调函数时,`currentInstance` 变量必定指向当前所在的组件实例**。 接下来为了方便,封装了 `createLifecycleMethod` 函数,在组件实例上挂载了一些形如 `_bm`、`_bu` 的数组,比如 `_bm` 表示 `beforeMount`,`_bu` 表示 `beforeUpdate`。 接下来就是在对应位置调用对应函数了: 首先在 `attachShadow` 执行之前执行 `_bm` - `onBeforeMount`,因为这个过程确实是准备组件挂载的最后一步。 然后在 `effect` 中调用了两个生命周期,因为 `effect` 会在每次渲染时执行,所以还特意存储了 `isMounted` 标记是否为初始化渲染: ```js effect(() => { if (isMounted) { this._bu && this._bu.forEach((cb) => cb()) } render(template(), root) if (isMounted) { this._u && this._u.forEach((cb) => cb()) } else { isMounted = true } }) ``` 这样就很容易看懂了,只有初始化渲染过后,从第二次渲染开始,在执行 `render`(该函数来自 `lit-html` 渲染模版引擎)之前调用 `_bu` - `onBeforeUpdate`,在执行了 `render` 函数后调用 `_u` - `onUpdated`。 由于 `render(template(), root)` 根据 `lit-html` 的语法,会直接把 `template()` 返回的 HTML 元素挂载到 `root` 节点,而 `root` 就是这个 web component `attachShadow` 生成的 shadow dom 节点,因此这句话执行结束后渲染就完成了,所以 `onBeforeUpdate` 与 `onUpdated` 一前一后。 最后几个生命周期函数都是利用 web component 原生 API 实现的: ```js connectedCallback() { this._m && this._m.forEach((cb) => cb()) } disconnectedCallback() { this._um && this._um.forEach((cb) => cb()) } ``` 分别实现 `mount`、`unmount`。这也说明了浏览器 API 分层的清晰之处,只提供创建和销毁的回调,而更新机制完全由业务代码实现,不管是 [@vue/reactivity](https://github.com/vuejs/vue-next/tree/master/packages/reactivity) 的 `effect` 也好,还是 `addEventListener` 也好,都不关心,所以如果在这之上做完整的框架,需要自己根据实现 `onUpdate` 生命周期。 最后的最后,还利用 `attributeChangedCallback` 生命周期监听自定义组件 html attribute 的变化,然后将其直接映射到对 `this._props[name]` 的变化,这是为什么呢? ```js attributeChangedCallback(name, oldValue, newValue) { this._props[name] = newValue } ``` 看下面的代码片段就知道原因了: ```js const props = (this._props = shallowReactive({})) const template = factory.call(this, props) effect(() => { render(template(), root) }) ``` 早在初始化时,就将 `_props` 创建为响应式变量,这样只要将其作为 [lit-html](https://github.com/lit/lit/blob/main/packages/lit-html/README.md) 模版表达式的参数(对应 `factory.call(this, props)` 这段,而 `factory` 就是 `defineComponent('my-child', ['msg'], (props) => { ..` 的第三个参数),这样一来,只要这个参数变化了就会触发子组件的重渲染,因为这个 `props` 已经经过 Reactive 处理了。 ## 总结 [vue-lit](https://github.com/yyx990803/vue-lit) 实现非常巧妙,学习他的源码可以同时了解一下几种概念: - reative。 - web component。 - string template。 - 模版引擎的精简实现。 - 生命周期。 以及如何将它们串起来,利用 70 行代码实现一个优雅的渲染引擎。 最后,用这种模式创建的 web component 引入的 runtime lib 在 gzip 后只有 6kb,但却能享受到现代化框架的响应式开发体验,如果你觉得这个 runtime 大小可以忽略不计,那这就是一个非常理想的创建可维护 web component 的 lib。 > 讨论地址是:[精读《vue-lit 源码》· Issue #396 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/396) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 源码解读/241.精读《react-snippets - Router 源码》.md ================================================ 造轮子就是应用核心原理 + 周边功能的堆砌,所以学习成熟库的源码往往会受到非核心代码干扰,[Router](https://github.com/ashok-khanna/react-snippets/blob/main/Router.js) 这个 repo 用不到 100 行源码实现了 React Router 核心机制,很适合用来学习。 ## 精读 [Router](https://github.com/ashok-khanna/react-snippets/blob/main/Router.js) 快速实现了 React Router 3 个核心 API:`Router`、`navigate`、`Link`,下面列出基本用法,配合理解源码实现会更方便: ```tsx const App = () => ( }, { path: '/articles', component: } ]} /> ) const Home = () => (
home, go articles, navigate('/details')}>or jump to details
) ``` 首先看 `Router` 的实现,在看代码之前,思考下 `Router` 要做哪些事情? - 接收 routes 参数,根据当前 url 地址判断渲染哪个组件。 - 当 url 地址变化时(无论是用户触发还是自己的 `navigate` `Link` 触发),渲染新 url 对应的组件。 所以 `Router` 是一个路由渲染分配器与 url 监听器: ```tsx export default function Router ({ routes }) { // 存储当前 url path,方便其变化时引发自身重渲染,以返回新的 url 对应的组件 const [currentPath, setCurrentPath] = useState(window.location.pathname); useEffect(() => { const onLocationChange = () => { // 将 url path 更新到当前数据流中,触发自身重渲染 setCurrentPath(window.location.pathname); } // 监听 popstate 事件,该事件由用户点击浏览器前进/后退时触发 window.addEventListener('popstate', onLocationChange); return () => window.removeEventListener('popstate', onLocationChange) }, []) // 找到匹配当前 url 路径的组件并渲染 return routes.find(({ path, component }) => path === currentPath)?.component } ``` 最后一段代码看似每次都执行 `find` 有一定性能损耗,但其实根据 `Router` 一般在最根节点的特性,该函数很少因父组件重渲染而触发渲染,所以性能不用太担心。 但如果考虑做一个完整的 React Router 组件库,考虑了更复杂的嵌套 API,即 `Router` 套 `Router` 后,不仅监听方式要变化,还需要将命中的组件缓存下来,需要考虑的点会逐渐变多。 下面该实现 `navigate` `Link` 了,他俩做的事情都是跳转,有如下区别: 1. API 调用方式不同,`navigate` 是调用式函数,而 `Link` 是一个内置 `navigate` 能力的 `a` 标签。 2. `Link` 其实还有一种按住 `ctrl` 后打开新 tab 的跳转模式,该模式由浏览器对 `a` 标签默认行为完成。 所以 `Link` 更复杂一些,我们先实现 `navigate`,再实现 `Link` 时就可以复用它了。 既然 `Router` 已经监听 `popstate` 事件,我们显然想到的是触发 url 变化后,让 `popstate` 捕获,自动触发后续跳转逻辑。但可惜的是,我们要做的 React Router 需要实现单页跳转逻辑,而单页跳转的 API `history.pushState` 并不会触发 `popstate`,为了让实现更优雅,我们可以在 `pushState` 后手动触发 `popstate` 事件,如源码所示: ```tsx export function navigate (href) { // 用 pushState 直接刷新 url,而不触发真正的浏览器跳转 window.history.pushState({}, "", href); // 手动触发一次 popstate,让 Route 组件监听并触发 onLocationChange const navEvent = new PopStateEvent('popstate'); window.dispatchEvent(navEvent); } ``` 接下来实现 `Link` 就很简单了,有几个考虑点: 1. 返回一个正常的 `
` 标签。 2. 因为正常 `` 点击后就发生网页刷新而不是单页跳转,所以点击时要阻止默认行为,换成我们的 `navigate`(源码里没做这个抽象,笔者稍微优化了下)。 3. 但按住 `ctrl` 时又要打开新 tab,此时用默认 `` 标签行为就行,所以此时不要阻止默认行为,也不要继续执行 `navigate`,因为这个 url 变化不会作用于当前 tab。 ```tsx export function Link ({ className, href, children }) { const onClick = (event) => { // mac 的 meta or windows 的 ctrl 都会打开新 tab // 所以此时不做定制处理,直接 return 用原生行为即可 if (event.metaKey || event.ctrlKey) { return; } // 否则禁用原生跳转 event.preventDefault(); // 做一次单页跳转 navigate(href) }; return ( {children} ); }; ``` 这样的设计,既能兼顾 `` 标签默认行为,又能在点击时优化为单页跳转,里面对 `preventDefault` 与 `metaKey` 的判断值得学习。 ## 总结 从这个小轮子中可以学习到一下几个经验: - 造轮子之前先想好使用 API,根据使用 API 反推实现,会让你的设计更有全局观。 - 实现 API 时,先思考 API 之间的关系,能复用的就提前设计好复用关系,这样巧妙的关联设计能为以后维护减少很多麻烦。 - 即便代码无法复用的地方,也要尽量做到逻辑复用。比如 `pushState` 无法触发 `popstate` 那段,直接把 `popstate` 代码复用过来,或者自己造一个状态沟通就太 low 了,用浏览器 API 模拟事件触发,既轻量,又符合逻辑,因为你要做的就是触发 `popstate` 行为,而非只是更新渲染组件这个动作,万一以后再有监听 `popstate` 的地方,你的触发逻辑就能很自然的应用到那儿。 - 尽量在原生能力上拓展,而不是用自定义方法补齐原生能力。比如 `Link` 的实现是基于 `` 标签拓展的,如果采用自定义 `` 标签,不仅要补齐样式上的差异,还要自己实现 `ctrl` 后打开新 tab 的行为,甚至 `` 默认访问记录行为你也得花高成本补上,所以错误的设计方向会导致事倍功半,甚至无法实现。 > 讨论地址是:[精读《react-snippets - Router 源码》· Issue #418 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/418) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 源码解读/48.精读《Immer.js》源码.md ================================================ 本周精读的仓库是 [immer](https://github.com/mweststrate/immer)。 ## 1 引言 Immer 是最近火起来的一个项目,由 [Mobx](https://github.com/mobxjs/mobx) 作者 [Mweststrate](https://github.com/mweststrate) 研发。 了解 mobx 的同学可能会发现,Immer 就是更底层的 Mobx,它将 Mobx 特性发扬光大,得以结合到任何数据流框架,使用起来非常优雅。 ## 2 概述 ### 麻烦的 Immutable Immer 想解决的问题,是利用元编程简化 Immutable 使用的复杂度。举个例子,我们写一个纯函数: ```typescript const addProducts = products => { const cloneProducts = products.slice() cloneProducts.push({ text: "shoes" }) return cloneProducts } ``` 虽然代码并不复杂,但写起来内心仍隐隐作痛。我们必须将 `products` 拷贝一份,再调用 `push` 函数修改新的 `cloneProducts`,再返回它。 如果 js 原生支持 Immutable,就可以直接使用 `push` 了!对,Immer 让 js 现在就支持: ```typescript const addProducts = produce(products => { products.push({ text: "shoes" }) }) ``` 很有趣吧,这两个 `addProducts` 函数功能一模一样,而且都是纯函数。 ### 别扭的 setState 我们都知道,react 框架中,`setState` 支持函数式写法: ```typescript this.setState(state => ({ ...state, isShow: true })) ``` 配合解构语法,写起来仍是如此优雅。那数据稍微复杂些呢?我们就要默默忍受 “糟糕的 Immutable” 了: ```typescript this.setState(state => { const cloneProducts = state.products.slice() cloneProducts.push({ text: "shoes" }) return { ...state, cloneProducts } }) ``` **然而有了 Immer,一切都不一样了:** ```typescript this.setState(produce(state => (state.isShow = true))) this.setState(produce(state => state.products.push({ text: "shoes" }))) ``` ### 方便的柯里化 上面讲述了 Immer 支持柯里化带来的好处。所以我们也可以直接把两个参数一次性消费: ```typescript const oldObj = { value: 1 } const newObj = produce(oldObj, draft => (draft.value = 2)) ``` 这就是 Immer:Create the next immutable state by mutating the current one. ## 3 精读 虽然笔者之前在这方面已经有所研究,比如做出了 Mutable 转 Immutable 的库:[dob-redux](https://github.com/dobjs/dob-redux),但 Immer 实在是太惊艳了,Immer 是更底层的拼图,它可以插入到任何数据流框架作为功能增强,不得不赞叹 Mweststrate 真的是非常高瞻远瞩。 所以笔者认真阅读了它的源代码,带大家从原理角度认识 Immer。 Immer 是一个支持柯里化,**仅支持同步计算的工具**,所以非常适合作为 redux 的 reducer 使用。 > Immer 也支持直接 return value,这个功能比较简单,所以本篇会跳过所有对 return value 的处理。PS: mutable 与 return 不能同时返回不同对象,否则弄不清楚到哪种修改是有效的。 柯里化这里不做拓展介绍,详情查看 [curry](https://github.com/dominictarr/curry)。我们看 `produce` 函数 callback 部分: ```typescript produce(obj, draft => { draft.count++ }) ``` `obj` 是个普通对象,那黑魔法一定出现在 `draft` 对象上,Immer 给 `draft` 对象的所有属性做了监听。 **所以整体思路就有了:`draft` 是 `obj` 的代理,对 `draft` mutable 的修改都会流入到自定义 `setter` 函数,它并不修改原始对象的值,而是递归父级不断浅拷贝,最终返回新的顶层对象,作为 `produce` 函数的返回值。** ### 生成代理 第一步,也就是将 `obj` 转为 `draft` 这一步,为了提高 Immutable 运行效率,我们需要一些额外信息,因此将 `obj` 封装成一个包含额外信息的代理对象: ```typescript { modified, // 是否被修改过 finalized, // 是否已经完成(所有 setter 执行完,并且已经生成了 copy) parent, // 父级对象 base, // 原始对象(也就是 obj) copy, // base(也就是 obj)的浅拷贝,使用 Object.assign(Object.create(null), obj) 实现 proxies, // 存储每个 propertyKey 的代理对象,采用懒初始化策略 } ``` 在这个代理对象上,绑定了自定义的 `getter` `setter`,然后直接将其扔给 `produce` 执行。 ### getter `produce` 回调函数中包含了用户的 `mutable` 代码。所以现在入口变成了 `getter` 与 `setter`。 `getter` 主要用来懒初始化代理对象,也就是当代理对象子属性被访问的时候,才会生成其代理对象。 这么说比较抽象,举个例子,下面是原始 obj: ```typescript { a: {}, b: {}, c: {} } ``` 那么初始情况下,`draft` 是 `obj` 的代理,所以访问 `draft.a` `draft.b` `draft.c` 时,都能触发 `getter` `setter`,进入自定义处理逻辑。可是对 `draft.a.x` 就无法监听了,因为代理只能监听一层。 代理懒初始化就是要解决这个问题,当访问到 `draft.a` 时,自定义 `getter` 已经悄悄生成了新的针对 `draft.a` 对象的代理 `draftA`,因此 `draft.a.x` 相当于访问了 `draftA.x`,所以能递归监听一个对象的所有属性。 同时,如果代码中只访问了 `draft.a`,那么只会在内存生成 `draftA` 代理,`b` `c` 属性因为没有访问,因此不需要浪费资源生成代理 `draftB` `draftC`。 当然 Immer 做了一些性能优化,以及在对象被修改过(`modified`)获取其 `copy` 对象,为了保证 `base` 是不可变的,这里不做展开。 ### setter 当对 `draft` 修改时,会对 `base` 也就是原始值进行浅拷贝,保存到 `copy` 属性,同时将 `modified` 属性设置为 `true`。这样就完成了最重要的 Immutable 过程,而且浅拷贝并不是很消耗性能,加上是按需浅拷贝,因此 Immer 的性能还可以。 **同时为了保证整条链路的对象都是新对象,会根据 `parent` 属性递归父级,不断浅拷贝,直到这个叶子结点到根结点整条链路对象都换新为止。** 完成了 `modified` 对象再有属性被修改时,会将这个新值保存在 `copy` 对象上。 ### 生成 Immutable 对象 当执行完 `produce` 后,用户的所有修改已经完成(所以 Immer 没有支持异步),如果 `modified` 属性为 `false`,说明用户根本没有改这个对象,那直接返回原始 `base` 属性即可。 如果 `modified` 属性为 `true`,说明对象发生了修改,返回 `copy` 属性即可。但是 `setter` 过程是递归的,`draft` 的子对象也是 `draft`(包含了 `base` `copy` `modified` 等额外属性的代理),我们必须一层层递归,拿到真正的值。 所以在这个阶段,所有 `draft` 的 `finalized` 都是 `false`,`copy` 内部可能还存在大量 `draft` 属性,因此递归 `base` 与 `copy` 的子属性,如果相同,就直接返回;如果不同,递归一次整个过程(从这小节第一行开始)。 最后返回的对象是由 `base` 的一些属性(没有修改的部分)和 `copy` 的一些属性(修改的部分)最终拼接而成的。最后使用 `freeze` 冻结 `copy` 属性,将 `finalized` 属性设置为 `true`。 至此,返回值生成完毕,我们将最终值保存在 `copy` 属性上,并将其冻结,返回了 Immutable 的值。 Immer 因此完成了不可思议的操作:Create the next immutable state by mutating the current one。 > 源码读到这里,发现 Immer 其实可以支持异步,只要支持 produce 函数返回 Promise 即可。最大的问题是,最后对代理的 `revoke` 清洗,需要借助全局变量,这一点阻碍了 Immer 对异步的支持。 ## 4 总结 读到这,如果觉得不过瘾,可以看看 [redux-box](https://github.com/anish000kumar/redux-box) 这个库,利用 immer + redux 解决了 reducer 冗余 `return` 的问题。 > 同样我们也开始思考并设计新的数据流框架,笔者在 2018.3.24 的携程技术沙龙将会分享 [《mvvm 前端数据流框架精讲》](http://mp.weixin.qq.com/s/54BJPM7aldH6yq6qj2Yrpw),分享这几年涌现的各套数据流技术方案研究心得,感兴趣的同学欢迎报名参加。 ## 5 更多讨论 > 讨论地址是:[精读《Immer.js》源码》 · Issue #68 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/68) **如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,每周五发布。** ================================================ FILE: 源码解读/73.精读《sqorn 源码》.md ================================================ ## 1 引言 前端精读[《手写 SQL 编译器系列》](https://github.com/dt-fe/weekly/blob/master/64.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%8D%E6%B3%95%E5%88%86%E6%9E%90%E3%80%8B.md) 介绍了如何利用 SQL 生成语法树,而还有一些库的作用是根据语法树生成 SQL 语句。 除此之外,还有一种库,是根据编程语言生成 SQL。[sqorn](https://github.com/lusakasa/sqorn) 就是一个这样的库。 可能有人会问,利用编程语言生成 SQL 有什么意义?既没有语法树规范,也不如直接写 SQL 通用。对,有利就有弊,这些库不遵循语法树,但利用简化的对象模型快速生成 SQL,使得代码抽象程度得到了提高。而代码抽象程度得到提高,第一个好处就是易读,第二个好处就是易操作。 数据库特别容易抽象为面向对象模型,而对数据库的操作语句 - SQL 是一种结构化查询语句,只能描述一段一段的查询,而面向对象模型却适合描述一个整体,将数据库多张表串联起来。 举个例子,利用 [typeorm](https://github.com/typeorm/typeorm),我们可以用 `a` 与 `b` 两个 Class 描述两张表,同时利用 `ManyToMany` 装饰器分别修饰 `a` 与 `b` 的两个字段,将其建立起 **多对多的关联**,而这个映射到 SQL 结构是三张表,还有一张是中间表 `ab`,以及查询时涉及到的 left join 操作,而在 typeorm 中,一条 `find` 语句就能连带查询处多对多关联关系。 这就是这种利用编程语言生成 SQL 库的价值,所以本周我们分析一下 [sqorn](https://github.com/lusakasa/sqorn) 这个库的源码,看看利用对象模型生成 SQL 需要哪些步骤。 ## 2 概述 我们先看一下 sqorn 的语法。 ```js const sq = require("sqorn-pg")(); const Person = sq`person`, Book = sq`book`; // SELECT const children = await Person`age < ${13}`; // "select * from person where age < 13" // DELETE const [deleted] = await Book.delete({ id: 7 })`title`; // "delete from book where id = 7 returning title" // INSERT await Person.insert({ firstName: "Rob" }); // "insert into person (first_name) values ('Rob')" // UPDATE await Person({ id: 23 }).set({ name: "Rob" }); // "update person set name = 'Rob' where id = 23" ``` 首先第一行的 `sqorn-pg` 告诉我们 sqorn 按照 SQL 类型拆成不同分类的小包,这是因为不同数据库支持的方言不同,sqorn 希望在语法上抹平数据库间差异。 其次 sqorn 也是利用面向对象思维的,上面的例子通过 sq\`person\` 生成了 Person 实例,实际上也对应了 person 表,然后 Person\`age < ${13}\` 表示查询:`select * from person where age < 13` 上面是利用 ES6 模板字符串的功能实现的简化 where 查询功能,sqorn 主要还是利用一些函数完成 SQL 语句生成,比如 `where` `delete` `insert` 等等,比较典型的是下面的 Example: ```js sq.from`book`.return`distinct author` .where({ genre: "Fantasy" }) .where({ language: "French" }); // select distinct author from book // where language = 'French' and genre = 'Fantsy' ``` 所以我们阅读 sqorn 源码,探讨如何利用实现上面的功能。 ## 3 精读 我们从四个方面入手,讲明白 sqorn 的源码是如何组织的,以及如何满足上面功能的。 ### 方言 为了实现各种 SQL 方言,需要在实现功能之前,将代码拆分为内核代码与拓展代码。 内核代码就是 `sqorn-sql` 而拓展代码就是 `sqorn-pg`,拓展代码自身只要实现 pg 数据库自身的特殊逻辑, 加上 `sqorn-sql` 提供的核心能力,就能形成完整的 pg SQL 生成功能。 **实现数据库连接** sqorn 不但生成 query 语句,也会参与数据库连接与运行,因此方言库的一个重要功能就是做数据库连接。sqorn 利用 `pg` 这个库实现了连接池、断开、查询、事务的功能。 **覆写接口函数** 内核代码想要具有拓展能力,暴露出一些接口让 `sqorn-xx` 覆写是很基本的。 ### context 内核代码中,最重要的就是 context 属性,因为人类习惯一步一步写代码,而最终生成的 query 语句是连贯的,所以这个上下文对象通过 `updateContext` 存储了每一条信息: ```js { name: 'limit', updateContext: (ctx, args) => { ctx.lim = args } } { name: 'where', updateContext: (ctx, args) => { ctx.whr.push(args) } } ``` 比如 `Person.where({ name: 'bob' })` 就会调用 `ctx.whr.push({ name: 'bob' })`,因为 where 条件是个数组,因此这里用 `push`,而 `limit` 一般仅有一个,所以 context 对 `lim` 对象的存储仅有一条。 其他操作诸如 `where` `delete` `insert` `with` `from` 都会类似转化为 `updateContext`,最终更新到 context 中。 ### 创建 builder 不用太关心下面的 `sqorn-xx` 包名细节,这一节主要目的是说明如何实现 Demo 中的链式调用,至于哪个模块放在哪并不重要(如果要自己造轮子就要仔细学习一下作者的命名方式)。 在 `sqorn-core` 代码中创建了 `builder` 对象,将 `sqorn-sql` 中创建的 `methods` merge 到其中,因此我们可以使用 `sq.where` 这种语法。而为什么可以 `sq.where().limit()` 这样连续调用呢?可以看下面的代码: ```js for (const method of methods) { // add function call methods builder[name] = function(...args) { return this.create({ name, args, prev: this.method }); }; } ``` 这里将 `where` `delete` `insert` `with` `from` 等 `methods` merge 到 `builder` 对象中,且当其执行完后,通过 `this.create()` 返回一个新 `builder`,从而完成了链式调用功能。 ### 生成 query 上面三点讲清楚了如何支持方言、用户代码内容都收集到 context 中了,而且我们还创建了可以链式调用的 `builder` 对象方便用户调用,那么只剩最后一步了,就是生成 query。 为了利用 context 生成 query,我们需要对每个 key 编写对应的函数做处理,拿 `limit` 举例: ```js export default ctx => { if (!ctx.lim) return; const txt = build(ctx, ctx.lim); return txt && `limit ${txt}`; }; ``` 从 `context.lim` 拿取 `limit` 配置,组合成 `limit xxx` 的字符串并返回就可以了。 > `build` 函数是个工具函数,如果 ctx.lim 是个数组,就会用逗号拼接。 大部分操作比如 `delete` `from` `having` 都做这么简单的处理即可,但像 `where` 会相对复杂,因为内部包含了 `condition` 子语法,注意用 `and` 拼接即可。 最后是顺序,也需要在代码中确定: ```js export default { sql: query(sql), select: query(wth, select, from, where, group, having, order, limit, offset), delete: query(wth, del, where, returning), insert: query(wth, insert, value, returning), update: query(wth, update, set, where, returning) }; ``` 这个意思是,一个 `select` 语句会通过 `wth, select, from, where, group, having, order, limit, offset` 的顺序调用处理函数,返回的值就是最终的 query。 ## 4 总结 通过源码分析,可以看到制作一个这样的库有三个步骤: 1. 创建 context 存储结构化 query 信息。 2. 创建 builder 供用户链式书写代码同时填充 context。 3. 通过若干个 SQL 子处理函数加上几个主 statement 函数将其串联起来生成最终 query。 最后在设计时考虑到 SQL 方言的话,可以将模块拆成 核心、SQL、若干个方言库,方言库基于核心库做拓展即可。 ## 5 更多讨论 > 讨论地址是:[精读《sqorn 源码》 · Issue #103 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/103) **如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。** ================================================ FILE: 源码解读/75.精读《Epitath 源码 - renderProps 新用法》.md ================================================ ## 1 引言 很高兴这一期的话题是由 [epitath](https://github.com/Astrocoders/epitath) 的作者 [grsabreu](https://github.com/grsabreu) 提供的。 前端发展了 20 多年,随着发展中国家越来越多的互联网从业者涌入,现在前端知识玲琅满足,概念、库也越来越多。虽然内容越来越多,但作为个体的你的时间并没有增多,如何持续学习新知识,学什么将会是个大问题。 前端精读通过吸引优质的用户,提供最前沿的话题或者设计理念,虽然每周一篇文章不足以概括这一周的所有焦点,但可以保证你阅读的这十几分钟没有在浪费时间,每一篇精读都是经过精心筛选的,我们既讨论大家关注的焦点,也能找到仓库角落被遗忘的珍珠。 ## 2 概述 在介绍 Epitath 之前,先介绍一下 renderProps。 renderProps 是 jsx 的一种实践方式,renderProps 组件并不渲染 dom,但提供了持久化数据与回调函数帮助减少对当前组件 state 的依赖。 ### RenderProps 的概念 [react-powerplug](https://github.com/renatorib/react-powerplug) 就是一个 renderProps 工具库,我们看看可以做些什么: ```jsx {({ on, toggle }) => } ``` `Toggle` 就是一个 renderProps 组件,它可以帮助控制受控组件。比如仅仅利用 `Toggle`,我们可以大大简化 `Modal` 组件的使用方式: ```jsx class App extends React.Component { state = { visible: false }; showModal = () => { this.setState({ visible: true }); }; handleOk = e => { this.setState({ visible: false }); }; handleCancel = e => { this.setState({ visible: false }); }; render() { return (

Some contents...

Some contents...

Some contents...

); } } ReactDOM.render(, mountNode); ``` 这是 Modal 标准代码,我们可以使用 `Toggle` 简化为: ```jsx class App extends React.Component { render() { return ( {({ on, toggle }) => (

Some contents...

Some contents...

Some contents...

)}
); } } ReactDOM.render(, mountNode); ``` 省掉了 state、一堆回调函数,而且代码更简洁,更语义化。 > renderProps 内部管理的状态不方便从外部获取,因此只适合保存业务无关的数据,比如 Modal 显隐。 ### RenderProps 嵌套问题的解法 renderProps 虽然好用,但当我们想组合使用时,可能会遇到层层嵌套的问题: ```jsx {counter => { {toggle => { ; }} ; }} ``` 因此 react-powerplugin 提供了 compose 函数,帮助聚合 renderProps 组件: ```jsx import { compose } from 'react-powerplug' const ToggleCounter = compose( , ) {(toggle, counter) => ( )} ``` ### 使用 Epitath 解决嵌套问题 Epitath 提供了一种新方式解决这个嵌套的问题: ```jsx const App = epitath(function*() { const { count } = yield const { on } = yield return ( ) }) ``` renderProps 方案与 Epitath 方案,可以类比为 回调 方案与 `async/await` 方案。Epitath 和 `compose` 都解决了 renderProps 可能带来的嵌套问题,而 `compose` 是通过将多个 renderProps merge 为一个,而 Epitath 的方案更接近 `async/await` 的思路,利用 `generator` 实现了伪同步代码。 ## 3 精读 Epitath 源码一共 40 行,我们分析一下其精妙的方式。 下面是 Epitath 完整的源码: ```jsx import React from "react"; import immutagen from "immutagen"; const compose = ({ next, value }) => next ? React.cloneElement(value, null, values => compose(next(values))) : value; export default Component => { const original = Component.prototype.render; const displayName = `EpitathContainer(${Component.displayName || "anonymous"})`; if (!original) { const generator = immutagen(Component); return Object.assign( function Epitath(props) { return compose(generator(props)); }, { displayName } ); } Component.prototype.render = function render() { // Since we are calling a new function to be called from here instead of // from a component class, we need to ensure that the render method is // invoked against `this`. We only need to do this binding and creation of // this function once, so we cache it by adding it as a property to this // new render method which avoids keeping the generator outside of this // method's scope. if (!render.generator) { render.generator = immutagen(original.bind(this)); } return compose(render.generator(this.props)); }; return class EpitathContainer extends React.Component { static displayName = displayName; render() { return ; } }; }; ``` ### immutagen immutagen 是一个 immutable `generator` 辅助库,每次调用 `.next` 都会生成一个新的引用,而不是自己发生 mutable 改变: ```javascript import immutagen from "immutagen"; const gen = immutagen(function*() { yield 1; yield 2; return 3; })(); // { value: 1, next: [function] } gen.next(); // { value: 2, next: [function] } gen.next(); // { value: 2, next: [function] } gen.next().next(); // { value: 3, next: undefined } ``` ### compose 看到 compose 函数就基本明白其实现思路了: ```javascript const compose = ({ next, value }) => next ? React.cloneElement(value, null, values => compose(next(values))) : value; ``` ```javascript const App = epitath(function*() { const { count } = yield ; const { on } = yield ; }); ``` 通过 immutagen,依次调用 `next`,生成新组件,且下一个组件是上一个组件的子组件,因此会产生下面的效果: ```plain yield
yield yield // 等价于 ``` 到此其源码精髓已经解析完了。 ### 存在的问题 [crimx](https://github.com/crimx) 在讨论中提到,Epitath 方案存在的最大问题是,每次 `render` 都会生成全新的组件,这对内存是一种挑战。 稍微解释一下,无论是通过 原生的 renderProps 还是 `compose`,同一个组件实例只生成一次,React 内部会持久化这些组件实例。而 [immutagen](https://github.com/pelotom/immutagen) 在运行时每次执行渲染,都会生成不可变数据,也就是全新的引用,这会导致废弃的引用存在大量 GC 压力,同时 React 每次拿到的组件都是全新的,虽然功能相同。 ## 4 总结 [epitath](https://github.com/Astrocoders/epitath) 巧妙的利用了 [immutagen](https://github.com/pelotom/immutagen) 的不可变 `generator` 的特性来生成组件,并且在递归 `.next` 时,将顺序代码解析为嵌套代码,有效解决了 renderProps 嵌套问题。 喜欢 [epitath](https://github.com/Astrocoders/epitath) 的同学赶快入手吧!同时我们也看到 `generator` 手动的步骤控制带来的威力,这是 `async/await` 完全无法做到的。 是否可以利用 [immutagen](https://github.com/pelotom/immutagen) 解决 React Context 与组件相互嵌套问题呢?还有哪些其他前端功能可以利用 immutagen 简化的呢?欢迎加入讨论。 ## 5 更多讨论 > 讨论地址是:[精读《Epitath - renderProps 新用法》 · Issue #106 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/106) **如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** ================================================ FILE: 源码解读/82.精读《Htm - Hyperscript 源码》.md ================================================ # 1 引言 [htm](https://github.com/developit/htm) 是 preact 作者的新尝试,利用原生 HTML 规范支持了类 JSX 的写法。 # 2 概要 [htm](https://github.com/developit/htm) 没有特别的文档,假如你用过 JSX,那只需要记住下面三个不同点: - `className` -> `class`。 - 标签引号可选(回归 html 规范):`
`。 - 支持 HTML 模式的注释:`
`。 > 另外支持了可选结束标签、快捷组件 End 标签,不过这些自己发明的语法不建议记忆。 用法也没什么特别的地方,你可以利用 HTML 原生规范,用直觉去写 JSX: ```js html`
<${Header} name="ToDo's (${page})" />
    ${todos.map( todo => html`
  • ${todo}
  • ` )}
<${Footer}>footer content here
`; ``` 很显然,由于跳过了 JSX 编译,换成了原生的 [Template Strings](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/template_strings) ,所以所有组件、属性部分都需要改成 `${}` 语法,比如: `<${Header}>` 这种写法略显别扭,但整体上还是蛮直观的。 你不一定非要用在项目环境中,但当你看到这种语法时,内心一定情不自禁的 WoW,竟然还有这种写法! 下面将带你一起分析 [htm](https://github.com/developit/htm) 的源码,看看作者是如何做到的。 # 3 精读 你可以先自己尝试阅读,源码加上注释一共 90 行:[源码](https://github.com/developit/htm/blob/master/src/index.mjs)。 好了,欢迎继续阅读。 首先你要认识到, `htm` + `vhtml` 才等于你上面看到的 DEMO。 ## Htm `Htm` 是一个 dom template 解析器,它可以将任何 dom template 解析成一颗语法树,而这个语法树的结构是: ```typescript interface VDom { tag: string; props: { [attrKey: string]: string; }; children: VDom[]; } ``` 我们看一个 demo: ```js function h(tag, props, ...children) { return { tag, props, children }; } const html = htm.bind(h); html`
123
`; // { tag: "div", props: {}, children: ["123"] } ``` 那具体是怎么做语法解析的呢? 其实实现方式有点像脑经急转弯,毕竟解析 dom template 是浏览器引擎做的事,规范也早已定了下来,有了规范和实现,当然没必要重复造轮子,办法就是利用 HTML 的 AST 生成我们需要的 AST。 首先创建一个 `template` 元素: ```js const TEMPLATE = document.createElement("template"); ``` 再装输入的 dom template 字符串塞入(作者通过正则,机智的将自己支持的额外语法先转化为标准语法,再交给 HTML 引擎): ```js TEMPLATE.innerHTML = str; ``` 最后我们会发现进入了 `walk` 函数,通过 `localName` 拿到标签名;`attributes` 拿到属性值,通过 `firstChild` 与 `nextSibling` 遍历子元素继续走 `walk`,最后 `tag` `props` `children` 三剑客就生成了。 可能你还没看完,就已经结束了。笔者分析这个库,除了告诉你作者的机智思路,还想告诉你的是,站在巨人的肩膀造轮子,真的事半功倍。 ## VDom VDom 是个抽象概念,它负责将实体语法树解析为 DOM。这个工具可以是 preact、vhtml,或者由你自己来实现。 当然,你也可以利用这个 AST 生成 JSON,比如: ```javascript import htm from "htm"; import jsxobj from "jsxobj"; const html = htm.bind(jsxobj); console.log(html` `); // { // watch: true, // mode: 'production', // entry: { // path: 'src/index.js' // } // } ``` 读到这,你觉得还有哪些 “VDom” 可以写呢?其实任何可以根据 `tag` `props` `children` 推导出的结构都可以写成解析插件。 # 4 总结 [htm](https://github.com/developit/htm) 是一个教科书般借力造论子案例: - 利用 `innerHTML` 会自动生成的标准 AST,解析出符合自己规范的 AST,这其实是进一步抽象 AST。 - 利用原有库进行 DOM 解析,比如 preact 或 vhtml。 - 基于第二点,所以可以生成任何目标代码,比如 json,pdf,excel 等等。 不过这也带来了一个问题:依赖原生 DOM API 会导致无法运行在 NodeJS 环境。 想一想你现在开发的工具库,有没有可以借力的地方呢?有哪些点可以通过借力做得更好从而实现双赢呢?欢迎留下你的思考。 > 讨论地址是:[精读《Htm - Hyperscript 源码》 · Issue #114 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/114) **如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** ================================================ FILE: 源码解读/92.精读《React PowerPlug 源码》.md ================================================ # 1. 引言 [React PowerPlug](https://github.com/renatorib/react-powerplug) 是利用 render props 进行更好状态管理的工具库。 React 项目中,一般一个文件就是一个类,状态最细粒度就是文件的粒度。**然而文件粒度并非状态管理最合适的粒度,所以有了 Redux 之类的全局状态库。** **同样,文件粒度也并非状态管理的最细粒度,更细的粒度或许更合适,因此有了 React PowerPlug。** 比如你会在项目中看到这种眼花缭乱的 `state`: ```typescript class App extends React.PureComponent { state = { name: 1, isLoading: false, isFetchUser: false, data: {}, disableInput: false, validate: false, monacoInputValue: "", value: "" }; render() { /**/ } } ``` 其实真正 `App` 级别的状态并没有那么多,很多 **诸如受控组件 `onChange` 临时保存的无意义 Value 找不到合适的地方存储。** 这时候可以用 `Value` 管理局部状态: ```tsx {({ value, set, reset }) => ( <> )} ``` ### 源码 - [源码地址](https://github.com/renatorib/react-powerplug/blob/master/src/components/Value.js) - 原料:无 State 只存储一个属性 `value`,并赋初始值为 `initial`: ```typescript export default { state = { value: this.props.initial }; } ``` 方法有 `set` `reset`。 `set` 回调函数触发后调用 `setState` 更新 `value`。 `reset` 就是调用 `set` 并传入 `this.props.initial` 即可。 ## 2.2. Toggle Toggle 是最直接利用 Value 即可实现的功能,因此放在 Value 之后说。Toggle 值是 boolean 类型,特别适合配合 Switch 等组件。 > 既然 Toggle 功能弱于 Value,为什么不用 Value 替代 Toggle 呢?这是个好问题,如果你不担心自己代码可读性的话,的确可以永远不用 Toggle。 ### 用法 ```tsx {({ on, toggle }) => } ``` ### 源码 - [源码地址](https://github.com/renatorib/react-powerplug/blob/master/src/components/Toggle.js) - 原料:Value 核心就是利用 Value 组件,`value` 重命名为 `on`,增加了 `toggle` 方法,继承 `set` `reset` 方法: ```typescript export default { toggle: () => set(on => !on); } ``` 理所因当,将 value 值限定在 boolean 范围内。 ## 2.3. Counter 与 Toggle 类似,这也是继承了 Value 就可以实现的功能,计数器。 ### 用法 ```tsx {({ count, inc, dec }) => ( )} ``` ### 源码 - [源码地址](https://github.com/renatorib/react-powerplug/blob/master/src/components/Counter.js) - 原料:Value 依然利用 Value 组件,`value` 重命名为 `count`,增加了 `inc` `dec` `incBy` `decBy` 方法,继承 `set` `reset` 方法。 与 Toggle 类似,Counter 将 value 限定在了数字,那么比如 `inc` 就会这么实现: ```typescript export default { inc: () => set(value => value + 1); } ``` 这里用到了 Value 组件 `set` 函数的多态用法。一般 set 的参数是一个值,但也可以是一个函数,回调是当前的值,这里返回一个 +1 的新值。 ## 2.4. List 操作数组。 ### 用法 ```tsx {({ list, pull, push }) => (
{list.map({ tag }) => ( pull(value => value === tag)}> {tag} )}
)}
``` ### 源码 - [源码地址](https://github.com/renatorib/react-powerplug/blob/master/src/components/List.js) - 原料:Value 依然利用 Value 组件,`value` 重命名为 `list`,增加了 `first` `last` `push` `pull` `sort` 方法,继承 `set` `reset` 方法。 ```typescript export default { list: value, first: () => value[0], last: () => value[Math.max(0, value.length - 1)], set: list => set(list), push: (...values) => set(list => [...list, ...values]), pull: predicate => set(list => list.filter(complement(predicate))), sort: compareFn => set(list => [...list].sort(compareFn)), reset }; ``` 为了利用 React Immutable 更新的特性,因此将 `sort` 函数由 Mutable 修正为 Immutable,`push` `pull` 同理。 ## 2.5. Set 存储数组对象,可以添加和删除元素。类似 ES6 Set。和 List 相比少了许多功能函数,因此只承担添加、删除元素的简单功能。 ### 用法 需要注意的是,`initial` 是数组,而不是 Set 对象。 ```tsx {({ values, remove, add }) => ( {values.map(tag => ( remove(tag)}>{tag} ))} )} ``` ### 源码 - [源码地址](https://github.com/renatorib/react-powerplug/blob/master/src/components/Set.js) - 原料:Value 依然利用 Value 组件,`value` 重命名为 `values` 且初始值为 `[]`,增加了 `add` `remove` `clear` `has` 方法,保留 `reset` 方法。 实现依然很简单,`add` `remove` `clear` 都利用 Value 提供的 `set` 进行赋值,只要实现几个操作数组方法即可: ```typescript const unique = arr => arr.filter((d, i) => arr.indexOf(d) === i); const hasItem = (arr, item) => arr.indexOf(item) !== -1; const removeItem = (arr, item) => hasItem(arr, item) ? arr.filter(d => d !== item) : arr; const addUnique = (arr, item) => (hasItem(arr, item) ? arr : [...arr, item]); ``` `has` 方法则直接复用 `hasItem`。核心还是利用 Value 的 `set` 函数一招通吃,将操作目标锁定为数组类型罢了。 ## 2.6. map Map 的实现与 Set 很像,类似 ES6 的 Map。 ### 用法 与 Set 不同,Map 允许设置 Key 名。需要注意的是,`initial` 是对象,而不是 Map 对象。 ```tsx {({ set, get }) => ( set("sounds", c)}> Game Sounds set("music", c)}> Bg Music
You are {focused ? "focusing" : "not focusing"} the input.
)} ``` ### 源码 - [源码地址](https://github.com/renatorib/react-powerplug/blob/master/src/components/Focus.js) - 原料:Value 依然利用 Value 组件,`value` 重命名为 `focused` 且初始值为 `false`,增加了 `bind` 方法。 `bind` 方法与 Active 如出一辙,仅是监听时机变成了 `onFocus` 和 `onBlur`。 ## 2.10. FocusManager 不知道出于什么考虑,FocusManager 的官方文档是空的,而且 Help wanted。。 正如名字描述的,这是一个 Focus 控制器,你可以直接调用 `blur` 来取消焦点。 ### 用法 笔者给了一个例子,在 5 秒后自动失去焦点: ```tsx {({ focused, blur, bind }) => (
{ setTimeout(() => { blur(); }, 5000); }} />
You are {focused ? "focusing" : "not focusing"} the input.
)}
``` ### 源码 - [源码地址](https://github.com/renatorib/react-powerplug/blob/master/src/components/FocusManager.js) - 原料:Value 依然利用 Value 组件,`value` 重命名为 `focused` 且初始值为 `false`,增加了 `bind` `blur` 方法。 `blur` 方法直接调用 `document.activeElement.blur()` 来触发其 `bind` 监听的 `onBlur` 达到更新状态的效果。 By the way, 还监听了 `onMouseDown` 与 `onMouseUp`: ```typescript export default { bind: { tabIndex: -1, onBlur: () => { if (canBlur) { set(false); } }, onFocus: () => set(true), onMouseDown: () => (canBlur = false), onMouseUp: () => (canBlur = true) } }; ``` 可能意图是防止在 `mouseDown` 时触发 `blur`,因为 `focus` 的时机一般是 `mouseDown`。 ## 2.11. Hover 与 Focus 类似,只是触发时机为 Hover。 ### 用法 ```tsx {({ hovered, bind }) => (
You are {hovered ? "hovering" : "not hovering"} this div.
)}
``` ### 源码 - [源码地址](https://github.com/renatorib/react-powerplug/blob/master/src/components/Hover.js) - 原料:Value 依然利用 Value 组件,`value` 重命名为 `hovered` 且初始值为 `false`,增加了 `bind` 方法。 `bind` 方法与 Active、Focus 如出一辙,仅是监听时机变成了 `onMouseEnter` 和 `onMouseLeave`。 ## 2.12. Touch 与 Hover 类似,只是触发时机为 Hover。 ### 用法 ```tsx {({ touched, bind }) => (
You are {touched ? "touching" : "not touching"} this div.
)}
``` ### 源码 - [源码地址](https://github.com/renatorib/react-powerplug/blob/master/src/components/Hover.js) - 原料:Value 依然利用 Value 组件,`value` 重命名为 `touched` 且初始值为 `false`,增加了 `bind` 方法。 `bind` 方法与 Active、Focus、Hover 如出一辙,仅是监听时机变成了 `onTouchStart` 和 `onTouchEnd`。 ## 2.13. Field 与 Value 组件唯一的区别,就是支持了 `bind`。 ### 用法 这个用法和 Value 没区别: ```tsx {({ value, set }) => ( set(e.target.value)} /> )} ``` 但是用 `bind` 更简单: ```tsx {({ bind }) => } ``` ### 源码 - [源码地址](https://github.com/renatorib/react-powerplug/blob/master/src/components/Field.js) - 原料:Value 依然利用 Value 组件,`value` 保留不变,初始值为 `''`,增加了 `bind` 方法,保留 `set` `reset` 方法。 与 Value 的唯一区别是,支持了 `bind` 并封装 `onChange` 监听,与赋值受控属性 `value`。 ```typescript export default { bind: { value, onChange: event => { if (isObject(event) && isObject(event.target)) { set(event.target.value); } else { set(event); } } } }; ``` ## 2.14. Form 这是一个表单工具,有点类似 Antd 的 Form 组件。 ### 用法 ```tsx
{({ field, values }) => ( { e.preventDefault(); console.log("Form Submission Data:", values); }} >
)} ``` ### 源码 - [源码地址](https://github.com/renatorib/react-powerplug/blob/master/src/components/Form.js) - 原料:Value 依然利用 Value 组件,`value` 重命名为 `values` 且初始值为 `{}`,增加了 `setValues` `field` 方法,保留 `reset` 方法。 表单最重要的就是 `field` 函数,为表单的每一个控件做绑定,同时设置一个表单唯一 `key`: ```typescript export default { field: id => { const value = values[id]; const setValue = updater => typeof updater === "function" ? set(prev => ({ ...prev, [id]: updater(prev[id]) })) : set({ ...values, [id]: updater }); return { value, set: setValue, bind: { value, onChange: event => { if (isObject(event) && isObject(event.target)) { setValue(event.target.value); } else { setValue(event); } } } }; } }; ``` 可以看到,为表单的每一项绑定的内容与 Field 组件一样,只是 Form 组件的行为是批量的。 ## 2.15. Interval Interval 比较有意思,将定时器以 JSX 方式提供出来,并且提供了 `stop` `resume` 方法。 ### 用法 ```tsx {({ start, stop }) => ( <>
The time is now {new Date().toLocaleTimeString()}
)}
``` ### 源码 - [源码地址](https://github.com/renatorib/react-powerplug/blob/master/src/components/Interval.js) - 原料:无 提供了 `start` `stop` `toggle` 方法。 实现方式是,在组件内部维护一个 Interval 定时器,实现了组件更新、销毁时的计时器更新、销毁操作,可以认为这种定时器的生命周期绑定了 React 组件的生命周期,不用担心销毁和更新的问题。 具体逻辑就不列举了,利用 `setInterval` `clearInterval` 函数基本上就可以了。 ## 2.16. Compose Compose 也是个有趣的组件,可以将上面提到的任意多个组件组合使用。 ### 用法 ```tsx {(counter, toggle) => ( )} ``` ### 源码 - [源码地址](https://github.com/renatorib/react-powerplug/blob/master/src/utils/compose.js) - 原料:无 通过递归渲染出嵌套结构,并将每一层结构输出的值存储到 `propsList` 中,最后一起传递给组件。**这也是为什么每个函数 `value` 一般都要重命名的原因。** 在 [精读《Epitath 源码 - renderProps 新用法》](https://github.com/dt-fe/weekly/blob/master/75.%E7%B2%BE%E8%AF%BB%E3%80%8AEpitath%20%E6%BA%90%E7%A0%81%20-%20renderProps%20%E6%96%B0%E7%94%A8%E6%B3%95%E3%80%8B.md) 文章中,笔者就介绍了利用 `generator` 解决高阶组件嵌套的问题。 在 [精读《React Hooks》](https://github.com/dt-fe/weekly/blob/master/79.%E7%B2%BE%E8%AF%BB%E3%80%8AReact%20Hooks%E3%80%8B.md) 文章中,介绍了 React Hooks 已经实现了这个特性。 所以当你了解了这三种 "compose" 方法后,就可以在合适的场景使用合适的 compose 方式简化代码。 # 3. 总结 看完了源码分析,不知道你是更感兴趣使用这个库呢,还是已经跃跃欲试开始造轮子了呢?不论如何,这个库的思想在日常的业务开发中都应该大量实践。 另外 Hooks 版的 PowerPlug 已经 4 个月没有更新了(非官方):[react-powerhooks](https://github.com/kalcifer/react-powerhooks),也许下一个维护者/贡献者 就是你。 > 讨论地址是:[精读《React PowerPlug 源码》 · Issue #129 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/129) **如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** ================================================ FILE: 源码解读/93.精读《syntax-parser 源码》.md ================================================ # 1. 引言 [syntax-parser](https://github.com/ascoders/syntax-parser) 是一个 JS 版语法解析器生成器,具有分词、语法树解析的能力。 通过两个例子介绍它的功能。 第一个例子是创建一个词法解析器 `myLexer`: ```typescript import { createLexer } from "syntax-parser"; const myLexer = createLexer([ { type: "whitespace", regexes: [/^(\s+)/], ignore: true }, { type: "word", regexes: [/^([a-zA-Z0-9]+)/] }, { type: "operator", regexes: [/^(\+)/] } ]); ``` 如上,通过正则分别匹配了 “空格”、“字母或数字”、“加号”,并将匹配到的空格忽略(不输出)。 > 分词匹配是从左到右的,优先匹配数组的第一项,依此类推。 接下来使用 `myLexer`: ```typescript const tokens = myLexer("a + b"); // tokens: // [ // { "type": "word", "value": "a", "position": [0, 1] }, // { "type": "operator", "value": "+", "position": [2, 3] }, // { "type": "word", "value": "b", "position": [4, 5] }, // ] ``` `'a + b'` 会按照上面定义的 “三种类型” 被分割为数组,数组的每一项都包含了原始值以及其位置。 第二个例子是创建一个语法解析器 `myParser`: ```typescript import { createParser, chain, matchTokenType, many } from "syntax-parser"; const root = () => chain(addExpr)(ast => ast[0]); const addExpr = () => chain(matchTokenType("word"), many(addPlus))(ast => ({ left: ast[0].value, operator: ast[1] && ast[1][0].operator, right: ast[1] && ast[1][0].term })); const addPlus = () => chain("+"), root)(ast => ({ operator: ast[0].value, term: ast[1] })); const myParser = createParser( root, // Root grammar. myLexer // Created in lexer example. ); ``` 利用 `chain` 函数书写文法表达式:通过字面量的匹配(比如 `+` 号),以及 `matchTokenType` 来模糊匹配我们上面词法解析出的 “三种类型”,就形成了完整的文法表达式。 `syntax-parser` 还提供了其他几个有用的函数,比如 `many` `optional` 分别表示匹配多次和匹配零或一次。 接下来使用 `myParser`: ```typescript const ast = myParser("a + b"); // ast: // [{ // "left": "a", // "operator": "+", // "right": { // "left": "b", // "operator": null, // "right": null // } // }] ``` # 2. 精读 按照下面的思路大纲进行源码解读: - 词法解析 - 词汇与概念 - 分词器 - 语法解析 - 词汇与概念 - 重新做一套 “JS 执行引擎” - 实现 Chain 函数 - 引擎执行 - 何时算执行完 - “或” 逻辑的实现 - many, optional, plus 的实现 - 错误提示 & 输入推荐 - First 集优化 ## 词法解析 词法解析有点像 NLP 中分词,但比分词简单的时,词法解析的分词逻辑是明确的,一般用正则片段表达。 ### 词汇与概念 - Lexer:词法解析器。 - Token:分词后的词素,包括 `value:值`、`position:位置`、`type:类型`。 ### 分词器 分词器 `createLexer` 函数接收的是一个正则数组,因此思路是遍历数组,一段一段匹配字符串。 我们需要这几个函数: ```typescript class Tokenizer { public tokenize(input: string) { // 调用 getNextToken 对输入字符串 input 进行正则匹配,匹配完后 substring 裁剪掉刚才匹配的部分,再重新匹配直到字符串裁剪完 } private getNextToken(input: string) { // 调用 getTokenOnFirstMatch 对输入字符串 input 进行遍历正则匹配,一旦有匹配到的结果立即返回 } private getTokenOnFirstMatch({ input, type, regex }: { input: string; type: string; regex: RegExp; }) { // 对输入字符串 input 进行正则 regex 的匹配,并返回 Token 对象的基本结构 } } ``` `tokenize` 是入口函数,循环调用 `getNextToken` 匹配 Token 并裁剪字符串直到字符串被裁完。 ## 语法解析 语法解析是基于词法解析的,输入是 Tokens,根据文法规则依次匹配 Token,当 Token 匹配完且完全符合文法规范后,语法树就出来了。 词法解析器生成器就是 “生成词法解析器的工具”,只要输入规定的文法描述,内部引擎会自动做掉其余的事。 这个生成器的难点在于,匹配 “或” 逻辑失败时,调用栈需要恢复到失败前的位置,而 JS 引擎中调用栈不受代码控制,因此代码需要在模拟引擎中执行。 ### 词汇与概念 - Parser:语法解析器。 - ChainNode:连续匹配,执行链四节点之一。 - TreeNode:匹配其一,执行链四节点之一。 - FunctionNode:函数节点,执行链四节点之一。 - MatchNode:匹配字面量或某一类型的 Token,执行链四节点之一。每一次正确的 Match 匹配都会消耗一个 Token。 ### 重新做一套 “JS 执行引擎” 为什么要重新做一套 JS 执行引擎?看下面的代码: ```typescript const main = () => chain(functionA(), tree(functionB1(), functionB2()), functionC()); const functionA = () => chain("a"); const functionB1 = () => chain("b", "x"); const functionB2 = () => chain("b", "y"); const functionC = () => chain("c"); ``` 假设 `chain('a')` 可以匹配 Token `a`,而 `chain(functionC))` 可以匹配到 Token `c`。 当输入为 `a b y c` 时,我们该怎么写 `tree` 函数呢? 我们期望匹配到 `functionB1` 时失败,再尝试 `functionB2`,直到有一个成功为止。 那么 `tree` 函数可能是这样的: ```typescript function tree(...funs) { // ... 存储当前 tokens for (const fun of funs) { // ... 复位当前 tokens const result = fun(); if (result === true) { return result; } } } ``` 不断尝试 `tree` 中内容,直到能正确匹配结果后返回这个结果。由于正确的匹配会消耗 Token,因此需要在执行前后存储当前 Tokens 内容,在执行失败时恢复 Token 并尝试新的执行链路。 **这样看去很容易,不是吗?** 然而,下面这个例子会打破这个美好的假设,让我们稍稍换几个值吧: ```typescript const main = () => chain(functionA(), tree(functionB1(), functionB2()), functionC()); const functionA = () => chain("a"); const functionB1 = () => chain("b", "y"); const functionB2 = () => chain("b"); const functionC = () => chain("y", "c"); ``` 输入仍然是 `a b y c`,看看会发生什么? 线路 `functionA -> functionB1` 是 `a b y` 很显然匹配会通过,但连上 `functionC` 后结果就是 `a b y y c`,显然不符合输入。 此时正确的线路应该是 `functionA -> functionB2 -> functionC`,结果才是 `a b y c`! 我们看 `functionA -> functionB1 -> functionC` 链路,当执行到 `functionC` 时才发现匹配错了,此时想要回到 `functionB2` 门也没有!因为 `tree(functionB1(), functionB2())` 的执行堆栈已退出,再也找不回来了。 **所以需要模拟一个执行引擎,在遇到分叉路口时,将 `functionB2` 保存下来,随时可以回到这个节点重新执行。** ### 实现 Chain 函数 用链表设计 `Chain` 函数是最佳的选择,我们要模拟 JS 调用栈了。 ```typescript const main = () => chain(functionA, [functionB1, functionB2], functionC)(); const functionA = () => chain("a")(); const functionB1 = () => chain("b", "y")(); const functionB2 = () => chain("b")(); const functionC = () => chain("y", "c")(); ``` 上面的例子只改动了一小点,那就是函数不会立即执行。 `chain` 将函数转化为 `FunctionNode`,将字面量 `a` 或 `b` 转化为 `MatchNode`,将 `[]` 转化为 `TreeNode`,将自己转化为 `ChainNode`。 我们就得到了如下的链表: ```plain ChainNode(main) └── FunctionNode(functionA) ─ TreeNode ─ FunctionNode(functionC) │── FunctionNode(functionB1) └── FunctionNode(functionB2) ``` > 至于为什么 `FunctionNode` 不直接展开成 `MatchNode`,请思考这样的描述:`const list = () => chain(',', list)`。直接展开则陷入递归死循环,实际上 Tokens 数量总有限,用到再展开总能匹配尽 Token,而不会无限展开下去。 那么需要一个函数,将 `chain` 函数接收的不同参数转化为对应 Node 节点: ```typescript const createNodeByElement = ( element: IElement, parentNode: ParentNode, parentIndex: number, parser: Parser ): Node => { if (element instanceof Array) { // ... return TreeNode } else if (typeof element === "string") { // ... return MatchNode } else if (typeof element === "boolean") { // ... true 表示一定匹配成功,false 表示一定匹配失败,均不消耗 Token } else if (typeof element === "function") { // ... return FunctionNode } }; ``` [`createNodeByElement` 函数源码](https://github.com/ascoders/syntax-parser/blob/ab6b628bef418999900670919e38c2be57e7a0c4/src/parser/chain.ts#L28) ### 引擎执行 引擎执行其实就是访问链表,通过 `visit` 函数是最佳手段。 ```typescript const visit = tailCallOptimize( ({ node, store, visiterOption, childIndex }: { node: Node; store: VisiterStore; visiterOption: VisiterOption; childIndex: number; }) => { if (node instanceof ChainNode) { // 调用 `visitChildNode` 访问子节点 } else if (node instanceof TreeNode) { // 调用 `visitChildNode` 访问子节点 visitChildNode({ node, store, visiterOption, childIndex }); } else if (node instanceof MatchNode) { // 与当前 Token 进行匹配,匹配成功则调用 `visitNextNodeFromParent` 访问父级 Node 的下一个节点,匹配失败则调用 `tryChances`,这会在 “或” 逻辑里说明。 } else if (node instanceof FunctionNode) { // 执行函数节点,并替换掉当前节点,重新 `visit` 一遍 } } ); ``` > 由于 `visit` 函数执行次数至多可能几百万次,因此使用 `tailCallOptimize` 进行尾递归优化,防止内存或堆栈溢出。 `visit` 函数只负责访问节点本身,而 `visitChildNode` 函数负责访问节点的子节点(如果有),而 `visitNextNodeFromParent` 函数负责在没有子节点时,找到父级节点的下一个子节点访问。 ```typescript function visitChildNode({ node, store, visiterOption, childIndex }: { node: ParentNode; store: VisiterStore; visiterOption: VisiterOption; childIndex: number; }) { if (node instanceof ChainNode) { const child = node.childs[childIndex]; if (child) { // 调用 `visit` 函数访问子节点 `child` } else { // 如果没有子节点,就调用 `visitNextNodeFromParent` 往上找了 } } else { // 对于 TreeNode,如果不是访问到了最后一个节点,则添加一次 “存档” // 调用 `addChances` // 同时如果有子元素,`visit` 这个子元素 } } const visitNextNodeFromParent = tailCallOptimize( ( node: Node, store: VisiterStore, visiterOption: VisiterOption, astValue: any ) => { if (!node.parentNode) { // 找父节点的函数没有父级时,下面再介绍,记住这个位置叫 END 位。 } if (node.parentNode instanceof ChainNode) { // A B <- next node C // └── node <- current node // 正如图所示,找到 nextNode 节点调用 `visit` } else if (node.parentNode instanceof TreeNode) { // TreeNode 节点直接利用 `visitNextNodeFromParent` 跳过。因为同一时间 TreeNode 节点只有一个分支生效,所以它没有子元素了 } } ); ``` 可以看到 `visitChildNode` 与 `visitNextNodeFromParent` 函数都只处理好了自己的事情,而将其他工作交给别的函数完成,这样函数间职责分明,代码也更易懂。 有了 `vist` `visitChildNode` 与 `visitNextNodeFromParent`,就完成了节点的访问、子节点的访问、以及当没有子节点时,追溯到上层节点的访问。 [`visit` 函数源码](https://github.com/ascoders/syntax-parser/blob/ab6b628bef418999900670919e38c2be57e7a0c4/src/parser/chain.ts#L376) ### 何时算执行完 当 `visitNextNodeFromParent` 函数访问到 `END 位` 时,是时候做一个了结了: - 当 Tokens 正好消耗完,完美匹配成功。 - Tokens 没消耗完,匹配失败。 - 还有一种失败情况,是 `Chance` 用光时,结合下面的 “或” 逻辑一起说。 ### “或” 逻辑的实现 “或” 逻辑是重构 JS 引擎的原因,现在这个问题被很好解决掉了。 ```typescript const main = () => chain(functionA, [functionB1, functionB2], functionC)(); ``` 比如上面的代码,当遇到 `[]` 数组结构时,被认为是 “或” 逻辑,子元素存储在 `TreeNode` 节点中。 在 `visitChildNode` 函数中,与 `ChainNode` 不同之处在于,访问 `TreeNode` 子节点时,还会调用 `addChances` 方法,为下一个子元素存储执行状态,以便未来恢复到这个节点继续执行。 `addChances` 维护了一个池子,调用是先进后出: ```typescript function addChances(/* ... */) { const chance = { node, tokenIndex, childIndex }; store.restChances.push(chance); } ``` 与 `addChance` 相对的就是 `tryChance`。 下面两种情况会调用 `tryChances`: - `MatchNode` 匹配失败。节点匹配失败是最常见的失败情况,但如果 `chances` 池还有存档,就可以恢复过去继续尝试。 - 没有下一个节点了,但 Tokens 还没消耗完,也说明匹配失败了,此时调用 `tryChances` 继续尝试。 我们看看神奇的存档回复函数 `tryChances` 是如何做的: ```typescript function tryChances( node: Node, store: VisiterStore, visiterOption: VisiterOption ) { if (store.restChances.length === 0) { // 直接失败 } const nextChance = store.restChances.pop(); // reset scanner index store.scanner.setIndex(nextChance.tokenIndex); visit({ node: nextChance.node, store, visiterOption, childIndex: nextChance.childIndex }); } ``` `tryChances` 其实很简单,除了没有 `chances` 就失败外,找到最近的一个 `chance` 节点,恢复 Token 指针位置并 `visit` 这个节点就等价于读档。 [`addChance` 源码](https://github.com/ascoders/syntax-parser/blob/ab6b628bef418999900670919e38c2be57e7a0c4/src/parser/chain.ts#L517) [`tryChances` 源码](https://github.com/ascoders/syntax-parser/blob/ab6b628bef418999900670919e38c2be57e7a0c4/src/parser/chain.ts#L517) ### many, optional, plus 的实现 这三个方法实现的也很精妙。 先看可选函数 `optional`: ```typescript export const optional = (...elements: IElements) => { return chain([chain(...elements)(/**/)), true])(/**/); }; ``` 可以看到,可选参数实际上就是一个 `TreeNode`,也就是: ```typescript chain(optional("a"))(); // 等价于 chain(["a", true])(); ``` 为什么呢?因为当 `'a'` 匹配失败后,`true` 是一个不消耗 Token 一定成功的匹配,整体来看就是 “可选” 的意思。 > 进一步解释下,如果 `'a'` 没有匹配上,则 `true` 一定能匹配上,匹配 `true` 等于什么都没匹配,就等同于这个表达式不存在。 再看匹配一或多个的函数 `plus`: ```typescript export const plus = (...elements: IElements) => { const plusFunction = () => chain(chain(...elements)(/**/), optional(plusFunction))(/**/); return plusFunction; }; ``` 能看出来吗?`plus` 函数等价于一个新递归函数。也就是: ```typescript const aPlus = () => chain(plus("a"))(); // 等价于 const aPlus = () => chain(plusFunc)(); const plusFunc = () => chain("a", optional(plusFunc))(); ``` 通过不断递归自身的方式匹配到尽可能多的元素,而每一层的 `optional` 保证了任意一层匹配失败后可以及时跳到下一个文法,不会失败。 最后看匹配多个的函数 `many`: ```typescript export const many = (...elements: IElements) => { return optional(plus(...elements)); }; ``` `many` 就是 `optional` 的 `plus`,不是吗? 这三个神奇的函数都利用了已有功能实现,建议每个函数留一分钟左右时间思考为什么。 [`optional` `plus` `many` 函数源码](https://github.com/ascoders/syntax-parser/blob/ab6b628bef/src/parser/match.ts#L111-L140) ### 错误提示 & 输入推荐 错误提示与输入推荐类似,都是给出错误位置或光标位置后期待的输入。 输入推荐,就是给定字符串与光标位置,给出光标后期待内容的功能。 首先通过光标位置找到光标的 **上一个 `Token`**,再通过 `findNextMatchNodes` 找到这个 `Token` 后所有可能匹配到的 `MatchNode`,这就是推荐结果。 那么如何实现 `findNextMatchNodes` 呢?看下面: ```typescript function findNextMatchNodes(node: Node, parser: Parser): MatchNode[] { const nextMatchNodes: MatchNode[] = []; let passCurrentNode = false; const visiterOption: VisiterOption = { onMatchNode: (matchNode, store, currentVisiterOption) => { if (matchNode === node && passCurrentNode === false) { passCurrentNode = true; // 调用 visitNextNodeFromParent,忽略自身 } else { // 遍历到的 MatchNode nextMatchNodes.push(matchNode); } // 这个是画龙点睛的一笔,所有推荐都当作匹配失败,通过 tryChances 可以找到所有可能的 MatchNode tryChances(matchNode, store, currentVisiterOption); } }; newVisit({ node, scanner: new Scanner([]), visiterOption, parser }); return nextMatchNodes; } ``` 所谓找到后续节点,就是通过 `Visit` 找到所有的 `MatchNode`,而 `MatchNode` 只要匹配一次即可,因为我们只要找到第一层级的 `MatchNode`。 通过每次匹配后执行 `tryChances`,就可以找到所有 `MatchNode` 节点了! 再看错误提示,我们要记录最后出错的位置,再采用输入推荐即可。 但光标所在的位置是期望输入点,这个输入点也应该参与语法树的生成,而错误提示不包含光标,所以我们要 [执行两次 `visit`](https://github.com/ascoders/syntax-parser/blob/ab6b628bef/src/parser/chain.ts#L188-L241)。 举个例子: ```sql select | from b; ``` `|` 是光标位置,此时语句内容是 `select from b;` 显然是错误的,但光标位置应该给出提示,给出提示就需要正确解析语法树,所以对于提示功能,我们需要将光标位置考虑进去一起解析。因此一共有两次解析。 [`findNextMatchNodes` 函数源码](https://github.com/ascoders/syntax-parser/blob/ab6b628bef/src/parser/chain.ts#L574) ### First 集优化 构建 First 集是个自下而上的过程,当访问到 `MatchNode` 节点时,其值就是其父节点的一个 First 值,当父节点的 First 集收集完毕后,,就会触发它的父节点 First 集收集判断,如此递归,最后完成 First 集收集的是最顶级节点。 篇幅原因,不再赘述,可以看 [这张图](https://github.com/dt-fe/weekly/blob/master/78.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96%E4%B9%8B%E7%BC%93%E5%AD%98%E3%80%8B.md#%E6%9E%84%E5%BB%BA-first-%E9%9B%86)。 [`generateFirstSet` 函数源码](https://github.com/ascoders/syntax-parser/blob/ab6b628bef418999900670919e38c2be57e7a0c4/src/parser/chain.ts#L621) # 3. 总结 这篇文章是对 《手写 SQL 编译器》 系列的总结,从源码角度的总结! 该系列的每篇文章都以图文的方式介绍了各技术细节,可以作为补充阅读: - [精读《手写 SQL 编译器 - 词法分析》](https://github.com/dt-fe/weekly/blob/master/64.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%8D%E6%B3%95%E5%88%86%E6%9E%90%E3%80%8B.md) - [精读《手写 SQL 编译器 - 文法介绍》](https://github.com/dt-fe/weekly/blob/master/65.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E6%96%87%E6%B3%95%E4%BB%8B%E7%BB%8D%E3%80%8B.md) - [精读《手写 SQL 编译器 - 语法分析》](https://github.com/dt-fe/weekly/blob/master/66.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%AD%E6%B3%95%E5%88%86%E6%9E%90%E3%80%8B.md) - [精读《手写 SQL 编译器 - 回溯》](https://github.com/dt-fe/weekly/blob/master/67.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E5%9B%9E%E6%BA%AF%E3%80%8B.md) - [精读《手写 SQL 编译器 - 语法树》](https://github.com/dt-fe/weekly/blob/master/70.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%AD%E6%B3%95%E6%A0%91%E3%80%8B.md) - [精读《手写 SQL 编译器 - 错误提示》](https://github.com/dt-fe/weekly/blob/master/71.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E9%94%99%E8%AF%AF%E6%8F%90%E7%A4%BA%E3%80%8B.md) - [精读《手写 SQL 编译器 - 性能优化之缓存》](https://github.com/dt-fe/weekly/blob/master/78.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96%E4%B9%8B%E7%BC%93%E5%AD%98%E3%80%8B.md) - [精读《手写 SQL 编译器 - 智能提示》](https://github.com/dt-fe/weekly/blob/master/85.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E6%99%BA%E8%83%BD%E6%8F%90%E7%A4%BA%E3%80%8B.md) > 讨论地址是:[精读《syntax-parser 源码》 · Issue #133 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/133) **如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** ================================================ FILE: 源码解读/98.精读《react-easy-state 源码》.md ================================================ # 1. 引言 [react-easy-state](https://github.com/solkimicreb/react-easy-state) 是个比较有趣的库,利用 Proxy 创建了一个非常易用的全局数据流管理方式。 ```jsx import React from "react"; import { store, view } from "react-easy-state"; const counter = store({ num: 0 }); const increment = () => counter.num++; export default view(() => ); ``` 上手非常轻松,通过 `store` 创建一个数据对象,这个对象被任何 React 组件使用时,都会自动建立双向绑定,**任何对这个对象的修改,都会让使用了这个对象的组件重渲染。** 当然,为了实现这一点,需要对所有组件包裹一层 `view`。 # 2. 精读 这个库利用了 [nx-js/observer-util](https://github.com/nx-js/observer-util) 做 Reaction 基础 API,其他核心功能分别是 `store` `view` `batch`,所以我们就从这四个点进行解读。 ## Reaction 这个单词名叫 “反应”,是实现双向绑定库的最基本功能单元。 拥有最基本的两个单词和一个概念:`observable` `observe` 与自动触发执行的特性。 ```js import { observable, observe } from "@nx-js/observer-util"; const counter = observable({ num: 0 }); const countLogger = observe(() => console.log(counter.num)); // 会自动触发 countLogger 函数内回调函数的执行。 counter.num++; ``` 在第 35 期精读 [精读《dob - 框架实现》](https://github.com/dt-fe/weekly/blob/master/35.%E7%B2%BE%E8%AF%BB%E3%80%8Adob%20-%20%E6%A1%86%E6%9E%B6%E5%AE%9E%E7%8E%B0%E3%80%8B.md#%E6%8A%BD%E4%B8%9D%E5%89%A5%E8%8C%A7%E5%AE%9E%E7%8E%B0%E4%BE%9D%E8%B5%96%E8%BF%BD%E8%B8%AA) “抽丝剥茧,实现依赖追踪” 一节中有详细介绍实现原理,这里就不赘述了。 有了一个具有反应特性的函数,与一个可以 “触发反应” 的对象,那么实现双向绑定更新 View 就不远了。 ## store react-easy-state 的 `store` 就是 `observable(obj)` 包装一下,唯一不同是,由于支持本地数据: ```js import React from 'react' import { view, store } from 'react-easy-state' export default view(() => { const counter = store({ num: 0 }) const increment = () => counter.num++ return {counter.num}
}) ``` 所以当监测到在 React 组件内部创建 `store` 且是 Hooks 环境时,会返回: ```js return useMemo(() => observable(obj), []); ``` 这是因为 React Hooks 场景下的 Function Component 每次渲染都会重新创建 Store,会导致死循环。因此利用 `useMemo` 并将依赖置为 `[]` 使代码在所有渲染周期内,只在初始化执行一次。 > 更多 Hooks 深入解读,可以阅读 [精读《useEffect 完全指南》](https://github.com/dt-fe/weekly/blob/master/96.%E7%B2%BE%E8%AF%BB%E3%80%8AuseEffect%20%E5%AE%8C%E5%85%A8%E6%8C%87%E5%8D%97%E3%80%8B.md)。 ## view 根据 Function Component 与 Class Component 的不同,分别进行两种处理,本文主要介绍对 Function Component 的处理方式,因为笔者推荐使用 Function Component 风格。 首先最外层会套上 `memo`,这类似 `PureComponent` 的效果: ```js return memo(/**/); ``` 然后构造一个 `forceUpdate` 用来强制渲染组件: ```js const [, forceUpdate] = useState(); ``` 之后,只要利用 `observe` 包裹组件即可,需要注意两点: 1. **使用刚才创建的 `forceUpdate` 在 `store` 修改时调用。** 2. `observe` 初始化不要执行,因为初始化组件自己会渲染一次,再渲染一次就会造成浪费。 所以作者通过 `scheduler` `lazy` 两个参数完成了这两件事: ```js const render = useMemo( () => observe(Comp, { scheduler: () => setState({}), lazy: true }), [] ); return render; ``` 最后别忘了在组件销毁时取消监听: ```js useEffect(() => { return () => unobserve(render); }, []); ``` ## batch 这也是双向绑定数据流必须解决的经典问题,批量更新合并。 由于修改对象就触发渲染,**这个过程太自动化了,以至于我们都没有机会告诉工具,连续的几次修改能否合并起来只触发一次渲染。** 尤其是 For 循环修改变量时,如果不能合并更新,在某些场景下代码几乎是不可用的。 所以 `batch` 就是为解决这个问题诞生的,让我们有机会控制合并更新的时机: ```js import React from "react"; import { view, store, batch } from "react-easy-state"; const user = store({ name: "Bob", age: 30 }); function mutateUser() { // this makes sure the state changes will cause maximum one re-render, // no matter where this function is getting invoked from batch(() => { user.name = "Ann"; user.age = 32; }); } export default view(() => (
name: {user.name}, age: {user.age}
)); ``` `react-easy-state` 通过 `scheduler` 模块完成 `batch` 功能,核心代码只有五行: ```js export function batch(fn, ctx, args) { let result; unstable_batchedUpdates(() => (result = fn.apply(ctx, args))); return result; } ``` 利用 `unstable_batchedUpdates`,可以保证在其内执行的函数都不会触发更新,也就是之前创建的 `forceUpdate` 虽然被调用,但是失效了,等回调执行完毕时再一起批量更新。 同时代码里还对 `setTimeout` `setInterval` `addEventListener` `WebSocket` 等公共方法进行了 `batch` 包装,让这些回调函数中自带 `batch` 效果。 # 4. 总结 好了,`react-easy-state` 神奇的效果解释完了,希望大家在使用第三方库的时候都能理解背后的原理。 > PS:最后,笔者目前不推荐在 Function Component 模式下使用任何三方数据流库,因为官方功能已经足够好用了! > 讨论地址是:[精读《react-easy-state》 · Issue #144 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/144) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** **special Sponsors** - [DevOps 全流程平台](https://e.coding.net/?utm_source=weekly) > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 生活/290.个人养老金利与弊.md ================================================ 许久没有更新精读了,在停更的这段时间,每天仍有许多朋友关注前端精读,再次谢谢大家的支持。我最近仍在学习新领域知识中,预计 24 年初就可以恢复更新。 为什么我会写关于个人养老金的文章?因为我是经历了从极度排斥到最后“真香”的过程。因为老婆(下文用康女士指代)的工作原因,她在极力推广这个政策,虽然故事从她那里开始,但是我觉得投资不能这么轻率,所以仔细分析了开通个人养老金的利弊,顺便把思考写成文章,包含以下几个部分: 1. **我为什么决定开通个人养老金?** 2. **个人养老金需要投入了什么,能得到什么?** 3. 广告部分 # 1. 我为什么决定开通个人养老金? 我对个人养老金最初的印象是:**每年交一些钱,可以抵税,但这些钱退休后才能取出来**。这个规则让我觉得个人养老金比较坑,因为这些钱到退休时已经通胀到不值钱了。而且个人养老金出来时,正赶上网传国家养老金账户见底,很容易联想到个人养老金是为了填上这个短期窟窿的策略。 我对个人养老金**第一次改观**,发生在康女士告诉我个人养老金虽然取不出来,但可以购买理财产品,每年 4~5 个百分点,比绝大多数理财收益高,再算上节税,确实更划算。 但即便如此我还是没有想买,因为虽然理财收益高,但本金短时间内拿不出来,是赚是赔还得仔细计算一番,我又觉得计算太麻烦所以就搁置了。 **第二次改观** 她认认真真地和我讲了为什么要开通的环境因素,首先,国家生育率下降的趋势不可能改变了,房地产颓势已不可挽回,想要重振国家经济需要刺激大家消费,而大家消费的前提是对未来充满信心,房地产无法带来信心,就只能靠股市了,但股市现在也在一路走低,大家都被套牢,想要解套就需要政府入场,而政府入场需要发放国债,而刺激购买国债就需要降低银行存款利率,所以利率还会继续下行。在利率下行的情况下,未来很长时间都不会有更好的投资方式。 那么顺势而为,开通个人养老金,每年就投入 1.2 万,除去退回来的税,一年也就付出几千块钱而已,如果再用账户里的钱买个合适的产品,收益还是要超过其他绝大部分投资方式的。 正如开篇所说,事后我发现还是要算一下这笔投资怎么样,那么直接进入第二节吧。 # 2. 个人养老金投入了什么,能得到什么? 先了解个人养老金基本规则,再分别计算个人养老金的投入与回报,就能对这笔投资有一个较为全面的认知了。 **个人养老金需要投入什么?** **个人养老金的基本规则非常简单:每年往养老金账户存入 0~12000 元,抵扣 360~5400 元个人所得税**。计税公式是 `税收 = (应纳税所得额-个税抵扣) * 阶梯税率`。 所以也可以这么理解个人养老金:假设税率是 100%,那么个人养老金投多少就能抵多少税,比如买 100 元个人养老金可以抵扣 100 元税,那就等于白嫖老金账户的钱,再算上投资收益,妥妥的稳赚不赔。 但天下没有这等好事,税率不可能达到 100%,在中国最高的阶梯税率是 45%,所以你能够到的税率越高,就能越大比例的 “白嫖” 个人养老金。这就是为什么大家总会说,你的收入越高,购买个人养老金越划算,这个说法是正确的。 **但这到底是不是件划算的事,不止由投入决定,还要由回报共同决定**。假设个人养老金账户的回报是 0,那就算税收到了 100% 档,免费往里面存个人养老金也是没有意义的,何况税率还不可能达到 100%,所以仅凭前者就下结论要开通个人养老金账户,是我不认可的逻辑,因为没有收益的投资,即便投资返利再高也是亏的呀。 **总结一下,个人养老金账户一年存满 12000 元,需要净付出 10800~6600 元**,收入越高免税金额越高,所以净付出就越小。具体到我们阶梯税率的每个阶段,免税额度如下: | 年应税工资 | 减税金额 | | -------- | ------- | | 3.6~14.4w | 1200 | | 14.4~30w | 2400 | | 30~42w | 3000 | | 42~66w | 3600 | | 66~96w | 4200 | | >96w | 5400 | **个人养老金的投资回报是多少?** 这是我最关注的一块。**首先个人养老金账户的钱在退休之前没法取出来,但可以投资**。投资方式目前有4种,分别是银行存款、储蓄存款、商业养老保险和公募基金。但是从这一年来的投资数据统计,存款利率太低,基金亏损较多,养老保险倒是一种不错的选择。而我最后也是选择了购买养老保险。 **养老保险的收益规则是:每年从个人养老金账户定投一些钱,可以选择退休后一次性把钱拿出来,或者每年拿固定的钱持续终身**。但总的来说 **起交时年龄越小,连续交的时间越长,整体收益率越高**。 为了计算投资回报是否划算,我们拿一个具有普遍意义的案例作为基准: **假设 30 岁开始购买,每年交满个人 12000 元额度,连续购买 5 年,按照 65 岁退休的话,在退休时一次性可以拿到 18 万左右**。 让我们计算一下这段期间的单利年利率。为了方便计算,假设你在 30~35 岁的年收入在 42~66w 之间,每年存 12000 元个人养老金可以节税 3600 元。那么净总投入是 `5 * (12000 - 3600)` 一共 42000 元,也就是为了产生这 18 万的收益,净投入的本金相当于 4.2 万元。 由于购买是从 30~35 岁连续 5 年的,而计算年利率起始时间非常重要,为了方便,我们取一个平均,按照 32.5 岁交 4.2 万元来等价计算,那么到 65 岁一共经历了 `65 - 32.5` 一共 32.5 年,从 4.2 万元增长到 18 万元,平均单利年化收益率为 `(18 - 4.2) / 4.2 / 32.5` 为 **10.1%**。 **也就是 32.5 年间,这笔投资稳定年化 10%,换算成年化复利也有 4.7%**,是相当高的收益率了。 如果连续购买10年,或者20年,也是一个很不错的选择,因为保险的计算逻辑是以当下的利率去计算整个保单的利息的,这样可以锁定一个更长的利息。经济下行周期下,锁定一个不错的利率,是一个相当明确的决定。而且也可以给退休之后储备更多的养老金。 **总结一下,算上个人养老金节税与分红型保险的复利收益,4.2 万元可以在 32.5 年间产生 10% 的单利年化收益**。 当然个人养老金也有限制,即缴纳上限每年是 1.2 万元,结合上面分析的投入年限越短年化收益越高的特性,锁死了个人养老金能产生的收益总额上限。换句话说,如果分红型养老保险不是由个人养老金账户出,而是由你个人出资,虽然解决了每年只能投 1.2 万元限额的问题,但缺少了节税,其年化单利会下降,从收益率角度来说,并不是那么划算。 假设这 1.2 万元不是个人养老金出的,而是直接由工资拿出来交的,我们也算一下年化单利利率:假设 30 岁开始购买,每年购买 10 万,连续买 5 年,那么 65 岁可以拿到 150 万,平均单利年化收益率为 `(150 - 50) / 50 / 32.5` 为 **6.1%**。 所以用个人养老金撬动分红型养老保险,可以把 32.5 年的单利年化利率从 6.1% 提高到 10.1%,我个人觉得是绝对划算,可以闭眼买的。 # 3. 广告部分 文章开头也说了,本篇文章是给康女士打广告的,她的工作就是帮助大家合理规划个人养老金账户里的钱,所以,如果你觉得我以上的分析是有道理的,并且觉得可以每年拿出1.2万左右的资金(其实减去退回来的税,只有几千块)用于未来的养老规划的话,可以直接添加她的微信。备注“前端精读”,还会获得小惊喜哦~ 微信号:kangyanan2676 ================================================ FILE: 算法/198.精读《算法 - 动态规划》.md ================================================ 很多人觉得动态规划很难,甚至认为面试出动态规划题目是在为难候选人,这可能产生一个错误潜意识:认为动态规划不需要掌握。 其实动态规划非常有必要掌握: 1. 非常锻炼思维。动态规划是非常锻炼脑力的题目,虽然有套路,但每道题解法思路差异很大,作为思维练习非常合适。 2. 非常实用。动态规划听起来很高级,但实际上思路和解决的问题都很常见。 动态规划用来解决一定条件下的最优解,比如: - 自动寻路哪种走法最优? - 背包装哪些物品空间利用率最大? - 怎么用最少的硬币凑零钱? 其实这些问题乍一看都挺难的,毕竟都不是一眼能看出答案的问题。但得到最优解又非常重要,谁能忍受游戏中寻路算法绕路呢?谁不希望背包放的东西更多呢?所以我们一定要学好动态规划。 ## 精读 动态规划不是魔法,它也是通过暴力方法尝试答案,只是方式更加 “聪明”,使得实际上时间复杂度并不高。 ### 动态规划与暴力、回溯算法的区别 上面这句话也说明了,所有动态规划问题都能通过暴力方法解决!是的,所有最优解问题都可以通过暴力方法尝试(以及回溯算法),最终找出最优的那个。 暴力算法几乎可以解决一切问题。回溯算法的特点是,通过暴力尝试不同分支,最终选择结果最优的线路。 而动态规划也有分支概念,但不用把每条分支尝试到终点,而是在走到分叉路口时,可以直接根据前面各分支的表现,直接推导出下一步的最优解!然而无论是直接推导,还是前面各分支判断,都是有条件的。动态规划可解问题需同时满足以下三个特点: 1. 存在最优子结构。 2. 存在重复子问题。 3. 无后效性。 ### 存在最优子结构 即子问题的最优解可以推导出全局最优解。 什么是子问题?比如寻路算法中,走完前几步就是相对于走完全程的子问题,必须保证走完全程的最短路径可以通过走完前几步推导出来,才可以用动态规划。 不要小看这第一条,动态规划就难在这里,你到底如何将最优子结构与全局最优解建立上关系? - 对于爬楼梯问题,由于每层台阶都是由前面台阶爬上来的,因此必然存在一个线性关系推导。 - 如果变成二维平面寻路呢?那么就升级为二维问题,存在两个变量 `i,j` 与上一步之间关系了。 - 如果是背包问题,同时存在物品数量 `i`、物品重量 `j` 和物品质量 `k` 三个变量呢?那就升级为三位问题,需要寻找三个之间的关系。 依此类推,复杂度可以上升到 N 维,维度越高思考的复杂度就越高,空间复杂度就越需要优化。 ### 存在重复子问题 即同一个子问题在不同场景下存在重复计算。 比如寻路算法中,同样两条路线的计算中,有一段路线是公共的,是计算的必经之路,那么只算一次就好了,当计算下一条路时,遇到这个子路,直接拿第一次计算的缓存即可。典型例子是斐波那契数列,对于 `f(3)` 与 `f(4)`,都要计算 `f(1)` 与 `f(2)`,因为 `f(3) = f(2) + f(1)`,而 `f(4) = f(3) + f(2) = f(2) + f(1) + f(2)`。 这个是动态规划与暴力解法的关键区别,动态规划之所以性能高,是因为 **不会对重复子问题进行重复计算**,算法上一般通过缓存计算结果或者自底向上迭代的方式解决,但核心是这个场景要存在重复子问题。 当你觉得暴力解法可能很傻,存在大量重复计算时,就要想想是哪里存在重复子问题,是否可以用动态规划解决了。 ### 无后效性 即前面的选择不会影响后面的游戏规则。 寻路算法中,不会因为前面走了 B 路线而对后面路线产生影响。斐波那契数列因为第 N 项与前面的项是确定关联,没有选择一说,所以也不存在后效性问题。 什么场景存在后效性呢?比如你的人生是否能通过动态规划求最优解?其实是不行的,因为你今天的选择可能影响未来人生轨迹,比如你选择了计算机这个职业,会直接影响到工作的领域,接触到的人,后面的人生路线因此就完全变了,所以根本无法与选择了土木工程的你进行比较,因为人生赛道都变了。 有同学可能觉得这样局限是不是很大?其实不然,无后效性的问题仍然很多,比如背包放哪件物品、当前走哪条路线、用了哪些零钱,都不会影响整个背包大小、整张地图的地形、以及你最重要付款的金额。 ### 解法套路 - 状态转移方程 解决动态规划问题的核心就是写出状态转移方程,所谓状态转移,即通过某些之前步骤推导出未来步骤。 状态转移方程一般写为 `dp(i) = 一系列 dp(j) 的计算`,其中 `j < i`。 其中 `i` 与 `dp(i)` 的含义很重要,一般 `dp(i)` 直接代表题目的答案,`i` 就有技巧了。比如斐波那契数列,`dp(i)` 表示的答案就是最终结果,`i` 表示下标,由于斐波那契数列直接把状态转移方程告诉你了 `f(x) = f(x-1) + f(x-2)`,那么根本连推导都不必了。 **对于复杂问题,难在如何定义 `i` 的含义,以及下一步状态如何通过之前状态推导。** 这个做多了题目就有体会,如果没有,那即便再如何解释也难以说明,所以后面还是直接看例子吧。 先举一个最简单的动态规划例子 - 爬楼梯来说明问题。 ### 爬楼梯问题 爬楼梯是一道简单题,题目如下: > 假设你正在爬楼梯。需要 `n` 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?(给定 `n` 是一个正整数) 首先 `dp(i)` 就是问题的答案(解法套路,`dp(i)` 大部分情况就是答案,这样解题思路会最简化),即爬到第 `i` 阶台阶的方法数量,那么 `i` 自然就是要爬到第几阶台阶。 我们首先看是否存在 **最优子结构**?因为只能往上爬,所以第 `i` 阶台阶有几种爬方完全取决于前面有几种爬方,**而一次只能爬 1 或 2 个台阶,所以第 `i` 阶台阶只可能从第 `i-1` 或 `i-2` 个台阶爬上来的**,所以第 `i` 个台阶的爬法就是 `i-1` 与 `i-2` 总爬法之和。所以显然有最优子结构,连状态转移方程都呼之欲出了。 再看是否存在 **存在重复子问题**,其实爬楼梯和斐波那契数列类似,最终的状态转移方程是一样的,所以显然存在重复子问题。当然直观来看也容易分析出,10 阶台阶的爬法包含了 8、9 阶的爬法,而 9 阶台阶爬法包含了 8 阶的,所以存在重复子问题。 最后看是否 **无后效性**?由于前面选择一次爬 1 个或 2 个台阶并不会影响总台阶数,也不会影响你下一次能爬的台阶数,所以无后效性。如果你爬了 2 个台阶,因为太累,下次只能爬 1 个台阶,就属于有后效性了。或者只要你一共爬了 3 次 2 阶,就会因为太累而放弃爬楼梯,直接下楼休息,那么问题提前结束,也属于有后效性。 所以爬楼梯的状态转移方程为: - `dp(i) = dp(i-1) + dp(i-2)` - `dp(1) = 1` - `dp(2) = 2` 注意,因为 1、2 阶台阶无法应用通用状态转移方程,所以要特殊枚举。这种枚举思路在代码里其实就是 **递归终结条件**,也就是作为函数 `dp(i)` 不能无限递归,当 `i` 取值为 1 或 2 时直接返回枚举结果(对这道题而言)。所以在写递归时,一定要优先写上递归终结条件。 然后我们考虑,对于第一阶台阶,只有一种爬法,这个没有争议吧。对于第二阶台阶,可以直接两步跨上来,也可以走两个一步,所以有两种爬法,也很容易理解,到这里此题得解。 关于代码部分,仅这道题写一下,后面的题目如无特殊原因就不写代码了: ```typescript function dp(i: number) { switch (i) { case 1: return 1; case 2: return 2; default: return dp(i - 1) + dp(i - 2); } } return dp(n); ``` 当然这样写重复计算了子结构,所以我们不要每次傻傻的执行 `dp(i - 1)`(因为这样计算了超多重复子问题),我们需要用缓存兜底: ```typescript const cache: number[] = []; function dp(i: number) { switch (i) { case 1: cache[i] = 1; break; case 2: cache[i] = 2; break; default: cache[i] = cache[i - 1] + cache[i - 2]; } return cache[i]; } // 既然用了缓存,最好子底向上递归,这样前面的缓存才能优先算出来 for (let i = 1; i <= n; i++) { dp(i); } return cache[n]; ``` 当然这只是简单的一维线性缓存,更高级的缓存模式还有 **滚动缓存**。我们观察发现,这道题缓存空间开销是 `O(n)`,但每次缓存只用了上两次的值,所以计算到 `dp(4)` 时,`cache[1]` 就可以扔掉了,或者说,我们可以滚动利用缓存,让 `cache[3]` 占用 `cache[1]` 的空间,那么整体空间复杂度可以降低到 `O(1)`,具体做法是: ```typescript const cache: [number, number] = []; function dp(i: number) { switch (i) { case 1: cache[i % 2] = 1; break; case 2: cache[i % 2] = 2; break; default: cache[i % 2] = cache[(i - 1) % 2] + cache[(i - 2) % 2]; } return cache[i % 2]; } for (let i = 1; i <= n; i++) { dp(i); } return cache[n % 2]; ``` 通过取余,巧妙的让缓存永远交替占用 `cache[0]` 与 `cache[1]`,达到空间利用最大化。当然,这道题因为状态转移方程是连续用了前两个,所以可以这么优化,如果遇到用到之前所有缓存的状态转移方程,就无法使用滚动缓存方案了。然而还有更高级的多维缓存,这个后面提到的时候再说。 接下来看一个进阶题目,最大子序和。 ### 最大子序和 最大子序和是一道简单题,题目如下: > 给定一个整数数组 `nums` ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。 首先按照爬楼梯的套路,`dp(i)` 就表示最大和,由于整数数组可能存在负数,所以越多数相加,和不一定越大。 接着看 `i`,对于数组问题,大部分 `i` 都可以代表以第 `i` 位结尾的字符串,那么 `dp(i)` 就表示以第 `i` 位结尾的字符串的最大和。 可能你觉得以 `i` 结尾,就只能是 `[0-i]` 范围的值,那么 `[j-i]` 范围的字符串不就被忽略了?其实不然,`[j-i]` 如果是最大和,也会被包含在 `dp(i)` 里,因为我们状态转移方程可以选择不连上 `dp(i-1)`。 现在开始解题:首先题目是最大和的连续子数组,一般连续的都比较简单,因为对于 `dp(i)`,要么和前面连上,要么和前面断掉,所以状态转移方程为: - `dp(i) = dp(i-1) + nums[i]` 如果 `dp(i-1) > 0`。 - `dp(i) = nums[i]` 如果 `dp(i-1) <= 0`。 怎么理解呢?就是第 `i` 个状态可以直接由第 `i-1` 个状态推导出来,既然 `dp(i)` 是指以第 `i` 个字符串结尾的最大和,那么 `dp(i-1)` 就是以第 `i-1` 个字符串结尾的最大和,而且此时 `dp(i-1)` 已经算出来了,那么 `dp(i)` 怎么推导就清楚了: 因为字符串是连续的,所以 `dp(i)` 要么是 `dp(i-1)` + `nums[i]`,要么就直接是 `nums[i]`,所以选择哪种,取决于前面的 `dp(i-1)` 是否是正数,**因为以 `i` 结尾一定包含 `nums[i]`,所以 `nums[i]` 不管是正还是负,都一定要带上。** 所以容易得知,`dp(i-1)` 如果是正数就连起来,否则就不连。 好了,经过这么详细的解释,相信你已经完全了解动态规划的解题套路,后面的题目解释方式我就不会这么啰嗦了! 这道题如果再复杂一点,不连续怎么办呢?让我们看看最长递增子序列问题吧。 ### 最长递增子序列 最长递增子序列是一道中等题,题目如下: > 给你一个整数数组 `nums` ,找到其中最长严格递增子序列的长度。 > > 子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,`[3,6,2,7]` 是数组 `[0,3,1,6,2,2,7]` 的子序列。 其实之前的 [精读《DOM diff 最长上升子序列》](https://github.com/ascoders/weekly/blob/master/%E5%89%8D%E6%B2%BF%E6%8A%80%E6%9C%AF/192.%E7%B2%BE%E8%AF%BB%E3%80%8ADOM%20diff%20%E6%9C%80%E9%95%BF%E4%B8%8A%E5%8D%87%E5%AD%90%E5%BA%8F%E5%88%97%E3%80%8B.md) 有详细解析过这道题,包括还有更优的贪心解法,不过我们这次还是聚焦在动态规划方法上。 这道题与上一道的区别就是,首先递增,其次不连续。 按照套路,`dp(i)` 就表示以第 `i` 个字符串结尾的最长上升子序列长度,那么重点是,`dp(i)` 怎么通过之前的推导出来呢? 由于是不连续的,因此不能只看 `dp(i-1)` 了,因为 `nums[i]` 项与 `dp(j)`(其中 `0 <= j < i`)组合后都可能达到最大长度,因此需要遍历所有 `j`,尝试其中最大长度的组合。 所以状态转移方程为: `dp[i] = max(dp[j]) + 1`,其中 `0<=j 给你一个只包含 `'('` 和 `')'` 的字符串,找出最长有效(格式正确且连续)括号子串的长度。 这道题之所以是困难题,就因为状态转移方程存在嵌套思维。 我们首先按套路定义 `dp(i)` 为答案,即以第 `i` 下标结尾的字符串中最长有效括号长度。看出来了吗?一般字符串题目中,`i` 都是以字符串下标结尾来定义,很少有定义为开头或者别的定义行为。当然非字符串问题就不是这样了,这个在后面再说。 我们继续题目,如果 `s[i]` 是 `(`,那么不可能组成有效括号,因为最右边一定不闭合,所以考虑 `s[i]` 为 `)` 的场景。 如果 `s[i-1]` 为 `(`,那么构成了 `...()` 之势,最后两个自成合法闭合,所以只要看前面的即可,即 `dp(i-2)`,所以这种场景的状态转移方程为: `dp(i) = dp(i-2) + 2` 如果 `s[i-1]` 是 `)` 呢?构成了 `...))` 的状态,那么只有 `i-1` 是合法闭合的,且这个合法闭合段之前必须是 `(` 与第 `i` 项形成闭合,才构成此时最长有效括号长度,所以这种场景的状态转移方程为: `dp(i) = dp(i-1) + dp(i - dp(i-1) - 2) + 2`,你可以结合下面的图来理解: 可以看到,`dp(i-1)` 就是第二条横线的长度,然后如果红色括号匹配的话,长度又 +2,最后别忘了最左边如果有满足匹配的也要带上,这就是 `dp(i - dp(i-1) - 2)`,所以加到一起就是这种场景的括号最大长度。 到这里,一维动态规划问题深度基本上探索完了,在进入多维动态规划问题前,还有一类一维动态规划问题,属于表达式不难,也没有这题这么复杂的嵌套 DP,但是思维复杂度极高,**你一定不要盯着全流程看,那样复杂度太高,你需要充分认可 dp(i-x) 已经算出来部分的含义,进行高度抽象的思考。** ### 栅栏涂色 栅栏涂色是一道困难题,题目如下: > 有 `k` 种颜色的涂料和一个包含 `n` 个栅栏柱的栅栏,每个栅栏柱可以用其中一种颜色进行上色。 > > 你需要给所有栅栏柱上色,并且保证其中相邻的栅栏柱 **最多连续两个** 颜色相同。然后,返回所有有效涂色的方案数。 这道题 `k` 和 `n` 都非常巨大,常规暴力解法甚至普通 DP 都会超时。选择 `i` 的含义也很重要,这里 `i` 到底代表用几种颜色还是几个栅栏呢?选择栅栏会好做一些,因为栅栏是上色的主体。这样 `dp(i)` 就表示上色前 `i` 个栅栏的所有涂色方案。 首先看下递归终止条件。由于最多连续两个颜色相同,因此 `dp(0)` 与 `dp(1)` 分别是 `k` 与 `k*k`,因为每个栅栏随便刷颜色,自由组合。那么 `dp(2)` 有三个栅栏,非法情况是三个栅栏全同色,所以用所有可能减掉非法即可,非法场景只有 `k` 中,所以结果是 `k*k*k - k`。 那么考虑一般情况,对于 `dp(i)` 有几种涂色方案呢?直接思考情况太多,我们把情况一分为二,考虑 `i` 与 `i-1` 颜色相同与不同两种情况考虑。 如果 `i` 与 `i-1` 颜色相同,那么为了合法,`i-1` 肯定不能与 `i-2` 颜色相同了,否则就三个同色,这样的话,不管 `i-2` 是什么颜色,`i-1` 与 `i` 都只能少取一种颜色,少取的颜色就是 `i-2` 的颜色,因此 `[i-1,i]` 这个区间有 `k-1` 中取色方案,前面有 `dp(i-2)` 种取色方案,相乘就是最终方案数:`dp(i-2) * (k-1)`。 **这背后其实存在动态思维,即每种场景的 `k-1` 都是不同的颜色组合,只是无论前面 `dp(i-2)` 是何种组合,后面两个栅栏一定有 `k-1` 种取法,虽然颜色组合的色值不同,但颜色组合数量是不变的,所以可以统一计算。理解这一点非常关键。** 如果 `i` 与 `i-1` 颜色不同,那么第 `i` 项只有 `k-1` 种取法,一样也是动态的,因为永远不能和 `i-1` 颜色相同。最后乘上 `dp(i-1)` 的取色方案,就是总方案数:`dp(i-1) * (k-1)`。 所以最后总方案数就是两者之和,即 `dp(i) = dp(i-2) * (k-1) + dp(i-1) * (k-1)`。 这道题的不同之处在于,变化太多,任何一个栅栏取的颜色都会影响后面栅栏要取的颜色,**乍一看觉得是个有后效性的题目,无法用动态规划解决**。但实际上,虽然有后效性,但如果进行合理的拆解,后面栅栏的总可能性 `k-1` 是不变的,**所以考虑总可能性数量,是无后效性的**,因此站在方案总数上进行抽象思考,才可能破解此题。 接下来介绍多维动态规划,从二维开始。二维动态规划就是用两个变量表示 DP,即 `dp(i,j)`,一般在二维数组场景出现较多,当然也有一些两个数组之间的关系,也属于二维动态规划,为了继续探讨字符串问题,我选择了字符串问题的二维动态规划范例,编辑距离这道题来说明。 ### 编辑距离 编辑距离是一道困难题,题目如下: > 给你两个单词 `word1` 和 `word2`,请你计算出将 `word1` 转换成 `word2` 所使用的最少操作数。 > > 你可以对一个单词进行如下三种操作: > > - 插入一个字符 > - 删除一个字符 > - 替换一个字符 只要是字符串问题,基本上 `i` 都表示以第 `i` 项结尾的字符串,但这道题有两个单词字符串,**为了考虑任意匹配场景,必须用两个变量表示,即 `i` `j` 分别表示 `word1` 与 `word2` 结尾下标时,最少操作次数。** 那么对于 `dp(i,j)` 考虑 `word1[i]` 与 `word2[j]` 是否相同,最后通过双重递归,先递归 `i`,在递归内再递归 `j`,答案就出来了。 假设最后一个字符相同,即 `word1[i] === word2[j]` 时,**由于最后一个字符不用改就相同了,所以操作次数就等价于考虑到前一个字符**,即 `dp(i,j) = dp(i-1,j-1)` 假设最后一个字符不同,那么 **最后一步** 有三种模式可以得到: 1. 假设是替换,即 `dp(i,j) = dp(i-1,j-1) + 1`,因为替换最后一个字符只要一步,并且和前面字符没什么关系,所以前面的最小操作次数直接加过来。 2. 假设是插入,即 `word1` 插入一个字符变成 `word2`,那么只要变换到这一步再 +1 插入操作就行了,变换到这一步由于插入一个就行了,因此 `word1` 比 `word2` 少一个单词,其它都一样,要变换到这一步,就要进行 `dp(i,j-1)` 的变换,因此 `dp(i,j) = dp(i,j-1) + 1`。。 3. 假设是删除,即 `word1` 删除一个字符变成 `word2`,同理,要进行 `dp(i-1,j)` 的变化后多一步删除,因此 `dp(i,j) = dp(i-1,j) + 1`。 由于题目取操作最少次数,所以这三种情况取最小即可,即 `dp(i,j) = min(dp(i-1,j-1), dp(i,j-1), dp(i-1,j)) + 1`。 所以同时考虑了最后一个字符是否相同后,合并了的状态转移方程就是最终答案。 我们再考虑终止条件,即 `i` 或 `j` 为 -1 时的情况,因为状态转移方程 `i` 和 `j` 不断减小,肯定会减少到 0 或 -1,因为 0 是字符串还有一个字符,相对比如考虑 -1 字符串为空时方便,因此我们考虑 -1 时作为边界条件。 当 `i` 为 -1 时,即 `word1` 为空,此时要变换为 `word2` 很显然,只有插入 `j` 次是最小操作次数,因此此时 `dp(i,j) = j`;同理,当 `j` 为 -1 时,即 `word2` 为空,此时要删除 `i` 次,因此操作次数为 `i`,所以 `dp(i,j) = i`。 ### 非字符串问题 说到这,相信你在字符串动规问题上已经如鱼得水了,我们再看看非字符串场景的动规问题。非字符串场景的动规比较经典的有三个,第一是矩形路径最小距离,或者最大收益;第二是背包问题以及变种;第三是打家劫舍问题。 这些问题解决方式都一样,只是对于 `dp(i)` 的定义略有区别,比如对于矩形问题来说,`dp(i,j)` 表示走到 `i,j` 格子时的最小路径;对于背包问题,`dp(i,j)` 表示装了第 `i` 个物品时,背包还剩 `j` 空间时最大价格;对于打家劫舍问题,`dp(i)` 表示打劫到第 `i` 个房间时最大收益。 因为篇幅问题这里就不一详细介绍了,只简单说明一下矩形问题于打家劫舍问题。 对于矩形问题,状态转移方程重点看上个状态是如何转移过来的,一般矩形只能向右或者向下移动,路途可能有一些障碍物不能走,我们要做分支判断,然后选择一条符合题目最值要求的路线作为当前 `dp(i)` 的转移方程即可。 对于打家劫舍问题,由于不能同时打劫相邻的房屋,所以对于 `dp(i)`,要么为了打劫 `i-1` 而不打劫第 `i` 间,或者打劫 `i-2` 于第 `i` 间,取这两种终态的收益最大值即可,即 `dp(i) = max(dp(i-1), dp(i-2) + coins[i])`。 ## 总结 动态规划的核心分为三步,首先定义清楚状态,即 `dp(i)` 是什么;然后定义状态转移方程,这一步需要一些思考技巧;最后思考验证一下正确性,即尝试证明你写的状态转移方程是正确的,在这个过程要做到状态转移的不重不漏,所有情况都被涵盖了进来。 动态规划最经典的还是背包问题,由于篇幅原因,可能下次单独出一篇文章介绍。 > 讨论地址是:[精读《算法 - 动态规划》· Issue #327 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/327) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 算法/199.精读《算法 - 滑动窗口》.md ================================================ 滑动窗口算法是较为入门题目的算法,一般是一些有规律数组问题的最优解,也就是说,如果一个数组问题可以用动态规划解,但又可以使用滑动窗口解决,那么往往滑动窗口的效率更高。 双指针也并不局限在数组问题,像链表场景的 “快慢指针” 也属于双指针的场景,其快慢指针滑动过程中本身就会产生一个窗口,比如当窗口收缩到某种程度,可以得到一些结论。 因此掌握滑动窗口非常基础且重要,接下来按照我的经验给大家介绍这个算法。 ## 精读 滑动窗口使用双指针解决问题,所以一般也叫双指针算法,因为两个指针间形成一个窗口。 什么情况适合用双指针呢?一般双指针是暴力算法的优化版,所以: 1. 如果题目较为简单,且是数组或链表问题,往往可以尝试双指针是否可解。 2. 如果数组存在规律,可以尝试双指针。 3. 如果链表问题限制较多,比如要求 O(1) 空间复杂度解决,也许只有双指针可解。 也就是说,当一个问题比较有规律,或者较为简单,或较为巧妙时,可以尝试双指针(滑动窗口)解法。 我们还是拿例子说明,首先是两数之和。 ### 两数之和 两数之和是一道简单题,实际上和滑动窗口没什么关系,但为了引出三数之和,还是先讲这道题。题目如下: > 给定一个整数数组 `nums` 和一个整数目标值 `target`,请你在该数组中找出 **和为目标值** `target`  的那 **两个** 整数,并返回它们的数组下标。 > > 你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。 暴力解法就是穷举所有两数之和,发现和为 `target` 结束,显然这种做法有点慢,我们换一种思路。 由于可以用空间换时间,又只有两个数,我们可以对题目进行转化,即通过一次遍历,将 `nums` 每一项都减去 `target`,然后找到后面任意一项值为前面的结果,即表示它们和为 `target`。 可以用哈希表 `map` 加速查询,即将每一项 `target - num` 作为 key,如果后面任何一个 `num` 作为 key 可以在 `map` 中找到,则得解,且上一个数的原始值可以存在 `map` 的 value 中。这要仅需遍历一次,时间复杂度为 O(n)。 之所以说这道题,是因为这道题是单指针,即只有一个指针在数组中移动,并配合哈希表快速求解。对于稍微复杂的问题,单指针就不够了,需要用双指针解决(一般来说不会用到三或以上指针),那复杂点的题目就是三数之和了。 ### 三数之和 三数之和是一道中等题,别以为只是两数之和的加强版,其思路完全不同。题目如下: > 给你一个包含 `n` 个整数的数组 `nums`,判断 `nums` 中是否存在三个元素 `a`,`b`,`c` ,使得 `a + b + c = 0` ?请你找出所有和为 `0` 且不重复的三元组。 由于超过了两个数,所以不能像双指针一样求解了,因为即便用了哈希表存储,也会在遍历时遇到 “两数之和” 的问题,而哈希表方案无法继续嵌套使用,即无法进一步降低复杂度。 为了降低时间复杂度,我们希望只遍历一次数组,这就需要数组满足一定条件我们才能用滑动窗口,所以我们对数组进行排序,使用快排的时间复杂度为 O(nlogn),时间复杂度已超出两数之和,不过因为题目复杂,这个牺牲是无法避免的。 假设从小到大排序,那我们就拿到一个递增数组了,此时经典滑动窗口方法就可用了!怎么滑动呢?首先创建两个指针,分别叫 `left` 与 `right`,通过不断修改 `left` 与 `right`,让它们在数组间滑动,这个窗口大小就是符合题目要求的,当滑动完毕时,返回所有满足条件的窗口即可,记录其实很简单,只要在滑动过程中记录一下就行。 首先排除异常值,即数组长度过小,然后对于常规情况,我们拿一个全局变量存储当前窗口数的和,这样 `right + 1` 只要累加 `nums[right+1]`,`left + 1` 只要减去 `nums[left]` 即可快速拿到求和。 由于需要考虑所有情况,所以需要一次数组遍历,对于每次遍历的起始点 `i`,如果 `nums[i] > 0` 则直接跳过,因为数组排序后是递增的,后面的和只会永远大于 0;否则进行窗口滑动,先形成三个点 `[i, i+1, n-1]`,这样保持 `i` 不动,不断包夹后两个数字即可,只要它们的和大于 0,就将第三个点左移(数字会变小),否则将第二个点右移(数字会变大),其实第二个和第三个数就是滑动窗口。 这样的话时间复杂度是 O(n²),因为存在两次遍历,忽略快排较小的时间复杂度。 那么四数之和,五数之和呢? ### 四数之和 该题和三数之和完全一样,除了要求变成四个数。 首先还是排序,然后双重递归,即确定前两个数不变,不断包夹后两个数,后两个数就是 `i+1` 和 `n-1`,算法和三数之和一样,所以最终时间复杂度为 O(n³)。 那么 N 数之和(N > 2)都可以采用这个思路解决。 为什么没有更优的方法呢?我想可能因为: 1. 无论几数之和,快排一次时间复杂度都是固定的,所以沿用三数之和的方案其实占了排序算法便宜。 2. 滑动窗口只能用两个指针进行移动,而没有三指针但又保持时间复杂度不变的窗口滑动算法存在。 所以对于 N 数之和,通过排序付出了 O(nlogn) 时间复杂度之后,可以用滑动窗口,将 2 个数时间复杂度优化为 O(n),所以整体时间复杂度就是 O(N - 2 + 1 个 n),即 O(N-1 个 n),而最小的时间复杂度 O(n²) 比 O(nlogn) 大,所以总是忽略快排的时间复杂度,所以三数之和时间复杂度是 O(n²),四数之和时间复杂度为 O(n³),依此类推。 可以看到,我们从最简单的两数之和,到三数之和、四数之和,跨入了滑动窗口的门槛,**本质上是利用排序后数组有序的特性,让我们在不用遍历数组的前提下,可以对窗口进行滑动**,这是滑动窗口算法的核心思想。 为了加强这个理解,再看一道类似的题目,无重复字符的最长子串。 ### 无重复字符的最长子串 无重复字符的最长子串是一道中等题,题目如下: > 给定一个字符串,请你找出其中不含有重复字符的 **最长子串** 的长度。 由于最长子串是连续的,所以显然可以考虑滑动窗口解法。其实确定了滑动窗口解法后,问题很简单,只要设定 `left` 和 `right`,并用一个哈希 Set 记录哪些元素存在过,在过程中记录最大长度,并尝试 `right` 右移,如果右移过程中发现出现重复字符,则 `left` 右移,直到消除这个重复字符为止。 解法并不难,但问题是,我们要想清楚,为什么用滑动窗口遍历一次就可以做到 **不重不漏**?即这道题时间复杂度只有 O(n) 呢? 只要想明白两个问题: 1. 由于子串是连续的,既然不存在跳跃的情况,只要一次滑动窗口内能包含所有解,就涵盖了所有情况。 2. 一次滑动窗口内不包含什么?由于我们只将 `right` 右移,且出现重复后尝试将 `left` 右移到不重复后,`right` 再继续右移,这忽略了出现重复后, `right` 左移的情况。 我们重点看二个问题,显然,如果 `abcd` 这四个连续的字符不重复,那么 `left` 右移后,`bcd` 也显然不重复,所以如果此时就可以将 `right` 右移形成 `bcda` 的窗口继续找下去,而不需要尝试 `bc` 这种情况,因为这种情况虽然不重复,但一定不是最优解。 好了,通过这个例子我们看到,滑动窗口如何缩小窗口范围其实不难,但更要注重的是,背后对于为什么可以用滑动窗口的思考,滑动窗口有没有做到不重不漏,如果没有想清楚,可能整个思路都错了。 那么滑动窗口的应用已经说透了?其实没有,我们上面只说了缩小窗口这种比较单一的脑回路,其实双指针构成的滑动窗口不一定都是那么正常滑的,一种有意思的场景是快慢指针,即是以相对速度决定窗口如何滑动。 关于快慢指针,经典的题目有环形链表、删除有序数组中的重复项。 ### 环形链表 环形链表是一道简单题,题目如下: > 给定一个链表,判断链表中是否有环。 如果不是进阶要求空间复杂度 O(1),我们可以在遍历时稍稍 “污染” 一下原始链表,这样总能发现是否走了回头路。 但要求空间开销必须是常数,我们不得不考虑快慢指针。说实话第一次看到这道题时,如果能想到快慢指针的解法,绝对是相当聪明的,因为必须要有知识迁移的能力。怎么迁移呢?想象学校在开运动会,相信每次都有一个跑的最慢的同学,慢到被最快的同学追了一圈。 等等,操场不就是环形链表吗?**只要有人跑得慢,就会被跑得快的追上,追上不就是相遇了吗?** 所以快慢指针分别跑,只要相遇则判定为环形链表,否则不是环形链表,且一定有一个指针先走完。 那么细枝末节就是优化效率了,慢指针到底慢多少呢? 有人会说,运动会上,跑步慢的人如果想被快的人追上,最好就不要跑。对,但环形链表问题中,链表不是操场,可能只有某一段是环,也就是跑步慢的人至少要跑到环里,才可能与跑得快人的相遇,但跑得慢的人又不知道哪里开始成环,这就是难点。 你有没有想过,为什么快排用二分法,而不是三分法?为什么每次中间来一刀,可以最快排完?原因是二分可以用最小的 “深度” 将数组切割为最小粒度。那么同理,快慢指针中,慢指针要想被尽快追上,速度可能最好是快指针的一半。那从逻辑上分析,为什么呢? 直观来看,如果慢指针太慢,可能大部分时间都在进入环形之前的位置转悠,快指针虽然快,但永远在环里跑,所以总是无法遇到慢指针,这给我们的启示是,慢指针不能太慢;如果慢指针太快,几乎速度和快指针一样,就像两个运动员都互不相让的争夺第一一样,他们真的想相遇,估计得连续跑几个小时吧,所以慢指针也不能过快。所以这样分析下来,慢指针只能取折中的一半速度。 但用一半的慢速真的能最快相遇吗?不一定,举一个例子,假设链表是完美环形,一共有 [1,6] 共 6 个节点,那么慢指针一次走 1 步,快指针一次走 2 步,那么一共是 `2,3 3,5 4,1 5,3 6,5 1,1` 共走 6 步,但如果快指针一次走 3 步呢?一共是 `2,4 3,1 4,4` 3 步。这么说一般速度不一定最优?其实不是的,计算机在链表寻址时,节点访问的消耗也要考虑进去,后者虽然看上去更快,但其实访问链表 `next` 的次数更多,对计算机来说,还不如第一种来得快。 所以准确来说,不是快指针比慢指针快一倍速度,而是慢指针一次走一步,快指针一次走两步最优,因为相遇时,总移动步数最少。 再说一个简单问题,即用快慢指针判断链表中倒数第k个节点或者链表中点。 ### 判断链表中点 快指针是慢指针速度 2 倍,当快指针到达尾部,慢指针的位置就是链表中点。 ### 链表中倒数第k个节点 链表中倒数第k个节点是一道简单题,题目如下: > 输入一个链表,输出该链表中倒数第 `k` 个节点。为了符合大多数人的习惯,本题从 `1` 开始计数,即链表的尾节点是倒数第 `1` 个节点。 这道题就是判断链表中点的变种,只要让慢指针比快指针慢 `k` 个节点,当快指针到达末尾时,慢指针就指向倒数第 `k+1` 个节点了。这道题注意一下数数别数错了即可。 接下来终于说道快慢指针的另一种经典用法题型,删除有序数组中的重复项了。 ### 删除有序数组中的重复项 删除有序数组中的重复项是一道简单题,题目如下: > 给你一个有序数组 `nums` ,请你 **原地** 删除重复出现的元素,使每个元素 只出现一次 ,返回删除后数组的新长度。 这道题,要原地删除重复元素,并返回长度,所以只能用快慢指针。但怎么用呢?快多少慢多少? 其实这道题快多少慢多少并不像前面题目一样预设好了,而是根据遇到的实际数字来判断。 我们假设慢指针是 `slow` 快指针是 `fast`,注意变量命名也有意思,同样是双指针问题,有的是 `slow right`,有的是 `slow fast`,重点在于用何种方法移动指针。 我们只要让 `fast` 扫描完全表,把所有不重复的挪到一起就好了,这样时间复杂度是 O(n),具体做法是: 1. 让 `slow` 和 `fast` 初始都指向 index 0。 2. 由于是 **有序数组**,所以就算有重复也一定连在一起,所以可以让 `fast` 直接往后扫描,只有遇到和 `slow` 不同的值,才把其和 `slow+1` 交换,然后 `slow` 自增,继续递归,直到 `fast` 走到数组尾部结束。 做完这套操作后,`slow` 的下标值就是答案。 可以看到,这道题对于慢指针要如何慢,其实是根据值来判断的,如果 `fast` 的值与 `slow` 一样,那么 `slow` 就一直等着,因为相同的值要被忽略掉,让 `fast` 走就是在跳过重复值。 说完了常见的双指针用法,我们再来看一些比较难啃的特殊问题,这里主要讲两个,分别是 **盛最多水的容器** 与 **接雨水**。 ### 盛最多水的容器 盛最多水的容器是一道中等题,题目如下: > 给你 `n` 个非负整数 `a1,a2,...,an`,每个数代表坐标中的一个点 `(i, ai)` 。在坐标内画 `n` 条垂直线,垂直线 `i` 的两个端点分别为 `(i, ai)` 和 `(i, 0)` 。找出其中的两条线,使得它们与 `x` 轴共同构成的容器可以容纳最多的水。 建议先仔细读一读题目再继续,这道题相对比较复杂。 好了,为什么说这是一道双指针题目呢?因为我们看怎么计算容纳水的体积?其实这道题就简化为长乘宽。 长度就是选取的两个柱子的间距,宽就是其中最短柱子的高度。问题就是,虽然柱子间距越远,长度越大,但宽度不一定最大,一眼是没法看出来最优解的。 所以还是得多次尝试,那怎么样可以用最少的尝试次数,但又不重不漏呢?定义 `left` `right` 两个指针,分别指向 `0` 与 `n-1` 即首尾两个位置,此时长度是最大的(柱子间距离是最远的),接下来尝试一下别的柱子,试哪个呢? - 较长的那个?如果新的比较短的更短,那么宽度更短了;如果新的比较短的更长,也没用,因为较短的决定了水位。 - 较短的那个?如果新的较长,那么才有机会整体体积更大。 所以我们移动较短的那个,并每次计算一下体积,最后当两根柱子相遇时结束,过程中最大体积就是全局最大体积。 这道题双指针的移动规则比较巧妙,与上面普通题目不一样,重点不是在是否会运用滑动窗口算法,而是能否找到移动指针的规则。 当然你可能会说,为什么两个指针要定义在最两端,而非别的地方?因为这样就无法控制变量了。 如果指针选在中间位置,那么指针外移时,柱子的间距与柱子长度同时变化,就很难找到一条完美路线。比如我们移动较短的柱子,是因为较短的柱子确定了最低水位,改变它,可能让最低水位变高,但问题是两根柱子的间距也在变大,这样移动较短还是较长的柱子哪个更优就说不准了。 说实话这种方法不太容易想到,需要多找几种选择尝试才能发现。当然,算法如果按照固定套路就能推导出来,也就没有难度了,所以要接受这种思维跳跃。 接下来我们看一道更特殊的滑动窗口问题,接雨水,它甚至分为多段滑动窗口。 ### 接雨水 接雨水是一道困难题,题目如下: > 给定 `n` 个非负整数表示每个宽度为 `1` 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。 与盛雨水不同,这道接雨水看的是整体,我们要算出能接的所有水的数量。 其实相比上一道题,这道题还算比较好切入,因为我们从左到右计算即可。思考发现,只有产生了 “凹槽” 才能接到雨水,而凹槽由它两边最高的柱子决定,那什么范围算一段凹槽呢? 显然凹槽是可以明确分组的,一个凹槽也无法被分割为多个凹槽,就像你看水坑一样,无论有多少,多深的坑在一起,总能一个一个数清楚,所以我们就从左到右开始数。 怎么数凹槽呢?用滑动窗口办法,每个窗口就是一个凹槽,那么窗口的起点 `left` 就是左边第一根柱子,有以下情况: - 如果直接相邻的右边柱子更高(或一样高),那从它开始向右看,根本无法接雨水,所以直接抛弃,`left++`。 - 如果直接相邻的右边柱子更矮,那就有产生凹槽的机会。 - 那么继续往右看,如果右边一直都更矮,那也接不到雨水。 - 如果右边出现一个高一些的,就可以接到雨水,那问题是怎么算能接多少,以及找到哪结束呢? - 只要记录最左边柱子高度,右边柱子的结束判断条件是 “遇到一个与最左边一样高的柱子”,因为一个凹槽能接多少水,取决于最短的柱子。当然,如果右边没有柱子了,虽然比最左边低一点,但只要比最深的高,也算一个结束点。 这道题,一旦遇到凹槽结束点,`left` 就会更新,开始新的一轮凹槽计算,所以存在多个滑动窗口。从这道题可以看出,滑动窗口题型相当灵活,不仅判断条件因题而异,窗口数量可能也有多个。 ## 总结 滑动窗口本质是双指针的玩法,不同题目有不同的套路,从最简单的按照规律包夹,到快慢指针,再到无固定套路的因题而异的特殊算法。 其实按照规律包夹的套路属于碰撞指针范畴,一般对于排序好的数组,可以一步一步判断,或者用二分法判断,总之不用根据整体遍历来判断,效率自然高。 快慢指针也有套路可循,但具体快多少,或者慢多少,可能具体场景要具体看。 对于无固定套路的滑动窗口,就要根据题目仔细品味啦,如果所有套路都能总结出来,算法也少了乐趣。 > 讨论地址是:[精读《算法 - 滑动窗口》· Issue #328 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/328) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 算法/200.精读《算法 - 回溯》.md ================================================ 如何尝试走迷宫呢?遇到障碍物就从头 “回溯” 继续探索,这就是回溯算法的形象解释。 更抽象的,可以将回溯算法理解为深度遍历一颗树,每个叶子结点都是一种方案的终态,而对某条路线的判断可能在访问到叶子结点之前就结束。 相比动态规划,回溯可以解决的问题更复杂,尤其是针对具有后效性的问题。 动态规划之所以无法处理有后效性问题,原因是其 `dp(i)=F(dp(j))` 其中 `0<=j 0) func(restParams, results.concat(currentResult)); } ``` 这里 `params` 就类似迷宫后面的路线,而 `results` 记录了已走的最佳路线,当 `params` 路线消耗完了,就走出了迷宫,否则终止,让其它递归继续走。 所以回溯逻辑其实挺好写的,难在如何判断这道题应该用回溯做,以及如何优化算法复杂度。 先从两道入门题讲起,分别是电话号码的字母组合与复原 IP 地址。 ### 电话号码的字母组合 电话号码的字母组合是一道中等题,题目如下: > 给定一个仅包含数字  `2-9`  的字符串,返回所有它能表示的字母组合。答案可以按 **任意顺序** 返回。 > > 给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。 > > 电话号码数字对应的字母其实是个映射表,比如 `2` 映射 `a,b,c`,`3` 映射 `d,e,f`,那么 `2,3` 能表示的字母组合就有 `3x3=9` 种,而要打印出比如 `ad`、`ae` 这种组合,肯定要用穷举法,穷举法也是回溯的一种,只不过每一种可能性都要而已,而复杂点儿的回溯可能并不是每条路径都符合要求。 所以这道题就好做了,只要构造出所有可能的组合就行。 接下来我们看一道类似,但有一定分支合法判断的题目,复原 IP 地址。 ### 复原 IP 地址 复原 IP 地址是一道中等题,题目如下: > 给定一个只包含数字的字符串,用以表示一个 IP 地址,返回所有可能从 s 获得的 **有效 IP 地址** 。你可以按任何顺序返回答案。 > > **有效 IP 地址** 正好由四个整数(每个整数位于 0 到 255 之间组成,且不能含有前导 0),整数之间用 '.' 分隔。 > > 例如:"0.1.2.201" 和 "192.168.1.1" 是 有效 IP 地址,但是 "0.011.255.245"、"192.168.1.312" 和 "192.168@1.1" 是 **无效 IP 地址**。 首先肯定一个一个字符读取,问题就在于,一个字符串可能表示多种可能的 IP,比如 `25525511135` 可以表示为 `255.255.11.135` 或 `255.255.111.35`,原因在于,`11.135` 和 `111.35` 都是合法的表示,所以我们必须用回溯法解决问题,只是回溯过程中,会根据读取数据动态判定增加哪些新分支,以及哪些分支是非法的。 比如读取到 `[1,1,1,3,5]` 时,由于 `11` 和 `111` 都是合法的,因为这个位置的数字只要在 `0~255` 之间即可,而 `1113` 超过这个范围,所以被忽略,所以从这个场景中分叉出两条路: - 当前项:`11`,余项 `135`。 - 当前项:`111`,余项 `35`。 之后再递归,直到非法情况终止,比如以及满了 4 项但还有剩余数字,或者不满足 IP 范围等。 可见,只要梳理清楚合法与非法的情况,直到如何动态生成新的递归判断,这道题就不难。 这道题输入很直白,直接给出来了,其实不是每道题的输入都这么容易想,我们看下一道全排列。 ### 全排列 全排列是一道中等题,题目如下: > 给定一个不含重复数字的数组 `nums` ,返回其 **所有可能的全排列** 。你可以 **按任意顺序** 返回答案。 与还原 IP 地址类似,我们也是消耗给的输入,比如 `123`,我们可以先消耗 `1`,余下 `23` 继续组合。但与 IP 复原不同的是,第一个数字可以是 `1` `2` `3` 中的任意一个,所以其实在生成当前项时有所不同:当前项可以从所有余项里挑选,然后再递归即可。 比如 `123` 的第一次可以挑选 `1` 或 `2` 或 `3`,对于 `1` 的情况,还剩 `23`,那么下次可以挑选 `2` 或 `3`,当只剩一项时,就不用挑了。 全排列的输入虽然不如还原 IP 地址的输入直白,但好歹是基于给出的字符串推导而出的,那么再复杂点的题目,输入可能会拆解为多个,这需要你灵活思考,比如括号生成题目。 ### 括号生成 括号生成是一道中等题,题目如下: > 数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 **有效的** 括号组合。 > > 示例: > 输入:`n = 3` > > 输出:["((()))","(()())","(())()","()(())","()()()"] 这道题基本思路与上一题很像,而且由于题目问的是所有可能性,而不是最优解,所以无法用动规,所以我们考虑回溯算法。 上一道 IP 题目的输入是已知字符串,而这道题的输入就要你动动脑经了。这道题的输入是字符串吗?显然不是,因为输入是括号数量,那么只有一个括号数量就够了吗?不够,因为题目要求有效括号,那什么是有效括号?闭合的才是,所以我们想到用左右括号数量表示这个数字,即输入是 `n`,那么转化为 `open=n, close=n`。 有了输入,如何消耗输入呢?我们每一步都可以用一个左括号 `open` 或一个右括号 `close`,但第一个必须是 `open`,且当前已消耗 `close` 数量必须小于已消耗 `open` 数量时,才可以加上 `close`,因为一个 `close` 左边必须有个 `open` 形成合法闭合。 所以这道题就迎刃而解了。回顾来看,回溯的入参要能灵活思考,而这个思考取决于你的经验,比如遇到括号问题,下意识就直到拆解为左右括号。所以算法之间是相通的,适当的知识迁移可以事半功倍。 好了,在此我们先打住,其实不是所有题目都可以用回溯解决,但有些题目看上去只是回溯题目的变种,但其实不然。我们回到上一道全排列题,与之比较像的是 **下一个排列**,这道题看上去好像是基于全排列衍生的,但却无法用回溯算法解决,我们看看这道题。 ### 下一个排列 下一个排列是一道中等题,题目如下: > 实现获取 **下一个排列** 的函数,算法需要将给定数字序列重新排列成字典序中下一个更大的排列。 > > 如果不存在下一个更大的排列,则将数字重新排列成最小的排列(即升序排列)。 > > 必须 **原地** 修改,只允许使用额外常数空间。 比如: > 输入:nums = [1,2,3] > > 输出:[1,3,2] > 输入:nums = [3,2,1] > > 输出:[1,2,3] 如果你在想,能否借鉴全排列的思想,在全排列过程中自然推导出下一个排列,那大概率是想不通的,因为从整体推导到局部的效率太低,这道题直接给出一个局部值,我们必须用相对 “局部的方法” 快速推导出下一个值,所以这道题无法用回溯算法解决。  对于 `3,2,1` 的例子,由于已经是最大排列了,所以下个排列只能是初始化的 `1,2,3` 升序,这个是特例。除此之外,都有下一个更大排列,以 `1,2,3` 为例,更大的是 `1,3,2` 而不是 `2,1,3`。 我们再观察长一点的例子,比如 `3,2,1,4,5,6`,可以发现,无论前面如何降序,只要最后几个是升序的,只要把最后两个扭转即可:`3,2,1,4,6,5`。 如果是 `3,2,1,4,5,6,9,8,7` 呢?显然 `9,8,7` 任意相邻交换都会让数字变得更小,不符合要求,我们还是要交换 `5,6` .. 不 `6,9`,因为 `65x` 比 `596` 要大更多。到这里我们得到几个规律: 1. 尽可能交换后面的数。交换 `5,6` 会比交换 `6,9` 更大,因为 `6,9` 更靠后,位数更小。 2. 我们将 `3,2,1,4,5,6,9,8,7` 分为两段,分别是前段 `3,2,1,4,5,6` 和后段 `9,8,7`,我们要让前段尽可能大的数和后段尽可能小的数交换,同时还要保证,后段尽可能小的数比前段尽可能大的数还要 **大**。 为了满足第二点,我们必须从后向前查找,如果是升序就跳过,直到找到一个数字 `j` 比 `j-1` 小,那么前段作为交换的就是第 `j` 项,后段要找一个最小的数与之交换,由于搜索的算法导致后段一定是降序的,因此从后向前找到第一个比 `j` 大的项交换即可。 最后我们发现,交换后也不一定是完美下一项,因为后段是降序的,而我们已经把前面一个尽可能最小的 “大” 位改大了,后面一定要升序才满足下一个排列,因此要把后段进行升序排列。 因为后段已经满足降序了,因此采用双指针交换法相互对调即可变成升序,这一步千万不要用快排,会导致整体时间复杂度提高 O(nlogn)。 最后由于只扫描了一次 + 反转后段一次,所以算法复杂度是 O(n)。 从这道题可以发现,不要轻视看似变种的题目,从全排列到下一个排列,可能要完全换一个思路,而不是对回溯进行优化。 我们继续回到回溯问题,回溯最经典的问题就是 N 皇后,也是难度最大的题目,与之类似的还有解决数独问题,不过都类似,我们这次还是以 N 皇后作为代表来理解。 ### N 皇后问题 N 皇后问题是一道困难题,题目如下: > n  皇后问题 研究的是如何将 `n`  个皇后放置在 `n×n` 的棋盘上,并且使皇后彼此之间不能相互攻击。 > > 给你一个整数 `n` ,返回所有不同的  `n`  皇后问题 的解决方案。 > > 每一种解法包含一个不同的  `n` 皇后问题 的棋子放置方案,该方案中 `'Q'` 和 `'.'` 分别代表了皇后和空位。 皇后的攻击范围非常广,包括横、纵、斜,所以当 `n<4` 时是无解的,而神奇的时,`n>=4` 时都有解,比如下面两个图: 这道题显然具有 “强烈的” 后效性,因为皇后攻击范围是由其位置决定的,换而言之,一个皇后位置确定后,其他皇后的可能摆放位置会发生变化,因此只能用回溯算法。 那么如何识别合法与非法位置呢?核心就是根据横、纵、斜三种攻击方式,建立四个数组,分别存储哪些行、列、撇、捺位置是不能放置的,然后将所有合法位置都作为下一次递归的可能位置,直到皇后放完,或者无位置可放为止。 容易想到的就是四个数组,分别存储被占用的下标,这样的话,只是递归中条件判断分支复杂一些,其它其实并无难度。 这道题的空间复杂度进阶算法是,利用二进制方式,使用 **4 个数字** 代替四个下标数组,每个数组转化为二进制时,1 的位置代表被占用,0 的位置代表未占用,通过位运算,可以更快速、低成本的进行位置占用,与判断当前位置是否被占用。 这里只提一个例子,就可以感受到二进制魅力: 由于按照行看,一行只能放一个皇后,所以每次都从下一行看起,因此行限制就不用看了(至少下一行不可能和前面的行冲突),所以我们只要记录列、撇、捺三个位置即可。 不同之处在于,我们采用二进制的数字,只要三个数字即可表示列、撇、捺。二进制位中的 1 表示被占用,0 表示不被占用。 比如列、撇、捺分别是变量 `x,y,z`,对应二进制可能是: - `0000001` - `0010000` - `0001100` “非” 逻辑是任意为 1 就是 1,因此 “非” 逻辑可以将所有 1 合并,即 `x | y | z` 即 `0011101`。 然后将这个结果取反,用非逻辑,即 `~(x | y | z)`,结果是 `1100010`,那这里所有的 `1` 就表示可放的位置,我们记这个变量为 `p`,通过 `p & -p` 不断拿最后一位 `1` 得到安放位置,即可调用递归了。 从这道题可以发现,N 皇后难度不在于回溯算法,而在于如何利用二进制写出高效的回溯算法。所以回溯算法考察的比较综合,因为算法本身很模式化,而且相对比较 “笨拙”,所以需要将更多重心放在优化效率上。 ## 总结 回溯算法本质上是利用计算机高速计算能力,将所有可能都尝试一遍,唯一区别是相对暴力解法,可能在某个分支提前终止(枝剪),所以其实是一个较为笨重的算法,当题目确实具有后效性,且无法用贪心或者类似下一排列这种巧妙解法时,才应该采用。 最后我们要总结对比一下回溯与动态规划算法,其实动态规划算法的暴力递归过程就与回溯相当,只是动态规划可以利用缓存,存储之前的结果,避免重复子问题的重复计算,而回溯因为面临的问题具有后效性,不存在重复子问题,所以无法利用缓存加速,所以回溯算法高复杂度是无法避免的。 回溯算法被称为 “通用解题方法”,因为可以解决许多大规模计算问题,是利用计算机运算能力的很好实践。 > 讨论地址是:[精读《算法 - 回溯》· Issue #331 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/331) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 算法/201.精读《算法 - 二叉树》.md ================================================ 二叉树是一种数据结构,并且拥有种类复杂的分支,本文作为入门篇,只介绍一些基本二叉树的题型,像二叉搜索树等等不在此篇介绍。 二叉树其实是链表的升级版,即链表同时拥有两个 Next 指针,就变成了二叉树。 二叉树可以根据一些特性,比如搜索二叉树,将查找的时间复杂度降低为 logn,而且堆这种数据结构,也是一种特殊的二叉树,可以以 O(1) 的时间复杂度查找最大值或者最小值。所以二叉树的变种很多,都可以很好的解决具体场景的问题。 ## 精读 要入门二叉树,就必须理解二叉树的三种遍历策略,分别是:前序遍历、中序遍历、后序遍历,这些都属于深度优先遍历。 所谓前中后,就是访问节点值在什么时机,其余时机按先左后右访问子节点。比如前序遍历,就是先访问值,再访问左右;后续遍历就是先访问左右,再访问值;中序遍历就是左,值,右。 用递归方式遍历树非常简单: ```typescript function visitTree(node: TreeNode) { // 三选一:前序遍历 // console.log(node.val) visitTree(node.left) // 三选一:中序遍历 // console.log(node.val) visitTree(node.right) // 三选一:后序遍历 // console.log(node.val) } ``` 当然题目需要我们巧妙利用二叉树三种遍历的特性来解题,比如重建二叉树。 ### 重建二叉树 重建二叉树是一道中等题,题目如下: > 输入某二叉树的前序遍历和中序遍历的结果,请重建该二叉树。假设输入的前序遍历和中序遍历的结果中都不含重复的数字。 > > 例如 > > 前序遍历 preorder = `[3,9,20,15,7]` > > 中序遍历 inorder = `[9,3,15,20,7]` 先给你二叉树前序与中序遍历结果,让你重建二叉树,这种逆向思维的题目就难了不少。 仔细观察遍历特性可以看出,我们也许能推测出一些关键节点的位置,再通过数组切割递归一下就能解题。 前序遍历第一个访问的一定是根节点,因此 `3` 一定是根节点,然后我们在中序遍历找到 `3`,这样 **左边就是所有左子树的中序遍历结果,右边就是所有右子树的中序遍历结果**,我们只要再找到 **左子树的前序遍历结果与右子树的前序遍历结果**,就可以递归了,终止条件是左或右子树只有一个值,那样就代表叶子节点。 那么怎么找左右子树的前序遍历呢?上面例子中,我们找到了 `3` 的左右子树的中序遍历结果,由于前序遍历优先访问左子树,因此我们数一下中序遍历中,`3` 左边的数量,只有一个 `9`,那么我们从前序遍历的 `3,9,20,15,7` 在 `3` 之后推一位,那么 `9` 就是左子树前序遍历结果,`9` 后面的 `20,15,7` 就是右子树的前序遍历结果。 最后只要递归一下就能解题了,我们将输入不断拆解为左右子树的的输入,直到达到终止条件。 解决此题的关键是,不仅要知道如何写前中后序遍历,还要知道前序遍历第一个节点是根节点,后序遍历最后一个节点是根节点,中序遍历以根节点为中心,左右分别是其左右子树,这几个重要延伸特征。 说完了反向,我们说正向,即递归一棵二叉树。 其实二叉树除了递归,还有一种常见的遍历方法是利用栈进行广度优先遍历,典型题目有从上到下打印二叉树。 ### 从上到下打印二叉树 从上到下打印二叉树是一道简单题,题目如下: > 从上到下按层打印二叉树,同一层的节点按从左到右的顺序打印,每一层打印到一行。 这道题要求从左到右顺序打印,完全遵循广度优先遍历,我们可以在二叉树递归时,先不要急着读取值,而是按照左、中、右,遇到左右子树节点,就推入栈的末尾,利用 `while` 语句不断循环,直到栈空为止。 利用展开时追加到栈尾,并不断循环处理栈元素的方式非常优雅,而且符合栈的特性。 当然如果题目要求倒序打印,你就可以以 右、中、左 的顺序进行处理。 接下来看看深度优先遍历,典型题目是二叉树的深度。 ### 二叉树的深度 二叉树的深度是一道简单题,题目如下: > 输入一棵二叉树的根节点,求该树的深度。从根节点到叶节点依次经过的节点(含根、叶节点)形成树的一条路径,最长路径的长度为树的深度。 由于二叉树有多种分支,在遍历前,我们并不知道哪条路线是最深的,所以必须利用递归尝试。 我们可以转换一下思路,用函数式语义方式来理解。假设我们有了这样一个函数 `deep` 来求二叉树深度,那么这个函数内容是什么呢?二叉树只可能存在左右子树,所以 `deep` 必然是左右子树的最大深度的最大值 +1(它自己)。 而求左右子树深度可以复用 `deep` 函数形成递归,我们只需要考虑边界情况,即访问节点不存在时,返回深度 `0` 即可,因此代码如下: ```typescript function deep(node: TreeNode) { if (!node) return 0 return Math.max(deep(node.left), deep(node.right)) + 1 } ``` 从这可以看出,二叉树一般能用比较优雅的递归函数解决,如果你的解题思路不包含递归,往往就不是最优雅的解法。 类似优雅的题目还有,平衡二叉树。 ### 平衡二叉树 平衡二叉树是一道简单题,题目如下: > 输入一棵二叉树的根节点,判断该树是不是平衡二叉树。如果某二叉树中任意节点的左右子树的深度相差不超过 1,那么它就是一棵平衡二叉树。 同理,我们设函数 `isBalance` 就是答案函数,那么一个平衡二叉树的特征,必然是其左右子树也是平衡的,所以可以写成: ```typescript function isBalance(node: TreeNode) { if (root == null) return true return isBalance(node.left) && isBalance(node.right) } ``` 但是哪里不对,左右子树平衡还不够啊,万一左右子树之间深度相差超过 1 就坏了,所以还要求一下左右子树的深度,我们复用上题的函数 `deep`,整理一下如下: ```typescript function isBalance(node: TreeNode) { if (root == null) return true return isBalance(root.left) && isBalance(root.right) && Math.abs(deep(root.left) - deep(root.right)) < 2 } ``` 这道题提醒我们,不是所有递归都能完美写成仅自己调用自己的模式,不同题目要辅以其他函数,要敏锐的察觉到还缺少哪些条件。 还有一种递归,不是简单的函数自身递归自身,而是要构造出另一个函数进行递归,原因是递归参数不同。典型的题目有对称的二叉树。 ### 对称的二叉树 对称的二叉树是一道简单题,题目如下: > 请实现一个函数,用来判断一棵二叉树是不是对称的。如果一棵二叉树和它的镜像一样,那么它是对称的。 我们要注意,一颗二叉树的镜像比较特殊,比如最左节点与最右节点互为镜像,但它们的父节点并不相同,因此 `isSymmetric(tree)` 这样的参数是无法子递归的,我们必须拆解为左右子树作为参数,让它们进行相等判断,在传参时,将父级不同,但互为镜像的左右节点传入即可。 所以我们必须起一个新函数 `isSymmetricNew(left, right)`,将 `left.left` 与 `right.right` 对比,将 `left.right` 与 `right.left` 对比即可。 具体代码就不写了,然后注意一下边界情况即可。 这道题的重点是,由于镜像的关系,并不拥有相同的父节点,因此必须用一个新参数的函数进行递归。 那如果这道题反过来呢?要求构造一个二叉树镜像呢? ### 二叉树的镜像 二叉树的镜像是一道简单题,题目如下: > 请完成一个函数,输入一个二叉树,该函数输出它的镜像。 判断镜像比较容易,但构造镜像就要想一想了: ```text 例如输入:      4    /   \   2     7  / \   / \ 1   3 6   9 镜像输出:      4    /   \   7     2  / \   / \ 9   6 3   1 ``` 观察发现,其实镜像可以理解为左右子树互换,同时 **其各子树的左右子树再递归互换**,这就构成了一个递归: ```typescript function mirrorTree(node: TreeNode) { if (node === null) return null const left = mirrorTree(node.left) const right = mirrorTree(node.right) node.left = right node.right = left return node } ``` 我们要从下到上,因此先生成递归好的左右子树,再进行当前节点的互换,最后返回根节点即可。 接下来介绍一些有一定难度的经典题。 ### 二叉树的最近公共祖先 二叉树的最近公共祖先是一道中等题,题目如下: > 给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。 题目很简短,也很明确,就是寻找最近的公共祖先。显然,根节点是所有节点的公共祖先,但不一定是最近的。 我们还是用递归,先考虑特殊情况:如果任意节点等于当前节点,那么当前节点一定就是最近公共祖先,因为另一个节点一定在其子节点中。 然后,利用递归思想思考,假设我们利用 `lowestCommonAncestor` 函数分别找到左右子节点的最近公共祖先会怎样? ```typescript function lowestCommonAncestor(node, a, b) { const left = lowestCommonAncestor(node.left) const right = lowestCommonAncestor(node.right) } ``` 如果左右节点都找不到,说明只可能当前节点是最近公共子节点: ```typescript if (!left && !right) return node ``` 如果左节点找不到,则右节点就是答案,否则相反: ```typescript if (!left) return right return left ``` 这里巧妙利用了函数语义进行结果判断。 ### 二叉树的右视图 二叉树的右视图是一道中等题,题目如下: > 给定一棵二叉树,想象自己站在它的右侧,按照从顶部到底部的顺序,返回从右侧所能看到的节点值。 想象一束光照,从二叉树右侧向左照射,自上而下读取即是答案。 其实这道题可以认为是一道融合题。右侧的光束可以认为是分层照射的,那么当我们用广度优先算法遍历时,对于每一层,都找到最后一个节点打印,并且按顺序打印就是最终答案。 有一道二叉树的题目,是根据树的深度,按照广度优先遍历打印成二维数组,记录树的深度其实也有巧妙办法,即在栈尾追加元素时,增加一个深度 key,那么访问时自然就可以读到深度值。 ### 完全二叉树的节点个数 完全二叉树的节点个数是一道中等题,题目如下: > 给你一棵 **完全二叉树** 的根节点 `root` ,求出该树的节点个数。 > > **完全二叉树** 的定义如下:在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若最底层为第 `h` 层,则该层包含 `1 ~ 2^h` 个节点。 用递归解决这道题的话,关键要分几种情况探讨完全二叉树。 由于最底层可能没有填满,但最底层一定有节点,而且是按照从左到右填的,那么递归遍历左节点就可以获取树的最大深度,通过最大深度我们可以快速计算出节点个树,前提是二叉树必须是满的。 但最底层节点可能不满,那怎么办呢?分情况即可,首先,如果一直按照 `node.right....right` 递归获得右侧节点深度,发现和最大深度相同,那么就是一个满二叉树,直接计算出结果即可。 我们再看 `node.right...left` 的深度如果等于最大深度,说明 `node.left` 也就是左子树是个满二叉树,可以通过数学公式 `2^n-1` 快速算出节点个树。 如果不等于最大深度呢?**则说明右子树深度减 1 是满二叉树**,也可以通过数学公式快速计算节点个数,再通过递归计算另一边即可。 ## 总结 从题目中可以感受到,二叉树的解题魅力在于递归,二叉树问题中,我们可以同时追求优雅与答案。 > 讨论地址是:[精读《算法 - 二叉树》· Issue #331 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/331) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 算法/203.精读《算法 - 二叉搜索树》.md ================================================ 二叉搜索树的特性是,任何一个节点的值: - 都大于左子树任意节点。 - 都小于右子树任意节点。 因为二叉搜索树的特性,我们可以更高效的应用算法。 ## 精读 还记得 [《算法 - 二叉树》](https://github.com/ascoders/weekly/blob/master/%E7%AE%97%E6%B3%95/201.%E7%B2%BE%E8%AF%BB%E3%80%8A%E7%AE%97%E6%B3%95%20-%20%E4%BA%8C%E5%8F%89%E6%A0%91%E3%80%8B.md) 提到的 [二叉树的最近公公祖先](https://github.com/ascoders/weekly/blob/master/%E7%AE%97%E6%B3%95/201.%E7%B2%BE%E8%AF%BB%E3%80%8A%E7%AE%97%E6%B3%95%20-%20%E4%BA%8C%E5%8F%89%E6%A0%91%E3%80%8B.md) 问题吗?如果这是一颗二叉搜索树,是不是存在更巧妙的解法?你可以暂停先思考一下。 ### 二叉搜索树的最近公共祖先 二叉搜索树的最近公共祖先是一道简单题,题目如下: > 给定一个二叉搜索树, 找到该树中两个指定节点的最近公共祖先。 > > 百度百科中最近公共祖先的定义为:“对于有根树 `T` 的两个结点 `p`、`q`,最近公共祖先表示为一个结点 `x`,满足 `x` 是 `p`、`q` 的祖先且 `x` 的深度尽可能大(一个节点也可以是它自己的祖先)。” 第一个判断条件是相同的,即当前节点值等于 `p` 或 `q` 任意一个,则当前节点就是其最近公共祖先。 如果不是呢?同时考虑二叉搜索树与公共祖先的特性可以发现: 1. 如果 `p` `q` 两个节点分别位于当前节点的左 or 右边,则当前节点符合要求。 2. 如果 `p` `q` 值一个大于,一个小于当前节点,说明 `p` `q` 分布在当前节点左右两侧。 基于以上考虑,可以仅通过值大小来判断,因此题目就被简化了。 接下来看一道入门题,即如何验证一颗二叉树是二叉搜索树。 ### 验证二叉搜索树 验证二叉搜索树是一道中等题,题目如下: > 给定一个二叉树,判断其是否是一个有效的二叉搜索树。 > > 假设一个二叉搜索树具有如下特征: > > - 节点的左子树只包含小于当前节点的数。 > - 节点的右子树只包含大于当前节点的数。 > - 所有左子树和右子树自身必须也是二叉搜索树。 这道题看上去就应该用非常优雅的递归来实现。 二叉搜索树最重要的就是对节点值的限制,我们如果能正确卡住每个节点的值,就可以判断了。 如何判断节点值是否正确呢?我们可以用递归的方式倒推,即从根节点开始,假设根节点值为 `x`,那么左树节点的值就必须小于 `x`,再往左,那么值就要小于(假设第一个左节点值为 `x1`) `x1`,右树也是一样判断,因此就可以写出答案: ```typescript function isValidBST(node: TreeNode, min = -Infinity, max = Infinity) { if (node === null) return true // 判断值范围是否合理 if (node.val < min || node.val > max) return false // 继续递归,并且根据二叉搜索树特定,进一步缩小最大、最小值的锁定范围 return // 左子树值 max 为当前节点值 isValidBST(node.left, min, node.val) && // 右子树值 min 为当前节点值 isValidBST(node.right, node.val, max) && } ``` 接下来看一些简单的二叉搜索树操作问题,比如删除二叉搜索树中的节点。 ### 删除二叉搜索树中的节点 删除二叉搜索树中的节点是一道中等题,题目如下: > 给定一个二叉搜索树的根节点 root 和一个值 key,删除二叉搜索树中的 key 对应的节点,并保证二叉搜索树的性质不变。返回二叉搜索树(有可能被更新)的根节点的引用。 > > 一般来说,删除节点可分为两个步骤: > > 1. 首先找到需要删除的节点; > 2. 如果找到了,删除它。 > > 说明: 要求算法时间复杂度为 `O(h)`,`h` 为树的高度。 要删除二叉搜索树的节点,找到节点本身并不难,因为如果值小了,就从左子树找;如果值大了,就从右子树找,这本身查找起来是非常简单的。难点在于,如何保证删除元素后,这棵树还是一颗二叉搜索树? 假设我们删除的是叶子结点,很显然,二叉搜索树任意子树都是二叉搜索树,我们又没有破坏其他节点的关系,因此直接删除就行了,最简单。 如果删除的不是叶子结点,那么谁来 “上位” 代替这个节点呢?题目要求复杂度为 `O(h)` 显然不能重新构造,我们需要仔细考虑。 假设删除的节点存在右节点,那么肯定从右节点找到一个代替值移上来,找谁呢?找右节点的最小值呀,最小值很好找的,找完代替后,相当于 **问题转移为删除这个最小值节点,递归就完事了。** 假设删除的节点存在左节点,但是没有右节点,那就从左节点找一个最大的替换掉,同理递归删除找到的节点。 可以看到,删除二叉搜索树,为了让二叉搜索树性质保持不变,需要不断进行重复子问题的递归删除节点。 当你掌握二叉搜索树特性后,可以尝试构造二叉搜索树了,下面就是一道让你任意构造二叉搜索树的题目:不同的二叉搜索树。 ### 不同的二叉搜索树 不同的二叉搜索树是一道中等题,题目如下: > 给你一个整数 `n` ,求恰由 `n` 个节点组成且节点值从 `1` 到 `n` 互不相同的 **二叉搜索树** 有多少种?返回满足题意的二叉搜索树的种数。 这道题重点在于动态规划思维 + 笛卡尔积组合的思维。 需要将所有可能性想象为确定了根节点后,左右子树到底有几种组合方式? 举个例子,假设 `n=10`,那么这 10 个节点,假设我取第 3 个节点为根节点,那么左子树有 2 个节点,右子树有 7 个节点,这种组合情况就有 `DP(2) * DP(7)` 这么多,假设 `DP(n)` 表示 n 个节点能组成任意二叉搜索树的数量。 这仅是第 3 个节点为根节点的情况,实际上每个节点作为根节点都是不同的树(轴对称也算不同的),那么我们就要从第 1 个节点计算到第 `n` 个节点。 因此答案就出来了,我们先考虑特殊情况 `DP(0)=1` `DP(1)=1`,所以: ```typescript function numTrees(n: number) { const dp: number[] = [1, 1] for (let i = 2; i <= n; i++) { for (let j = 1; j <= i; j++) { dp[i] += dp[j - 1] * dp[i - j] } } return dp[n] } ``` 最后再看一道找值题,并不是找最大值,而是找第 k 大值。 ### 二叉搜索树的第 K 大节点 二叉搜索树的第 K 大节点是一道简单题,题目如下: > 给定一棵二叉搜索树,请找出其中第 `k` 大的节点。 这道题之所以简单,是因为二叉搜索树的中序遍历是从小到大的,因此只要倒序中序遍历,就可以找到第 `k` 大的节点。 倒序中序遍历,即右、根、左。 这道题就解决啦。 ## 总结 二叉搜索树的特性很简单,就是根节点值夹在左右子树中间,利用这个特性几乎可以解决一切相关问题。 但通过上面几个例子可以发现,仅熟悉二叉搜索树特性还是不够的,一些题目需要结合二叉树中序遍历、公共祖先特征等通用算法思路结合来解决,因此学会融会贯通很重要。 > 讨论地址是:[精读《算法 - 二叉搜索树》· Issue #337 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/337) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 算法/283.精读《算法题 - 通配符匹配》.md ================================================ 今天我们看一道 [leetcode](https://leetcode.cn/problems/wildcard-matching/description/) hard 难度题目:通配符匹配。 ## 题目 给你一个输入字符串 (`s`) 和一个字符模式 (`p`) ,请你实现一个支持 `'?'` 和 `'*'` 匹配规则的通配符匹配: - `'?'` 可以匹配任何单个字符。 - `'*'` 可以匹配任意字符序列(包括空字符序列)。 判定匹配成功的充要条件是:字符模式必须能够 **完全匹配** 输入字符串(而不是部分匹配)。 **示例 1:** ``` 输入:s = "aa", p = "a" 输出:false 解释:"a" 无法匹配 "aa" 整个字符串。 ``` ## 思考 最直观的思考是模拟匹配过程,以 s = "abc", p = "abd" 为例,匹配过程是这样的: 1. "a" 匹配 "a",通过 2. "b" 匹配 "b",通过 3. "c" 不匹配 "d",失败 只要匹配过程有任何一个字符匹配失败,则整体匹配失败。如果没有 `'?'` 与 `'*'` 号,题目则异常简单,只要一个指针按顺序扫描,扫描过程每个字符必须相等,且同时结束才算成功,否则判断失败。 加上 `'?'` 依然很简单,因为 `'?'` 号一定会消耗掉,只是它可以匹配任何字符,所以还是一个指针扫描,遇到 `p` 中 `'?'` 号时,跳过判等继续向后扫描即可。 加上 `'*'` 号时该题成为 hard 的第一个原因。由于 `'*'` 可以匹配空字符,也可以匹配任意多个字符,所以遇到 `p` 中 `'*'` 时有三种处理可能性: 1. 当做没见过 `'*'`,直接判等,不消耗 `s`,并匹配 `p` 的下一个字符。此时对应 `'*'` 不匹配任何字符。 2. 直接消耗掉 `'*'` 判等,同时消耗 `s` 与 `p`。此时 `'*'` 与 `'?'` 的作用等价。 3. 不消耗 `'*'`,但是消耗 `s`。此时对应 `'*'` 匹配多个字符而可以不消耗自己的特性。 很容易想到写一个递归的实现,代码如下: ```js function isMatch(s: string, p: string): boolean { return myIsMatch(s.split(''), p.split('')) }; function myIsMatch(sArr: string[], pArr: string[]): boolean { // 如果 s p 都匹配完了,或 p 还剩任意数量的 *,都算匹配通过 if ( (sArr.length === 0 && pArr.length === 0) || (sArr.length === 0 && pArr.every(char => char === '*')) ) { return true } // 如果任意一项长度为 0,另一项不为 0,则匹配失败 if ( (sArr.length === 0 && pArr.length !== 0) || (sArr.length !== 0 && pArr.length === 0) ) { return false } const newSArr = [...sArr] const newPArr = [...pArr] const sShfit = newSArr.shift() const pShift = newPArr.shift() // 此时 sShfit、pShift 一定都存在 switch(pShift) { case '?': // 无条件判过 return myIsMatch(newSArr, newPArr) case '*': // 无条件判过,其中有以下几种情况 // 消耗 *、消耗 sShfit // 消耗 *、不消耗 sShfit // 不消耗 *、消耗 sShfit return ( myIsMatch(newSArr, newPArr) || myIsMatch([sShfit, ...newSArr], newPArr) || myIsMatch(newSArr, [pShift, ...newPArr]) ) default: if (sShfit !== pShift) { return false } else { return myIsMatch(newSArr, newPArr) } } } ``` 非常简洁清晰的代码,即判断 `pShfit`(p 下一个字符)的状态,根据我们分析的可能性判断匹配命中的条件,比如当 `pShfit` 为 `'?'` 时直接判定下一组字符,而为 `'*'` 时,三种可能性都可以判对,其余情况必须在当前字符相等时,才继续判断下一组字符。 然而上面的代码无法 AC,原因是性能不达标,无论如何优化都无法 AC,这是该题成为 hard 的第二个原因。 遇到思路正确,但遇到比较复杂的用例超时,此时 99% 的情况应该换到动态规划思路,而该题动态规划思路是比较难想到的。 ## 动态规划思路 之所以动态规划思路难想到,是因为我们大脑的局限性造成的。因为人类最自然理解事物的方式是线性还原该场景的每一幕,对于这道题,我们自然会假设匹配是从第一个字符开始的,匹配完后进行下一个字符的匹配,直到判断失败。 但动态规划的思路是寻找 dp(i) 与 dp(i-1) 甚至 i-n 的关系,这使得直观上觉得不可能,因为想到 `'*'` 号的匹配可能存在不消耗 `'*'` 号的情况,此时向前回溯感觉就像字符串从后向前匹配了一样。但仔细想想会发现,从后向前匹配的结果与从前向后的匹配结果是相同的,因此这条路是可行的。 之所以从前向后与从后向前判断是等价的,最简单的理由是把 s 与 p 字符串倒序,此时从前向后匹配在逻辑上完全等价于倒序前的从后向前匹配。 接下来要思考的是状态转移方程,首先由于 `'*'` 的存在,导致 s 与 p 的游标可能不同,所以我们要定义两个游标,分别是 si、pi。 所以 dp(si, pi) 可以确定下来了。 接下来要如何转移,取决于 `p[pi]` 的值: - 为非 `'?'` 或 `'*'` 时,如果 `s[si] === p[pi]`,则整体能否 match 取决于 dp(si-1, pi-1) 能否 match。 - 展开说一下,因为此时 s 与 p 字符都会消耗,所以上一个状态是 si, pi 同时减 1。 - 为 `'?'` 时,不用判断当前字符是否相同,整体能否 match 取决于 dp(si-1, pi-1) 能否 match。 - 为 `'*'` 时: 1. 如果该 `'*'` 不匹配任何字符,则可以认为这个字符不存在,pi 回退一位,所以整体能否 match 取决于 dp(si, pi-1) 的结果。 2. 如果该 `'*'` 匹配字符,则当前肯定能匹配上,但整体能否 match 取决于之前的结果,之前结果分两种: 1. 消耗该 `'*'`,则等价于 dp(si-1, pi-1) 的结果。 2. 不消耗该 `'*'`,则等价于 dp(si-1, pi) 的结果。 由于所有的分支包含了所有可能性,因此上面逻辑梳理是不重不漏的。 **特别的,消耗该 `'*'` 等价于 dp(si-1, pi-1) 的 case 可以忽略,因为已经被上述逻辑覆盖了**,具体是怎么覆盖的呢?见下面的表达: **消耗该 `'*'` 等价于 dp(si-1, pi-1)** 这个场景等价于: 1. 不消耗该 `'*'`,等价于 dp(si-1, pi)。 2. 接着该 `'*'` 不匹配任何字符。 看到了吗,如果不消耗该 `'*'` 匹配字符后,接着再让其不匹配任何字符,**就等价于消耗该 `'*'` 匹配字符!** 所以这块是一个性能优化点,看你能不能意识到,这样可以少一个逻辑分支的执行。 代码如下: ```js function isMatch(s: string, p: string): boolean { // key 为 si_pi const resultSet = new Set() // 初始值 // 俩空字符串 match resultSet.add('0_0') // 为了让 0_0 命中空字符串,在 s,p 前面补上空字符串 s = ' ' + s p = ' ' + p for (let si = 0; si < s.length; si++) { for (let pi = 0; pi < p.length; pi++) { switch(p[pi]) { case '?': // 只要 [si-1, pi-1] match, [si, pi] 就 match if (resultSet.has(`${si-1}_${pi-1}`)) { resultSet.add(`${si}_${pi}`) } break case '*': // * 可以匹配空字符,则等价于 [si, pi-1] // * 可以匹配 1~oo 个字符, 如果 [si-1, pi-1] match & si > 0, 可以等价于 [si-1, pi] if ( resultSet.has(`${si}_${pi-1}`) || (si > 0 && resultSet.has(`${si-1}_${pi}`)) ) { resultSet.add(`${si}_${pi}`) } break default: // [si-1, pi-1] match & 最后一个字符也相等, [si, pi] 就 match if (resultSet.has(`${si-1}_${pi-1}`) && s[si] === p[pi]) { resultSet.add(`${si}_${pi}`) } } } } return resultSet.has(`${s.length-1}_${p.length-1}`) }; ``` 其中我们用 `Set` 结构很方便的定义 dp 缓存,然后给字符串前缀塞了空格,目的是方便在 si = 0, pi = 0 时收敛到 match 的情况,这样 dp 就能转起来了,否则 s[0] 和 p[0] 可能不匹配,让 dp(0, 0) 找不到一个稳定的落点(服务很到位)。 ## 动态规划 * 号处理详解 dp 思路中,可能有些同学不好理解 `p[pi] = '*'` 时的推演逻辑,我们展开画个图就清楚了: ``` s = a b c d p = a b c d * ``` 如果 * 不用于匹配,则结果等价于 ``` s = a b c d p = a b c d ``` 这个例子显然符合 p 可以匹配 s 的直觉。 如果 * 用于匹配,且消耗 * 比较好理解,s 与 p 各退一个字符;但不消耗 * 还是要画个图说明: ``` s = a b c d p = a b c d * ``` `'*'` 匹配了 s 最后一个字符 d,但自己又不消耗,则等价于: ``` s = a b c p = a b c d * ``` 从左到右看不太好理解,但从右到左看就比较容易了,可以认为 `'*'` 把 s 的最后一个字符 d “吃掉了”,但自己没有被消耗。要理解到这一步,还需要理解到 `'*'` 从左到右与从右到左匹配都是等价的这个事实。 如果非要从左到右看,也可以解释得通:**既然 `'*'` 已经确定要在不消耗自己的情况下把 s 最后一个 d “吃掉”,那么这个 d 写于不写是等价的**,所以可以把它从末尾 “抹去”。 ## 总结 从这道题可以看出,该题 hard 点不在于动态规划,不然理解了动态规划大家都能秒杀 hard 题了,这与面试时大部分面试者实际反应不符。 本题真正难点在于: 1. 首先为了能 AC,正匹配的思路走不通,如果你不能抛下从左到右匹配字符串的成见,就没办法逼自己试试动态规划,因为动态规划是向前推导的,很多人过不去这个坎。 2. 短时间内很难理解到 `'*'` 号匹配从左向右吃,与从右向左吃最终结果是等价的,所以潜意识会觉得 dp 思路无法处理 `'*'` 号匹配规则,非得整出个 `dp(i+1)` 才能理解,这样就迟迟无法下笔了。 不得不说 `p[pi] = '*'` 时结果等价于 `dp(si-1, pi)` 是具有思维跳跃的,因为它满足 dp 利用历史结果推导的结构,同时在匹配逻辑上又确实是等价的,能否想到这一步是这道题解题的关键。 如果你在其他地方看到本题的题解,但是在 `p[pi] = '*'` 时等价于 `dp(si-1, pi)` 这一步没看懂,大概率是那个题解忽略了这个 “神之细节”,而这个 “神之细节” 却是你在做题时真正的思维卡点,请确保这一点可以在你正序思考时推导出来,而不是看了答案后觉得这个转移方程有道理,从答案反推总是轻而易举的,但解题时却需要跳跃性思维。 最后,本文的实现还留了一些优化项可以更进一步,留给阅读本文的你探索: 1. dp 缓存是否可以用滚动数组优化空间消耗。 2. 两层 for 循环还是比较笨拙的,在某些情况下其实可以提前终止。 3. 当字符串 p 存在多个连续 * 时效果与单个 * 是一样的,可以提前简化 p 的复杂度。 > 讨论地址是:[精读《算法 - 二叉搜索树》· Issue #493 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/493) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 算法/284.精读《算法题 - 统计可以被 K 整除的下标对数目》.md ================================================ 今天我们看一道 [leetcode](https://leetcode.cn/problems/count-array-pairs-divisible-by-k/description/) hard 难度题目:统计可以被 K 整除的下标对数目。 ## 题目 给你一个下标从 0 开始、长度为 `n` 的整数数组 `nums` 和一个整数 `k` ,返回满足下述条件的下标对 `(i, j)` 的数目: - `0 <= i < j <= n - 1` 且 - `nums[i] * nums[j]` 能被 k 整除。 **示例 1:** ``` 输入:nums = [1,2,3,4,5], k = 2 输出:7 解释: 共有 7 对下标的对应积可以被 2 整除: (0, 1)、(0, 3)、(1, 2)、(1, 3)、(1, 4)、(2, 3) 和 (3, 4) 它们的积分别是 2、4、6、8、10、12 和 20 。 其他下标对,例如 (0, 2) 和 (2, 4) 的乘积分别是 3 和 15 ,都无法被 2 整除。 ``` ## 思考 首先想到的是动态规划,一个长度为 `n` 的数组结果与长度为 `n-1` 的关系是什么? 首先 `n-1` 时假设算好了一个结果 `result`,那么长度为 `n` 时,新产生的匹配是下标 `[0, n-1]` 与下标 `n` 数字的匹配关系,假设这些关系中有 `q` 个满足题设,则最终答案是 `result + q`。 这种想法适合 `(i, j)` 满足任意关系的题目,代码如下: ```js function countPairs(nums: number[], k: number): number { if (nums.length < 2) { return 0 } const dpCache: Record = {} for (let i = 1; i < nums.length; i++) { switch (i) { case 1: if (nums[0] * nums[1] % k === 0) { dpCache[1] = 1 } else { dpCache[1] = 0 } break default: // [0,i-1] 洗标范围内与 i 下标组合,看看有多少种可能 let currentCount = 0 for (let j = 0; j <= i - 1; j++) { if (nums[j] * nums[i] % k === 0) { currentCount++ } } dpCache[i] = dpCache[i - 1] + currentCount } } return dpCache[nums.length - 1] }; ``` 很可惜超时了,因为回头想想,虽然思路是 dp,但本质上是暴力解法,时间复杂度是 O(n²)。 为了 AC,必须采用更低复杂度的算法。 ## 利用最大公约数解题 如果只循环一次数组,那么必须在循环到数组每一项的时候,就能立刻知道该项与其他哪几项的乘积符合 `nums[i] * nums[j]` 能被 k 整除,这样的话累加一下就能得到答案。 也就是说,拿到数字 `nums[i]` 与 `k`,我们要知道有哪些 `nums[j]` 是满足要求的。 当然,如果把所有剩余数字循环一遍来找满足条件的 `nums[j]`,那时间复杂度就还是 O(n²),但不循环似乎无法继续思考了,这道题很容易在这里陷入僵局。 接下来就要发散思维了,先想这个问题:满足条件的 `nums[j]` 要满足 `nums[i] * nums[j] % k === 0`,那除了通过遍历把每一项 `nums[j]` 拿到真正的算一遍之外,还有什么更快的办法呢? 除了真的算一下之外,想想 `nums[j]` 还要具备什么特性?这个特性最好和倍数有关,因为如果我们计算所有数字倍数出现的个数,时间复杂度会比较低。 `nums[i]` 与 `k` 的最大公约数就满足这个条件,因为我们希望的是 `nums[j] * nums[i]` 是 `k` 的倍数,那么 `nums[j]` 最小的值就是 `k / nums[i]`,但这个除出来可能不是整数,那必须保证 k 除以的数字是一个整数,这个除数用 `nums[i]` 与 `k` 的最大公约数最划算。`nums[j]` 可以更大,只要是这个结果的倍数就行了,总结一下,`nums[j]` 要满足是 `k / gcd(nums[i], k)` 的倍数。 再重点解释下原因,我们假设 `nums[i] = 2`, `k=100`,此时是 k 比较大的情况,那么其最大公约数一定小于等于 `nums[i]`,因此 `k / 最大公约数 * nums[j]` 得到的数字一定大于 `k / nums[i] * nums[j]`,毕竟最大公约数比 `nums[i]` 小嘛,而 `k / nums[i] * nums[j]` 就是不考虑 `nums[j]` 是整数情况下让 k 可以整除 `nums[i] * nums[j]` 时,`nums[j]` 取的最小值的情况,因此 `nums[j]` 只要是 `k / 最大公约数` 的倍数就行了。 反之,如果 k 比 `nums[i]` 小,比如 `nums[i] = 100`, `k=2`,此时最大公约数是小于等于 k 的,但用一个比 k 还要大的 `nums[i]` 作为乘法的一边,乘出来的结果肯定大于 `k`,所以不用担心 `nums[i] * nums[j] < k` 的情况,所以 `nums[j]` 只要是 `k / 最大公约数` 的倍数就行了。 综上,无论如何 `nums[j]` 只要是 `k / 最大公约数` 的倍数就行了。 所以对于每一个 `nums[i]`,我们能快速计算出 `x = k / gcd(nums[i], k)`,接下来只要找到 nums 所有数字中,是 `x` 倍数的有多少累加起来就行了。这一步也不能鲁莽,因为数组长度非常大,性能更好的方案是:**先从1开始到最大值,计算出每个数字的倍数有几个,存在一个 map 表里,之后找倍数有几个直接从 map 表里获取就行了**。 比如有数字 `1 ~ 10`,我们要计算每个数字的倍数出现了几次,大概是这么算的: - 1,2,3... 数到 10,那么 1 的倍数有 10 个数字。 - 2,4,6,8,10 数 5 次,那么 2 的倍数有 5 个数字。 - 3,6,9 数 3 次,那么 3 的倍数有 3 个数字。 以此类推,我们发现一个规律,即对于长度为 n 的数组,要数的总次数为 `n + n/2 + n/3 + ... + 1`,这是一个调和数列,具体怎么证明的笔者已经忘了,但可以记住它的值趋向于欧拉常数 + ln(n+1),这就是要数的次数,所以用这个方案,整体时间复杂度是 O(nlnn),比 O(n²) 小了很多。 所以我们只要 “暴力” 的从 1 开始到 nums 最大的数字,把所有数字的倍数都提前计算出来,最后的时间复杂度反而会更小,这是非常神奇的结论。为了避免计算多余的倍数关系,反而时间复杂度是 O(n²),而暴力计算所有数字倍数的时间复杂度竟然是 O(nlnn),这个可以背下来。 接下来就简单了,直接上代码。 用 js 实现 gcd(最大公约数)计算可以用辗转相除法: ```js function gcd(left: number, right: number) { return right === 0 ? left : gcd(right ,left % right) } ``` 整体代码实现: ```js function countPairs(nums: number[], k: number): number { // nums 最大的数字 let max = 0 nums.forEach(num => max = Math.max(num, max)) // Map<数字x, 数字x 倍数在 nums 中出现的次数> const mutipleMap: Record = {} // 先遍历一次 nums,将其倍数次自增 nums.forEach(num => { if (mutipleMap[num] === undefined) { mutipleMap[num] = 1 } else { mutipleMap[num]++ } }) // 按以下规律数倍数出现的次数,但忽略自身 // 1,2,3...,max // 2,4,6...,max // 3,6,9...,max for (let i = 1; i <= max; i++) { for (let j = i * 2; j <= max; j+=i) { if (mutipleMap[i] === undefined) { mutipleMap[i] = 0 } mutipleMap[i] += mutipleMap[j] ?? 0 } } // 答案 let result = 0 // k / gcd(num, k) 的数组出现的次数累加 nums.forEach(num => { const targetMutiple = k / gcd(num, k) result += mutipleMap[targetMutiple] ?? 0 }) // 排除自己乘以自己满足条件的情况 nums.forEach(num => { if (num * num % k === 0) { result-- } }) return result / 2 }; ``` 有几个注意要点。 第一个是 `for (let j = i * 2`,之所以要乘以 2,是因为在前面遍历 nums 时,自己的倍数已经被算过一次,比如 3,6,9 的 3 已经被初始化算过一次,所以从 3*2=6 开始就行了。 第二个是 `mutipleMap[i] += mutipleMap[j]`,比如 i=3,j=9 时,因为 9 是 3 的倍数,所以此时 3 的倍数可以继承 9 的倍数的数量,而数字是不断变大的,所以不会重复。 第三个是 `if (num * num % k === 0) { result-- }`,因为题目要求 `0 <= i < j <= n - 1`,但我们计算倍数时,比如 9 是 3 的倍数,但 9 可以通过 3 * 3 得到,这种不合规的数据要过滤掉。 第四个是 `return result / 2`,因为在最后累加次数时,把每个数字与其他数字都判断了一遍,假设 `1, 3` 是合法的,那么 `3, 1` 也肯定是合法的,但因为 `i < j` 的要求,我们要把 `3, 1` 干掉,所有合法的结果都存在顺序颠倒的 case,所以除以 2. ## 总结 这道题很容易栽在动态规划超时的坑上面,要解决此题需要跨越两座大山: 1. 想到最大公约数与另一个数字之间的关系。 2. 意识到暴力计算倍数的时间复杂度是 O(nlnn)。 最后,本题还隐含了 `n + n/2 + n/3 + ... + 1` 为什么极限是 O(nlnn) 的知识,背后有一个 [调和数列](https://zh.wikipedia.org/zh-cn/%E8%B0%83%E5%92%8C%E7%BA%A7%E6%95%B0) 的大知识背景,感兴趣的同学可以深入了解。 > 讨论地址是:[精读《算法 - 统计可以被 K 整除的下标对数目》· Issue #495 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/495) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 算法/285.精读《算法题 - 最小覆盖子串》.md ================================================ 今天我们看一道 leetcode hard 难度题目:[最小覆盖子串](https://leetcode.cn/problems/minimum-window-substring/description/)。 ## 题目 给你一个字符串 `s` 、一个字符串 `t` 。返回 `s` 中涵盖 `t` 所有字符的最小子串。如果 `s` 中不存在涵盖 `t` 所有字符的子串,则返回空字符串 `""` 。 注意: 对于 `t` 中重复字符,我们寻找的子字符串中该字符数量必须不少于 `t` 中该字符数量。 如果 `s` 中存在这样的子串,我们保证它是唯一的答案。 示例 1: ``` 输入:s = "ADOBECODEBANC", t = "ABC" 输出:"BANC" 解释:最小覆盖子串 "BANC" 包含来自字符串 t 的 'A'、'B' 和 'C'。 ``` ## 思考 最容易想到的思路是,s 从下标 0~n 形成的子串逐个判断是否满足条件,如: - ADOBEC.. - DOBECO.. - OBECOD.. 因为最小覆盖子串是连续的,所以该方法可以保证遍历到所有满足条件的子串。代码如下: ```js function minWindow(s: string, t: string): string { // t 剩余匹配总长度 let tLeftSize = t.length // t 每个字母对应出现次数表 const tCharCountMap = {} for (const char of t) { if (!tCharCountMap[char]) { tCharCountMap[char] = 0 } tCharCountMap[char]++ } let globalResult = '' for (let i = 0; i < s.length; i++) { let currentResult = '' let currentTLeftSize = tLeftSize const currentTCharCountMap = { ...tCharCountMap } // 找到以 i 下标开头,满足条件的字符串 for (let j = i; j < s.length; j++) { currentResult += s[j] // 如果这一项在 t 中存在,则减 1 if (currentTCharCountMap[s[j]] !== undefined && currentTCharCountMap[s[j]] !== 0) { currentTCharCountMap[s[j]]-- currentTLeftSize-- } // 匹配完了 if (currentTLeftSize === 0) { if (globalResult === '') { globalResult = currentResult } else if (currentResult.length < globalResult.length) { globalResult = currentResult } break } } } return globalResult }; ``` 我们用 `tCharCountMap` 存储 `t` 中每个字符出现的次数,在遍历时每次找到出现过的字符就减去 1,直到 `tLeftSize` 变成 0,表示 `s` 完全覆盖了 `t`。 这个方法因为执行了 n + n-1 + n-2 + ... + 1 次,所以时间复杂度是 O(n²),无法 AC,因此我们要寻找更快捷的方案。 ## 滑动窗口 追求性能的降级方案是滑动窗口或动态规划,该题目计算的是字符串,不适合用动态规划。 那滑动窗口是否合适呢? 该题要计算的是满足条件的子串,该子串肯定是连续的,滑动窗口在连续子串匹配问题上是不会遗漏结果的,所以肯定可以用这个方案。 思路也很容易想,即:**如果当前字符串覆盖 `t`,左指针右移,否则右指针右移**。就像一个窗口扫描是否满足条件,需要右指针右移判断是否满足条件,满足条件后不一定是最优的,需要左指针继续右移找寻其他答案。 这里有一个难点是如何高效判断当前窗口内字符串是否覆盖 `t`,有三种想法: 第一种想法是对每个字符做一个计数器,再做一个总计数器,每当匹配到一个字符,当前字符计数器与总计数器 +1,这样直接用总计数器就能判断了。但这个方法有个漏洞,即总计数器没有包含字符类型,比如连续匹配 100 个 `b`,总计数器都 +1,此时其实缺的是 `c`,那么当 `c` 匹配到了之后,总计数器的值并不能判定出覆盖了。 第一种方法的优化版本可能是二进制,比如用 26 个 01 表示,但可惜每个字符出现的次数会超过 1,并不是布尔类型,所以用这种方式取巧也不行。 第二种方法是笨方法,每次递归时都判断下 s 字符串当前每个字符收集的数量是否超过 t 字符串每个字符出现的数量,坏处是每次递归都至多多循环 25 次。 笔者想到的第三种方法是,还是需要一个计数器,但这个计数器 `notCoverChar` 是一个 `Set` 类型,记录了每个 char 是否未 ready,所谓 ready 即该 char 在当前窗口内出现的次数 >= 该 char 在 `t` 字符串中出现的次数。同时还需要有 `sCharMap`、`tCharMap` 来记录两个字符串每个字符出现的次数,当右指针右移时,`sCharMap` 对应 `char` 计数增加,如果该 `char` 出现次数超过 `t` 该 `char` 出现次数,就从 `notCoverChar` 中移除;当左指针右移时,`sCharMap` 对应 `char` 计数减少,如果该 `char` 出现次数低于 `t` 该 `char` 出现次数,该 `char` 重新放到 `notCoverChar` 中。 代码如下: ```js function minWindow(s: string, t: string): string { // s 每个字母出现次数表 const sCharMap = {} // t 每个字母对应出现次数表 const tCharMap = {} // 未覆盖的字符有哪些 const notCoverChar = new Set() // 计算各字符在 t 出现次数 for (const char of t) { if (!tCharMap[char]) { tCharMap[char] = 0 } tCharMap[char]++ notCoverChar.add(char) } let leftIndex = 0 let rightIndex = -1 let result = '' let currentStr = '' // leftIndex | rightIndex 超限才会停止 while (leftIndex < s.length && rightIndex < s.length) { // 未覆盖的条件:notCoverChar 长度 > 0 if (notCoverChar.size > 0) { // 此时窗口没有 cover t,rightIndex 右移寻找 rightIndex++ const nextChar = s[rightIndex] currentStr += nextChar if (sCharMap[nextChar] === undefined) { sCharMap[nextChar] = 0 } sCharMap[nextChar]++ // 如果 tCharMap 有这个 nextChar, 且已收集数量超过 t 中数量,此 char ready if ( tCharMap[nextChar] !== undefined && sCharMap[nextChar] >= tCharMap[nextChar] ) { notCoverChar.delete(nextChar) } } else { // 此时窗口正好 cover t,记录最短结果 if (result === '') { result = currentStr } else if (currentStr.length < result.length) { result = currentStr } // leftIndex 即将右移,将 sCharMap 中对应 char 数量减 1 const previousChar = s[leftIndex] sCharMap[previousChar]-- // 如果 previousChar 在 sCharMap 数量少于 tCharMap 数量,则不能 cover if (sCharMap[previousChar] < tCharMap[previousChar]) { notCoverChar.add(previousChar) } // leftIndex 右移 leftIndex++ currentStr = currentStr.slice(1, currentStr.length) } } return result }; ``` 其中还用了一些小缓存,比如 `currentStr` 记录当前窗口内字符串,这样当可以覆盖 `t` 时,随时可以拿到当前字符串,而不需要根据左右指针重新遍历。 ## 总结 该题首先要排除动态规划,并根据连续子串特性第一时间想到滑动窗口可以覆盖到所有可能性。 滑动窗口方案想到后,需要想到如何高性能判断当前窗口内字符串可以覆盖 `t`,`notCoverChar` 就是一种不错的思路。 > 讨论地址是:[精读《算法 - 最小覆盖子串》· Issue #496 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/496) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 算法/286.精读《算法题 - 地下城游戏》.md ================================================ 今天我们看一道 leetcode hard 难度题目:[地下城游戏](https://leetcode.cn/problems/dungeon-game/description/)。 恶魔们抓住了公主并将她关在了地下城 `dungeon` 的 右下角 。地下城是由 `m x n` 个房间组成的二维网格。我们英勇的骑士最初被安置在 左上角 的房间里,他必须穿过地下城并通过对抗恶魔来拯救公主。 骑士的初始健康点数为一个正整数。如果他的健康点数在某一时刻降至 0 或以下,他会立即死亡。 有些房间由恶魔守卫,因此骑士在进入这些房间时会失去健康点数(若房间里的值为负整数,则表示骑士将损失健康点数);其他房间要么是空的(房间里的值为 0),要么包含增加骑士健康点数的魔法球(若房间里的值为正整数,则表示骑士将增加健康点数)。 为了尽快解救公主,骑士决定每次只 向右 或 向下 移动一步。 返回确保骑士能够拯救到公主所需的最低初始健康点数。 注意:任何房间都可能对骑士的健康点数造成威胁,也可能增加骑士的健康点数,包括骑士进入的左上角房间以及公主被监禁的右下角房间。 > 输入:`dungeon = [[-2,-3,3],[-5,-10,1],[10,30,-5]]` > > 输出:`7` > > 解释:如果骑士遵循最佳路径:右 -> 右 -> 下 -> 下 ,则骑士的初始健康点数至少为 7 。 ## 思考 挺像游戏的一道题,首先只能向下或向右移动,所以每个格子可以由上面或左边的格子移动而来,很自然想到可以用动态规划解决。 再想一想,该题必须遍历整个地下城而无法取巧,因为最低健康点数无法由局部数据算出,这是因为如果不把整个地下城走完,肯定不知道是否有更优路线。 ## 动态规划 二维迷宫用两个变量 `i` `j` 定位,其中 `dp[i][j]` 描述第 `i` 行 `j` 列所需的最低 HP。 但最低所需 HP 无法推断出是否能继续前进,我们还得知道当前 HP 才行,比如: ```js // 从左到右走 3 -> -5 -> 6 -> -9 ``` 在数字 `6` 的位置所需最低 HP 是 `3`,但我们必须知道在 `6` 时勇者剩余 HP 才能判断 `-9` 会不会直接导致勇者挂了,因此我们将 `dp[i][j]` 结果定义为一个数组,第一项表示当前 HP,第二项表示初始所需最低 HP。 代码实现如下: ```js function calculateMinimumHP(dungeon: number[][]): number { // dp[i][j] 表示 i,j 位置 [当前HP, 所需最低HP] const dp = Array.from(dungeon.map(item => () => [0, 0])) // dp[i][j] = 所需最低HP最低(dp[i-1][j], dp[i][j-1]) dp[0][0] = [ dungeon[0][0] > 0 ? 1 + dungeon[0][0] : 1, dungeon[0][0] > 0 ? 1 : 1 - dungeon[0][0] ] for (let i = 0; i < dungeon.length; i++) { for (let j = 0; j < dungeon[0].length; j++) { if (i === 0 && j === 0) { continue } const paths = [] if (i > 0) { paths.push([i - 1, j]) } if (j > 0) { paths.push([i, j - 1]) } const pathResults = paths.map(path => { let leftMaxHealth = dp[path[0]][path[1]][0] + dungeon[i][j] // 剩余HP大于 0 则无需刷新最低HP,否则尝试刷新取最大值 let lowestNeedHealth = dp[path[0]][path[1]][1] if (leftMaxHealth <= 0) { // 最低要求HP补上差价 lowestNeedHealth += 1 - leftMaxHealth // 最低需要HP已补上,所以剩余HP也变成了 1 leftMaxHealth = 1 } return [leftMaxHealth, lowestNeedHealth] }) // 找到 pathResults 中 lowestNeedHealth 最小项 let minLowestNeedHealth = Infinity let minIndex = 0 pathResults.forEach((pathResult, index) => { if (pathResult[1] < minLowestNeedHealth) { minLowestNeedHealth = pathResult[1] minIndex = index } }) dp[i][j] = [pathResults[minIndex][0], pathResults[minIndex][1]] } } return dp[dungeon.length - 1][dungeon[0].length - 1][1] }; ``` 首先计算初始位置 `dp[0][0]`,因为只看这一个点,因此如果有恶魔,最少初始 HP 为能击败恶魔后自己剩 1 HP 就行了,如果房间是空的,至少自己 HP 得是 1(否则勇者进迷宫之前就挂了),如果有魔法球,那么初始 HP 为 1(一样防止进迷宫前挂了)。 初始 HP 稍有不同,如果房间是空的或者有恶魔,那打完恶魔之后最多剩 1 HP 最经济,所以此时 HP 初始值就是 1,如果有魔法球,那么一方面为了防止进入迷宫前自己就挂了,得有个初始 1 的 HP,魔法球又必须得吃,所以 HP 是 1 + 魔法球。 接着就是状态转移方程了,由于 `dp[i][j]` 可以由 `dp[i-1][j]` 或 `dp[i][j-1]` 移动得到(注意 i 或 j 为 0 时的场景),因此我们判断一下从哪条路过来的最低初始 HP 最低就行了。 如果进入当前房间后,房间是空的,有魔法球,或者当前 HP 可以打败恶魔,则不影响最低初始 HP,如果当前 HP 不足以击败恶魔,则我们把缺的 HP 给勇者在初始时补上,此时极限一些还剩 1 HP,得到一个最经济的结果。 然后我们提交代码发现,无法 AC!下面是一个典型挂掉的例子: ``` 1 -3 3 0 -2 0 -3 -3 -3 ``` 我们把 DP 中间过程输出,发现右下角的 5 大于最优答案 3. ```js [ [ 2, 1 ], [ 1, 3 ], [ 4, 3 ] [ 2, 1 ], [ 1, 2 ], [ 1, 2 ] [ 1, 3 ], [ 1, 5 ], [ 1, 5 ] ] ``` 观察发现,勇者先往右走到头,再往下走到头答案就是 3,问题出在 `i=1,j=2` 处,也就是中间行最右列的 `[1, 2]`。但从这一点来看,勇者从左边过来比从上面过来需要的初始 HP 少,因为左边是 `[1, 2]` 上面是 `[4, 3]`,但这导致了答案不是最优解,因为此时剩余 HP 不够,右下角是一个攻击为 3 的恶魔,而如果此时我们选择了初始 HP 高一些的 `[4, 3]`,换来了更高的当前 HP,在不用补初始 HP 的情况就能把右下角恶魔干掉,整体是更划算的。 如果此时我们在玩游戏,读读档也就能找到最优解了,但悲剧的是我们在写一套算法,**我们发现当前 DP 项居然还可能由后面的值(攻击力为 3 的恶魔)决定!** 用专业的话来说就是有后效性导致无法使用 DP。 我们在判断每一步最优解时,其实有两个同等重要的因素影响判断,一个是初始最少所需 HP,它的重要度不言而喻,我们最终就希望这个答案尽可能小;但还有当前 HP 呢,当前 HP 高意味着后面的路会更好走,但我们如果不往后看,就不知道后面是否有恶魔,自然也不知道要不要留着高当前 HP 的路线,所以根本就无法根据前一项下结论。 因为考虑的因素太多了,我们得换成游戏制作者的视角,假设作为游戏设计者,而不是玩家,你会真的从头玩一遍吗?如果真的要设计这种条件很极限的地下城,设计者肯定从结果倒推啊,结果我们勇者就只剩 1 HP 了,至于路上会遇到什么恶魔或者魔法球,反过来倒推就一切尽在掌握了。所以我们得采用从右下角开始走的逆向思维。 ## 逆向思维 为什么从结果倒推,DP 判断条件就没有后效性了呢? 先回忆一下从左上角出发的情况,为什么除了最低初始 HP 外还要记录当前 HP?原因是当前 HP 决定了当前房间的怪物勇者能否打得过,如果打不过,我们得扩大最低初始 HP 让勇者能在仅剩 1 HP 的情况险胜当前房间的恶魔。**但这个当前 HP 值不仅要用来辅助计算最低初始 HP,它还有一个越大越好的性质,因为后面房间可能还有恶魔,得留一些 HP 预防风险**,而 "最低初始 HP" 尽可能低与 "当前 HP" 尽可能高,这两个因素无法同时考虑。 那为什么从右下角,以终为始的考虑就可以少判断一个条件了呢?首先最低初始 HP 我们肯定要判断的,因为答案要的就是这个,那当前 HP 呢?当前 HP 重要吗?不重要,因为你已经拯救到公主了,而且是以最低 HP 1 点的状态救到了公主,按故事路线逆着走,遇到恶魔房间,恶魔攻击是多少我就给你加多少初始 HP,遇到魔法球恢复了我就给你扣对应初始 HP,总之能让你正好战胜恶魔,魔法球补给你的 HP 我也扣掉,就可以了。**核心区别是,此时当前 HP 已经不会影响最低初始 HP 了,因为初始 HP 就是从头推的,我们反着走地下城,每次实际上都是在判断这个点作为起点时的状态,所以与之前的路径无关。** 代码很简单,如下: ```js function calculateMinimumHP(dungeon: number[][]): number { // dp[i][j] 表示 i,j 位置最少HP const dp = Array.from(dungeon.map(item => () => [0, 0])) // 右下角起始 HP 1,遇到怪物加血,遇到魔法球扣血,实际上就是 -dungeon 计算 const si = dungeon.length - 1 const sj = dungeon[0].length - 1 dp[si][sj] = dungeon[si][sj] > 0 ? 1 : 1 - dungeon[si][sj] for (let i = si; i >= 0; i--) { for (let j = sj; j >= 0; j--) { if (i === si && j === sj) { continue } const paths = [] if (i < si) { paths.push([i + 1, j]) } if (j < sj) { paths.push([i, j + 1]) } const pathResults = paths.map(path => dp[path[0]][path[1]] - dungeon[i][j]) // 选出最小 HP 作为 dp[i][j],但不能小于 1 dp[i][j] = Math.max(Math.min(...pathResults), 1) } } return dp[0][0] }; ``` 逆向思维为什么就能减少当前 HP(或者说路径和,或者说所有之前节点的影响)判断呢?我猜你大概率还是没彻底明白。因为这个思考非常关键,可以说是这道题 99% 的困难所在,还是画个图解释一下: 上图是勇者正常探险的思路,下面是逆向(或公主救勇者)的思路。 ## 总结 该题很容易想到使用动态规划解决,但因为目标是求最低的初始健康点需求,所以按照勇者路径走的话,后续未探索的路径会影响到目标,所以我们需要从公主角度反向寻找勇者,才可以保证动态规划的每个判断点都只考虑一个影响因素。 > 讨论地址是:[精读《算法 - 地下城游戏》· Issue #498 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/498) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 算法/288.精读《算法题 - 编辑距离》.md ================================================ 今天我们看一道 leetcode hard 难度题目:[编辑距离](https://leetcode.cn/problems/edit-distance/description/)。 ## 题目 给你两个单词 `word1` 和 `word2`, 请返回将 `word1` 转换成 `word2` 所使用的最少操作数。 你可以对一个单词进行如下三种操作: - 插入一个字符 - 删除一个字符 - 替换一个字符 示例1: ``` 输入:word1 = "horse", word2 = "ros" 输出:3 解释: horse -> rorse (将 'h' 替换为 'r') rorse -> rose (删除 'r') rose -> ros (删除 'e') ``` ## 思考 看到题目的第一感觉是按照人的直觉做题,比如示例中 `horse` 与 `ros` 其中都有 `os`,那么最短编辑距离肯定要维持 `os` 相对位置不变。但该方法可能更适合大模型用直觉做题,而不是和用代码编写,背后有太多无法固化的逻辑。 看来只能用动态规划暴力解决,但如何定义变量呢? 如果我们仅用一个变量,只有两种定义方法: - `dp(i)` 返回 `word1` 下标为 `i` 时最短编辑距离。 - `dp(i)` 返回 `word2` 下标为 `i` 时最短编辑距离。 对第一种定义,我们的目标是计算出 `dp(word1.length-1)`,其中 `dp(-1)` 即 `word1` 从空字符串转换为 `word2` 需要的编剧距离显然是 `word2.length`,即把 `word2` 依次添加到 `word1`。但严重的问题是 `dp(i)` 与 `dp(i-1)` 的关系没有意义,因为从 `dp(-1)` 开始,就已经全部完成了 `word1` 到 `word2` 的转换,如果要计算 `dp(0)`,就只能删除 `word1` 的第 `i` 项,依此类推,这样无法模拟各种可能的操作。对第二中定义也类似。 这种想法的根本问题是,将 `word1` 到 `word2` 转换时,要么一次从空字符串转换为完整的 `word2`,要么从完整的 `word1` 转换为空字符串,这背后无法体现一个一个字符的考虑,所以必须用两个变量,分别定义 `i`、`j` 下标才行。 ## 动态规划 有了上面的思考,动态规划的定义就清楚了: 定义 `i` 为 `word1` 下标,`j` 为 `word2` 下标,`dp(i,j)` 返回 `word1` 下标为 `i`,且 `word2` 下标为 `j` 时最短编辑距离。 让我们再审视一下 `dp(i,j)` 的含义:除了返回最短编辑距离外,正因为我们知道了最短编辑距离,所以无论操作步骤、过程如何,**都可以假设我们只要做了若干步操作,下标分别截止到 `i`、`j` 的 `word1`、`word2` 内容就完全相同了**!这真是太优美了,一下就把复杂的问题简单化了,我们只要考虑当前步骤,也要清晰的知道之前步骤的状态代表什么含义。 接下来考虑状态转移,由于没有任何操作限制,我们必须把所有可能的转移都考虑进去,包括: - `i-1,j` - `i,j-1` - `i-1,j-1` 也许会有新手问,为什么没有考虑 `i-2,j`、`i-3,j-1` 等等情况?这是因为函数是递归调用的,`i-2,j` 等价于 `i-1,j` + `i-1,j`,为了简化代码复杂度,只考虑一层转换的心智负担最低,又能通过递归实现所有操作可能性。 此时老手可能会问,那 `i-1,j-1` 不就等价于 `i-1,j` + `i,j-1` 吗?这么列出来不是重复了吗?说的很对,我们要意识到这一点,但同时还要进一步意识到每一步操作都有成本,本题还支持替换字符串操作,该操作可以一步实现 `i-1,j` + `i,j-1` 两步行为,考虑它可以减少操作步骤,肯定会影响到最终答案。 接着我们要思考状态转移方程是什么,仔细阅读下面的思考过程: 对于 `i-1,j`,因为 `i-1,j` 经过 `dp(i-1,j)` 次数的操作后,`word1[0,i-1]` 与 `word2[0,j]` 已经完全相同了,我们的目的就是让两边字符串相同,所以 `word1[i]` 这个多出来的字符串需要毫不留情的删除,删除需要一步,因此 `dp(i,j) = dp(i-1,j) + 1`,该步是删除。 对于 `i,j-1`,因为 `i,j-1` 经过 `dp(i,j-1)` 次数的操作后,`word1[0,i]` 与 `word2[0,j-1]` 已经完全相同了,我们的目的就是让两边字符串相同,所以 `word2[j]` 这个字符是截止到 `word1[0,i]` 需要新增上去的,因此 `dp(i,j) = dp(i,j-1) + 1`,该步是新增。 对于 `i-1,j-1`,因为 `i-1,j-1` 经过 `dp(i-1,j-1)` 次数的操作后,`word1[0,i-1]` 与 `word2[0,j-1]` 已经完全相同了,我们的目的就是让两边字符串相同,所以对于 `word1[i]` 到 `word2[j]` 来说,如果它俩字符相同,则不需要操作,如果不相同,执行一次替换操作即可,因此在 `word1[i]` 与 `word2[j]` 不同是,`dp(i,j) = dp(i,j-1) + 1`,该步是替换。 对 `i-1,j-1` 的思考让我们意识到需要优先考虑 `word1[i]` 与 `word2[j]` 相同的情况,这种情况只要看前一个字符的结果就行,不需要产生额外的操作,因此转移方程是:`dp(i,j) = dp(i-1,j-1)`。 最后再考虑一下边界情况,当 `word1` 与 `word2` 任意为空时,只要执行非空字符串长度次数的增或删就行了。 ```ts function minDistance(word1: string, word2: string): number { // 任意一个字符串为空时,执行非空字符串长度次数的增或删 if(word1 === '' || word2 === '') { return word1.length + word2.length } const dp = new Map() function getDp(i: number, j: number) { return dp.get(`${i},${j}`) } function calcDp(i: number, j: number) { // 兼容下边界情况 if (i === 0 && j === 0) { return word1[0] === word2[0] ? 0 : 1 } if (word1[i] === word2[j]) { return getDp(i-1, j-1) } return Math.min( // i-1, j: 删除第 i 项 getDp(i-1, j) + 1, // i, j-1: 增加第 i 项 getDp(i, j-1) + 1, // i-1, j-1: 替换第 i 项 getDp(i-1, j-1) + 1 ) } for (let i = 0; i < word1.length; i++) { dp.set(`${i},-1`, i + 1) } for (let j = 0; j < word2.length; j++) { dp.set(`-1,${j}`, j + 1) } for (let i = 0; i < word1.length; i++) { for (let j = 0; j < word2.length; j++) { dp.set(`${i},${j}`, calcDp(i, j)) } } return getDp(word1.length-1, word2.length-1) }; ``` 其中花了我一些时间的地方在于边界情况处理,比如当 `i=0` 时,`dp(i-1,j)` 就出现了 `i=-1` 的情况,因此对于 `i`、`j` 都要提前计算一下为 `-1` 时的值,而当下标为 `-1` 时,等价于该字符串为空,那么空字符串如何转换为 `word2`,或 `word1` 如何转换为空字符串呢?只要执行对方下标 + 1 次的增或删就行了。 ## 总结 当意识到该题没有捷径时,就要考虑动态规划方案了,而动态规划第一难点在于定义参数,第二难点在于写状态转移方程,而只要定义对了参数,状态转移方程也就呼之欲出了,因此最难的一步就是定义参数,对这道题参数定义还有疑问的小伙伴可以回到 思考 章节重新阅读一下。 > 讨论地址是:[精读《算法 - 编辑距离》· Issue #501 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/501) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 算法/289.精读《算法题 - 二叉树中的最大路径和》.md ================================================ 今天我们看一道 leetcode hard 难度题目:[二叉树中的最大路径和](https://leetcode.cn/problems/binary-tree-maximum-path-sum/description/)。 ## 题目 二叉树中的 路径 被定义为一条节点序列,序列中每对相邻节点之间都存在一条边。同一个节点在一条路径序列中 至多出现一次 。该路径 至少包含一个 节点,且不一定经过根节点。 路径和 是路径中各节点值的总和。 给你一个二叉树的根节点 `root` ,返回其 最大路径和 。 示例1: ``` 输入:root = [1,2,3] 输出:6 解释:最优路径是 2 -> 1 -> 3 ,路径和为 2 + 1 + 3 = 6 ``` ## 思考 第一想法是,这道题不安常理出牌,因为路径竟然不是自上而下的,而是可以横向蛇形游走的,如下图: ## 尝试动态规划 第二想法是,这种蛇形游走的路径,求路径最大值应该用什么方法?大概率是暴力解法,因为 **必须遍历完所有节点,才知道是否有更大的值的可能性**,而应对暴力解法最好的策略是动态规划,那么应该如何定义状态?经过一番思考,二叉树点到点之间仅有唯一一条路径,如果我们能枚举计算经过每个点的所有可能路径的最大值,那么找到其中最大的就可以得到答案。但可惜的是,以 “点” 为变量没办法写转移方程。 ## 以暴力解法为基础思考 此时要切换想法,经过一些思考,我决定以正序角度模拟一下寻找最大路径和的思路:首先选择一个起点,找到以该起点开始的最大路径合。那么从该起点就有最多 3 种走法,分别是向根节点走、左子节点、右子节点走: **最暴力的解法是遍历每个点,把所有方向都走一遍,找到所有可能的最大值。** 这无疑是一个最有效的兜底解法,但效率太低,那么为了提升效率,假设一条路径的最大潜力已经计算过一次了,那么一条新路径经过时,就没必要重新算一遍。**所以我们要寻找每个方向的最大贡献**。 ## 寻找每个方向的最大贡献 假设我们提前找到了经过每个点的最大贡献如下: 根节点的最大贡献 10 的含义为:从 3 向根节点走,所有可能路径能带来的最大正数收益为 10。所以此时最大路径和显然为:5 + 3 + 10 = 18. 但此时矛盾来了,根节点的最大贡献 10 是从 3 向根节点走的角度定义的,它有两个致命问题: 1. 每个节点的最大贡献最好只能有一个数字,依赖方向的话复杂度太高了。 2. 如果要依赖方向,那么从根节点右子节点走向根节点的最大贡献,其实依赖从左子结点出发的最大贡献,相互死锁了。 这种最大贡献几乎不可能找到,再花时间思考只是浪费时间,所以我们要改变策略了。再想想二叉树的特征是什么,怎么样能最稳定的定义每个节点的最大贡献?很容易想到的是以树的深度来定义,即 **以当前节点向子节点遍历时,能带来的最大贡献**。这种最大贡献是比较容易计算的。 ## 每个子树的最大贡献 如上图所示,以 8 这个节点的子树,假设通过一系列递归找到,它能提供的最大贡献就是 8,**且这个贡献必须是一条没有分叉的线**,这样这个最大贡献对于它的父节点才有意义,即父节点可以把这个节点连上,形成一条更长的没有分叉的线。如果子线都有分叉,整条线就会存在分叉,就不符合题意了。 这个 8 很容易计算,从叶子结点向上推,找到最大且大于 0 的子节点连成线即可。 但回到这道题,如果我们仅仅计算了每个点所在子树的最大贡献,那么其最大值仅是垂直的线中的最大值,没有考虑到该题路径可以横向蛇形游走的特性: 如上图所示,红色的数字为以该点开始的子树的最大贡献,那么根节点 32 其实就是红色路径提供的路径和,对于纵向走位来说是最大的,但并不是本题最大的。本题最大的值,还得把下图红色的路径考虑上,变成一个横向的线,此时最大值达到了 32 + 8 = 40: **但其实要把线变成横向的,也仅需要多考虑另一个子节点而已**,因为所有子树的最大贡献已经提前算好,根本无需再深入子子节点。也就是说,在计算最大路径和时(重要内容字体加粗!): 1. 经过该点的最大路径和,要同时考虑该点 + 左右子树最大贡献,也就是此时路径会形成类似倒扣的 U 型。 2. 但该节点的最大贡献呢,只能考虑该点 + 左 or 右子树最大贡献的,不能形成倒扣的 U 型,因为这个最大贡献需要被其父节点作第 1 条规则时考虑,如果此时已经是倒扣 U 型了,那么父节点再分叉一次倒扣的 U 型,就不是一条线了,可能会形成如下图所示奇怪的形状: 这就是本题最精彩的思考点。 ## 代码实现 想通了之后,代码就很简单了: ```ts function maxPathSum(root: TreeNode | null): number { let maxValue = -Infinity function maxOneLinePathByNode(node: TreeNode): number { // 如果节点为空,返回负无穷,必然不会被最大路径和带上 if (node === null) return -Infinity // 左子树最大贡献(如果为负数则为 0,表示不带上左子树) const leftChildMaxValue = Math.max(maxOneLinePathByNode(node.left), 0) // 右子树最大贡献 - 同理 const rightChildMaxValue = Math.max(maxOneLinePathByNode(node.right), 0) // 经过该点的最大路径和 const currentPointMaxValue = node.val + leftChildMaxValue + rightChildMaxValue // 刷新 maxValue maxValue = Math.max(maxValue, currentPointMaxValue) // 返回不分叉的子树最大贡献 return node.val + Math.max(leftChildMaxValue, rightChildMaxValue) } maxOneLinePathByNode(root) return maxValue }; ``` 因为从根节点开始递归,可以算出所有子树的最大贡献,**把经过每一个点的路径都考虑到了**,所以答案是不重不漏的。 ## 总结 该题有两个难点: 1. 找到子树最大贡献思考方向。 2. 子树最大贡献与最大路径和的计算方式稍有不同,需要分别处理。 最后,在从根节点递归寻找子树最大贡献时,就可以顺便计算出最大路径和,一定程度上是 “目标的副产物”,甚至可以怀疑该题是在思考子树最大贡献时,逆向推导出来的副产物。另一方面,也说明了子树最大贡献的重要性,它的一个衍生计算就可以是一道 hard 题。 > 讨论地址是:[精读《算法 - 二叉树中的最大路径和》· Issue #504 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/505) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 编译原理/64.精读《手写 SQL 编译器 - 词法分析》.md ================================================ ## 1 引言 因为工作关系,需要开发支持众多方言的 SQL 编辑器,所以复习了一下编译原理相关知识。 相比编译原理专家,我们只需要了解部分编译原理即可实现 SQL 编辑器,所以这是一篇写给前端的编译原理文章。 解析 SQL 可以分为如下四步: 1. 词法分析,将 SQL 字符串拆分成包含关键词识别的字符段(Tokens)。 2. 语法分析,利用自顶向下或自底向上的算法,将 Tokens 解析为 AST,可以手动,也可以自动。 3. 错误检测、恢复、提示推断,都需要利用语法分析产生的 AST。 4. 语义分析,做完这一步就可以执行 SQL 语句了,不过对前端而言,不需要深入到这一步,可以跳过。 ## 2 精读 词法分析就像刀削面的过程,拿着一段字符串(面条)一端不断下刀,当面条被切完也就完成了词法分析,所以词法分析是 字符串 -> 一堆字符段 的过程。 流程很简单,难点就在下刀的分寸了,每次砍几厘米呢? 回到词法分析,为了准备切分,我们需要定义 SQL 的 Token 有哪些类型,即 Token 分类。 ### Token 分类 SQL 的 Token 可以分为如下几类: - 注释。 - 关键字(`SELECT`、`CREATE`)。 - 操作符(`+`、`-`、`>=`)。 - 开闭合标志(`(`、`CASE`)。 - 占位符(`?`)。 - 空格。 - 引号包裹的文本、数字、字段。 - 方言语法(`${variable}`)。 可以看到,在词法分析阶段,我们的 Tokens 不需要关心关键词是什么,只要识别是不是关键词即可,因为关键词的辨认会留到语法分析时处理。涉及到语意处理就要考虑上下文,而这都不是词法分析阶段要考虑的。 同样,操作符、空格、文本、占位符等构成了 SQL 语句的其他部分,最后通过开闭合标志比如左括号和右括号,让 SQL 支持子语句。 再强调一次,虽然 SQL 支持子语句,但并不是放在任何位置都是合理的,其他类型 Token 同理,但是词法分析不需要考虑 Token 是否合理,只要切分即可。 ### 用正则逐段分词 像大多数语言一样,SQL 为了方便人类阅读,采用从左到右的书写方式,因此**分词方向也从左到右**。 我们为每个 Token 类型写一个函数,比如匹配空格的匹配函数: ```typescript function getTokenWhitespace(restStr: string) { const matches = restStr.match(/^(\s+)/); if (matches) { return { type, value: matches[1] }; } } ``` `restStr` 表示掐去头部剩下的 SQL 字符串,所有匹配函数都拿 `restStr` 进行匹配,已经匹配的不需要再处理。 通过正则 `/^(\s+)/` 匹配到第一个以空格开头的空格(读起来有点别扭),匹配时必须保证以你要匹配的内容开头,而且只匹配一次,这样才不会在切词时发生遗漏。 同理匹配 `/**/` 类型注释时,也能通过正则轻而易举的实现: ```typescript function getTokenBlockComment(restStr: string) { const matches = restStr.match(/^(\/\*[^]*?(?:\*\/|$))/); if (matches) { return { type, value: matches[1] }; } } ``` 其中 `(?:\*/\)` 表示匹配到以 `*/` 结尾处,而 `(?:\*\/|$)` 后面的 `|$` 表示或者直接匹配到结尾(如果一直没有遇到 `*/` 那后面全部当作注释)。 所以只要 Token 分类得当,并且能为每一个分类写一个头匹配正则,分词功能就实现了 90%。 ### 方言拓展 为了支持某些方言,需要从分词时就开始做考虑。比如 `${variable}` 作为一种变量用法时,我们需要在普通字段的正则匹配中,加入一项 `\$\{[a-zA-Z0-9]+\}` 匹配。 如果要支持纯中文作为字段,可以再补充 `|\u4e00-\u9fa5`。 ### 分词主流程 有了一个个分词函数,再补充一个不断匹配、切割字符串、再匹配的主函数即可,这一步更简单: ```typescript while (sqlStr) { token = getTokenWhitespace(sqlStr, token) || getTokenBlockComment(sqlStr, token); sqlStr = sqlStr.substring(token.value.length); tokens.push(token); } ``` 上面的函数每取一次 Token,都将取到的 Token 长度丢掉,继续匹配剩下的字符串,直到字符串被切分完为止。 有些特殊情况需要拿到上次的 Token 才能判断下一个 Token 该如何切割,所以将 Token 传给每一个下一步 Match 函数。 最后,执行这个主函数,分词就完成了! ## 3 总结 分词比较简单,到这里就全部结束了。后面即将进入深水区语法分析,敬请期待。 ## 4 更多讨论 > 讨论地址是:[精读《手写 SQL 编译器 - 词法分析》 · Issue #93 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/93) **如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。** ================================================ FILE: 编译原理/65.精读《手写 SQL 编译器 - 文法介绍》.md ================================================ ## 1 引言 文法用来描述语言的语法规则,所以不仅可以用在编程语言上,也可用在汉语、英语上。 ## 2 精读 我们将一块语法规则称为 **产生式**,使用 “Left → Right” 表示任意产生式,用 “Left => Right” 表示产生式的推导过程,比如对于产生式: ```plain E → i E → E + E ``` 我们进行推导时,可以这样表示:E => E + E => i + E => i + i + E => i + i + i > 也有使用 Left : Right 表示产生式的例子,比如 ANTLR。[BNF](https://zh.wikipedia.org/wiki/%E5%B7%B4%E7%A7%91%E6%96%AF%E8%8C%83%E5%BC%8F) 范式通过 Left ::= Right 表示产生式。 举个例子,比如 `SELECT * FROM table` 可以被表达为: ```plain S → SELECT * FROM table ``` 当然这是最固定的语法,真实场景中,`*` 可能被替换为其他单词,而 `table` 不但可能有其他名字,还可能是个子表达式。 > 一般用大写的 S 表示文法的开头,称为开始符号。 ### 终结符与非终结符 > 下面为了方便书写,使用 BNF 范式表示文法。 终结符就是语句的终结,读到它表示产生式分析结束,相反,非终结符就是一个新产生式的开始,比如: ```plain ::= SELECT FROM ::= [ , ] ::= [ , ] ``` 所有 `::=` 号左边的都是非终结符,所以 `selectList` 是非终结符,解析 `selectStatement` 时遇到了 `selectList` 将会进入 `selectList` 产生式,而解析到普通 `SELECT` 单词就不会继续解析。 对于有二义性的文法,可以通过 **上下文相关文法** 方式描述,也就是在产生式左侧补全条件,解决二义性: ```plain aBc -> a1c | a2c dBe -> d3e ``` > 一般产生式左侧都是非终结符,大写字母是非终结符,小写字母是终结符。 上面表示,非终结符 `B` 在 `ac` 之间时,可以解析为 `1` 或 `2`,而在 `de` 之间时,解析为 `3`。但我们可以增加一个非终结符让产生式可读性更好: ```plain B -> 1 | 2 C -> 3 ``` 这样就将上下文相关文法转换为了上下文无关文法。 ### 上下文无关文法 根据是否依赖上下文,文法分为 **上下文相关文法** 与 **上下文无关文法**,一般来说 **上下文相关文法** 都可以转换为一堆 **上下文无关文法** 来处理,而用程序处理 **上下文无关文法** 相对轻松。 SQL 的文法就是上下文相关文法,在正式介绍 SQL 文法之前,举一个简单的例子,比如我们描述等号(=)的文法: ```sql SELECT CASE WHEN bee = 'red' THEN 'ANGRY' ELSE 'NEUTRAL' END AS BeeState FROM bees; SELECT * from bees WHERE bee = 'red'; ``` 上面两个 SQL 中,等号前后的关键字取决于当前是在 `CASE WHEN` 语句里,还是在 `WHERE` 语句里,所以我们认为等号所在位置的文法是上下文相关的。 但是当我们将文法粒度变细,将 `CASE WHEN` 与 `WHERE` 区块分别交由两块文法解决,将等号这个通用的表达式抽离出来,就可以不关心上下文了,这种方式称为 **上下文无关文法**。 附上一个 [mysql 上下文无关文法集合](https://github.com/antlr/grammars-v4/blob/master/sql/mysql/Positive-Technologies/MySqlParser.g4)。 ### 左推导与右推导 上面提到的推导符号 `=>` 在实际运行过程中,显然有两种方向左和右: ```plain E + E => ? ``` 从最左边的 E 开始分析,称为左推导,对语法解析来说是自顶向下的方式,常用方法是递归下降。 从最右边的 E 开始分析,称为右推导,对语法解析来说是自底向上的方式,常用方法是移进、规约。 右推导过程比左推导过程复杂,所以如果考虑手写,最好使用左推导的方式。 ### 左推导的分支预测 比如 `select ` 的 `selectList` 产生式,它可以表示为: ```plain ::= , | ``` 由于它可以展开:SelectList => SelectList , a => SelectList , b, a => c, b, a。 但程序执行时,读到这里会进入死循环,因为 SelectList 可以被无限展开,这就是左递归问题。 ### 消除左递归 消除左递归一般通过转化为右递归的方式,因为左递归完全不消耗 Token,而右递归可以通过消耗 Token 的方式跳出死循环。 > Token 见上一期精读 [精读《手写 SQL 编译器 - 词法分析》](https://github.com/dt-fe/weekly/blob/master/64.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%8D%E6%B3%95%E5%88%86%E6%9E%90%E3%80%8B.md) ```plain ::= ::= , | null ``` 这其实是一个通用处理,可以抽象出来: ```plain E → E + F E → F ``` ```plain E → FG G → + FG G → null ``` 不过我们也不难发现,通过通用方式消除左递归后的文法更难以阅读,这是因为用死循环的方式解释问题更容易让人理解,但会导致机器崩溃。 笔者建议此处不要生硬的套公式,在套了公式后,再对产生式做一些修饰,让其更具有语义: ```plain ::= | , ``` ### 提取左公因式 即便是上下文无关的文法,通过递归下降方式,许多时候也必须从左向右超前查看 K 个字符才能确定使用哪个产生式,这种文法称为 LL(k)。 但如果每次超前查看的内容都有许多字符相同,会导致第二次开始的超前查看重复解析字符串,影响性能。最理想的情况是,每次超前查看都不会对已确定的字符重复查看,解决方法是提取左公因式。 设想如下的 sql 文法: ```plain ::= as | as | | ``` 其实 Text 本身也是比较复杂的产生式,最坏的情况需要对 Text 连续匹配六遍。我们将 Text 公因式提取出来就可以仅匹配一遍,因为无论是何种 Field 产生式,都必定先遇到 Text: ```plain ::= ::= | ::= as ::= | ``` 和消除左递归一样,提取左公因式也会降低文法的可读性,需要进行人为修复。不过提取左公因式的修复没办法在文法中处理,在后面的 “函数式” 处理环节是有办法处理的,敬请期待。 ### 结合优先级 对 SQL 的文法来说不存在优先级的概念,所以从某种程度来说,SQL 的语法复杂度还不如基本的加减乘除。 ## 3 总结 在实现语法解析前,需要使用文法描述 SQL 的语法,文法描述就是语法分析的主干业务代码。 下一篇将介绍语法分析相关知识,帮助你一步步打造自己的 SQL 编译器。 ## 4 更多讨论 > 讨论地址是:[精读《手写 SQL 编译器 - 文法介绍》 · Issue #94 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/94) **如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。** ================================================ FILE: 编译原理/66.精读《手写 SQL 编译器 - 语法分析》.md ================================================ ## 1 引言 接着上周的文法介绍,本周介绍的是语法分析。 以解析顺序为角度,语法分析分为两种,自顶而下与自底而上。 自顶而下一般采用递归下降方式处理,称为 LL(k),第一个 L 是指从左到右分析,第二个 L 指从左开始推导,k 是指超前查看的数量,如果实现了回溯功能,k 就是无限大的,所以带有回溯功能的 LL(k) 几乎是最强大的。LL 系列一般分为 LL(0)、LL(1)、LL(k)、LL(∞)。 自底而上一般采用移进(shift)规约(reduce)方式处理,称为 LR,第一个 L 也是从左到右分析,第二个 R 指从右开始推导,而规约时可能产生冲突,所以通过超前查看一个符号解决冲突,就有了 SLR,后面还有功能更强的 [LALR(1)](https://www.cs.clemson.edu/course/cpsc827/material/LRk/LALR1.pdf) [LR(1)](https://www.cs.clemson.edu/course/cpsc827/material/LRk/LR1.pdf) [LR(k)](https://pdfs.semanticscholar.org/e450/eeebc5b37cdbf4d853a70955f7088984c8a5.pdf)。 通过这张图可以看到 LL 家族与 LR 家族的能力范围: 如图所示,无论 LL 还是 LR 都解决不了二义性文法,还好所有计算机语言都属于无二义性文法。 值得一提的是,如果实现了回溯功能的 LL(k) -> LL(∞),那么能力就可以与 LR(k) 所比肩,而 LL 系列手写起来更易读,所以笔者采用了 LL 方式书写,今天介绍如何手写无回溯功能的 LL。 > 另外也有一些根据文法自动生成 parser 的库,比如兼容多语言的 [antlr4](https://github.com/antlr/antlr4) 或者对 js 支持比较友好的 [pegjs](https://github.com/pegjs/pegjs)。 ## 2 精读 递归下降可以理解为走多出口的迷宫: 我们先根据 SQL 语法构造一个迷宫,进迷宫的不是探险家,而是 SQL 语句,这个 SQL 语句会拿上一堆令牌(切分好的 Tokens,详情见 [精读:词法分析](https://github.com/dt-fe/weekly/blob/master/64.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%8D%E6%B3%95%E5%88%86%E6%9E%90%E3%80%8B.md)),迷宫每前进一步都会要求按顺序给出令牌(交上去就没收),如果走到出口令牌刚好交完,就成功走出了迷宫;如果出迷宫时手上还有令牌,会被迷宫工作人员带走。这个迷宫会有一些分叉,在分岔路上会要求你亮出几个令牌中任意一个即可通过(LL1),有的迷宫允许你失败了存档,只要没有走出迷宫,都可以读档重来(LLk),理论上可以构造一个最宽容的迷宫,只要还没走出迷宫,可以在分叉处任意读档(LL∞),这个留到下一篇文章介绍。 ### 词法分析 首先对 SQL 进行词法分析,拿到 Tokens 列表,这些就是探险家 SQL 带上的令牌。 根据上次讲的内容,我们对 `select a from b` 进行词法分析,可以拿到四个 Token(忽略空格与注释)。 ### Match 函数 递归下降最重要的就是 Match 函数,它就是迷宫中索取令牌的关卡。每个 Match 函数只要匹配上当前 Token 便将 Token index 下移一位,如果没有匹配上,则不消耗 Token: ```typescript function match(word: string) { const currentToken = tokens[tokenIndex] // 拿到当前所在的 Token if (currentToken.value === word) { // 如果 Token 匹配上了,则下移一位,同时返回 true tokenIndex++ return true } // 没有匹配上,不消耗 Token,但是返回 false return false } ``` Match 函数就是精简版的 if else,试想下面一段代码: ```typescript if (token[tokenIndex].value === 'select') { tokenIndex++ } else { return false } if (token[tokenIndex].value === 'a') { tokenIndex++ } else { return false } ``` 通过不断对比与移动 Token 进行判断,等价于下面的 Match 实现: ```typescript match('select') && match('a') ``` 这样写出来的语法分析代码可读性会更强,我们能专注精神在对文法的解读上,而忽略其他环境因素。 --- 顺便一提,下篇文章笔者会带来更精简的描述方法: ```typescript chain('select', 'a') ``` 让函数式语法更接近文法形式。 > 最后这种语法不但描述更为精简,而且拥有 LL(∞) 的查找能力,拥有几乎最强大的语法分析能力。 ### 语法分析主体函数 既然关卡(Match)已经有了,下面开始构造主函数了,可以开始画迷宫了。 举个最简单的例子,我们想匹配 `select a from b`,只需要这么构造主函数: ```typescript let tokenIndex = 0 function match() { /* .. */ } const root = () => match("select") && match("a") && match("from") && match("b") tokens = lexer("select a from b") if (root() && tokenIndex === tokens.length) { // sql 解析成功 } ``` 为了简化流程,我们把 tokens、tokenIndex 作为全局变量。首先通过 `lexer` 拿到 `select a from b` 语句的 Tokens:`['select', ' ', 'a', ' ', 'from', ' ', 'b']`,注意**在语法解析过程中,注释和空格可以消除**,这样可以省去对空格和注释的判断,大大简化代码量。所以最终拿到的 Tokens 是 `['select', 'a', 'from', 'b']`。 很显然这样与我们构造的 Match 队列相吻合,所以这段语句顺利的走出了迷宫,而且走出迷宫时,Token 正好被消费完(`tokenIndex === tokens.length`)。 这样就完成了最简单的语法分析,一共十几行代码。 ### 函数调用 函数调用是 JS 最最基础的知识,但用在语法解析里可就不那么一样了。 考虑上面最简单的语句 `select a from b`,显然无法胜任真正的 SQL 环境,比如 `select [位置] from b` 这个位置可以放置任意用逗号相连的字符串,我们如果将这种 SQL 展开描述,将非常复杂,难以阅读。恰好函数调用可以帮我们完美解决这个问题,我们将这个位置抽象为 `selectList` 函数,所以主语句改造如下: ```typescript const root = () => match("select") && selectList() && match("from") && match("b") ``` 这下能否解析 `select a, b, c from table` 就看 `selectList` 这个函数了: ```typescript const selectList = match("a") && match(",") && match("b") && match(",") && match("c") ``` 显然这样做不具备通用性,因为我们将参数名与数量固定了。考虑到上期精读学到的[文法](https://github.com/dt-fe/weekly/blob/master/65.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E6%96%87%E6%B3%95%E4%BB%8B%E7%BB%8D%E3%80%8B.md),我们可以这样描述 `selectList`: ```plain selectList ::= word (',' selectList)? word ::= [a-zA-Z] ``` > 故意绕过了左递归,采用右递归的写法,因而避开了语法分析的核心难点。 > ? 号是可选的意思,与正则的 ? 类似。 这是一个右递归文法,不难看出,这个文法可以如此展开: selectList => word (',' selectList)? => a (',' selectList)? => a, word (',' selectList)? => a, b, word (',' selectList)? => a, b, word => a, b, c 我们一下遇到了两个问题: 1. 补充 word 函数。 2. 如何描述可选参数。 同理,利用函数调用,我们假定拥有了可选函数 `optional`,与函数 `word`,这样可以先把 `selectList` 函数描述出来: ```typescript const selectList = () => word() && optional(match(",") && selectList()) ``` 这样就通过可选函数 `optional` 描述了文法符号 `?`。 我们来看 `word` 函数如何实现。需要简单改造下 `match` 使其支持正则,那么 `word` 函数可以这样描述: ```typescript const word = () => match(/[a-zA-Z]*/) ``` 而 `optional` 不是普通的 `match` 函数,从调用方式就能看出来,我们提到下一节详细介绍。 注意 `selectList` 函数的尾部,通过右递归的方式调用 `selectList`,因此可以解析任意长度以 `,` 分割的字段列表。 > Antlr4 支持左递归,因此文法可以写成 selectList ::= selectList (, word)? | word,用在我们这个简化的代码中会导致堆栈溢出。 在介绍 `optional` 函数之前,我们先引出分支函数,因为可选函数是分支函数的一种特殊形式(猜猜为什么?)。 ### 分支函数 我们先看看函数 `word`,其实没有考虑到函数作为字段的情况,比如 `select a, SUM(b) from table`。所以我们需要升级下 `selectList` 的描述: ```typescript const selectList = () => field() && optional(match(",") && selectList()) const field = () => word() ``` 这时注意 `field` 作为一个字段,也可能是文本或函数,我们假设拥有函数处理函数 `functional`,那么用文法描述 `field` 就是: ```plain field ::= text | functional ``` `|` 表示分支,我们用 `tree` 函数表示分支函数,那么可以如此改写 `field`: ```typescript const field = () => tree(word(), functional()) ``` 那么改如何表示 `tree` 呢?按照分支函数的特性,`tree` 的职责是超前查看,也就是超前查看 `word` 是否符合当前 Token 的特征,如何符合,则此分支可以走通,如果不符合,同理继续尝试 `functional`。 > 若存在 A、B 分支,由于是函数式调用,若 A 分支为真,则函数堆栈退出到上层,若后续尝试失败,则无法再回到分支 B 继续尝试,因为函数栈已经退出了。这就是本文开头提到的 **回溯** 机制,对应迷宫的 **存档、读档** 机制。要实现回溯机制,要模拟函数执行机制,拿到函数调用的控制权,这个下篇文章再详细介绍。 根据这个特性,我们可以写出 `tree` 函数: ```typescript function tree(...args: any[]) { return args.some(arg => arg()) } ``` 按照顺序执行 `tree` 的入参,如果有一个函数执行为真,则跳出函数,如果所有函数都返回 false,则这个分支结果为 false。 考虑到每个分支都会消耗 Token,所以我们需要在执行分支时,先把当前 TokenIndex 保存下来,如果执行成功则消耗,执行失败则还原 Token 位置: ```typescript function tree(...args: any[]) { const startTokenIndex = tokenIndex return args.some(arg => { const result = arg() if (!result) { tokenIndex = startTokenIndex // 执行失败则还原 TokenIndex } return result }); } ``` ### 可选函数 可选函数就是分支函数的一个特例,可以描述为: ```plain func? => func | ε ``` ε 表示空,也就是这个产生式解析到这里永远可以解析成功,而且不消耗 Token。借助分支函数 `tree` 执行失败后还原 TokenIndex 的特性,我们先尝试执行它,执行失败的话,下一个 ε 函数一定返回 true,而且会重置 TokenIndex 且不消耗 Token,这与可选的含义是等价的。 所以可以这样描述 `optional` 函数: ```typescript const optional = fn => tree(fn, () => true) ``` ### 基本的运算连接 上面通过对 SQL 语句的实践,发现了 `match` 匹配单个单词、 `&&` 连接、`tree` 分支、`ε` 空字符串的产生式这四种基本用法,这是符合下面四个基本文法组合思想的: ```plain G ::= ε ``` 空字符串产生式,对应 `() => true`,不消耗 Token,总是返回 `true`。 ```plain G ::= t ``` 单词匹配,对应 `match(t)`。 ```plain G ::= x y ``` 连接运算,对应 `match(x) && match(y)`。 ```plain G ::= x G ::= y ``` 并运算,对应 `tree(x, y)`。 有了这四种基本用法,几乎可以描述所有 SQL 语法。 比如简单描述一下 select 语法: ```typescript const root = () => match("select") && select() && match("from") && table() const selectList = () => field() && optional(match(",") && selectList()) const field = () => tree(word, functional) const word = () => match(/[a-zA-Z]+/) ``` ## 3 总结 递归下降的 SQL 语法解析就是一个走迷宫的过程,将 Token 从左到右逐个匹配,最终能找到一条路线完全贴合 Token,则 SQL 解析圆满结束,这个迷宫采用空字符串产生式、单词匹配、连接运算、并运算这四个基本文法组合就足以构成。 掌握了这四大法宝,基本的 SQL 解析已经难不倒你了,下一步需要做这些优化: - 回溯功能,实现它才可能实现 LL(∞) 的匹配能力。 - 左递归自动消除,因为通过文法转换,会改变文法的结合律与语义,最好能实现左递归自动消除(左递归在上一篇精读 [文法](https://github.com/dt-fe/weekly/blob/master/65.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E6%96%87%E6%B3%95%E4%BB%8B%E7%BB%8D%E3%80%8B.md) 有说明)。 - 生成语法树,仅匹配语句的正确性是不够的,我们还要根据语义生成语法树。 - 错误检查,在错误的地方给出建议,甚至对某些错误做自动修复,这个在左 SQL 智能提示时需要用到。 - 错误恢复。 下篇文章会介绍如何实现回溯,让递归下降达到 LL(∞) 的效果。 从本文不难看出,通过函数调用方式我们无法做到 **迷宫存档和读档机制**,也就是遇到岔路 A B 时,如果 A 成功了,函数调用栈就会退出,而后面迷宫探索失败的话,我们无法回到岔路 B 继续探索。而 **回溯功能就赋予了这个探险者返回岔路 B 的能力**。 为了实现这个功能,几乎要完全推翻这篇文章的代码组织结构,不过别担心,这四个基本组合思想还会保留。 下篇文章也会放出一个真正能运行的,实现了 LL(∞) 的代码库,函数描述更精简,功能(比这篇文章的方法)更强大,敬请期待。 ## 4 更多讨论 > 讨论地址是:[精读《手写 SQL 编译器 - 语法分析》 · Issue #95 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/95) **如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。** ================================================ FILE: 编译原理/67.精读《手写 SQL 编译器 - 回溯》.md ================================================ ## 1 引言 上回 [精读《手写 SQL 编译器 - 语法分析》](https://github.com/dt-fe/weekly/blob/master/66.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%AD%E6%B3%95%E5%88%86%E6%9E%90%E3%80%8B.md) 说到了如何利用 Js 函数实现语法分析时,留下了一个回溯问题,也就是存档、读档问题。 我们把语法分析树当作一个迷宫,有直线有岔路,而想要走出迷宫,在遇到岔路时需要提前进行存档,在后面走错时读档换下一个岔路进行尝试,这个功能就叫回溯。 上一篇我们实现了 **分支函数**,在分支执行失败后回滚 TokenIndex 位置并重试,但在函数调用栈中,如果其子函数执行完毕,堆栈跳出,我们便无法找到原来的函数栈重新执行。 为了更加详细的描述这个问题,举一个例子,存在以下岔路: ```plain a -> tree() -> c -> b1 -> b1' -> b2 -> b2' ``` 上面描述了两条判断分支,分别是 `a -> b1 -> b1' -> c` 与 `a -> b2 -> b2' -> c`,当岔路 `b1` 执行失败后,分支函数 `tree` 可以复原到 `b2` 位置尝试重新执行。 但设想 `b1 -> b1'` 通过,但 `b1 -> b1' -> c` 不通过的场景,由于 `b1'` 执行完后,分支函数 `tree` 的调用栈已经退出,无法再尝试路线 `b2 -> b2'` 了。 要解决这个问题,我们要 **通过链表手动构造函数执行过程**,这样不仅可以实现任意位置回溯,还可以解决左递归问题,因为函数并不是立即执行的,在执行前我们可以加一些 Magic 动作,比如调换执行顺序!这文章主要介绍如何通过链表构造函数调用栈,并实现回溯。 ## 2 精读 假设我们拥有了这样一个函数 `chain`,可以用更简单的方式表示连续匹配: ```typescript const root = (tokens: IToken[], tokenIndex: number) => match('a', tokens, tokenIndex) && match('b', tokens, tokenIndex) && match('c', tokens, tokenIndex) ↓ ↓ ↓ ↓ ↓ ↓ const root = (chain: IChain) => chain('a', 'b', 'c') ``` 遇到分支条件时,通过数组表示取代 `tree` 函数: ```typescript const root = (tokens: IToken[], tokenIndex: number) => tree( line(match('a', tokens, tokenIndex) && match('b', tokens, tokenIndex)), line(match('c', tokens, tokenIndex) && match('d', tokens, tokenIndex)) ) ↓ ↓ ↓ ↓ ↓ ↓ const root = (chain: IChain) => chain([ chain('a', 'b'), chain('c', 'd') ]) ``` 这个 `chain` 函数有两个特质: 1. 非立即执行,我们就可以 **预先生成执行链条** ,并对链条结构进行优化、甚至控制执行顺序,实现回溯功能。 2. 无需显示传递 Token,减少每一步匹配写的代码量。 ### 封装 scanner、matchToken 我们可以制作 scanner 函数封装对 token 的操作: ```typescript const query = "select * from table;"; const tokens = new Lexer(query); const scanner = new Scanner(tokens); ``` scanner 拥有两个主要功能,分别是 `read` 读取当前 token 内容,和 `next` 将 token 向下移动一位,我们可以根据这个功能封装新的 `matchToken` 函数: ```typescript function matchToken( scanner: Scanner, compare: (token: IToken) => boolean ): IMatch { const token = scanner.read(); if (!token) { return false; } if (compare(token)) { scanner.next(); return true; } else { return false; } } ``` 如果 token 消耗完,或者与比对不匹配时,返回 false 且不消耗 token,当匹配时,消耗一个 token 并返回 true。 现在我们就可以用 `matchToken` 函数写一段匹配代码了: ```typescript const query = "select * from table;"; const tokens = new Lexer(query); const scanner = new Scanner(tokens); const root = matchToken(scanner, token => token.value === "select") && matchToken(scanner, token => token.value === "*") && matchToken(scanner, token => token.value === "from") && matchToken(scanner, token => token.value === "table") && matchToken(scanner, token => token.value === ";"); ``` 我们最终希望表达成这样的结构: ```typescript const root = (chain: IChain) => chain("select", "*", "from", "table", ";"); ``` 既然 chain 函数作为线索贯穿整个流程,那 scanner 函数需要被包含在 chain 函数的闭包里内部传递,所以我们需要构造出第一个 chain。 ### 封装 createChainNodeFactory 我们需要 createChainNodeFactory 函数将 scanner 传进去,在内部偷偷存起来,不要在外部代码显示传递,而且 chain 函数是一个高阶函数,不会立即执行,由此可以封装二阶函数: ```typescript const createChainNodeFactory = (scanner: Scanner, parentNode?: ChainNode) => ( ...elements: any[] ): ChainNode => { // 生成第一个节点 return firstNode; }; ``` 需要说明两点: 1. chain 函数返回第一个链表节点,就可以通过 visiter 函数访问整条链表了。 2. `(...elements: any[]): ChainNode` 就是 chain 函数本身,它接收一系列参数,根据类型进行功能分类。 有了 createChainNodeFactory,我们就可以生成执行入口了: ```typescript const chainNodeFactory = createChainNodeFactory(scanner); const firstNode = chainNodeFactory(root); // const root = (chain: IChain) => chain('select', '*', 'from', 'table', ';') ``` 为了支持 `chain('select', '*', 'from', 'table', ';')` 语法,我们需要在参数类型是文本类型时,自动生成一个 matchToken 函数作为链表节点,同时通过 reduce 函数将链表节点关联上: ```typescript const createChainNodeFactory = (scanner: Scanner, parentNode?: ChainNode) => ( ...elements: any[] ): ChainNode => { let firstNode: ChainNode = null; elements.reduce((prevNode: ChainNode, element) => { const node = new ChainNode(); // ... Link node node.addChild(createChainChildByElement(node, scanner, element)); return node; }, parentNode); return firstNode; }; ``` 使用 reduce 函数对链表上下节点进行关联,这一步比较常规所以忽略掉,通过 createChainChildByElement 函数对传入函数进行分类,如果 **传入函数是字符串,就构造一个 matchToken 函数塞入当前链表的子元素**,当执行链表时,再执行 matchToken 函数。 重点是我们对链表节点的处理,先介绍一下链表结构。 ### 链表结构 ```typescript class ChainNode { public prev: ChainNode; public next: ChainNode; public childs: ChainChild[] = []; } class ChainChild { // If type is function, when run it, will expend. public type: "match" | "chainNode" | "function"; public node?: IMatchFn | ChainNode | ChainFunctionNode; } ``` ChainNode 是对链表节点的定义,这里给出了和当前文章内容相关的部分定义。这里用到了双向链表,因此每个 node 节点都拥有 prev 与 next 属性,分别指向上一个与下一个节点,而 childs 是这个链表下挂载的节点,可以是 matchToken 函数、链表节点、或者是函数。 整个链表结构可能是这样的: ```plain node1 <-> node2 <-> node3 <-> node4 |- function2-1 |- matchToken2-1 |- node2-1 <-> node2-2 <-> node2-3 |- matchToken2-2-1 ``` 对每一个节点,都至少存在一个 child 元素,如果存在多个子元素,则表示这个节点是 tree 节点,存在分支情况。 而节点类型 `ChainChild` 也可以从定义中看到,有三种类型,我们分别说明: #### matchToken 类型 这种类型是最基本类型,由如下代码生成: ```typescript chain("word"); ``` 链表执行时,match 是最基本的执行单元,决定了语句是否能匹配,也是唯一会消耗 Token 的单元。 #### node 类型 链表节点的子节点也可能是一个节点,类比嵌套函数,由如下代码生成: ```typescript chain(chain("word")); ``` 也就是 chain 的一个元素就是 chain 本身,那这个 chain 子链表会作为父级节点的子元素,当执行到链表节点时,会进行深度优先遍历,如果执行通过,会跳到父级继续寻找下一个节点,其执行机制类比函数调用栈的进出关系。 #### 函数类型 函数类型非常特别,我们不需要递归展开所有函数类型,因为文法可能存在无限递归的情况。 好比一个迷宫,很多区域都是相同并重复的,如果将迷宫完全展开,那迷宫的大小将达到无穷大,所以在计算机执行时,我们要一步步展开这些函数,让迷宫结束取决于 Token 消耗完、走出迷宫、或者 match 不上 Token,而不是在生成迷宫时就将资源消耗完毕。函数类型节点由如下代码生成: ```typescript chain(root); ``` 所有函数类型节点都会在执行到的时候展开,在展开时如果再次遇到函数节点仍会保留,等待下次执行到时再展开。 #### 分支 普通的链路只是分支的特殊情况,如下代码是等价的: ```typescript chain("a"); chain(["a"]); ``` 再对比如下代码: ```typescript chain(["a"]); chain(["a", "b"]); ``` 无论是直线还是分支,都可以看作是分支路线,而直线(无分支)的情况可以看作只有一条分叉的分支,对比到链表节点,对应 childs 只有一个元素的链表节点。 ### 回溯 现在 chain 函数已经支持了三种子元素,一种分支表达方式: ```typescript chain("a"); // MatchNode chain(chain("a")); // ChainNode chain(foo); // FunctionNode chain(["a"]); // 分支 -> [MatchNode] ``` 而上文提到了 chain 函数并不是立即执行的,所以我们在执行这些代码时,只是生成链表结构,而没有真正执行内容,内容包含在 childs 中。 我们需要构造 execChain 函数,拿到链表的第一个节点并通过 visiter 函数遍历链表节点来真正执行。 ```typescript function visiter( chainNode: ChainNode, scanner: Scanner, treeChances: ITreeChance[] ): boolean { const currentTokenIndex = scanner.getIndex(); if (!chainNode) { return false; } const nodeResult = chainNode.run(); let nestedMatch = nodeResult.match; if (nodeResult.match && nodeResult.nextNode) { nestedMatch = visiter(nodeResult.nextNode, scanner, treeChances); } if (nestedMatch) { if (!chainNode.isFinished) { // It's a new chance, because child match is true, so we can visit next node, but current node is not finished, so if finally falsely, we can go back here. treeChances.push({ chainNode, tokenIndex: currentTokenIndex }); } if (chainNode.next) { return visiter(chainNode.next, scanner, treeChances); } else { return true; } } else { if (chainNode.isFinished) { // Game over, back to root chain. return false; } else { // Try again scanner.setIndex(currentTokenIndex); return visiter(chainNode, scanner, treeChances); } } } ``` 上述代码中,nestedMatch 类比嵌套函数,而 treeChances 就是实现回溯的关键。 #### 当前节点执行失败时 由于每个节点都包含 N 个 child,所以任何时候执行失败,都给这个节点的 child 打标,并判断当前节点是否还有子节点可以尝试,并尝试到所有节点都失败才返回 false。 #### 当前节点执行成功时,进行位置存档 当节点成功时,为了防止后续链路执行失败,需要记录下当前执行位置,也就是利用 treeChances 保存一个存盘点。 然而我们不知道何时整个链表会遭遇失败,所以必须等待整个 visiter 执行完才知道是否执行失败,所以我们需要在每次执行结束时,判断是否还有存盘点(treeChances): ```typescript while (!result && treeChances.length > 0) { const newChance = treeChances.pop(); scanner.setIndex(newChance.tokenIndex); result = judgeChainResult( visiter(newChance.chainNode, scanner, treeChances), scanner ); } ``` 同时,我们需要对链表结构新增一个字段 tokenIndex,以备回溯还原使用,同时调用 scanner 函数的 `setIndex` 方法,将 token 位置还原。 最后如果机会用尽,则匹配失败,只要有任意一次机会,或者能一命通关,则匹配成功。 ## 3 总结 本篇文章,我们利用链表重写了函数执行机制,不仅使匹配函数拥有了回溯能力,还让其表达更为直观: ```typescript chain("a"); ``` 这种构造方式,本质上与根据文法结构编译成代码的方式是一样的,只是许多词法解析器利用文本解析成代码,而我们利用代码表达出了文法结构,同时自身执行后的结果就是 “编译后的代码”。 下次我们将探讨如何自动解决左递归问题,让我们能够写出这样的表达式: ```typescript const foo = (chain: IChain) => chain(foo, bar); ``` 好在 chain 函数并不是立即执行的,我们不会立即掉进堆栈溢出的漩涡,但在执行节点的过程中,会导致函数无限展开从而堆栈溢出。 解决左递归并不容易,除了手动或自动重写文法,还会有其他方案吗?欢迎留言讨论。 ## 4 更多讨论 > 讨论地址是:[精读《手写 SQL 编译器 - 回溯》 · Issue #96 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/96) **如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。** ================================================ FILE: 编译原理/70.精读《手写 SQL 编译器 - 语法树》.md ================================================ ## 1 引言 重回 “手写 SQL 编辑器” 系列。之前几期介绍了 词法、文法、语法的解析,以及回溯功能的实现,这次介绍如何生成语法树。 基于 [《回溯》](https://github.com/dt-fe/weekly/blob/master/67.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E5%9B%9E%E6%BA%AF%E3%80%8B.md) 一文介绍的思路,我们利用 JS 实现一个微型 SQL 解析器,并介绍如何生成语法树,如何在 JS SQL 引擎实现语法树生成功能! 解析目标是: ```sql select name, version from my_table; ``` 文法: ```typescript const root = () => chain(selectStatement, many(";", selectStatement)); const selectStatement = () => chain("select", selectList, fromClause); const selectList = () => chain(matchWord, many(",", matchWord)); const fromClause = () => chain("from", matchWord); const statement = () => chain( "select", selectList, "from", chain(tableName, [whereStatement, limitStatement]) ); ``` > 这是本文为了方便说明,实现的一个精简版本。完整版见我们的开源仓库 [cparser](https://github.com/dt-fe/cparser)。 `root` 是入口函数,`many()` 包裹的文法可以执行任意次,所以 ```typescript chain(selectStatement, many(";", selectStatement)); ``` 表示允许任意长度的 `selectStatement` 由 `;` 号连接,`selectList` 的写法也同理。 `matchWord` 表示匹配任意单词。 语法树是人为对语法结构的抽象,本质上,如果我们到此为止,是可以生成一个 **基本语法树** 的,这个语法树是多维数组,比如: ```typescript const fromClause = () => chain("from", matchWord); ``` 这个文法生成的默认语法树是:`['from', 'my_table']`,只不过 `from` `my_table` 具体是何含义,只有当前文法知道(第一个标志无含义,第二个标志表示表名)。 `fromClause` 返回的语法树作为结果被传递到文法 `selectStatement` 中,其结果可能是:`['select', [['name', 'version']], ['from', 'my_table']]`。 大家不难看出问题:**当默认语法树聚集在一起,就无法脱离文法结构单独理解语法含义了**,为了脱离文法结构理解语法树,我们需要将其抽象为一个有规可循的结构。 ## 2 精读 通过上面的分析,我们需要对 `chain` 函数提供修改局部 AST 结构的能力: ```typescript const selectStatement = () => chain("select", selectList, fromClause)(ast => ({ type: "statement", variant: "select", result: ast[1], from: ast[2] })); ``` 我们可以通过额外参数对默认语法树进行改造,将多维数组结构改变为对象结构,并增加 `type` `variant` 属性标示当前对象的类型、子类型。比如上面的例子,返回的对象告诉使用者:“我是一个表达式,一个 select 表达式,我的结果是 result,我的来源表是 from”。 那么,`chain` 函数如何实现语法树功能呢? 对于每个文法(每个 `chain` 函数),其语法树必须等待所有子元素执行完,才能生成。所以这是个深度优先的运行过程。 下图描述了 `chain` 函数执行机制: ![](https://img.alicdn.com/tfs/TB1lFZEsOMnBKNjSZFCXXX0KFXa-1300-1126.png) > 生成结构中有四个基本结构,分别是 Chain、Tree、Function、Match,足以表达语法解析需要的所有逻辑。(不包含 可选、多选 逻辑)。 每个元素的子节点全部执行完毕,才会生成当前节点的语法树。实际上,每个节点执行完,都会调用 `callParentNode` 访问父节点,执行到了这个函数,说明子元素已成功执行完毕,补全对应节点的 AST 信息即可。 对于修改局部 AST 结构函数,需等待整个 `ChainNode` 执行完毕才调用,并将返回的新 AST 信息存储下来,作为这个节点的最终 AST 信息并传递给父级(或者没有父级,这就是根结点的 AST 结果)。 ## 3 总结 本文介绍了如何生成语法树,并说明了 **默认语法树** 的存在,以及我们之所以要一个定制的语法树,是为了更方便的理解含义。 同时介绍了如何通过 JS 运行一套完整的语法解析器,以及如何提供自定义 AST 结构的能力。 本文介绍的模型,只是为了便于理解而定制的简化版,了解全部细节,请访问 [cparser](https://github.com/dt-fe/cparser)。 最后说一下为何要做这个语法解析器。如今有许多开源的 AST 解析工具,但笔者要解决的场景是语法自动提示,需要在语句不完整,甚至错误的情况,给出当前光标位置的所有可能输入。所以通过完整重写语法解析器内核,在解析的同时,生成语法树的同时,也给出光标位置下一个可能输入提示,在通用错误场景自动从错误中恢复。 目前在做性能优化,通用 SQL 文法还在陆续完善中,目前仅可当学习参考,不要用于生产环境。 ## 4 更多讨论 > 讨论地址是:[精读《手写 SQL 编译器 - 语法树》 · Issue #99 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/99) **如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。** ================================================ FILE: 编译原理/71.精读《手写 SQL 编译器 - 错误提示》.md ================================================ ## 1 引言 ![](https://img.alicdn.com/tfs/TB1f7TquTqWBKNjSZFAXXanSpXa-1522-272.png) 编译器除了生成语法树之外,还要在输入出现错误时给出恰当的提示。 比如当用户输入 `select (name`,这是个未完成的 SQL 语句,我们的目标是提示出这个语句未完成,并给出后续的建议: `)` `-` `+` `%` `/` `*` `.` `(` 。 ## 2 精读 分析一个 SQL 语句,现将 query 字符串转成 Token 数组,再构造文法树解析,那么可能出现错误的情况有两种: 1. 语句错误。 2. 文法未完成。 给出错误提示的第一步是判断错误发生。 ![](https://img.alicdn.com/tfs/TB1NC7nuTCWBKNjSZFtXXaC3FXa-2474-1950.png) 通过这张 Token 匹配过程图可以发现,当深度优先遍历文法节点时,匹配成功后才会返回父元素继续往下走。而当走到父元素没有根节点了才算匹配成功;当尝试 Chance 时没有机会了,就是错误发生的时机。 所以我们只要找到最后一个匹配成功的节点,再根据最后成功与否,以及搜索出下一个可能节点,就能知道错误类型以及给出建议了。 ```typescript function onMatchNode(matchNode, store) { const matchResult = matchNode.run(store.scanner); if (!matchResult.match) { tryChances(matchNode, store); } else { const restTokenCount = store.scanner.getRestTokenCount(); if (matchNode.matching.type !== "loose") { if (!lastMatch) { lastMatch = { matchNode, token: matchResult.token, restTokenCount }; } } callParentNode(matchNode, store, matchResult.token); } } ``` 所以在运行语法分析器时,在遇到匹配节点(`MatchNode`)时,如果匹配成功,就记录下这个节点,这样我们最终会找到最后一个匹配成功的节点:`lastMatch`。 之后通过 `findNextMatchNodes` 函数找到下一个可能的推荐节点列表,作为错误恢复的建议。 > `findNextMatchNodes` 函数会根据某个节点,找出下一节点所有可能 Tokens 列表,这个函数后面文章再专门介绍,或者你也可以先阅读 [源码](https://github.com/dt-fe/cparser/blob/master/src/parser/chain.ts#L579). ### 语句错误 也就是任何一个 Token 匹配失败。比如: ```sql select * from table_name as table1 error_string; ``` 这里 error_string 就是冗余的语句。 通过语法解析器分析,可以得到执行失败的结果,然后通过 `findNextMatchNodes` 函数,我们可以得到下面分析结果: ![](https://img.alicdn.com/tfs/TB1XE3suqAoBKNjSZSyXXaHAVXa-1148-618.png) 可以看到,程序判断出了 error_string 这个 Token 属于错误类型,同时给出建议,可以将 error_string 替换成这 14 个建议字符串中任意一个,都能使语句正确。 之所以失败类型判断为错误类型,是因为查找了这个正确 Token `table1` 后面还有一个没有被使用的 `error_string`,所以错误归类是 `wrong`。 > 注意,这里给出的是下一个 Token 建议,而不是全部 Token 建议,因此推荐了 where 表示 “或者后面跟一个完整的 where 语句”。 ### 文法未完成 和语句错误不同,这种错误所有输入的单词都是正确的,但却没有写完。比如: ```sql select * ``` 通过语法解析器分析,可以得到执行失败的结果,然后通过 `findNextMatchNodes` 函数,我们可以得到下面分析结果: ![](https://img.alicdn.com/tfs/TB1GAQwuOQnBKNjSZFmXXcApVXa-1030-478.png) 可以看到,程序判断出了 \* 这个 Token 属于未完成的错误类型,建议在后面补全这 14 个建议字符串中任意一个。比较容易联想到的是 `where`,但也可以是任意子文法的未完成状态,比如后面补充 `,` 继续填写字段,或者直接跟一个单词表示别名,或者先输入 `as` 再跟别名。 之所以失败类型判断为未完成,是因为最后一个正确 Token `*` 之后没有 Token 了,但语句解析失败,那只有一个原因,就是语句为写完,因此错误归类是 `inComplete`。 ### 找到最易读的错误类型 在一开始有提到,我们只要找到最后一个匹配成功的节点,就可以顺藤摸瓜找到错误原因以及提示,但最后一个成功的节点可能和我们人类直觉相违背。举下面这个例子: ```sql select a from b where a = '1' ~ -- 这里手滑了 ``` 正常情况,我们都认为错误点在 `~`,而最后一个正确输入是 `'1'`。但词法解析器可不这么想,在我初版代码里,判断出错误是这样的: ![](https://img.alicdn.com/tfs/TB18yMIkNtnkeRjSZSgXXXAuXXa-612-332.png) 提示是 `where` 错了,而且提示是 `.`,有点摸不着头脑。 读者可能已经想到了,这个问题与文法结构有关,我们看 `fromClause` 的文法描述: ```typescript const fromClause = () => chain( "from", tableSources, optional(whereStatement), optional(groupByStatement), optional(havingStatement) )(); ``` 虽然实际传入的 `where` 语句多了一个 `~` 符号,但由于文法认为整个 `whereStatement` 是可选的,**因此出错后会跳出,跳到 `b` 的位置继续匹配**,而 显然 `groupByStatement` 与 `havingStatement` 都不能匹配到 `where`,因此编译器认为 “不会从 `b where a = '1' ~`” 开始就有问题吧?因此继续往回追溯,从 `tableName` 开始匹配: ```typescript const tableName = () => chain([matchWord, chain(matchWord, ".", matchWord)()])(); ``` 此时第一次走的 `b where a = '1' ~` 路线对应 `matchWord`,因此尝试第二条路线,所以认为 `where` 应该换成 `.`。 要解决这个问题,首先要 **承认这个判断是对的**,因为这是一种 **错误提前的情况,只是人类理解时往往只能看到最后几步**,所以我们默认用户想要的错误信息,是 **正确匹配链路最长的那条**,并对 `onMatchNode` 作出下面优化: 将 `lastMatch` 对象改为 `lastMatchUnderShortestRestToken`: ```typescript if ( !lastMatchUnderShortestRestToken || (lastMatchUnderShortestRestToken && lastMatchUnderShortestRestToken.restTokenCount > restTokenCount) ) { lastMatchUnderShortestRestToken = { matchNode, token: matchResult.token, restTokenCount }; } ``` 也就是每次匹配到正确字符,都获取剩余 Token 数量,只保留最后一匹配正确 **且剩余 Token 最少的那个**。 ## 3 总结 做语法解析器错误提示功能时,再次刷新了笔者三观,原来我们以为的必然,在编译器里对应着那么多 “可能”。 当我们遇到一个错误 SQL 时,错误原因往往不止一个,你可以随便截取一段,说是从这一步开始就错了。语法解析器为了让报错符合人们的第一直觉,对错误信息做了 **过滤**,只保留剩余 Token 数最短的那条错误信息。 ## 4 更多讨论 > 讨论地址是:[精读《手写 SQL 编译器 - 错误提示》 · Issue #101 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/101) **如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。** ================================================ FILE: 编译原理/78.精读《手写 SQL 编译器 - 性能优化之缓存》.md ================================================ ## 1 引言 重回 “手写 SQL 编辑器” 系列。这次介绍如何利用缓存优化编译器执行性能。 可以利用 **First 集** 与 **Match 节点缓存** 这两种方式优化。 本文会用到一些图做解释,下面介绍图形规则: ![image](https://user-images.githubusercontent.com/7970947/47950071-44588b80-df88-11e8-9760-6fb3bdaf0f42.png) First 集优化,是指在初始化时,**将整体文法的 First 集找到,因此在节点匹配时,如果 Token 不存在于 First 集中,可以快速跳过这个文法**,在文法调用链很长,或者 “或” 的情况比较多时,可以少走一些弯路: ![image](https://user-images.githubusercontent.com/7970947/47949738-1cb2f480-df83-11e8-8e54-2edc9f85bee3.png) 如图所示,只要构建好了 First 集,**不论这个节点的路径有多长,都可以以最快速度判断节点是否不匹配**。如果节点匹配,则继续深度遍历方式访问节点。 现在节点不匹配时性能已经最优,那下一步就是如何优化匹配时的性能,这时就用到 Match 节点缓存。 Match 节点缓存,指在运行时,缓存节点到其第一个终结符的过程。与 First 集相反,First 集可以快速跳过,而 Match 节点缓存可以快速找到终结符进行匹配,在非终结符很多时,效果比较好: ![image](https://user-images.githubusercontent.com/7970947/47949864-05750680-df85-11e8-96b8-97a6d7d2ec29.png) 如图所示,当匹配到节点时,如果已经构建好了缓存,可以直接调到真正匹配 Token 的 Match 节点,从而节省了大量节点遍历时间。 这里需要注意的是,由于 Tree 节点存在分支可能性,因此缓存也包含将 “沿途” Chances 推入 Chances 池的职责。 ## 2 精读 那么如何构建 First 集与 Match 节点缓存呢?通过两张图解释。 ### 构建 First 集 ![image](https://user-images.githubusercontent.com/7970947/47950030-951bb480-df87-11e8-80b4-419a1522fc8d.png) 如图所示,构建 First 集是个自下而上的过程,当访问到 MatchNode 节点时,就可以收集作为**父节点的** First 集了!父集判断 First 集收集完毕的话,就会触发它的父节点 First 集收集判断,如此递归,最后完成 First 集收集的是最顶级节点。 ### 构建 Match 节点缓存 ![image](https://user-images.githubusercontent.com/7970947/47950470-d4e59a80-df8d-11e8-963a-e6a11313b44d.png) 如图所示,访问节点时,如果没有缓存,则会将这个节点添加到 **Match 缓存查找队列**,同时路途遇到 TreeNode,也会将下一个 Chance 添加到缓存查找队列。直到遇到了第一个 MatchNode 节点,则这个节点是 “Match 缓存查找队列” 所有节点的 Match 节点缓存,此时这些节点的缓存就可以生效了,指向这个 MatchNode,同时清空缓存查找队列,等待下一次查找。 ## 3 总结 拿 `select a, b, c, d from e` 这个语句做测试: | node 节点访问次数 | First 集优化 | First 集 + Match 节点缓存优化 | | ----------------- | ------------ | ----------------------------- | | 784 | 669 | 652 | 从这个简单 Demo 来看,提效了 16% 左右。不过考虑到文法结构会影响到提效,对于层级更深的文法、能激活深层级文法的输入可以达到更好的效率提升。 ## 4 更多讨论 > 讨论地址是:[精读《手写 SQL 编译器 - 性能优化之缓存》 · Issue #110 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/110) **如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** ================================================ FILE: 编译原理/85.精读《手写 SQL 编译器 - 智能提示》.md ================================================ # 1 引言 词法、语法、语义分析概念都属于编译原理的前端领域,而这次的目的是做 具备完善语法提示的 SQL 编辑器,只需用到编译原理的前端部分。 经过连续几期的介绍,《手写 SQL 编译器》系列进入了 “智能提示” 模块,前几期从 词法到文法、语法,再到构造语法树,错误提示等等,都是为 “智能提示” 做准备。 由于智能提示需要对词法分析、语法分析做深度定制,所以我们没有使用 antlr4 等语法分析器生成工具,而是创造了一个 JS 版语法分析生成器 [syntax-parser](https://github.com/ascoders/syntax-parser)。 这次一口气讲完如何从 syntax-parser 到做一个具有智能提示功能的 SQL 编辑器。 # 2 精读 从语法解析、智能提示和 SQL 编辑器封装三个层次来介绍,这三个层次就像俄罗斯套娃一样具有层层递进的关系。 为了更清晰展现逻辑层次,同时满足解耦的要求,笔者先从智能提示整体设计架构讲起。 ## 智能提示的架构 syntax-parser 是一个 JS 版的**语法分析器生成器**,除了类似 antlr4 基本语法分析功能外,还支持专门为智能提示优化的功能,后面会详细介绍。整体架构设计如下图所示: 1. 首先需要实现 SQL 语法,我们利用语法分析器生成器 syntax-parser,生成一个 **SQL 语法分析器**,这一步其实是利用 syntax-parser 能力完成了 `sql lexer` 与 `sql parser`。 2. 为了解析语法树含义,我们需要在 `sql parser` 基础之上编写一套 `sql reader`,包含了一些分析函数解析语法树的语义。 3. 利用 monaco-editor 生态,利用 `sql reader` 封装 monaco-editor 插件,同时实现 用户 <=> 编辑器 间的交互,与 编辑器 <=> 语义分析器 间的交互。 ## 语法解析器 syntax-parser 分为词法分析、语法分析两步。词法分析主要利用正则构造一个有穷自动机,大家都学过的 “编译原理” 里有更完整的解读,或者移步 [精读《手写 SQL 编译器 - 词法分析》](https://github.com/dt-fe/weekly/blob/master/64.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%8D%E6%B3%95%E5%88%86%E6%9E%90%E3%80%8B.md),这里主要介绍语法分析。 词法分析的输入是语法分析输出的 Tokens。Tokens 就是一个个单词,Token 结构存储了单词的值、位置、类型。 我们需要构造一个执行链条消费这些 Token,也就是可以执行文法扫描的程序。我们用四种类型节点描述文法,如下图所示: > 如果不了解文法概念,可以阅读 [精读《手写 SQL 编译器 - 文法介绍》](https://github.com/dt-fe/weekly/blob/master/65.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E6%96%87%E6%B3%95%E4%BB%8B%E7%BB%8D%E3%80%8B.md) 能消耗 Token 的只有 MatchNode 节点,ChainNode 节点描述先后关系(比如 expr -> **name** **id**),TreeNode 节点描述并列关系(比如 factor -> **num** | **id**),FunctionNode 是函数节点,表示还未展开的节点(如果把文法匹配比做迷宫探险,那这是个无限迷宫,无法穷尽展开)。 如何用 syntax-parser 描述一个文法,可以访问[文档](https://github.com/ascoders/syntax-parser),现在我们已经描述了一个文法树,应该如何解析呢? 我们先找到一个非终结符作为根节点,深度遍历所有非终结符节点,遇到 MatchNode 时如果匹配,就消耗一个 Token 并继续前进,否则文法匹配失败。 遇到 ChainNode 会按照顺序执行其子节点;遇到 FunctionNode(非终结符节点)会执行这个函数,转换为一个非 FunctionNode 节点,如下图所示: 遇到 TreeNode 节点时保存这个节点运行状态并继续执行,在 MatchNode 匹配失败时可以还原到此节点继续尝试下个节点,如下图所示: 这样就具备了最基本的语法分析功能,如需更详细阅读,可以移步 [精读《手写 SQL 编译器 - 语法分析》](https://github.com/dt-fe/weekly/blob/master/66.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%AD%E6%B3%95%E5%88%86%E6%9E%90%E3%80%8B.md)。 我们还做了一些优化,比如 First 集优化与路径缓存优化。限于篇幅,分布在以下几篇文章: - [精读《手写 SQL 编译器 - 回溯》](https://github.com/dt-fe/weekly/blob/master/67.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E5%9B%9E%E6%BA%AF%E3%80%8B.md) - [精读《手写 SQL 编译器 - 语法树》](https://github.com/dt-fe/weekly/blob/master/67.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E5%9B%9E%E6%BA%AF%E3%80%8B.md) - [精读《手写 SQL 编译器 - 错误提示》](https://github.com/dt-fe/weekly/blob/master/71.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E9%94%99%E8%AF%AF%E6%8F%90%E7%A4%BA%E3%80%8B.md) - [精读《手写 SQL 编译器 - 性能优化之缓存》](https://github.com/dt-fe/weekly/blob/master/78.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96%E4%B9%8B%E7%BC%93%E5%AD%98%E3%80%8B.md) SQL 编辑器重点在于如何做输入提示,也就是如何在用户光标位置给出恰当的提示。这就是我们定制 SQL 编辑器的原因,输入提示与语法检测需要分开来做,而语法树并不能很好解决输入提示的问题。 ## 智能提示 为了找到一个较为完美的语法提示方案,通过查阅大量资料,**我决定将光标作为一个 Token 考虑来实现智能提示。** ### 思考 我们用 `|` 表示光标所在位置,那么下面的 SQL 应该如何处理? ```sql select | from b; ``` - 从语法角度来看,它是错的,因为实际上是一个不完整语句 "select from b;" - 从提示角度来看,它是对的,因为这是一个正确的输入过程,光标位置再输入一个单词就正确了。 你会发现,从语法和提示角度来看同一个输入,结果往往是矛盾的,**所以我们需要分两条线程分别处理语法与提示。** **但输入错误时,我们是无法构造语法树的,而智能提示的时机往往都是语句语法错误的时机**,用过 AST 工具的人都知道。可是没有语法树,我们怎么做到智能的提示呢?试想如下语句: ```sql select c.| from ( select * from dt; ) c; ``` 面对上面这个语句,很显然 `c.` 没有写完,一般的语法树解析器提示你语法错误。你可能想到这几种方案: 1. 字符串匹配方式强行提示。但很显然这样提示不准确,没有完整语法树,是无法做精确解析的。而且当语法复杂时,字符串解析方案几乎无从下手。 2. 把光标位置用一个特殊的字符串补上,先构造一个临时正确的语句,生成 AST 后再找到光标位置。 一般我们会采取第二种方案,看上去相对靠谱。处理过程是这样的: ```sql select c.$my_custom_symbol$ from ... ``` 之后在 AST 中找到 `$my_custom_symbol$` 字符串,对应的节点就是光标位置。**实际上这可以解决大部分问题,除了关键字。** 这种方案唯有关键字场景不兼容,试想一下: ```sql select a |from b; # select a $my_custom_symbol$ from b; ``` 你会发现,“补全光标文字” 法,在关键字位置时,会把原本正确的语句变成错误的语句,根本解析不出语法树。 我们在 syntax-parser 解析引擎层就解决了这个问题,解决方案是 **连同光标位置一起解析。** ### 两个假设 我们做两个基本假设: 1. 需要自动补全的位置分为 “关键字” 与 “非关键字”。 2. “非关键字” 位置基本都是由字符串构成的。 **关键字:** 因此针对第一种假设,syntax-parser 内置了 “关键字提示” 功能。因为 syntax-parser 可以拿到你配置的文法,因此当给定光标位置时,可以拿到当前位置前一个 Token,通过回溯和平行尝试,将后面所有可能性提示出来,如下图: 输入是 `select a |`,灰色部分是已经匹配成功的部分,而我们发现光标位置前一个 Token 正是红色标识的 `word`,通过尝试运行推导,我们发现,桔红色标记的 `','` 和 `'from'` 都是 `word` 可能的下一个确定单词,这种单词就是 SQL 语法中的 “关键字”,syntax-parser 会自动告诉你,光标位置可能的输入是 `[',', 'from']`。 所以关键字的提示已经在 syntax-parser 层内置解决了!而且无论语法正确与否,都不影响提示结果,因为算法是 “寻找光标位置前一个 Token 所有可能的下一个 Token”,这可以完全由词法分析器内置支持。 **非关键字:** 针对非关键字,我们解决方案和用特殊字符串补充类似,但也有不同: 1. 在光标位置插入一个新 Token,这个 Token 类型是特殊的 “光标类型”。 2. 在 word 解析函数加一个特殊判断,如果读到 “光标类型” Token,也算成功解析,且消耗 Token。 因此 syntax-parser 总是返回两个 AST 信息: ```json { "ast": {}, "cursorPath": [] } ``` 分别是语法树详细信息,与光标位置在语法树中的访问路径。 对于 `select a |` 的情况,会生成三个 Tokens:`['select', 'a', 'cursor']`,对于 `select a|` 的情况,会生成两个 Tokens:`['select', 'a']`,也就是光标与字符相连时,不会覆盖这个字符。 `cursorPath` 的生成也比 “字符串补充” 方案更健壮,syntax-parser 生成的 AST 会记录每一个 Token 的位置,最终会根据光标位置进行比对,进而找到光标对应语法树上哪个节点。 **对 .| 的处理:** 可能你已经想到了,`.|` 情况是很通用的输入场景,比如 `user.` 希望提示出 `user` 对象的成员函数,或者 SQL 语句表名存在项目空间的情况,可能 tableName 会存在 `.|` 的语法。 `.|` 状况时,语法是错误的,此时智能提示会遇到挑战。根据查阅的资料,这块也有两种常见处理手法: 1. 在 `.` 位置加上特殊标识,让语法解析器可以正确解析出语法树。 2. 抹去 `.`,先让语法正确解析,再分析语法树拿到 `.` 前面 Token 的属性,推导出后面的属性。 然而这两种方式都不太优雅,syntax-parser 选择了第三种方式:隔空打牛。 通过抽象,我们发现,无论是 `user.name` 还是 `udf:count()` 这种语法,都要求在某个制定字符打出时(比如 `.` 或 `:`),提示到这个字符后面跟着的 Token。 此时光标焦点在 `.` 而非之后的字符上,**那我们何不将光标偷偷移到 `.` 之后,进行空光标 Token 补位呢!**这样不但能完全复用之前的处理思想,还可以拿到我们真正想拿到的位置: ```sql select a(.|) from b; # select a. (|) from b ``` 对比后发现,第一行拥有 4 个 Token,语法错误,而经过修改的第二行拥有 5 个 Token(一个光标补位),语法正确,且光标所在位置等价于第一行我们希望提示的位置,此问题得以解决。 ## SQL 编辑器封装 我们拥有了内置 “智能提示” 功能的语法解析器,定制了一套自定义的 SQL 词法、文法描述,便完成了 `sql-lexer` 与 `sql-parser` 这一层。由于 SQL 文法完善工作非常庞大,且需要持续推进,这里举流计算中,申明动态维表的例子: ```sql CREATE TABLE dwd_log_pv_wl_ri( PRIMARY KEY(rowkey), PERIOD FOR SYSTEM_TIME ) WITH () ``` 要支持这种语法,我们在非终结符 `tableOption` 下增加两个分支即可: ```typescript const tableOption = () => chain([ chain(stringOrWord, dataType)(), chain("primary", "key", "(", primaryKeyList, ")")(), chain("period", "for", "system_time")() ])(); ``` **sql-reader:** 为了方便解析 SQL 语法树,我们在 `sql-reader` 内置了几个常用方法,比如: - 找到距离光标位置最近的父节点。比如 `select a, b, | from d` 会找到这个 `selectStatement`。 - 根据表源找到所有提供的字段。表源是指 `from` 之后跟的语法,不但要考虑嵌套场景,别名,分组,方言,还要追溯每个字段来源于哪张表(针对 join 或 union 的情况)。 有了 sql-reader,我们可以保证在这种层层嵌套 + 别名混淆 + select \* 这种复杂的场景下,仍然能追溯到字段的最原始名称,最原始的表名: 这样上层业务拓展时,可以拿到足够准、足够多的信息,具有足够好的拓展型。 **monaco-editor plugin:** 我们也支持了更上层的封装,Monaco Editor 插件级别的,只需要填一些参数:获取表名、获取字段的回调函数就能 Work,统一了内部业务的调用方式: ```typescript import { monacoSqlAutocomplete } from '@alife/monaco-sql-plugin'; // Get monaco and editor. monacoSqlAutocomplete(monaco, editor, { onInputTableField: async tableName => { // ...}, onInputTableName: async () => { // ... }, onInputFunctionName: async () => { // ... }, onHoverTableName: async cursorInfo => { // ... }, onHoverTableField: (fieldName, extra) => { // ... }, onHoverFunctionName: functionName => { // ... } }); ``` 比如实现了 `onInputTableField` 接口,我们可以拿到当前表名信息,轻松实现字段提示: 你也许会看到,上图中鼠标位置有错误提示(红色波浪线),但依然给出了正确的推荐提示。**这得益于我们对 syntax-parser 内部机制的优化,将语法检查与智能提示分为两个模块独立处理,经过语法解析,虽然抛出了语法错误,但因为有了光标的加入,最终生成了语法树。** 再比如实现了 `onHoverFunctionName`,可以自定义鼠标 hover 在函数时的提示信息: 得益于 `sql-reader`,我们对 sql 语句做了层层解析,所以才能把自动提示做到极致。比如在做字段自动提示时,经历了如下判断步骤: ![](https://cdn.nlark.com/lark/0/2018/png/29349/1545471403482-2d30add4-be41-4c6b-87ec-095bece3e1cf.png) 而你只需要实现 `onInputTableField`,告诉程序每个表可以提供哪些字段,整个流程就会严格的层层检查表名提供对原始字段与 `selectList` 描述的输出字段,找到映射关系并逐级传递、校验,最终 Merge 后一直冒泡到当前光标位置所在语句,形成输入建议。 # 4 总结 整个智能提示的封装链条如下: [syntax-parser](https://github.com/ascoders/syntax-parser) -> sql-parser -> monaco-editor-plugin 对应关系是: 语法解析器生成器 -> SQL 语法解析器 -> 编辑器插件 这样逻辑层次清晰,解耦,而且可以从任意节点切入,进行自定义,比如: **从 syntax-parser 开始使用** 从最底层开始使用,也许有两个目的: 1. 上层封装的 sql-parser 不够好用,我重写一个 sql-parser' 以及 monaco-editor-plugin'。 2. 我的场景不是 SQL,而是流程图语法、或 Markdown 语法的自动提示。 针对这种情况,首先将目标文法找到,转化成 syntax-parser 的语法,比如: ```typescript chain(word, "=>", word); ``` 再仿照 sql-parser -> monaco-editor-plugin 的结构把上层封装依次实现。 **从 sql-parser 开始使用** 也许你需要的仅仅是一颗 SQL 语法树?或者你的输出目标不是 SQL 编辑器而是一个 UI 界面?那可以试试直接使用 sql-parser。 sql-parser 不仅可以生成语法树,还能找到当前光标位置所在语法树的节点,找到 SQL 某个语法返回的所有字段列表等功能,基于它,甚至可以做 UI 与 SQL 文本互转的应用。 **从 monaco-editor-plugin 开始使用** 也许你需要支持自动提示的 SQL 编辑器,那太棒了,直接用 monaco-editor-plugin 吧,根据你的业务场景或个人喜好,实现一个定制的 monaco-editor 交互插件。 目前我们只开源最底层的 [syntax-parser](https://github.com/ascoders/syntax-parser),这也是业务无关的语法解析引擎生成器,期待您的使用与建议! > 讨论地址是:[精读《手写 SQL 编译器 - 智能提示》 · Issue #118 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/118) **如果你想参与讨论,请[点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** ================================================ FILE: 设计模式/167.精读《设计模式 - Abstract Factory 抽象工厂》.md ================================================ # Abstract Factory(抽象工厂) Abstract Factory(抽象工厂)属于创建型模式,工厂类模式抽象程度从低到高分为:简单工厂模式 -> 工厂模式 -> 抽象工厂模式。 **意图:提供一个接口以创建一系列相关或相互依赖的对象,而无须指定它们具体的类。** ## 举例子 如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 ### 汽车工厂 我们都知道汽车有很多零部件,随着工业革命带来的分工,很多零件都可以被轻松替换。但实际生活中我们消费者不愿意这样,我们希望买来的宝马车所包含的零部件都是同一系列的,以保证最大的匹配度,从而带来更好的性能与舒适度。 所以消费者不愿意到轮胎工厂、方向盘工厂、车窗工厂去一个个采购,而是将需求提给了宝马工厂这家抽象工厂,由这家工厂负责组装。那你是这家工厂的老板,已知汽车的组成部件是固定的,只是不同配件有不同的型号,分别来自不同的制造厂商,你需要推出几款不同组合的车型来满足不同价位的消费者,你会怎么设计? ### 迷宫游戏 你做一款迷宫游戏,已知元素有房间、门、墙,他们之间的组合关系是固定的,你通过一套算法生成随机迷宫,这套算法调用房间、门、墙的工厂生成对应的实例。但随着新资料片的放出,你需要生成具有新功能的房间(可以回复体力)、新功能的门(需要魔法钥匙才能打开)、新功能的墙(可以被炸弹破坏),但修改已有的迷宫生成算法违背了开闭原则(需要在已有对象进行修改),如果你希望生成迷宫的算法完全不感知新材料的存在,你会怎么设计? ### 事件联动 假设我们做一个前端搭建引擎,现在希望做一套关联机制,以实现点击表格组件单元格,可以弹出一个模态框,内部展示一个折线图。已知业务方存在定制表格组件、模态框组件、折线图组件的需求,但组件之间联动关系是确定的,你会怎么设计? ## 意图解释 在汽车工厂的例子中,我们已知车子的构成部件,**为了组装成一辆车子,需要以一定方式拼装部件,而具体用什么部件是需要可拓展的**。 在迷宫游戏的例子中,我们已知迷宫的组成部分是房间、门、墙,**为了生成一个迷宫,需要以某种算法生成许多房间、门、墙的实例,而具体用哪种房间、哪种门、哪种墙是这个算法不关心的,是需要可被拓展的**。 在事件联动的例子中,我们已知这个表格弹出趋势图的交互场景基本组成元素是表格组件、模态框组件、折线图组件,**需要以某种联动机制让这三者间产生联动关系,而具体是什么表格、什么模态框组件、什么折线图组件是这个事件联动所不关心的,是需要可以被拓展的**,表格可以被替换为任意业务方注册的表格,只要满足点击 `onClick` 机制就可以。 > **意图:提供一个接口以创建一系列相关或相互依赖的对象,而无须指定它们具体的类。** 这三个例子不正是符合上面的意图吗?我们要设计的抽象工厂就是要 **创建一系列相关或相互依赖的对象**,在上面的例子中分别是汽车的组成配件、迷宫游戏的素材、事件联动的组件。**而无须指定它们具体的类**,也就说明了我们不关心车子方向盘用的是什么牌子,迷宫的房间是不是普通房间,联动机制的折线图是不是用 `Echarts` 画的,我们只要描述好他们之间的关系即可,**这带来的好处是,未来我们拓展新的方向盘、新的房间、新的折线图时,不需要修改抽象工厂。** ## 结构图 `AbstractFactory` 就是我们要的抽象工厂,描述了创建产品的抽象关系,比如描述迷宫如何生成,表格和趋势图怎么联动。 至于具体用什么方向盘、用什么房间,是由 `ConcreteFactory` 实现的,所以我们可能有多个 `ConcreteFactory`,比如 `ConcreteFactory1` 实例化的墙壁是普通墙壁,`ConcreteFactory2` 实例化的墙壁是魔法墙壁,但其对 `AbstractFactory` 的接口是一致的,所以 `AbstractFactory` 不需要关心具体调用的是哪一个工厂。 `AbstractProduct` 是产品抽象类,描述了比如方向盘、墙壁、折线图的创建方法,而 `ConcreteProduct` 是具体实现产品的方法,比如 `ConcreteProduct1` 创建的表格是用 `canvas` 画的,折线图是用 `G2` 画的,而 `ConcreteProduct2` 创建的表格是用 `div` 画的,折线图是用 `Echarts` 画的。 这样,当我们要拓展一个用 `Echarts` 画的折线图,用 `svg` 画的表格,用 `div` 画的模态框组成的事件机制时,只需要再创建一个 `ConcreteFactory3` 做相应的实现即可,再将这个 `ConcreteFactory3` 传递给 `AbstractFactory`,并不需要修改 `AbstractFactory` 方法本身。 ## 代码例子 下面例子使用 javascript 编写。 ```typescript class AbstractFactory { createProducts(concreteFactory: ConcreteFactory) { const productA = concreteFactory.createProductA(); const productB = concreteFactory.createProductB(); // 建立 A 与 B 固定的关联,即便 A 与 B 实现换成任意实现都不受影响 productA.bind(productB); } } ``` `productA.bind(productB)` 是一种抽象表示: - 对于汽车工厂的例子,表示组装汽车的过程。 - 对于迷宫游戏的例子,表示生成迷宫的过程。 - 对于事件联动的例子,表示创建组件间关联的过程。 假设我们的迷宫有两套素材,分别是普通素材与魔法素材,只要在分别创建普通素材工厂 `ConcreteFactoryA`,与魔法素材工厂 `ConcreteFactoryB`,调用 `createProducts` 时传入的是普通素材,则产出的就是普通素材搭建的迷宫,传入的是魔法素材,则产出的就是用魔法素材搭建的迷宫。 当我们要创建一套新迷宫材料,比如熔岩迷宫,我们只要创建一套熔岩素材(熔岩房间、熔岩门、熔岩墙壁),再组装一个 `ConcreteFactoryC` 熔岩素材生成工厂传递给 `AbstractFactory.createProducts` 即可。 我们可以发现,使用抽象工厂模式,我们可以轻松拓展新的素材,比如拓展一套新的汽车配件,拓展一套新的迷宫素材,拓展一套新的事件联动组件,**这个过程只需要新建类即可,不需要修改任何类,符合开闭原则**。 ## 弊端 任何设计模式都有其适用场景,反过来也说明了在某些场景下不适用。 还是上面的例子,如果我们的需求不是拓展一个新轮子、新墙壁、新折线图,而是: - 汽车工厂要给汽车加一个新部件:自动驾驶系统。 - 迷宫游戏要新增一个功能素材:陷阱。 - 事件联动要新增一个联动对象:明细趋势统计表格。 你看,这种情况不是为已有元素新增一套实现,而是实现一些新元素,就会非常复杂,因为我们不仅要为所有 `ConcreteFactory` 新增每一个元素,还要修改抽象工厂,以将新元素与旧元素间建立联系,违背了开闭原则。 因此,对于已有元素固定的系统,适合使用抽象工厂,反之不然。 ## 总结 抽象工厂对新增已有产品的实现适用,对新增一个产品种类不适用,可以参考结合了例子的下图加深理解: 拓展一个熔岩素材包是 **增加一种产品风格**,适合使用抽象工厂设计模式;拓展一个陷阱是 **增加一个产品种类**,不适合使用抽象工厂设计模式。为什么呢?看下图: 创建迷宫这个抽象工厂做的事情,**是把已有的房间、门、墙壁建立关联**,因为操作的是抽象类,所以拓展一套具体实现(熔岩素材包)对这个抽象工厂没有感知,这样做很容易。 但如果新增一个产品种类 - 陷阱,可以看到,抽象工厂必须将陷阱与前三者重新建立关联,这就要修改抽象工厂,不符合开闭原则。同时,如果我们已有素材包 1 ~素材包 999,就需要同时增加 999 个对应的陷阱实现(普通陷阱、魔法陷阱、熔岩陷阱),其工作量会非常大。 因此,只有产品种类稳定时,需要频繁拓展产品风格时才适合用抽象工厂设计模式。 > 讨论地址是:[精读《设计模式 - Abstract Factory 抽象工厂》· Issue #271 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/271) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 设计模式/168.精读《设计模式 - Builder 生成器》.md ================================================ # Builder(生成器) Builder(生成器)属于创建型模式,针对的是单个复杂对象的创建。 **意图:将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。** ## 举例子 如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 ### 搭乐高积木 乐高积木是很典型的随机拼装场景,你有很多乐高积木,要搭一个小房子都太复杂了,可能不得不看着说明书一步步操作,这就像创建一个复杂的对象,要传入非常多的参数,而且顺序还不能错。 如果不考虑拼装乐高过程中的乐趣,你只是想快速得到一个标准的房子,怎么样才可以最快最省事? ### 工厂流水线 制作一个罐头要经历许多步骤,而其中一些步骤比如制作罐头是通用的,可以用这个罐头装很多东西,比如红枣罐头、黄桃罐头,那工厂流水线是怎么做到灵活可拓展的呢? ### 创建数据库连接池 建立一个数据库连接池,我们需要传入数据库的地址、用户名与密码、还有要创建多少大小的连接池,缓存的位置等等。 考虑到数据库必须正确连接后才有效,创建时必须校验传入的数据库地址与密码的正确性,甚至存储方式与数据库类型还有关系,这是一个简单的 `new` 实例化可以解决的吗? ## 意图解释 在乐高积木的例子中,我们为了得到一个房子其实不需要关心每一个积木应该如何摆放,**我们只要交给组装工厂(一个人或者一个程序)产出标准房子就行了**,这其中参数可能是 `.setHouseType().build()` 设置房屋类型,而不需要 `new House(block1, block2, ... block999)` 传递这些没必要的参数。**其中组装工厂就是生成器**。 在工厂流水线的例子中,**流水线就是生成器,一个流水线可以不通过不同组合生成不同作用的工厂**,黄桃罐头的流水线可以理解为 `new Builder().组装罐头().放入黄桃().build()`,红枣罐头的流水线可以理解为 `new Builder().组装罐头().放入红枣().build()`,我们可以复用生成器最基础的函数 `组装罐头()` 将其用于创建不同的产品中,复用了组装基础能力。 在创建数据库例子中,我们可以先设置一些必要的参数再创建,比如 `new Builder().setUrl().setPassword().setType().build()`,这样在最终执行 `build` 函数的时候,可以对参数中存在关联的进行校验,而得到的对象也无法再被修改,这样比直接暴露数据库连接池对象,再一个值一个值 Set 多了如下好处: 1. 对象无法被修改,保护了程序稳定性,减少了维护复杂度。 2. 可以对参数关联进行一次性校验。 3. 在创建对象之前不会存在中间态,即创建了对象实例,但缺少部分参数,这可能导致对象无法正确 work。 **意图:将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。** 我们再理解一次意图,所谓构建与表示分离,就是指一个对象 `Person` 并不是简单的 `new Person()` 就可以实例化出来的,如果可以,那就是构建与表示一体。**所谓构建与表示分离,就是指 `Person` 只能描述,而不能通过 `new Person()` 实例化,将实例化工作通过 Builder 实现,这样同样一个构建过程可以创建不同的 `Person` 实例。** 在乐高积木的例子中,通过乐高创建的房子并不是 `new House()` 出来,而是将构建与表示分离了,工厂流水线中我们创建一个黄桃罐头,不是通过 `new 黄桃罐头()`,而是通过流水线不同拼装方式来完成,在数据库例子中,我们没有通过 `new DB()` 的方式创建数据库,而是通过 Builder 来创建,这都体现了构建与表示的分离。 ## 结构图 - `Director` 指导器,用来指导构建过程。 - `Builder` 生成器接口,用来提供一系列构建对象的方法,以及最终的 `build` 生成对象函数,这个函数里可以做一些参数校验。 - `ConcreteBuilder` 是 `Builder` 的具体实现。 实际上,Builder 模式抽象层次可高可低,我们上面三个例子都没有用到指导器与生成器接口,这是因为在代码不太复杂的情况下,可以使用简化模型。 ## 代码例子 下面例子使用 javascript 编写。 ```typescript class Director { create(concreteBuilder: ConcreteBuilder) { // 创建了一些零件 concreteBuilder.buildA(); concreteBuilder.buildB(); // 校验参数已经生成实例 return concreteBuilder.build(); } } class HouseBuilder { public buildA() { // 创建房屋 // this.xxx = xxx } public buildB() { // 刷油漆 } public build() { // 最终创建实例 return new House(/* ..一堆参数 this.xxx.. */); } } // 接下来是正式使用 const director = new Director(); const builder = HouseBuilder(); const house = director.create(builder); ``` 上面的例子是完整版本的 Builder 模式,抽象了指导器 `Director` 与生成器 `Builder`,只要两者都严格按照接口实现,我们可以: 1. 替换任意 `Director`,使创建的过程做任意修改。 2. 替换任意 `Builder`,使创建的实现做任意修改。 做了任意的改动,都可以得到不同的房子实现,这就是创建与表示分离的好处,我们可以通过同样的构建过程创建不同的表示。 这个 `director.create()`: - 在搭乐高积木的例子,表示用乐高搭建房屋的过程。 - 在工程流水线的例子,表示罐头的组装构成。 - 在创建数据库连接池的例子,表示数据库连接池的创建过程。 而 `Builder` 以及其函数 `buildA` `buildB` 等方法表示具体制造方法,比如: - 在搭乐高积木的例子,表示如何盖房子,如何刷油漆。 - 在工程流水线的例子,表示如何做一个罐头,如何添加黄桃。 - 在创建数据库连接池的例子,表示如何设置数据库地址,如何设置用户名密码等。 对于数据库的例子中,我们不仅可以保证创建对象的便捷性,因为不需要传入过多参数,也保证了对象的正确校验,同时生成的实例也是不可变的。 更重要的是,如果使用完整模式,我们可以替换 `Director` 来修改创建数据库的方式,替换 `Builder` 来修改具体方法,比如 `.setUserName` 这个函数不做具体实现,而是统计性能,`build()` 函数创建的不是一个数据库连接实例,而是一个测试实例。 再比如前端同一个方法在 JS 和 Node 环境下运行效果不一样,我们可以实现 `BrowserBuild` 与 `NodeBuild`,实现相同的接口,这样可以共享相同的创建过程,创建不同环境可以运行的实例。 可以看到,使用 Builder 模式可以保证创建对象的便捷与稳定性,还留了足够的拓展空间改变对象的创建过程与创建方法,具有极强的拓展性。 ## 弊端 任何设计模式都有其适用场景,反过来也说明了在某些场景下不适用。 - 实例化对象非常繁琐,重复定义了许多对象成员变量的 `set` 方法,而且也不如 `new` 看的直观,也就是场景足够简单时,不需要任何地方都用 Builder 实例化对象。 - 一个对象只有一种表示时,没必要做如此地步的抽象。 上面的例子都是相对复杂的,假设我们的搭房子的例子中,我们不是用乐高积木搭建,而是用两块半成品模板拼起来就得到一个房子,那就没有必要使用 Builder 模式,直接 `new House()` 即可。 再者,如果我们只需要生产各种罐头,而不需要生产汽车,那么就没必要过度抽象 Builder,把创建汽车的方法也囊括进去,最后,如果我们的对象只有一种表示时,没有必要抽象 Builder,也就是流水线如果只生产黄桃罐头,就没必要把各个生产环节变成可拆卸的,因为也没有重新组合的需要。 ## 总结 Builder 模式对于创建一个复杂对象特别有用,可以看下图加深理解: 最后总结一下何时适合用 Builder 模式:只有当创建过程允许被构造对象有不同表示,或者对象复杂到对象描述与创建对象过程值得分离时,才使用 Builder 设计模式。 > 讨论地址是:[精读《设计模式 - Builder 生成器》· Issue #273 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/273) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 设计模式/169.精读《设计模式 - Factory Method 工厂方法》.md ================================================ # Factory Method(工厂方法) Factory Method(工厂方法)属于创建型模式,利用工厂方法创建对象实例而不是直接用 New 关键字实例化。 理解如何写出工厂方法很简单,但理解为什么要用工厂方法就需要动动脑子了。工厂方法看似简单的将 New 替换为一个函数,其实是体现了面向接口编程的思路,它创建的对象其实是一个符合通用接口的通用对象,这个对象的具体实现可以随意替换,以达到通用性目的。 **意图:定义一个用于创建对象的接口,让子类决定实例化哪一个类。Factory Method 使一个类的实例化延迟到其子类。** ## 举例子 如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 ### 换灯泡 我自己在家换过灯泡,以前我家里灯坏掉的时候,我看着这个奇形怪状的灯管,心里想,这种灯泡和这个灯座应该是一体的,市场上估计很难买到适配我这个灯座的灯泡了。结果等我把灯泡拧下来,跑到门口的五金店去换的时候,店员随便给了我一个灯泡,我回去随便拧了一下居然就能用了。 我买这个灯泡的过程就用到了工厂模式,而正是得益于这种模式,让我可以方便在家门口就买到可以用的灯泡。 ### 卡牌对战游戏 卡牌对战中,卡牌有一些基本属性,比如攻防、生命值,也符合一些通用约定,比如一回合出击一起等等,那么对于战斗系统来说,应该怎样实例化卡牌呢?如何批量操作卡牌,而不是通用功能也要拿到每个卡牌的实例才能调用?另外每个卡牌有特殊能力,这些特殊能力又应该如何拓展呢? ### 实现任意图形拖拽系统 一个可以被交互操作的图形,它可以用鼠标进行拉伸、旋转或者移动,不同图形实现这些操作可能并不相同,要存储的数据也不一样,这些数据应该独立于图形存储,我们的系统如果要对接任意多的图形,具备强大拓展能力,对象关系应该如何设计呢? ## 意图解释 在使用工厂方法之前,我们就要创建一个 **用于创建对象的接口**,这个接口具备通用性,**所以我们可以忽略不同的实现来做一些通用的事情**。 换灯泡的例子来说,我去门口五金店买灯泡,而不是拿到灯泡材料自己 New 一个出来,就是因为五金店这个 “工厂” 提供给我的灯泡符合国家接口标准,而我家里的灯座也符合这个标准,所以灯座不需要知道对接的灯泡是具体哪个实例,什么颜色,什么形状,这些都无所谓,只要灯泡符合国家标准接口,就可以对接上。 对卡牌对战的系统来说,**所有卡牌都应该实现同一种接口**,所以卡牌对战系统拿到的卡牌应该就是简单的 Card 类型,这种类型具备基本的卡片操作交互能力,系统就调用这些能力完成基本流程就好了,如果系统直接实例化具体的卡片,那不同的卡片类型会导致系统难以维护,卡片间操作也无法抽象化。 正是这种模式,使得我们可以在卡牌的具体实现上做一些特殊功能,比如修改卡片攻击时效果,修改卡牌销毁时效果。 对图形拖拽系统来说,用到了 “连接平行的类层次” 这个特性,所谓连接平行的类层次,就是指一个图形,与其对应的操作类是一个平行抽象类,而一个具体的图形与具体的操作类则是另一个平行关系,系统只要关注最抽象的 “通用图形类” 与 “通用操作类” 即可,操作时,底层可能是某个具体的 “圆类” 与 “圆操作类” 结合使用,具体的类有不同的实现,但都符合同一种接口,因此操作系统才可以把它们一视同仁,统一操作。 **意图:定义一个用于创建对象的接口,让子类决定实例化哪一个类。Factory Method 使一个类的实例化延迟到其子类。** 所以接口是非常重要的,工厂方法第一句话就是 “定义一个用于创建对象的接口”,这个接口就是 `Creator`,让子类,也就是具体的创建类(`ConcreteCreator`)决定要实例化哪个类(`ConcreteProduct`)。 所谓使一个类的实例化延迟到其子类,是因为抽象类不知道要实例化哪个具体类,所以实例化动作只能由具体的子类去做,这样绕一圈的好处是,我们可以将任意多对象看作是同一类事物,做统一的处理,比如 **无论何种灯泡实例都满足通用的灯座接口**,**所有工厂实例化的卡牌都具备玩一局卡牌游戏的基本功能**,**任何图形与交互类都满足特定功能关系**,这种思想让生活和设计得到了大幅简化。 ## 结构图 `Creator` 就是工厂方法,`ConcreteCreator` 是实现了 `Creator` 的具体工厂方法,每一个具体工厂方法生产一个具体的产品 `ConcreteProduct`,每个具体的产品都实现通用产品的特性 `Product`。 ## 代码例子 下面例子使用 typescript 编写。 ```typescript // 产品接口 interface Product { save: () => void; } // 工厂接口 interface Creator { createProduct: () => Product; } // 具体产品 class ConcreteProduct implements Product { save = () => {}; } // 具体工厂 class ConcreteCreator implements Creator { createProduct = () => { return new ConcreteProduct(); }; } ``` 创建一个 `Product` 的子类 `ConcreteCreator`,并返回一个实现了 `Product` 的具体实例 `ConcreteProduct`,这样我们就可以方便使用这个工厂了。 工厂方法并不是直接调用 `new ConcreteCreator().createProduct` 那么简单,这样体现不出任何抽象性,真正的场景是,在一个创建产品的流程中,我们只知道拿到的工厂是 `Creator`: ```typescript function main(anyCreator: Creator) { const product = anyCreator.createProduct() } ``` 在外面调用 `main` 函数时,实际传进去的是一个具体工厂,比如 `myCreator`,但关键是 `main` 函数不用关心到底是哪一个具体工厂,只要知道是个工厂就行了,具体对象创建过程交给了其子类。 **你也许也发现了,这就是抽象工厂中其中的一步,所以抽象工厂使用了工厂方法。** ## 弊端 工厂方法中,每创建一种具体的子类,就要写一个对应的 `ConcreteCreate`,这相对比较笨重,但有意思的是,如果将创建多个对象放到一个 `ConcreteCreate` 中,就变成了 **简单工厂模式**,新增产品要修改已有类不符合开闭模式,反而推荐写成本文说的这种模式。 彼之毒药吾之蜜糖,要知道没有一种设计模式解决所有问题,没有一种设计模式没有弊端,**而这个弊端不代表这个设计模式不好,一个弊端的出现可能是为了解决另一个痛点。** 要接受不完美的存在,这么多种设计模式就是对应了不同的业务场景,**为合适的场景选择一种能将优势发扬光大,以至于能掩盖弊端,就算进行了合理的架构设计**。 ## 总结 工厂方法并不是简单把 New 的过程换成了函数,而是抽象出一套面向接口的设计模式: 你看,我要做灯泡,可以直接做具体的灯泡,也可以定一个灯泡接口,通过灯泡工厂拿到具体灯泡,灯泡工厂对待所有灯泡的只做流程都是一样的,不管是中世纪风灯泡,还是复古灯泡,还是普通白织灯,都是一模一样的制作流程,具体怎么做由具体的子类去实现,这样我们可以统一管理 “灯泡” 这一个通用概念,而忽略不同灯泡之间不太重要的差别,程序的可维护性得到了大幅提升。 > 讨论地址是:[精读《设计模式 - Factory Method 工厂方法》· Issue #274 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/274) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 设计模式/170.精读《设计模式 - Prototype 原型模式》.md ================================================ # Prototype(原型模式) Prototype(原型模式)属于创建型模式,既不是工厂也不是直接 New,而是以拷贝的方式创建对象。 **意图:用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。** ## 举例子 如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 ### 做钥匙 很显然,为了房屋安全,要尽量做到一把钥匙只能开一扇门,每把钥匙结构都多多少少不一样,却又很相似,做钥匙的人按照你给的钥匙一模一样做一个新的,这属于什么模式呢? ### 两种状态表 当网站做不停机维护时,假设维护内容是给每个高级会员账户多打 100 元现金,现在需要改数据库表。已知: 1. 数据库表有几千万条数据,其中高级会员有几千位,为了方便调用已经缓存在中间层了,且数据库对应 ID 更新后对应缓存也会更新。 2. 几千条数据修改语句执行完需要几分钟,这几分钟内无法接受用户数据不同步的问题。 一种常见的做法是,我们生成一份高级会员列表的拷贝,代替数据库缓存的结果,数据库只要读到对应会员 ID 就从拷贝列表中获取,数据表新增一列状态标志,操作完后这个拷贝移除,更新高级会员缓存。 但是如何生成高级会员列表拷贝呢?如果直接从几千万条用户数据中重新查询,会有较高的数据库查询成本。 ### 模版组件 通用搭建系统中,我们可以将某个拖拽到页面的区块设置为 “模版”,这个模版可以作为一个新组件被重新拖拽到任意位置,实例化任意次。实际上,这是一种分段式复制粘贴,你会如何实现这个功能呢? ## 意图解释 解决上面问题的办法都很简单,就是基于已有对象进行复制即可,效率比 New 一个,或者工厂模式都要高。 **意图:用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象。** 所谓原型实例,就是被选为拷贝模版的那个对象,比如做钥匙例子中,你给老板的样板钥匙;两种状态表中的已有缓存高级会员列表;模版组件中选中的那个组件。然后,通过拷贝这些原型创建你想要的对象即可。 我们抽象思考一下,如果每把钥匙都遵循 `Prototype` 接口,提供了 `clone()` 方法以复制自己,那就可以快速复制任意一把钥匙。钥匙工厂可无法解决每把钥匙不一样的问题,我们要的就是和某个钥匙一模一样的副本,复制一份钥匙最简单。 高级会员状态表例子中,查询数据库的成本是高昂的,但如果仅仅复制已经查询好的列表,时间可以忽略不计,因此最经济的方案是直接复制,而不是通过工厂模式重新连接数据库并执行查询。 模版组件更是如此,我们根本没有定义那么多组件实例的基类,只要每个组件提供一个 `clone()` 函数,就可以立即复制任意组件实例,这无疑是最经济实惠的方案。 看到这里,你应该知道了,原型模式的精髓是对象要提供 `clone()` 方法,而这个 `clone()` 方法实现难度有高有低。 一般来说,原型模式的拷贝建议用深拷贝,毕竟新对象最好不要影响到旧对象,**但是在深拷贝性能问题较大的情况下,可以考虑深浅拷贝结合,也就是将在新对象中,不会修改的数据使用浅拷贝,可能被修改的数据使用深拷贝。** ## 结构图 `Client` 是发出指令的客户端,`Prototype` 是一个接口,描述了一个对象如何克隆自身,比如必须拥有 `clone()` 方法,而 `ConcretePrototype` 就是克隆具体的实现,不同对象有不同的实现来拷贝自身。 ## 代码例子 下面例子使用 typescript 编写。 ```typescript class Component implements Prototype { /** * 组件名 */ private name: string /** * 组件版本 */ private version: string /** * 拷贝自身 */ public clone = () => { // 构造函数省略了,大概就是传递 name 和 version return new Component(this.name, this.version) } } ``` 我们可以看到,实现了 `Prototype` 接口的 `Component` 必须实现 `clone` 方法,这样任意组件在执行复制时,就可以直接调用 `clone` 函数,而不用关心每个组件不同的实现方式了。 从这就能看出,原型模式与 Factory 与 Builder 模式还是有类似之处的,在隐藏创建对象细节这一点上。 使用的时候,我们就可以这样创建一个新对象: ```typescript const newComponent = oldComponent.clone() ``` 这里有两个注意点:一般来说,**如果要二次修改生成的对象,不建议给 `clone` 函数加参数,因为这样会导致接口的不一致。** 我们可以为对象实例提供一些 `set` 函数进行二次修改。另外,`clone` 函数要考虑性能,就像前面说过的,可以考虑深浅拷贝结合的方式,同时要注意当对象存在引用关系甚至循环引用时,甚至不一定能实现拷贝函数。 ## 弊端 每个设计模式必有弊端,但就像每一期都说的,有弊端不代表设计模式不好用,而是指在某种场景喜爱存在问题,我们只要规避这些场景,在合理的场景使用对应设计模式即可。 原型模式的弊端: 1. 每个类都要实现 `clone` 方法,对类的实现是有一定入侵的,要修改已有类时,违背了开闭原则。 2. 当类又调用了其他对象时,如果要实现深拷贝,需要对应对象也实现 `clone` 方法,整体链路可能会特别长,实现起来比较麻烦。 ## 总结 **原型模式一般与工厂模式搭配使用,一般工厂方法接收一个符合原型模式的实例,就可以调用它的 `clone` 函数创建返回新对象啦。** 代码大概是这样: ```typescript // buildComponentFactory 内部通过 targetComponent.clone() 创建对象,而不是 New 或者调用其他工厂函数。 const newComponent = buildComponentFactory(new Component()) ``` 最后来一张图快速理解原型模式: > 讨论地址是:[精读《设计模式 - Prototype 原型模式》· Issue #277 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/277) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 设计模式/171.精读《设计模式 - Singleton 单例模式》.md ================================================ # Singleton(单例模式) Singleton(单例模式)属于创建型模式,提供一种对象获取方式,保证在一定范围内是唯一的。 **意图:保证一个类仅有一个实例,并提供一个访问它的全局访问点。** 其实单例模式在前端体会的不明显,原因有: 1. 前端代码本身在单机运行,创建的任何变量都是天然分布式的,不需要担心影响另一个用户。 2. 后端代码是一对多的,分辨出哪些资源是请求间共享的,哪些是请求内独有的很重要。 另外我们说到单例,是隐含了一个范围的,指的是在某个范围内单例,比如在一个上下文中,还是一个房间中,还是一个进程,一个线程中单例,不同场景范围会不同。 ## 举例子 如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 ### 多人游戏的共享物品 玩过游戏的同学都知道,我们在每局游戏中使用的公共物品在当前房间中是唯一的,但在游戏房间间却不是唯一的,所以这些公共物品肯定有不同的类去描述,那每局游戏中怎么拿公共物品,可以保证拿到的是当前局内唯一的? ### Redux 数据流 其实前端的 Redux 数据流本身就是单例模式,在一个应用中,数据是唯一的,但可以有不同的 UI 使用这份唯一的数据,甚至把一个表格组件展示在两个不同地方,比如全屏模式,但数据依然是一份,我们没有必要为了全屏展示表格,就让它再发一次取数请求,完全可以和原来的表格共享一份数据。 ### 数据库连接池 每个 SQL 查询都依赖数据库连接池,如果每次查询都建立一次数据库连接池,则建立连接的速度会远远慢于 SQL 查询速度,因此你会怎么设计数据库连接池的获取方法? ## 意图解释 单例模式的意图很简单,几乎就是其字面含义: **意图:保证一个类仅有一个实例,并提供一个访问它的全局访问点。** 对于多人游戏的共享物品,比如一口锅,要保证在一局游戏内唯一,就要提供一种方法访问到唯一实例。 Redux 数据流的 `connect` 装饰器就是全局访问点的一种设计。 数据库连接池可以提前初始化好,并通过固定 API 提供这个唯一实例。 ## 结构图 `Singleton` 是单例模式的接口,客户只能通过其定义的 `instance()` 访问实例,以保证单例。 ## 代码例子 下面例子使用 typescript 编写。 ```typescript class Ball { private static _instance: Ball; // 构造函数申明为 private,就可以阻止 new Ball() 行为 private constructor() {} public static getInstance() { if (Ball._instance === undefined) { Ball._instance = new Ball(); } return Ball._instance; } } // 使用 const ball = Ball.getInstance() ``` 可以仔细想想,为什么这个例子把单例写成了静态方法,而不是一个全局变量?其实全局变量也能解决问题,但由于会污染全局,要尽可能通过模块化方式解决,上面的例子就是一个较好的封装方式。 当然这只是一个最简单的例子,实际上单例模式还有几种模式: ### 饿汉式 初始化时就生成一份实例,这样调用时直接就能获取。 ### 懒汉式 就是代码例子中写的,按需实例化,即调用的时候再实例化。 > **要注意,按需不一定是什么好事,如果 New 的成本很高还按需实例化,可能把系统异常的风险留到随机的触发时机,导致难以排查 BUG,另外也会影响第一次实例化时的系统耗时。** 对 JAVA 来说,单例还需要考虑并发性,有 **双重检测、静态内部类、枚举** 等办法解决,这里不具体展开。 ## 弊端 单例模式的问题有: - 对面向对象不太友好。对封装、继承、多态支持不够友好。 - 不利于梳理类之间的依赖关系。毕竟单例是直接调用的,而不是在构造函数申明的,所以要梳理关系要看完每一行代码才能确定。 - 可拓展性不好。万一要支持多例就比较难拓展,比如全局数据流可能因为微前端方案改成多实例、数据库连接池为了分治 SQL 改成多实例,都是有可能的,在系统设计之初就要考虑到未来是否还会保持单例。 - 可测试性不好,因为单例是全局共享的,无法保证测试用例间的隔离。 - 无法使用构造函数传参。 另外单例模式还可以被工厂方法所替代,所以不用特别纠结一种设计模式,可以结合使用,工厂函数也可以内嵌单例模式。 ## 总结 单例模式概念、用法都简单,是架构设计常用方案,但要充分理解到单例模式的弊端,防止不恰当的使用。 > 讨论地址是:[精读《设计模式 - Singleton 单例模式》· Issue #278 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/278) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 设计模式/172.精读《设计模式 - Adapter 适配器模式》.md ================================================ # Adapter(适配器模式) Adapter(适配器模式)属于结构型模式,别名 `wrapper`,结构性模式关注的是如何组合类与对象,以获得更大的结构,我们平常工作大部分时间都在与这种设计模式打交道。 **意图:将一个类的接口转换成客户希望的另一个接口。Adapter 模式使得原本由于接口不兼容而不能在一起工作的那些类可以一起工作。** 这个设计模式的意图很好懂,就是把接口不兼容问题抹平。注意,也仅仅能解决接口不一致的问题,而不能解决功能不一致的问题。 ## 举例子 如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 ### 接口转换器 插座的种类很多,我们都用过许多适配器,将不同的插头进行转换,可以在不替换插座的情况下正常使用。 USB 接口转换也同样精彩,有将 TypeC 接口转换为 TypeA 的,也有将 TypeA 接口转换为 TypeC 的,支持双向转换。 接口转换器就是我们在生活中使用到的适配器模式,因为厂商并没有生产一个新的插座,我们也没有因为接口不适配而换一个手机,一切只需要一个接口转换器即可,这就是运用设计模式的收益。 ### 数据库 ORM ORM 屏蔽了 SQL 这一层,带来的好处是不需要理解不同 SQL 语法之间的区别,对于通用功能,ORM 会根据不同的平台,比如 Postgresql、Mysql 进行 SQL 的转换。 对 ORM 来说,屏蔽不同平台的差异,就是利用适配器模式做到的。 ### API Deprecated 当一个广泛使用的库进行了含有 break change 的升级时,往往要留给开发者足够的时间去升级,而不能升级后就直接挂掉,因此被废弃的 API 要标记为 `deprecated`,而这种被废弃标记的 API 的实际实现,往往是使用新的 API 替代,这种场景正是使用了适配器模式,将新的 API 适配到旧的 API,实现 API Deprecated。 ## 意图解释 上面三个例子都满足下面两个条件: 1. API 不兼容:因为接口的不同;数据库 SQL 语法的不同;框架 API 的不同。 2. 但能力已支持:插座都拥有充电或读取能力;不同的 SQL 都拥有查询数据库能力;新 API 覆盖了旧 API 的能力。 这样就可以通过适配器满足 Adapter 的意图: **意图:将一个类的接口转换成客户希望的另一个接口。Adapter 模式使得原本由于接口不兼容而不能在一起工作的那些类可以一起工作。** ## 结构图 适配器的实现分为继承与组合模式。 下面是名词解释: - `Adapter` 适配器,把 `Adeptee` 适配成 `Target`。 - `Adaptee` 被适配的内容,比如不兼容的接口。 - `Target` 适配为的内容,比如需要用的接口。 继承: 适配器继承 `Adaptee` 并实现 `Target`,适用场景是 `Adaptee` 与 `Target` 结构类似的情况,因为这样只需要实现部分差异化即可。 组合: 组合的拓展性更强,但工作量更大,如果 `Target` 与 `Adaptee` 结构差异较大,适合用组合模式。 ## 代码例子 下面例子使用 typescript 编写。 继承: ```typescript interface ITarget { // 标准方式是 hello hello: () => void } class Adaptee { // 要被适配的类方法叫 sayHello sayHello() { console.log('hello') } } // 适配器继承 Adaptee 并实现 ITarget class Adapter extends Adaptee implements ITarget { hello() { // 用 sayHello 对接到 hello super.sayHello() } } ``` 组合: ```typescript interface ITarget { // 标准方式是 hello hello: () => void } class Adaptee { // 要被适配的类方法叫 sayHello sayHello() { console.log('hello') } } // 适配器继承 Adaptee 并实现 ITarget class Adapter implements ITarget { private adaptee: Adaptee constructor(adaptee: Adaptee) { this.adaptee = adaptee } hello() { // 用 adaptee.sayHello 对接到 hello this.adaptee.sayHello() } } ``` ## 弊端 **使用适配器模式本身就可能是个问题**,因为一个好的系统内部不应该做任何侨界,模型应该保持一致性。只有在如下情况才考虑使用适配器模式: 1. 新老系统接替,改造成本非常高。 2. 三方包适配。 3. 新旧 API 兼容。 4. 统一多个类的接口。一般可以结合工厂方法使用。 ## 总结 适配器模式也符合开闭原则,在不对原有对象改造的前提下,构造一个适配器就能完成模块衔接。 适配器模式的实现分为类与对象模式,类模式用继承,对象模式用组合,分别适用于 `Adaptee` 与 `Target` 结构相似与结构差异较大的场景,在任何情况下,组合模式都是灵活性最高的。 最后用一张图概括一下适配器模式的思维: > 讨论地址是:[精读《设计模式 - Adapter 适配器模式》· Issue #279 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/279) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 设计模式/173.精读《设计模式 - Bridge 桥接模式》.md ================================================ # Bridge(桥接模式) Bridge(桥接模式)属于结构型模式,是一种解决继承后灵活拓展的方案。 **意图:将抽象部分与它的实现部分分离,使它们可以独立地变化。** 桥接模式比较难理解,我会一步步还原该设计模式的思考,让你体会这个设计模式是如何一步一步被提炼出来的。 ## 举例子 如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 ### 汽车生产线改造为新能源生产线 汽油车与新能源汽车的生产流程有很大相似之处,那么汽油车生产线能否快速改造为新能源汽车生产线呢? 如果汽油车生产线没有将内部实现解耦,只把生产汽油车的各部分独立了出来,对新能源车生产线是没什么用处的,但如果汽油车生产线提供了更底层的能力,比如加装轮胎,加装方向盘,那么这些步骤是可以同时被汽油车与新能源车所共享的。 在设计汽油车生产线时,就将生产过程与汽油车解耦,使其可以快速运用到新能源汽车的生产,这就是桥接模式的一种运用。 ### 窗口(Window)类的派生 假设存在一个 Window 窗口类,其底层实现在不同操作系统是不一样的,假设对于操作系统 A 与 B,分别有 AWindow 与 BWindow 继承自 Window,现在要做一个新功能 ManageWindow(管理器窗口),就要针对操作系统 A 与 B 分别生成 AManageWindow 与 BManageWindow,这样显然不容易拓展。 无论我们新增支持 C 操作系统,还是新增支持一个 IconWindow,类的数量都会成倍提升,因为我们所做的 AMangeWindow 与 BMangeWindow 同时存在两个即以上的独立维度,这使得增加维度时,代码变得很冗余。 ### 适配多个搭建平台的物料 做前端搭建平台时,经常出现一些物料(组件)因为固化了某个搭建平台的 API,因此无法迁移到另一个搭建平台,如果要迁移,就需要为不同的平台写不同的组件,而这些组件中大部分 UI 逻辑都是一样的,这使得产生大量代码冗余,如果再兼容一个新搭建平台,或者为已有的 10 个搭建平台再创建一个新组件,工作量都是写一个组件的好几倍。 ## 意图解释 **意图:将抽象部分与它的实现部分分离,使它们可以独立地变化。** “抽象” 部分与 “实现” 部分分离,这句话看起来很像接口与实现。确实,如果 “抽象” 指的是 接口(Interface),而 “实现” 指的是 类(Class) 的话,这就是简简单单的 `class MyWindow implements Window` 类实现过程而已。 但后半句话 “使它们可以独立地变化” 会让你难以和前半句联系起来,如果说 “抽象” 不变,“实现” 可以随意改变还好理解,但反过来就难以解释了。 **其实桥接模式中,抽象指的是一种接口(Abstraction),实现指的也是一种接口(Implementor),其中 Implementor 并不是直接实现了 Abstraction 定义的接口,而是提供更底层的方法,使 Abstraction 可以基于它们封装出自己的接口实现。** 这样一来,Abstraction 的接口可以随意变化,毕竟调用的是 Implementor 提供函数的组合,只要 Implementor 提供的功能全面,Implementor 可以不变;相应的,Implementor 的实现也可以随意变化,只要提供的底层函数不变,就不影响 Abstraction 对其的使用。 上面举的三个例子都是这样,我们应该把汽油车生产线的标准与通用汽车生产线标准分离、将具体功能窗口与适配不同操作系统的基础 GUI 能力隔离、将组件功能与平台功能隔离,只有做到了抽象部分与实现部分的隔离,才可以通过组合满足更多场景。 ## 结构图 - Abstraction:定义抽象类的接口。 - RefinedAbstraction:扩充 Abstraction。 - Implementor:定义实现类的接口,该接口可以与 Abstraction 接口不一致。 - ConcreteImplementor:实现 Implementor 接口并定义它的具体实现。 抽象部分就是 Abstraction,实现部分就是 Implementor,在这个结构图中,它们是分离的,可以各自独立变化的,桥接模式,就是指 `imp` 这个桥,通过 Implementor 实现 Abstraction 接口,就算是桥接上了,这种组合的桥接相比普通的类实现更灵活,更具有拓展性。 ## 代码例子 对于完全版桥接模式,Implementor 可以有多套实现,Abstraction 不需关心具体用的是哪一种实现,而是通过抽象工厂方式封装。下面举一个简单版的例子。 下面例子使用 typescript 编写。 ```typescript class Window { private windowImp: WindowImp public drawBox() { // 通过画线生成 box this.windowImp.drawLine(0, 1) this.windowImp.drawLine(1, 1) this.windowImp.drawLine(1, 0) this.windowImp.drawLine(0, 0) } } // 拓展 window 就非常容易 class SuperWindow extends Window { public drawIcon { // 通过自定义画线 this.windowImp.drawLine(0, 5) this.windowImp.drawLine(3, 9) } } ``` 桥接模式的精髓,通过上面的例子可以这么理解: `Window` 的能力是 `drawBox`,那继承 `Window` 容易拓展 `drawIcon` 吗?默认是不行的,因为 `Window` 并没有提供这个能力。经分析可以看出,划线是一种基础能力,不应该与 `Window` 代码耦合,因此我们将基础能力放到 `windowImp` 中,这样 `drawIcon` 也可以利用其基础能力画线了。 ## 弊端 不要过度抽象,桥接模式是为了让类的职责更单一,维护更便捷,但如果只是个小型项目,桥接模式会增加架构设计的复杂度,而且不正确的模块拆分,把本来关联的逻辑强制解耦,在未来会导致更大的问题。 另外桥接模式也有简单与复杂模式之分,只有一种实现的场景就不要用抽象工厂做过度封装了。 ## 总结 桥接模式让我们重新审视类的设计是否合理,把类中不相关,或者说相互独立的维度抽出去,由桥接模式做桥接的方式使用,这样会使每个类功能更内聚,代码量更少更清晰,组合能力更强大,更容易做拓展。 下图做了一个简单的解释: > 讨论地址是:[精读《设计模式 - Bridge 桥接模式》· Issue #280 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/280) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 设计模式/174.精读《设计模式 - Composite 组合模式》.md ================================================ # Composite(组合模式) Composite(组合模式)属于结构型模式,是一种统一管理树形结构的抽象方式。 **意图:将对象组合成树形结构以表示 “部分 - 整体” 的层次结构。Composite 使得用户对单个对象和组合对象的使用具有一致性。** ## 举例子 如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 ### 公司组织关系树 公司组织关系可能分为部门与人,其中人属于部门,有的人有下属,有的人没有下属。如果我们统一将部门、人抽象为组织节点,就可以方便的统计某个部门下有多少人、财务数据等等,而不用关心当前节点是部门还是人。 ### 操作系统的文件夹与文件 操作系统的文件夹与文件也是典型的树状结构,为了方便递归出文件夹内文件数量或者文件总大小,我们最好设计的时候就将文件夹与文件抽象为文件,这样每个节点都拥有相同的方法添加、删除、查找子元素,而不需要关心当前节点是文件夹或是文件。 ### 搭建平台的组件与容器 容器与组件的关系很小,用户常常认为容器也是一种组件,但搭建平台实现时,容器与组件稍有不同,不同之处在于容器可以嵌套子元素,而组件不可以。如果因此搭建平台就将组件分为容器与组件,会导致 API 割裂为两套,不利于组件开发者维护与用户理解,比较好的设计思路是将组件与容器统一看成组件,组件只是一种没有子元素的特殊容器,这样组件与容器就可以拥有相同的 API,统一理解与操作了。 ## 意图解释 **意图:将对象组合成树形结构以表示 “部分 - 整体” 的层次结构。Composite 使得用户对单个对象和组合对象的使用具有一致性。** 比较好理解,组合是指多个对象虽然有一定差异,但共同组合成了一个树形结构,那么对象之间就一定存在 “部分 - 整体” 的关系,组合模式要求我们抽象一个对象 `Component` 作为统一操作模型,叶子结点与非叶子结点都实现了所有功能,即便是没有子元素的叶子结点,为了强调透明性,还是具备比如 `getChildren` 方法,只不过永远都返回 `null`。 ## 结构图 其中 `Component` 是组合中对象声明接口,一般会实现所有公共类的所有接口,还要提供一个接口管理其子组件。 `Leaf` 表示叶子结点,没有子结点,相应的 `Composite` 就是有子结点的节点。 可以看到,组合模式就是将树状结构中所有节点统一抽象了,**我们不需要关心叶子结点与非叶子结点的差异,而可以通过组合模式的抽象屏蔽掉这些差异,统一处理。** ## 代码例子 下面例子使用 typescript 编写。 ```typescript // 统一的抽象 class Component { // 添加子元素 public add() {} // 获取名称 public getName() {} // 获取子元素 public getChildren() {} } // 非叶子结点 class Composite extends Component { public add(component: Component) { this.children.push(component) } public getName() { return this.name } public getChildren() { return this.children } } // 叶子结点 class Leaf extends Component { public add(component: Component) { throw Error('叶子结点无法添加元素') } public getName() { return this.name } public getChildren() { return null } } ``` 最后我们把对所有节点的操作都转为 `Component` 对象,而不用关心这个对象具体是 `Composite` 或 `Leaf`。 ## 弊端 组合模式进行了一层抽象,其实增加了复杂系统中业务复杂度。如果 `Composite` 与 `Leaf` 差异过大,那么统一抽象带来的理解成本是很高的。 同时,`Leaf` 不得不实现一些仅 `Composite` 存在的空函数,比如 `add` `delete`,即便这些方法对他们是无意义的,此时可能要进行统一的无效或错误处理,才能使业务层真正不用感知他们的区别,否则 `add` 可能会失败,其本质上还是将节点的区别暴露给了业务层。 ## 总结 组合模式是针对树状结构这个特定场景的统一抽象方案,对降低系统复杂度有很重要的意义,同时也不要忘了过度抽象是有害的,我们要拿捏其中的度。 下图做了一个简单的解释: 程序中始终关注 `Component` 就行了,树状结构的差异已经被抹平。 > 讨论地址是:[精读《设计模式 - Composite 组合模式》· Issue #284 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/284) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 设计模式/175.精读《设计模式 - Decorator 装饰器模式》.md ================================================ # Decorator(装饰器模式) Decorator(装饰器模式)属于结构型模式,是一种拓展对象额外功能的设计模式,别名 `wrapper`。 **意图:动态地给一个对象添加一些额外的职责。就增加功能来说,Decorator 模式相比生成子类更为灵活。** ## 举例子 如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 ### 相框 照片 + 相框 = 带相框的照片,这背后就是一种装饰器模式:照片具有看的功能,相框具有装饰功能,在你看照片的基础上,还能看到精心设计的相框,增加了美感,同时相框还可以增加照片的保存时间与安全性。 相框与照片是一种组合关系,任何照片都可以放到相框中,而不是每个照片生成一个特定的相框,显然,组合的方式更加灵活。 ### 带有缓存的文件读写 假设我们有一个类 `FileIO` 用来读写文件,但是没有缓存能力,此时是新建一个 `CachedFileIO` 子类好,还是创建一个 `CachedIO`? 一眼看上去好像 `CachedFileIO` 用起来更方便,而 `CachedIO` 的用法是 `new CachedIO(new FileIO())` 稍微麻烦一些,但如果我们增加一个网络读写类 `NetworkIO`,一个数据库读写类 `DBIO` 呢? 显然,继承的方式会使子类数量极速膨胀,而组合的方式则非常灵活,生成一个支持缓存的网络读写器,只需要 `new CachedIO(new NetworkIO())` 即可,这就是组合灵活的地方。 当然,为了实现这个能力,`CachedIO` 需要与 `FileIO`、`CachedFileIO`、`CachedIO` 继承自同一个类,具备相同的接口。 ### 搭建平台的组件 wrapper 装饰器模式别名也叫 `wrapper`,`wrapper` 也经常在前端搭建场景中遇到,当搭建平台加载一个组件时,希望拓展其基础能力,一般会使用 `wrapper` 层对组件进行嵌套,`wrapper` 层就是在不改变 API 的基础上,对第三方组件进行增强。 ## 意图解释 **意图:动态地给一个对象添加一些额外的职责。就增加功能来说,Decorator 模式相比生成子类更为灵活。** 不同于继承,组合可以在运行时进行,所以称之为 “动态添加”,这里的 “额外职责” 泛指一切功能,比如在按钮点击时进行一些 log 日志的打印,在绘制 text 文本框时,额外绘制一个滚动条和边框等等。 “就增加功能来说,Decorator 模式相比生成子类更为灵活” 这句话的含义是,组合比继承更灵活,当可拓展的功能很多时,继承方案会产生大量的子类,而组合可以提前写好处理函数,在需要时动态构造,显然是更灵活的。 ## 结构图 `ConcreteComponent` 指的是需要被装饰的组件,可以看到,装饰器 `Decorator` 与他都继承同一个类,这样能保证 API 的一致,才保证无论装饰多少层,始终符合 `Component` 类型。 装饰器如果有多种,就要将 `Decorator` 申明为抽象类,`ConcreteDecoratorA`、`ConcreteDecoratorB` 分别实现它们,如果只有一种装饰器,可以退化到 `Decorator` 自身就是一种实现。 ## 代码例子 下面例子使用 typescript 编写。 ```typescript class Component { // 具有点击事件 public onClick = () => {} } class Decorator extends Component { private _component constructor(component) { this._component = component } public onClick = () => { log('打点') this._component.onClick() } } const component = new Component() // 一个普通的点击 component.onClick() const wrapperComponent = new Decorator(component) // 一个具有打点功能的点击 wrapperComponent.onClick() ``` 其实方法很简单,通过组合,我们得到了一个能力更强的组件,而实现的方式就是利用构造函数保存组件实例,并在复写函数时,增加一些增强实现。 ## 弊端 装饰器的问题也是组合的问题,过多的组合会导致: - 组合过程的复杂,要生成过多的对象。 - 包装器层次增多,会增加调试成本,我们比较难追溯到一个 bug 是在哪一层包装导致的。 ## 总结 装饰器模式是非常常用的模式,Decorator 是一个透明的包装,只要保证包装的透明性,就可以最大限度发挥装饰器模式的优势。 最后总结一个装饰器应用图: > 讨论地址是:[精读《设计模式 - Decorator 装饰器模式》· Issue #286 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/286) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 设计模式/176.精读《设计模式 - Facade 外观模式》.md ================================================ # Facade(外观模式) Facade(外观模式)属于结构型模式,是一种日常开发中经常被使用到的设计模式。 **意图:为子系统中的一组接口提供一个一致的界面,Facade 模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。** ## 举例子 如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 ## 意图解释 ### 图书管理员 图书馆是一个非常复杂的系统,虽然图书按照一定规则摆放,但也只有内部人员比较清楚,作为一位初次来的访客,想要快速找到一本书,最好的办法是直接问图书管理员,而不是先了解这个图书馆的设计,因为你可能要来回在各个楼宇间奔走,借书的流程可能也比较长。 图书管理员就起到了简化图书馆子系统复杂度的作用,我们只要凡事询问图书管理员即可,而不需要关心他是如何与图书馆内部系统打交道的。 ### 最多跑一次便民服务 浙江省推出的最多跑一次服务非常方便,很多办事流程都简化了,无论是证件办理还是业务受理,几乎只要跑一次,而必须要持续几天的流程也会通过手机短信或者 App 操作完成后续流程。 这就相当于外观模式,因为政府系统内部的办事流程可能没有太大变化,但通过抽象出 Facade(外观),让普通市民可以直接与便民办事处连接,而不需要在车管所与驾校之间来回奔波,背后的事情没有少,只是便民办事处帮你做了。 ### Iphone 快捷指令功能 手机的 App 非常多,而我们需要了解每个功能在哪个 App 上才能运用自如,而快捷指令功能可以将 App 的某些功能单独提取出来,形成一套新的功能组,我们可以只接触到 “拍照” “付款” “计算”,而不用管背后是调用了支付宝还是微信、系统内置摄像机还是其他摄像 App,也不用关心这个 App 内部功能的入口在哪里,这些对接都在快接指令中自动完成。 快捷指令也是一种外观模式。 ## 意图解释 **意图:为子系统中的一组接口提供一个一致的界面,Facade 模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。** 为降低一个拥有多个接口的子系统内部复杂性,我们需要一个外观来屏蔽内部的复杂性,因此外观模式就是定义一个高层接口,这个接口直连子系统的内部实现,但调用这个高层接口的人不需要关心子系统内部的实现,这样,对于不想了解子系统内部实现的人来说,提高了易用度。 当然如果想要深度定制,就可以绕过外观模式,直接使用子系统提供的类,所以说并不是有了外观模式就必须通过外观调用,而是根据实际需要判断使用哪种调用方式。 ## 结构图 可以看到,Facade 直接指向子系统中的类,**而子系统的类不会反向指向 Facade**。 ## 代码例子 下面例子使用 typescript 编写。 ```typescript // 假设一个子系统是三个类结合使用的,为了抽象而解耦开了 class A { constructor(b: B) { this.b = b } } class B { constructor(c: C) { this.c = c } } class C { } // 它们组合成了一种常用功能,我们可以使用外观模式屏蔽子类的细节直接使用 class Compile { public run() { const parser = new A(new B(new C)) parser.run() } } const compile = new Compile() compile.run() ``` 这样我们只要知道 `Compile` 类就可以了,而不需要了解背后的 `A` `B` `C` 以及其组合关系。 ## 弊端 外观模式并不适合于所有场景,当子系统足够易用时,再使用外观模式就是画蛇添足。 另外,当系统难以抽象出通用功能时,外观模式的设计可能也无所适从,因为设计的高层接口可能适用范围很窄,此时外观模式的意义就比较小。 ## 总结 其实抽象工厂模式也可以代替外观模式,来实现隐藏子类具体实现的效果,但外观模式描述更具有通用性。 > 讨论地址是:[精读《设计模式 - Facade 外观模式》· Issue #288 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/288) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 设计模式/177.精读《设计模式 - Flyweight 享元模式》.md ================================================ # Flyweight(享元模式) Flyweight(享元模式)属于结构型模式,是一种共享对象的设计模式。 **意图:运用共享技术有效地支持大量细粒度的对象。** ## 举例子 如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 ### 富文本编辑器的字母对象 富文本编辑器在英文环境下,其中的文本由大量字母组成,为了便于做统一的格式化、计算等处理,需要将每个字母都存储为对象,但这样存储的代价太大了。 已知英文字母一共 26 个,所以文档中存在大量重复使用的字母,而每个字母除了位置信息外,其它信息都是相同且只读的,那么有办法降低富文本场景巨大的字母对象数量吗? ### 网盘存储 当我们上传一部电影时,有时候几十 GB 的内容不到一秒就上传完了,这是网盘提示你,“已采用极速技术秒传”,你会不会心生疑惑,这么厉害的技术为什么不能每次都生效? 另外,网盘存储时,同一部电影可能都会存放在不同用户的不同文件夹中,而且电影文件又特别巨大,和富文本类似,电影文件也只有存放位置是不同的,而其余内容都特别巨大且只读,有什么办法能优化存储呢? ### 大型多人游戏 玩多人游戏时,为了防止外挂,一般对象的创建与计算是在服务器完成的,那如何保证一个玩家拾取物品后,另一个玩家看到的物品会消失? 其实道理已经不言而喻了,虽然在不同客户端之间,游戏对象是相互独立的,但在一局游戏中,所有玩家的对象在服务器是共享的。 ## 意图解释 “共享” 就是享元模式的精髓,将那些大量的,具有很多内部状态而外部状态很少的对象进行共享,就是享元模式的使用方式。 **意图:运用共享技术有效地支持大量细粒度的对象。** 共享技术可以理解为缓存,当一个对象创建后,再次访问相同对象时,就不再创建新的对象了,而只有在访问没有被缓存过的对象时,才创建新对象,并立即缓存起来。 这样做可以有效支持大量细粒度的对象,在富文本例子中,**无数的字母就是大量细粒度对象**,在网盘存储中,**电影文件就是大量细粒度对象**,在大型多人游戏中,**每局游戏内存在大量细粒度对象**。 这些细粒度对象都拥有相同的特征: - 量特别大,这个很容易理解。 - 具有大量内部状态,且不随着客户端的不同而改变。 - 富文本的字母,不因为展示到不同语句中而发生变化,变化的只有状态;电影文件,不因为放在不同用户的文件夹中而对电影内容产生变化,变化的只有属于哪些用户,放在哪些文件夹里;多人游戏中,同一把武器对象,不因为有多个人的电脑独立运行而拥有更多的弹药,变化的只有在哪些客户端被访问。 - 具有少量外部状态,甚至没有外部状态。在上面已经解释了,字母的位置、电影的位置、游戏对象的客户端都是外部状态,这些外部状态相比于其内部状态来说,大小微乎其微,且方便分离存储。 遇到这种情况,我们就可以将对象内部状态共享,外部状态独立存储,从而节省大量空间。 尤其是对于网盘的场景,承诺给用户 2 TB 的存储空间,这个用户看到其他人分享了 100 个电影,就点击 “下载到我的网盘”,**此时虽然占用了自己 1 TB 的网盘空间,但实际上网盘运营商并没有增加 1 TB 的存储空间,实际可能增加了 1kb 的存储空间,记录了存储位置**,这就是网盘鸡贼的地方,并不占用空间的内容,却占用了用户真金白银购买的存储空间。 当然,这就是享元模式的价值,对网盘公司来说,价值巨大,对用户来说,没有价值。所以享元模式的价值体现在全局,比如对整个富文本编辑器来说,减少了巨量字母对象数量,但对于每一个字母对象而言,并没有任何优化。 ## 结构图 对于 Client 而言,下图描述了如何共享 Flyweight: - Flyweight: 共享接口,通过这个接口可以操作对象的外部状态。 - ConcreteFlyweight: 实现 Flyweight 接口的对象,这个对象是可被共享的。 - UnsharedConcreteFlyweight: 不被共享的对象,因为在享元模式中,实际上并不是所有对象都可以被共享。 - FlyweightFactory: 创建并管理 Flyweight 对象,通过其返回的 Flyweight 对象,如果已创建,则会返回之前创建的那个,没有的话才会创建一个新的。 - Client: 使用 Flyweight 的客户端。 通过第二个图可以明显看到,两个不同的 Client 持有了相同 `aConcreteFlyweight` 引用。 ## 代码例子 下面例子使用 typescript 编写。 ```typescript class FlyweightFactory { public getFlyWeight(key) { if (this.flyweight[key]) { return this.flyweight[key] } const flyweight = new Flyweight() this.flyweight[key] = flyweight return flyweight } } ``` `FlyweightFactory` 提供的 `getFlyWeight` 方法,实际上是按照 `key` 对 `flyweight` 实例进行缓存,相同 `key` 下只存储一个 `flyweight` 实例。 ## 弊端 如果细粒度对象不多,则没必要使用享元模式。 另外,就算细粒度对象很多,如果对象内部状态并不多,主要都是外部状态,那么享元模式就起不到什么作用了,**因为享元模式通过共享对象,只能节省内部状态,而不能节省外部状态。** 另外,如果享元模式映射到的共享对象数量并没有比原始对象少出数量级关系,使用的意义也不大。比如富文本编辑器的例子,对于英文来说,一共就 26 个字母,那么 1 万字的文章优化比例是 10000:26,但对于中文文章而言,文字实例本身就很多,可能 1 万字的文章中,汉字去重后依然有 3000 个,那么优化比例就是 10000:3000,此时享元模式的意义就没那么大了。 ## 总结 享元模式的本质就是尽可能的共享对象,特别适用于存在大量细粒度对象,而这些对象内部状态特别多,外部状态较少的场景。 对于云存储来说,享元模式是必须使用的,因为云存储的场景决定了,存在大量细粒度文件对象,而存在大量只读的文件,就非常适合共享一个对象,每个用户存储的只是引用。 > 讨论地址是:[精读《设计模式 - Flyweight 享元模式》· Issue #290 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/290) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 设计模式/178.精读《设计模式 - Proxy 代理模式》.md ================================================ # Proxy(代理模式) Proxy(代理模式)属于结构型模式,通过访问代理对象代替访问原始对象,以获得一些设计上的便捷。 **意图:为其他对象提供一种代理以控制这个对象的访问。** ## 举例子 如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 ### 获得文本对象长度 获得一个文本对象长度,必须要真正渲染出来,而渲染是比较耗时的,我们可能只在某些场景下需要访问文本对象长度,而更多时候只需要读取文本内容,这两种操作耗时是完全不同的,如何做到业务层调用无感知,来优化执行耗时呢? 代理模式可以解决这个问题,我们将业务层使用的文本对象替换为代理对象,这个代理对象初始化并不渲染文本,而是在调用文本长度时才渲染。 ### 对象访问保护 某个大型系统开发完了,突然要求增加代码访问权限体系,不同模块对相同的底层对象拥有不同访问权限,此时这个权限控制逻辑如果写入底层对象,就违背了开闭原则,而对象本身的实现也不再纯粹,增加了维护成本,如何做到不修改对象本身,实现权限控制呢? 代理模式也能解决,将底层对象导出替换为代理对象,由代理对象控制访问权限即可。 ### 对象与视图双向绑定 Angular 或 Vue 这类前端框架采用双向绑定视图更新技术,即对象修改后,使用到的视图会自动刷新,这就需要做到以下两点: 1. 在对象被访问时,记录调用的视图绑定。 2. 在对象被修改时,刷新调用它的视图。 问题是,在业务代码使用对象与修改对象的地方插入这段逻辑,显然会增加巨大的维护成本,如何做到业务层无感知呢? 代理模式可以很好的解决这个问题,其实业务层拿到的对象已经是代理对象了,它在被访问与被修改时,都会执行固定的钩子做视图绑定与视图刷新。 ## 意图解释 **意图:为其他对象提供一种代理以控制这个对象的访问。** 代理模式的意图很容易理解,就是通过代理对象代替原始对象的访问。 这只是代理模式的实现方式,代理模式真正的难点不在于理解它是如何工作的,而是理解哪些场景适合用代理,或者说创建了代理对象,怎么用才能发挥它的价值。 在上面例子中,已经举出了几种常见代理使用场景: 1. 对开销大的对象使用代理,以按需使用。 2. 对需要保护的对象进行代理,在代理层做权限控制。 3. 在对象访问与修改时要执行一些其他逻辑,适合在代理层做。 ## 结构图 使用时关系如下: Subject 定义的是 RealSubject 与 Proxy 共用的接口,这样任何使用 RealSubject 的地方都可以使用 Proxy。 RealSubject 指的是原始对象,Proxy 是一个代理实体。 关系图中可以看出,当客户端要访问 subject 时,第一层访问的是 Proxy 代理,由这个代理将 realSubject 转发给客户端。 ## 代码例子 下面例子使用 typescript 编写。 ```typescript // 对象 obj const proxy = new Proxy(obj, { get(target,key) {} set(target,key,value) {} }) ``` JS 创建代理还是蛮简单的,代理可以控制对象的所有成员属性,包括成员变量与成员方法的访问(get)与修改(set)。 ## 弊端 代理模式会增加微弱的开销,因此请不要将所有对象都变成代理,没有意义的代理只会徒增程序开销。 另外代理对象过多,也会导致调试困难,因为代理层的存在,我们往往可能忽略这一层带来的影响,导致忘记这个对象其实是一个代理。 ## 总结 代理和继承有足够多的相似之处,继承中,子类几乎可以人为是对父类的代理,子类可以重写父类的方法。但代理和继承还是有区别的: 如果你没有采用 `new Proxy` 这种 API 创建代理,而是采用继承的方式实现,你会一下子继承这个类的所有方法,而做不到按需控制访问权限的灵活效果,所以代理比继承更加灵活。 JS 的 `new Proxy` 对应了 Java 动态代理模式,一般认为动态代理比静态代理更强大。 最后,还要重申那句话,代理模式理解与运用并不难,难就难在能否在恰当的场合想到它,双向绑定几乎是代理模式最好的例子。 > 讨论地址是:[精读《设计模式 - Proxy 代理模式》· Issue #291 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/291) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 设计模式/179.精读《设计模式 - Chain of Responsibility 职责链模式》.md ================================================ # Chain of Responsibility(职责链模式) Chain of Responsibility(职责链模式)属于行为型模式。行为型模式不仅描述对象或类的模式,还描述它们之间的通信模式,比如对操作的处理应该如何传递等等。 **意图:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。** > 几乎所有设计模式,在了解到它之前,笔者就已经在实战中遇到过了,因此设计模式的确是从实践中得出的真知。但另一方面,如果没有实战的理解,单看设计模式是枯燥的,而且难以理解的,因此大家学习设计模式时,要结合实际问题思考。 ## 举例子 如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 ### 中间件机制 设想我们要为一个后端框架实现中间件(知道 Koa 的同学可以理解为 Koa 的洋葱模型),在代码中可以插入任意多个中间件,每个中间件都可以对请求与响应进行处理。 由于每个中间件只响应自己感兴趣的请求,因此只有运行时才知道这个中间件是否会处理请求,那么中间件机制应该如何设计,才能保证其功能和灵活性呢? ### 通用帮助文案 如果一个大型系统中,任何一个模块点击都会弹出帮助文案,但并不是每个模块都有帮助文案的,如果一个模块没有帮助文案,则显示其父级的帮助文案,如果再没有,就继续冒泡到整个应用,展示应用级别的兜底帮助文案。这种系统应该如何设计? ### JS 事件冒泡机制 其实 JS 事件冒泡机制就是个典型的职责链模式,因为任何 DOM 元素都可以监听比如 `onClick`,不仅可以自己响应事件,还可以使用 `event.stopPropagation()` 阻止继续冒泡。 ## 意图解释 JS 事件冒泡机制对前端来说太常见了,但我们换个角度,站在点击事件的角度理解,就能重新发现其设计的精妙之处: 点击事件是叠加在每层 dom 上的,由于 dom 对事件的处理和绑定是动态的,浏览器本身不知道哪些地方会处理点击事件,但又要让每层 dom 拥有对点击事件的 “平等处理权”,所以就产生了冒泡机制,与事件阻止冒泡功能。 通用帮助文案和 JS 事件冒泡很类似,只是把点击事件换成了弹出帮助文案罢了,其场景机理是一样的。 说到这,我们可以再重新理解一下职责链模式的意图: **意图:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。** 请求指的是某个触发机制产生的请求,是一个通用概念。“避免请求的发送者和接收者之间的耦合关系”,指的是如果我们只有一个对象有处理请求的机会,那接收者就与发送者之间耦合了,其他接收者必须通过这个接收者才能继续处理,这种模式不够灵活。 后半句描述的是如何设计,可以实现这个灵活的模式,即将对象连成一条链,沿着链条传递该请求,直到有一个对象处理它为止。还要理解到,任何一个对象都拥有阻断请求继续传递的能力。 在中间件机制的例子中,后端 Web 框架对 Http 请求的处理就是个运用职责链模式的典型案例,因为后端框架要处理的请求是平行关系,任何请求都可能要求被响应,但对请求的处理是通过插件机制拓展的,且对每个请求的处理都是一个链条,存在处理、加工、再处理的逻辑关系。 ## 结构图 Handler 就是对请求的处理,可以看到这里是一条环路,只要处理完之后就可以交给下一个 Handler 进行处理,可以在中途拦截后中断,也可以穿透整条链路。 `ConcreteHandler` 是具体 Handler 的实现,他们都需要继承 Handler 以具备相同的 `HandleRequest` 方法,这样每一个处理中间件就都拥有了处理能力,使得这些对象连成的链条可以对请求进行传递。 ## 代码例子 职责链实现方式非常多,比如 Koa 的洋葱模型实现原理就值得再写一篇文章,感兴趣的同学可以阅读 [co 源码](https://github.com/tj/co)。这里仅介绍最简单场景的实现方案。 职责链的简单实现模式也分为两种,一种是每个对象本身维护到下一个对象的引用,另一种是由 Handler 维护后继者。 下面例子使用 typescript 编写。 ```typescript public class Handler { private nextHandler: Handler public handle() { if(nextHandler) { nextHandler.handle() } } } ``` 每个 Handler 的默认行为就是触发下一个链条的 `handle`,因此什么都不做的话,这个链条是完全打通的,因此我们可以在链条的任何一环进行处理。 处理的方式就是重写 `handle` 函数,我们在重写时,可以维持对 `nextHandler.handle()` 的调用,以使得链条继续向后传递,也可以不调用,从而终止链条向后传递。 ## 弊端 职责链模式不保证每个中间件都有机会处理请求,因为中间件顺序的问题,后面中间件可能被前面的中间件阻断,因此当中间件之间存在不信任关系时,职责链模式并不能保证中间件调用的可靠性。 另外就是不要扩大设计模式的使用范围,对一堆对象的连续调用就没必要使用职责链模式,因为职责链适合处理对象数量不确定、是否处理请求由每个对象灵活决定的场景,而确定了对象数量以及是否调用的场景,就没必要使用职责链模式了。 ## 总结 职责链模式是插件机制常用的设计模式,在事件机制、请求处理中有广泛的应用。 职责链模式还可以与组合模式组合使用,因为组合模式描述的是一种统一管理的树形结构,每个节点都可以把自己的父节点作为后继节点。实际上 dom 结构就是一种组合模式,事件冒泡就是在其基础上拓展的职责链模式。 > 讨论地址是:[精读《设计模式 - Chain of Responsibility(职责链模式)》· Issue #292 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/292) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 设计模式/180.精读《设计模式 - Command 命令模式》.md ================================================ # Command(命令模式) Command(命令模式)属于行为型模式。 **意图:将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化,对请求排队或记录请求日志,以及支持可撤销的操作。** ## 举例子 如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 ### 点菜是命令模式 为什么顾客会找服务员点菜,而不是直接冲到后厨盯着厨师做菜?因为做菜比较慢,肯定会出现排队的现象,而且有些菜可能是一起做效率更高,所以将点菜和做菜分离比较容易控制整体效率。 其实这个社会现象就对应编程领域的命令模式:点菜就是一个个请求,点菜员记录的菜单就是将请求生成的对象,点菜员不需要关心怎么做菜、谁来做,他只要把菜单传到后厨即可,由后厨统一调度。 ### 大型软件系统的操作菜单 大型软件操作系统都有一个特点,即软件非常复杂,菜单按钮非常多。但由于菜单按钮本身并没有业务逻辑,所以通过菜单按钮点击后触发的业务行为不适合由菜单按钮完成,此时可利用命令模式生成一个或一系列指令,由软件系统的实现部分来真正执行。 ### 浏览器请求排队 浏览器的请求不仅会排队,还会取消、重试,因此是个典型的命令模式场景。如果不能将 `window.fetch` 序列化为一个个指令放入到队列中,是无法实现请求排队、取消、重试的。 ## 意图解释 **意图:将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化,对请求排队或记录请求日志,以及支持可撤销的操作。** 一个请求指的是来自客户端的一个操作,比如菜单按钮点击。重点在点击后并不直接实现,而是将请求封装为一个对象,可以理解为从直接实现: ```typescript function onClick() { // ... balabala 实现逻辑 } ``` 改为生成一个对象,序列化这个请求: ```typescript function onClick() { concreteCommand.push({ // ... 描述这个请求 }) // 执行所有命令队列 concreteCommand.executeAll() } ``` 看上去繁琐了一些,但得到了后面所说的好处:“从而使你可用不同的请求对客户进行参数化”,**也就是可以对任何请求进行参数化存储,我们可以在任意时刻调用。** 这相当于掌握了执行时机,可以在任意时刻调用,以实现排队或记录日志,如果再记录下反向操作信息,就可以实现撤销重做了。 ## 结构图 Command 是命令的接口,一般固定有一个 `execute` 方法。 ConcreteCommand 是命令接口的实现,它会注入具体执行者 `Receiver`,它实现的 `execute` 方法会调用 `receiver.execute` 来具体执行。 `Invoker` 是执行请求的命令,其实上面都在推入命令,并没有真正执行,如果排队结束或点击撤销重做时,就触发了 Invoker 实际,就该调用对应的 Command 执行啦。 ## 代码例子 下面例子使用 typescript 编写。 首先看最终执行态,最终执行需要先添加命令,再执行命令: ```typescript const command1 = new Command('balabala1') const command2 = new Command('balabala2') const invoker = new Invoker() invoker.push(command1) invoker.push(command2) invoker.execute() ``` `Invoker` 内部用一个队列维护,执行的时候其实是 `for` 循环执行了每个 `command.execute()`: ```typescript class Invoker { push(command) { // 队列里推入命令 this.commands.push(command) } execute() { this.commands.forEach(command => command.execute()) // 别忘了清空 this.commands } } ``` ## 弊端 命令模式需要注意序列化大小,一般分为: 1. 仅记录操作。 2. 记录全量快照。 3. 全量快照共享内存。 记录操作是较为精细的管理方式,并且可以延伸出协同编辑功能。记录快照要注意尽量共享内存,防止快照过大,而且协同编辑场景因为快照无法做冲突处理,所以快照模式在协同编辑场景无法应用。 另外要识别没必要使用命令模式的场景,对于没有撤销重做的前端大部分场景来说,都无需改为命令模式。 ## 总结 命令模式本质上就是将操作抽象为可序列化的命令,使操作可以在合适的时间执行,这种设计带来了许多额外好处。 利用命令模式可以达到高内聚低耦合的效果,提升代码可维护性,也可以实现撤销重做、协同编辑等功能性需求。 > 讨论地址是:[精读《设计模式 - Command(命令模式)》· Issue #295 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/295) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 设计模式/181.精读《设计模式 - Interpreter 解释器模式》.md ================================================ # Interpreter(解释器模式) Interpreter(解释器模式)属于行为型模式。 **意图:给定一个语言,定义它的文法的一种表示,并定义一个解释器。这个解释器使用该表示来解释语言中的句子。** 任何一门语言,无论是日常语言还是编程语言都有明确的语法,只要有语法就可以用文法描述,并通过语法解释器将字符串的语言结构化。 ## 举例子 如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 ### SQL 解释器 SQL 是一种描述语言,所以也适用于解释器模式。不同的 SQL 方言有不同的语法,我们可以根据某种特定的 SQL 方言定制一套适配它的文法表达式,再利用 antlr 解析为一颗语法书。在这个例子中,antlr 就是解释器。 ### 代码编译器 程序语言也因为其天然是字符串的原因,和 SQL、日常语言都类似,需要一种模式解析后才能工作。不同的语言有不同的文法表示,我们只需要一个类似 antlr 的通用解释器,通过传入不同的文法表示,返回不同的对象结构。 ### 自然语言处理 自然语言处理也是解释器的一种,首先自然语言处理一般只能处理日常语言的子集,因此先定义好支持的范围,再定义一套分词系统与文法表达式,并将分词后的结果传入灌入了此文法表达式的解释器,这样解释器可以返回结构化数据,根据结构化数据再进行分析与加工。 ## 意图解释 **意图:给定一个语言,定义它的文法的一种表示,并定义一个解释器。这个解释器使用该表示来解释语言中的句子。** 对于给定的语言,可以是 SQL、代码或自然语言,“定义它的文法的一种表示” 即文法可以有多种表示,只需定义一种。要注意的是,不同文法执行效率会有差异。 “并定义一个解释器”,这个解释器就是类似 antlr 的东西,传给它一个文法表达式,就可以解析句子了。即:解释器(语言, 文法) = 抽象语法树。 我们可以直接把文法定义耦合到解释器里,但这样做会导致语法复杂时,解释器难以维护。比较好的方式是定义一套与解释器解耦的文法表达式,通过预处理器最终生成解释器。 ## 结构图 Context 是其他上下文变量,AbstractExpression 是抽象语法表达式。 可以看到,TerminalExpression(终结符)与 NonterminalExpression(非终结符) 都继承于 AbstractExpression,终结符指的是没有后续展开的符号,非终结符相反,所以非终结符又指向了 AbstractExpression,如此递归。 ## 代码例子 下面例子使用 typescript 编写。 假设我们要实现以下文法: ```text sum ::= number + number number ::= 1 | 2 ``` 表达一个最简单的加法文法,其中加法表达式 sum 和 number 都是非终结符,而 +、1、2 是终结符。这个例子只能做到 1 与 2 的加法,通过这个简单例子,了解一下解释器模式的精髓吧: ```typescript // 抽象表达式 class AbstractExpression { interpret (text: string) {} } // 终结符表达式 class TerminalExpression extends AbstractExpression { constructor(values: string[]) { this.values = values } interpret(value: string) { // 值必须是其中之一 return this.values.includes(value) } } // 非终结符表达式 class NonterminalExpression extends AbstractExpression { constructor(left: TerminalExpression, right: TerminalExpression) { this.left = left this.right = right } interpret(value: string) { if (value.indexOf("+") === -1) { // 必须包含 + 号 return false } const splitValue = value.split('+') return this.left.interpret(splitValue[0]) && this.right.interpret(splitValue[1]) } } // 调用 const context = new Context() const terminal = new TerminalExpression(["1", "2"]) const add = new AddExpression(terminal, terminal) add.interpreter("1 + 1") // true add.interpreter("1 + 2") // true add.interpreter("1 + 3") // false add.interpreter("2 - 1") // false ``` 遇到非终结符则继续调用,只有终结符才能直接判断,原理很简单。 ## 弊端 上面的例子是比较低效场景,因为当语法复杂后,类的数目会明显增多,难以维护,此时需要用一个通用语法解析器,了解更多可以看笔者之前的文章:[精读《手写 SQL 编译器 - 语法分析》](https://github.com/dt-fe/weekly/blob/v2/066.%E7%B2%BE%E8%AF%BB%E3%80%8A%E6%89%8B%E5%86%99%20SQL%20%E7%BC%96%E8%AF%91%E5%99%A8%20-%20%E8%AF%AD%E6%B3%95%E5%88%86%E6%9E%90%E3%80%8B.md) 系列。 ## 总结 解释器是一种思维,将复杂语法解析抽象为一个个独立的终结符与非终结符各自判断,只要每个文法自己的判断做好了,剩下的工作就是组装文法。 这种将单个逻辑判断与文法组装解耦的做法,可以使逻辑判断与文法组装独立变换,使复杂语法解析转化为一个个具体的简单问题。 > 讨论地址是:[精读《设计模式 - Interpreter 解释器模式》· Issue #296 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/296) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 设计模式/182.精读《设计模式 - Iterator 迭代器模式》.md ================================================ # Iterator(迭代器模式) Iterator(迭代器模式)属于行为型模式。 **意图:提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。** 这种设计模式要解决的根本问题是,聚合的种类有很多,比如对象、链表、数组、甚至自定义结构,但遍历这些结构时,不同结构的遍历方式又不同,所以我们必须了解每种结构的内部定义才能遍历。 比如数组我们可以利用 length + for 循环,对象我们可以 Object.keys,链表比较麻烦,需要内部暴露出元素的 `next` 以操作指向下一个元素。 迭代器模式可以做到用同一种 API 遍历任意类型聚合对象,且不用关心聚合对象的内部结构。 这种模式和 `Array.from` 有点像,但其实真正的迭代器在 JS 里是 `obj[Symbol.iterator]()`,也就是一个对象实现了 `[Symbol.interator]`,就认为是可遍历的。 ## 举例子 如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 迭代器的例子非常简单,我们平时工作中有大量使用到。 ### generator `generator` 天生为迭代器的 API: ```typescript function* func () { yield 'a'; yield 'b'; return 'c'; } var run = func(); run.next() // {value: "a", done: false} run.next() // {value: "b", done: false} run.next() // {value: "c", done: true} ``` 我们无需关心 generator 内部是何种存储结构,只需要调用 `.next()`,并根据返回的 `done` 来判断是否遍历完即可。在 generator 的场景中,迭代器不仅用来遍历聚合,还用于执行代码。 ### 数组迭代器 我们可以用迭代器的方式遍历数组: ```typescript const arr = [1, 2, 3] const run = arr[Symbol.iterator]() run.next() // {value: 1, done: false} run.next() // {value: 2, done: false} run.next() // {value: 2, done: false} run.next() // {value: undefined, done: true} ``` 可能有人觉得这是画蛇添足,因为毕竟遍历数组用 for 循环更方便,但这就是设计模式与非设计模式思维的区别,重要的不是用熟悉简单的 API 快速满足需求,**设计模式关注的是如何统一、抽象、低耦合的编码**。 ### Map 迭代器 Map 对象也可以用迭代器方式遍历: ```typescript const citys = new Map([['北京', 1], ['上海', 2], ['杭州', 3]]) const run = citys.entries() run.next() // {value: ['北京', 1], done: false} run.next() // {value: ['上海', 2], done: false} run.next() // {value: ['杭州', 3], done: false} run.next() // {value: undefined, done: true} ``` ## 意图解释 从上面的例子可以看出,虽然用迭代器遍历数组看上去比 for 循环麻烦一点,但当我们把所有聚合类型放到一起看时,可以发现只有迭代器的 API 是最统一的,是唯一一个不需要关心聚合类型就可以完成遍历的方案。 **意图:提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。** 再来看意图,就非常好理解了,我们无需关心 数组、generator、Map 内部是如何存储的,就可以进行遍历。实际上,深究 generator 内部的存储结构也没有意义,如果我们不用迭代器进行遍历,那么对于复杂结构的遍历成本是非常高的。 ## 结构图 - `Aggregate`: 聚合,需要定义创建迭代器的接口。比如前端规范的 `[Symbol.iterator]()`,或者这里定义的 `CreateIterator()`。 - `Iterator`: 迭代器,定义了访问与遍历的 API。 迭代器的定义很简单,实现时要考虑的因素可不少,包括: - 健壮性。即迭代过程中增加、删除元素后,还能正常遍历。或者遍历空聚合时也要能正常工作。 - 外部控制迭代还是内部。即类似 KOA 由插件调用 `next()` 控制迭代,还是由外层统一控制迭代。 - 如何定义遍历算法。即便对于对象这种简单场景,也存在深度优先和广度优先、冒泡与捕获这几种遍历顺序,迭代器可以提供选择或者拓展的方式,自定义遍历算法。 ## 代码例子 下面例子使用 typescript 编写。 ```typescript // 定义聚合接口 interface Aggregate{ getIterator: () => Iterator } // 定义迭代器接口 interface Iterator { // 指向下一个 next: () => void } // 定义一个聚合 class List implements Aggregate { // 存储元素 public values: string[] // 游标 public index: number getIterator() { return new ConcreteIterator(this); } } // List 的迭代器 class ConcreteIterator implements Iterator { constructor(list: List) { this.list = list } next() { return this.list.values[this.list.index] // 注意边界情况,这里就不展开 this.list.index++ } } ``` ## 弊端 如果你只是遍历数组,直接用 for 循环会比迭代器方便很多,没必要为了用设计模式而用设计模式。迭代器仅在以下情况可以考虑用于数组: 1. 这个数组比较特殊,是 N 维数组,需要一次性遍历完,那么可以用迭代器。 2. 同时遍历数组和其他类型的聚合,则不论数组还是其他聚合,都用相同的迭代器模式遍历最好。 ## 总结 迭代器模式比较好理解,这里补充几个相关设计模式: - 迭代器可以和组合模式配合,在组合结构内进行递归,这样一个迭代器就可以遍历完所有组合。 - 可以用工厂模式 + 多态模式,实例化不同的迭代器的实例。 - 迭代器模式还可以与备忘录模式配合使用,当我们要还原迭代器状态时,适合在迭代器内部使用备忘录模式进行状态存储。 > 讨论地址是:[精读《设计模式 - Iterator 迭代器模式》· Issue #298 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/298) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 设计模式/183.精读《设计模式 - Mediator 中介者模式》.md ================================================ # Mediator(中介者模式) Mediator(中介者模式)属于行为型模式。 **意图:用一个中介对象来封装一系列的对象交互。中介者使各对象不需要显示地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。** 前端开发中,最常用的 “数据驱动” 其实就最好的诠释了中介者模式。 想一个这样的场景: 1. 按钮点击后,表单提交。按钮需要调用所有表单项获取表单值。 2. 表单关联,当勾选了城市后,才出现满意度 Input 框,此时城市勾选按钮需要引用满意度 Input 框。 3. 甚至会出现循环引用,两个输入框是互斥的,输入了一个,另一个输入框就要 Disable。 4. 当新增加一个表单项时,需要重新建立所有引用关系。 以上过程式编程方式,维护大型项目几乎是不可能的。然而数据驱动可以很好的解决这个问题,所有表单项都依赖数据,并修改数据,这样当 Input 框联动 Check 时,Input 并不需要感知 Checkbox 的存在,他只要关联数据、修改数据就行了,Checkbox 也只要关联数据和修改数据,这样不但逻辑可以独立完成,甚至可以解决循环引用的问题。 **在数据驱动的例子中,数据就是中介。** 所有 UI 之间都不会相互引用,而是通过数据这个中介来协同工作,这样做带来的明显好处是可以处理复杂项目,且易于维护。 ## 举例子 如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 ### 数据驱动 正如开篇说的,数据驱动是中介者非常经典的例子,正是因为引入了 “数据中介者”,才让前端项目的复杂度可以呈几何倍数递增,而代码的逻辑复杂度仅线性递增。因为 UI 是杂乱的且动态的,UI 间依赖会导致关系网非常复杂,且关系网一旦形成,增加一个新元素或修改就变得异常困难。 中介者模式则避开了 UI 间依赖的关系网,通过数据层统一调度,UI 受控响应,可以大大减少逻辑复杂度。 ### 解决循环依赖 循环依赖几乎只能利用中介者模式解决: ```typescript import { b } from './b' export const a = 'a' ``` ```typescript import { a } from './a' export const b = 'b' ``` 当双方相互引用时,构成循环依赖,不仅对于模块化来说是有问题的,从逻辑上也是讲不通的,因为一定存在递归调用的问题。这是,引入第三方中介者就不仅仅是一种设计模式思维了,而是 a、b 模块中原本就有一些内容是两边公用的,一定需要提出来,而统一提出来的地方就是中介者模式的中介者部分。 ### 企业组织架构 一个树状企业组织架构中,每个非叶子结点都是中介者,需要给他的子节点分配任务,并协调他们的工作,这样一来,叶子结点不需要有全局观即可工作,因为他们只需负责 “去做自己的事情”,而不需要关心 “是如何协同的”。 如图所示,环境部不需要关心人事部做了什么,只要专注做好环境事物即可,他们之间的协调由总经理处理,这是一种分工协作的体现。 而只存在于理论中的网状企业管理模型,则是没有中介者的例子,所有节点都是非叶子结点,并相互引用,这样一来每个人既要做自己的工作,又要处理自己与公司里其他几万人的协同,几乎是一件不可能完成的事情,所以从设计模式角度来看,也更倾向于使用树状而不是网状模式管理企业。 ## 意图解释 **意图:用一个中介对象来封装一系列的对象交互。中介者使各对象不需要显示地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。** 中介者模式非常好理解,直接看字面意思即可。所谓的对象交互,指的是对象之间是如何协同的,中介者做的是处理对象间协同的工作,而不是 “替每个对象干活”。 最后一句 “可以独立地改变他们之间的交互”,指的是对象之间协同方式不是一成不变的,比如一个输入框组件,只要实现自己的输入功能就行了,而不需要关心是如何与外界交互的。外界可以通过将其嵌入到表单中,成为表单项的一部分,也可以将其包裹一层符号后缀,成为一个专门输入金额的金额输入框。 ## 结构图 - Mediator:中介者接口,定义一些通信 API。 - ConcreteMediator:具体的中介者,继承 Mediator,协调各个对象。 - Colleague:同事类,比如之前提到的输入框、文本框,每个同事之间只要知道中介者即可,他们之间不需要知道对方的存在。 ## 代码例子 下面例子使用 typescript 编写。 ```typescript const memberA = new Member('美术') const memberB = new Member('程序') const picture = memberA.draw() // 美术画出图 const product = memberB.code(picture) // 程序按照美术画的图做产品 ``` 这个例子中,完成了程序与美术的协同,他们各自不需要知道对方的存在。如果后续又引入了产品、测试工种,他们之间不需要做复杂的关联,只需要在中介者增加对应协同逻辑即可。 ## 弊端 中介者模式虽然好,但过度使用可能使中介者逻辑非常复杂。 我们常说管理者直接管理人数最好不要超过二十人,原因是协调本身也非常耗费精力,一个中介者节点如果管理的对象过多,可能会导致中介者本身难以维护,甚至出现 BUG。 另外则是不要过度解耦,当两个对象本身可以构成依赖关系时,使用中介者模式使其强行解耦,带来的只会是更重的理解负担。 ## 总结 当一个系统对象很多,且之间关联关系很复杂,交叉引用容易产生混乱时,就可能适用中介者模式。 中介者模式也符合迪米特法则,做到了每个对象了解最少的内容,这样做对于大型程序来说是非常有益的。 > 讨论地址是:[精读《设计模式 - Mediator 中介者模式》· Issue #299 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/299) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 设计模式/184.精读《设计模式 - Memoto 备忘录模式》.md ================================================ # Memento(备忘录模式) Memento(备忘录模式)属于行为型模式,是针对如何捕获与恢复对象内部状态的设计模式。 **意图:在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。** 其实备忘录模式思想非常简单,其核心是定义了一个 Memoto(备忘录) 封装对象,由这个对象处理原始对象的状态捕获与还原,其他地方不需要感知其内部数据结构和实现原理,而且 Memoto 对象本身结构也非常简单,只有 `getState` 与 `setState` 一存一取两个方法,后面会详细讲解。 ## 举例子 如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 ### 撤销重做 如果撤销重做涉及到大量复杂对象,每个对象内部状态的存储结构都不同,如果一个一个处理,很容易写出 case by case 的冗余代码,而且在拓展一种新对象结构时(如嵌入 ppt),还需要在撤销重做时对相应结构做处理。备忘录思维相当于一种统一封装思维,不管这个对象结构如何,都可以保存在一个 Memoto 对象中,通过 `setState` 设置对象状态与 `getState` 获取对象状态,这样对于任何类型的对象,画布都可以通过统一的 API 操作进行存取了。 ### 游戏保存 玩过游戏的同学都知道,许多游戏支持设置与读取多种存档,如果转换为代码模式,我们可能希望有这样一种 API 进行多存档管理: ```typescript // 创建一盘游戏。 const game = new Game() // 玩一会。 game.play() // 设置一个存档(archive) 1。 const gameArchive1 = game.createArchive() // 再玩一会。 game.play() // 设置一个存档(archive) 2。 const gameArchive2 = game.createArchive() // 再玩一会。 game.play() // 这个时候角色挂了,提示 “请读取存档”,玩家此时选择了存档 1。 game.loadArchive(gameArchive1) // 此时游戏恢复存档 1 状态,又可以愉快的玩耍了。 ``` 其实在游戏保存的例子中,存档就是备忘录(Memoto),而主进程管理游戏状态时,只是简单调用了 `createArchive` 创建存档,与 `load` 读取存档,即可实现复杂的游戏保存与读取功能,全程是不需要关心游戏内部状态到底有多少,以及这么多状态需要如何一一恢复的,这就是得益于备忘录模式的设计。 ### 文章草稿保存 富文本编辑器的文档草稿保存也是一样的原理,简单一点只需要一个 Memoto 对象即可,如果要实现复杂一点的多版本状态管理,只需要类似游戏保存机制,存储多个 Memoto 存档即可。 ## 意图解释 看到这里,会发现备忘录模式与前端状态管理的保存与恢复很像。以 Redux 类比: `setState` 就像 `reducer` 处理的最终 `state` 状态一样,对 redux 全局状态来说,它不用关心业务逻辑(有多少 `reducer`,以及每个 `reducer` 做了什么),它只需要知道任何 `reducer` 最后处理完后都是一个 `state` 对象,将其生成出来并存下来即可。 恢复也是一样,`initState` 就类似 `getState`,只要将上一次生成的 `state` 灌进来,就可以完全还原某个时刻的状态,而不需要关心这个状态内部是怎样的。 所以其实备忘录模式早已得到广泛的应用,仔细去理解后,会发现没必要去扣的太细,以及原始设计模式是如何定义的,因为经过几十年的演化,这些设计模式思路早已融入了编程框架的方方面面。 但依照惯例,我们还是再咬文嚼字解释一下意图: **意图:在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。** 重点在于 “不破坏封装性” 这几个字上,程序的可维护性永远是设计模式关注的重点,无论是游戏存档的例子,还是 Redux 的例子,上层框架使用状态时,都不需要知道具体对象状态的细节,而实现这一点的就是 Memoto 这个抽象的备忘录类。 ## 结构图 - `Originator`:创建、读取备忘录的发起者。 - `Memento`:备忘录,专门存储原始对象状态,并且防止 Originator 之外的对象读取。 - `Caretaker`:备忘录管理者,一般用数组或链表管理一堆备忘录,在撤销重做或者版本管理时会用到。 ## 代码例子 下面例子使用 typescript 编写。 下面是备忘录模式三剑客的定义: ```typescript // 备忘录 class Memento { public state: any constructor(state: any) { this.state = state } public getState() { return this.state } } // 备忘录管理者 class Caretaker { private stack: Memento[] = [] public getMemento(){ return this.stack.pop() } public addMemento(memoto: Memento){ this.stack.push(memoto) } } // 发起者 class Originator { private state: any public getState() { return this.state } public setState(state: any) { this.state = state } public createMemoto() { return new Memoto(this.state) } public setMemoto(memoto: Memoto) { this.state = memoto.getState() } public void setMemento(Memento memento) { state = memento.getState(); } } ``` 下面是一个简化版客户端使用的例子: ```typescript // 实例化发起者,比如画布、文章管理器、游戏管理器 const originator = new Originator() // 实例化备忘录管理者 const caretaker = new Caretaker() // 设置状态,分别对应: // 画布的组件操作。 // 文章的输入。 // 游戏的 .play() originator.setState('hello world') // 备忘录管理者记录一次状态,分别对应: // 画布的保存。 // 文章的保存。 // 游戏的保存。 caretaker.setMemento(originator.createMento()) // 从备忘录管理者还原状态,分别对应: // 画布的还原。 // 文章的读取。 // 游戏读取存档。 originator.setMemento(caretaker.getMemento()) ``` 在上面例子中,备忘录管理者存储状态是数组,所以可以实现撤销重做,如果要实现任意读档,可以将备忘录变为 `Map` 结构,按照 `key` 来读取,如果没有这些要求,存一个单一的 `Memoto` 也够用了。 ## 弊端 备忘录模式存储的是完整状态而非 Diff,所以可能会在运行时消耗大量内存(当然在 Immutable 模式下,通过引用共享可以极大程度缓解这个问题)。 另外就是,备忘录模式已经很大程度上被融合到现代框架中,你在使用状态管理工具时就已经使用了备忘录模式了,所以很多情况下,不需要机械的按照上面的代码例子使用。设计模式重点在于利用它优化了程序的可维护性,而不用强求使用方式和官方描述一模一样。 ## 总结 备忘录模式通过备忘录对象,将对象内部状态封装了起来,简化了程序复杂度,这符合设计模式一贯遵循的 “高内聚、低耦合” 原则。 其实践行备忘录模式最好的例子就是 Redux,当项目所有状态都使用 Redux 管理时,你会发现无论是撤销重做,还是保存读取,都可以非常轻松完成,这时候,不要质疑为什么备忘录模式还在解决这种 “遇不到的问题”,因为 Redux 本身就包含了备忘录设计模式的理念。 > 讨论地址是:[精读《设计模式 - Memento 备忘录模式》· Issue #301 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/301) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 设计模式/185.精读《设计模式 - Observer 观察者模式》.md ================================================ # Observer(观察者模式) Observer(观察者模式)属于行为型模式。 **意图:定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。** 拿项目的 npm 依赖举例子:npm 包与项目是一对多的关系(一个 npm 包被多个项目使用),当 npm 包发布新版本时,如果所有依赖于它的项目都能得到通知,并自动更新这个包的版本号,那么就解决了包版本更新的问题,这就是观察者模式要解决的基本问题。 ## 举例子 如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 ### 对象与视图双向绑定 在 [精读《设计模式 - Proxy 代理模式》](https://github.com/dt-fe/weekly/blob/v2/178.%E7%B2%BE%E8%AF%BB%E3%80%8A%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%20-%20Proxy%20%E4%BB%A3%E7%90%86%E6%A8%A1%E5%BC%8F%E3%80%8B.md) 中我们也提到了双向绑定概念,只不过代理是实现双向绑定的一个具体方案,而观察者模式才是在描述双向绑定这个概念。 观察者模式在最初提出的时候,就举了数据与 UI 相互绑定的例子。即同一份数据可以同时渲染为表格与柱状图,那么当操作表格更新数据时,如何让柱状图的数据也刷新?从这个场景引出了对观察者模式的定义,即 “数据” 与 “UI” 是一对多的关系,我们需要一种设计模式实现当 “数据” 变化时,所有依赖于它的 “UI” 都得到通知并自动更新。 ### 拍卖 拍卖由一个拍卖员与多为拍卖者组成。拍卖时,由 A 同学喊出的竞价(我出 100)就是观察者向目标发出的 `setState` 同时,此时拍卖员喊出(有人出价 100,还有更高的吗?)就是一个 `notify` 通知行为,拍卖员通知了现场竞价全员,刷新了他们对当前最高价的信息。 ### 聊天室 聊天室由一个中央服务器与多个客户端组成。客户端发送消息后,就是向中央服务器发送了 `setState` 更新请求,此时中央服务器通知所有处于同一聊天室的客户端,更新他们的信息,从而完成一次消息的发送。 ## 意图解释 数据与 UI 的例子已经详细说明了其意图含义,这里就不赘述了。 ## 结构图 - Subject: 目标,即例子中的 “数据”。 - Observer: 观察者,即例子中的 “表格”、“柱状图”。 还是以数据与 UI 同步为例,当表格发生操作修改数据时,表格这个 TableObserver 会调用 Subject(数据)的 `setState`,此时数据被更新了。然后数据这个 `Subject` 维护了所有监听(包括表格 `TableObserver` 与柱状图 `ColumnChartObserver`),此时 `setState` 内会调用 `notify` 遍历所有监听,并依次调用 `Update` 方法,每个监听的 `Update` 方法都会调用 `getState` 获取最新数据,从而实现表格更新后 -> 更新数据 -> 表格、柱状图同时刷新。 为了更好的理解,以这张协作图为例: - `aConcreteSubject`: 对应例子中的数据。 - `aConcreteObserver`: 对应例子中的表格。 - `anotherConcreteObserver`: 对应例子中的柱状图。 ## 代码例子 下面例子使用 typescript 编写。 > PS: 为了简化处理,就不定义 Subject 接口与 ConcreteSubject 了,而是直接用 Subject 类代替。Observer 也同理。 ```typescript // 目标,管理所有观察者 class Subject { // 观察者数组 private observers: Observer[] = [] // 状态 private state: State // 通知所有观察者 private notify() { this.observers.forEach(eachObserver => { eachObserver.update() }) } // 新增观察者 public addObserver(observer: Observer) { this.observers.push(observer) } // 更新状态 public setState(state: State) { this.state = state this.notify() } // 读取状态 public getState() { return this.state } } // 观察者 class Observer { // 维护目标 private subject: Subject constructor(subject: Subject) { this.subject = subject this.subject.addObserver(this) } // 更新 public update() { // 比如渲染表格 or 渲染柱状图 console.log(this.subject.getState()) } } // 客户端调用 const subject = new Subject() // 创建观察者 const observer1 = new Observer(subject) const observer2 = new Observer(subject) // 更新状态 subject.setState(10) ``` ## 弊端 不要拘泥于实现形式,比如上面代码中的例子,`subject` 与 `observer1`、`observer2` 是一对多的关系,但不一定非要用这种代码组织形式来实现观察者效果。我们也可以利用 Proxy 很轻松的实现: ```typescript const obj = new Proxy(obj, { get(target,key) {} set(target,key,value) {} }) renderTable(obj) renderChart(obj) ``` 我们可以在 `obj` 被任意一个组件访问时触发 `get`,进而对 UI 与视图进行绑定;被任意一个组件更新时触发 `set`,进而对所有使用到的视图进行刷新。使用设计模式切记不要死板,理解原理就行了,在不同平台有不同的更加优雅的实现方式。 ## 总结 观察者模式是非常常用的设计模式,它描述了对象一对多依赖关系下,如何通知并更新的机制,这种机制可以用在前端的 UI 与数据映射、后端的请求与控制器映射,平台间的消息通知等大部分场景,无论现实还是程序中,存在依赖且需要通知的场景非常普遍。 > 讨论地址是:[精读《设计模式 - Observer 观察者模式》· Issue #302 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/302) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 设计模式/186.精读《设计模式 - State 状态模式》.md ================================================ # State(状态模式) State(状态模式)属于行为型模式。 **意图:允许一个对象在其内部状态改变时改变它的行为。对象看起来似乎修改了它的类。** 简单来说,就是将 “一个大 class + 一堆 if else” 替换为 “一堆小 class”。一堆小 class 就是一堆状态,用一堆状态代替 if else 会更好拓展与维护。 ## 举例子 如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 ### 团队接口人 团队是由很多同学组成的,但有一位接口人 TL,这位 TL 可能一会儿和产品经理谈需求,一会儿和其他 TL 谈规划,一会儿和 HR 谈人事,总之要做很多事情,很显然一个人是忙不过来的。TL 通过将任务分发给团队中每个同学,而不让他们直接和产品经理、其他 TL、HR 接触,那么这位 TL 的办事效率就会相当高,因为每个同学只负责一块具体的业务,而 TL 在不同时刻叫上不同的同学,让他们出面解决他们负责的专业领域问题,那么在外面看,这位 TL 团队能力很广,在内看,每个人负责的事情也比较单一。 ### 台灯按钮 我们经常会看到只有一个按钮的台灯,但是可以通过按钮调节亮度,大概是如下一个循环 “关 -> 弱光 -> 亮 -> 强光 -> 关”,那么每次按按钮后,要跳转到什么状态,其实和当前状态有关。我们可以用 if else 解决这个问题,也可以用状态模式解决。 用状态模式解决,就是将这四个状态封装为四个类,每个类都执行按下按钮后要跳转到的状态,这样未来新增一种模式,只要改变部分类即可。 ### 数据库连接器 在数据库连接前后,这个连接器的状态显然非常不同,我们如果仅用一个类描述数据库连接器,则内部免不了写大量分支语句进行状态判断。那么此时有更好的方案吗?状态模式告诉我们,可以创建多个不同状态类,比如连接前、连接中、连接后三种状态类,在不同时刻内部会替换为不同的子类,它们都继承同样的父类,所以外面看上去不需要感知内部的状态变化,内部又可以进行状态拆分,进行更好的维护。 ## 意图解释 **意图:允许一个对象在其内部状态改变时改变它的行为。对象看起来似乎修改了它的类。** 重点在 “内部状态” 的理解,也就是状态改变是由对象内部触发的,而不是外部,所以 **外部根本无需关心对象是否用了状态模式**,拿数据库连接器的例子来说,不管这个类是用 if else 堆砌的,还是用状态模式做的,都完全不妨碍它对外提供的稳定 API(接口问题),所以状态模式实质上是一种内聚的设计模式。 ## 结构图 - State: 状态接口,类比为台灯状态。 - ConcreteState: 具体状态,都继承于 State,类比为台灯的强光、弱光状态。 ## 代码例子 下面例子使用 typescript 编写。 ```typescript abstract class Context { abstract setState(state: State): void; } // 定义状态接口 interface State { // 模拟台灯点亮 show: () => string } interface Light { click: () => void } type LightState = State & Light class TurnOff implements State, Light { context: Context; constructor(context: Context) { this.context = context } show() { return '关灯' } // 按下按钮 public click() { this.context.setState(new WeakLight(this.context)) } } class WeakLight implements State, Light { context: Context; constructor(context: Context) { this.context = context } show() { return '弱光' } // 按下按钮 public click() { this.context.setState(new StandardLight(this.context)) } } class StandardLight implements State, Light { context: Context; constructor(context: Context) { this.context = context } show() { return '亮' } // 按下按钮 public click() { this.context.setState(new StrongLight(this.context)) } } class StrongLight implements State, Light { context: Context; constructor(context: Context) { this.context = context } show() { return '强光' } // 按下按钮 public click() { this.context.setState(new TurnOff(this.context)) } } // 台灯 class Lamp extends Context { // 当前状态 #currentState: LightState = new TurnOff(this) setState(state: LightState) { this.#currentState = state } getState() { return this.#currentState } // 按下按钮 click() { this.getState().click() } } const lamp = new Lamp() // 关闭 console.log(lamp.getState().show()) // 关灯 lamp.click() // 弱光 console.log(lamp.getState().show()) // 弱光 lamp.click() // 亮 console.log(lamp.getState().show()) // 亮 lamp.click() // 强光 console.log(lamp.getState().show()) // 强光 lamp.click() // 关闭 console.log(lamp.getState().show()) // 关闭 ``` 其实有很多种方式来实现,不必拘泥于形式,大体上只要保证由多个类实现不同状态,每个类实现到下一个状态切换就好了。 ## 弊端 该用 if else 的时候还是要用,不要但凡遇到 if else 就使用状态模式,那样就是书读傻了。一定要判断,是否各状态间差异很大,且使用状态模式后维护性比 if else 更好,才应该用状态模式。 ## 总结 在合适场景下,状态模式可以使代码更符合开闭原则,每个类独立维护时,逻辑也更精简、聚焦,更易维护。 > 讨论地址是:[精读《设计模式 - State 状态模式》· Issue #303 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/303) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 设计模式/187.精读《设计模式 - Strategy 策略模式》.md ================================================ # Strategy(策略模式) Strategy(策略模式)属于行为型模式。 **意图:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。本模式使得算法可以独立于使用它的客户而变化。** 策略是个形象的表述,所谓策略就是方案,我们都知道任何事情都有多种方案,而且不同方案都能解决问题,所以这些方案可以相互替换。我们将方案从问题中抽象出来,这样就可以抛开问题,单独优化方案了,这就是策略模式的核心思想。 ## 举例子 如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 ### 地图导航 我们去任何地方都可以选择步行、骑车、开车、公交,不同的方案都可以帮助我们到达目的地,那么很明显应该将这些方案变成策略封装起来,接收的都是出发点和目的地,输出的都是路线。 ### 布局方式 比如我们做一个报表系统,在 PC 使用珊格布局,在移动端使用流式布局,其实内容还是那些,只是布局方式会随着不同终端大小做不同的适配,那么布局的适配就是一种策略,它可以与报表内容无关。 我们可以将布局策略单独抽取出来,以后甚至可以适配电视机、投影仪等等不同尺寸的场景,而不需要对其他代码做任何改动,这就是将布局策略从代码中解耦出来的好处。 ### 排序算法 当我们调用 `.sort` 时,使用的是什么排序算法?可能是冒泡、快速、插入排序?其实无论何种排序算法,本质上做的事情都是一样的,我们可以事先将排序算法封装起来,针对不同特性的数组调用不同的排序算法。 ## 意图解释 **意图:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。本模式使得算法可以独立于使用它的客户而变化。** 算法可以理解为策略,我们制定许多解决某个场景的策略,这些策略都可以独立的解决这个场景的问题,这样下次遇到这个场景时,我们就可以选择任何策略来解决,而且我们还可以脱离场景,单独优化策略,只要接口不变即可。 这个意图本质上就是解耦,解耦之后才可以分工。想想一个复杂的系统,如果所有策略都耦合在业务逻辑里,那么只有懂业务的人才能小心翼翼的维护,但如果将策略与业务解耦,我们就可以独立维护这些策略,为业务带来更灵活的变化。 ## 结构图 - Strategy: 策略公共接口。 - ConcreteStrategy: 具体策略,实现了上面这个接口。 只要你的策略符合接口,就满足策略模式的条件。 ## 代码例子 下面例子使用 typescript 编写。 ```typescript interface Strategy { doSomething: () => void } class Strategy1 implements Strategy { doSomething: () => { console.log('实现方案1') } } class Strategy2 implements Strategy { doSomething: () => { console.log('实现方案2') } } // 使用 new System(new Strategy1()) // 策略1实现的系统 new System(new Strategy2()) // 策略2实现的系统 ``` ## 弊端 不要走极端,不要每个分支走一个策略模式,这样会导致策略类过多。当分支逻辑简单清晰好维护时,不需要使用策略模式抽象。 ## 总结 策略模式是很重要的抽象思维,我们首先要意识到问题有许多种解法,才能意识到策略模式的存在。当一个问题需要采取不同策略,且策略相对较复杂,且未来可能要拓展新策略时,可以考虑使用策略模式。 > 讨论地址是:[精读《设计模式 - Strategy 策略模式》· Issue #304 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/304) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 设计模式/188.精读《设计模式 - Template Method 模版模式》.md ================================================ # Template Method(模版模式) Template Method(模版模式)属于行为型模式。 **意图:定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。TemplateMethod 使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。** ## 举例子 如果看不懂上面的意图介绍,没有关系,设计模式需要在日常工作里用起来,结合例子可以加深你的理解,下面我准备了三个例子,让你体会什么场景下会用到这种设计模式。 ### 模版文件 我们办事打印的文件就是模版文件,只需要写上个人基本信息再签字就可以了,我们不需要做太多的重复劳动,因为某些场景下大部分内容是可以固化下来的。比如买卖房屋,那大部分甲方乙方的条款是固定的,最大的变化是甲方与乙方的不同,我们在模版上签字时,就是利用了模版模式减少了大量写条款的时间。 ### 实例化 实例化也可以认为是模版模式的某种表现形式,因为对于工厂方法,我们传入不同的初始值可能给出不同结果,那么实际上就是用很少的代码撬动了很大一块功能,起到了抽象作用。 ### Vue 模版 Vue 模版更符合我们对模版直觉的理解。这个场景中,模版指的是 HTML 模版,我们只需要在模版中以 `{}` 形式描述一些变量,就可以生成一块只有局部变量变化的模版 DOM,非常方便。 ## 意图解释 **意图:定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。TemplateMethod 使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。** 这个设计模式初衷是用于面向对象的,所以考虑的是如何在类中运用模版模式。首先定义一个父类,实现了一些算法,再将需要被子类重载的方法提出来,子类重载这些部分方法后,即可利用父类实现好的算法做一些功能。 比如说父类方法 `function a() { b() + c() }`,此时子类只需要重定义 b 与 c 方法,即可复用 a 的算法(b 与 c 相加)。当然这个例子比较简单,当算法较为复杂时,模版模式的好处将凸显出来。 ## 结构图 - ConcreteClass: 具体的父类。可以看到父类中实现了 TemplateMethod,其调用了 primitiveOperation1 与 primitiveOperation2, 所以子类只需要重载这两个方法,即可享用 TemplateMethod 提供的算法。 假设 TemplateMethod 是 `OpenDocument` 打开文档的作用,那么 primitiveOperation1 可能是 `CanOpen` 校验,`primitiveOperation2` 可能是 `ReadDocument` 读取文档方法。 我们只要专心实现具体的细节方法,而不需要关心他们之间是如何相互作用的,父级会帮我们实现它。之后我们就可以调用子类的 `OpenDocument` 实现打开文档了。 ## 代码例子 下面例子使用 typescript 编写。 ```typescript class View { doDisplay(){} display() { this.setFocus() this.doDisplay() this.resetFocus() } } class MyView extends View { doDisplay(){ console.log('myDisplay') } } const myView = new MyView() myView.display() ``` 这个例子中,`doDisplay` 表示父类希望子类重载的方法,一般以 `do` 约定打头。 ## 弊端 模版模式用在类中,本质上是固定不可变的结构,进一步缩小重写方法的范围,重写的范围越小,代码可复用度就越高,所以一定要在具有通用算法可提取的情况下使用,而不要为了节省代码行数而过度使用。 另外前端开发中,HTML 本身就很契合模版模式,因为 HTML 中有大量标签描述千变万化的 UI 结构,可复用的地方实在太多太多,所以非常适合模版模式,所以不要认为模版模式仅能在类中使用,模版模式还能在脚手架使用呢,比如填入一些表单自动生成代码。 学习这个设计模式时,注意不要固化思维在其定义的类这个框子中,因为设计模式写于 1994 年,其中提到的模式已经被大量迁移运用,能否识别并做适当的知识迁移,是 20 多年后的今天学习设计模式的关键。 ## 总结 模版模式与策略模式有一定相似处,模版模式是改变算法的一部分,而策略模式是将策略完全提取出来,所以可以改变算法的全部。 > 讨论地址是:[精读《设计模式 - Template Method 模版模式》· Issue #305 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/305) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) ================================================ FILE: 设计模式/189.精读《设计模式 - Visitor 访问者模式》.md ================================================ # Visitor(访问者模式) Visitor(访问者模式)属于行为型模式。 **意图:表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。** 访问者,顾名思义,就是对象访问的一种设计模式,我们可以在不改变要访问对象的前提下,对访问对象的操作做拓展。 ## 举例子 由于能应用访问者模式的场景很少,所以这里只举一个例子。 ### 建造游戏中的资源设计 假设你制作一款城市建造游戏,游戏的基础资源只有毛皮、木材、铜矿、铁矿。你需要用这些资源建造各种,比如造楼房、做衣服、制作家具、门、空调、甚至锅、健身房、游泳馆等。记住一个前提,就是你想把游戏设计的非常逼真,所以每种资源的不同使用方法都非常定制,不是简单的消耗 N 个数量就能完成,比如制作家具时,需要用到毛皮和木材,此时毛皮和木材对环境、制作人、资金都有不同的要求。 常见的想法是,我们将资源的所有使用方法都枚举在资源类中,这样资源就在用到不同场景时,调用不同方法即可。但问题是资源本身其实较为固定,我们每增加一种用途就修改一次木材、铁矿的类会显得非常麻烦。 能不能在增加新用途时,不修改原始资源类呢?答案是可以用访问者模式。 ## 意图解释 **意图:表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。** 第一句话指明了 Visitor 的作用,即 “作用于某对象结构中的各元素的操作”,也就是 Visitor 是用于操作对象元素的。“它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作” 也就是说,你可以只修改 Visitor 本身完成新操作的定义,而不需要修改原本对象。 这看上去比较奇怪,给对象定义新的操作,竟然不用修改对象本身,而通过改另外一个对象就可以?这就是 Visitor 设计的奇妙之处,它将对象的操作权移交给了 Visitor。 ## 结构图 - Visitor:访问者接口。 - ConcreteVisitor:具体的访问者。 - Element 可以被访问者使用的元素,它必须定义一个 Accept 属性,接收 visitor 对象。这是实现访问者模式的关键。 - ObjectStructure:对象结构,存储了多个 Element,利用 Visitor 进行批量操作。 可以看到,要实现操作权转让到 Visitor,核心是元素必须实现一个 Accept 函数,将这个对象抛给 Visitor: ```typescript class ConcreteElement implements Element { public accept(visitor: Visitor) { visitor.visit(this) } } ``` 从上面代码可以看出这样一条链路:Element 通过 `accept` 函数接收到 Visitor 对象,并将自己的实例抛给 Visitor 的 `visit` 函数,**这样我们就可以在 Visitor 的 `visit` 方法中拿到对象实例,完成对对象的操作。** ## 代码例子 下面例子使用 typescript 编写。 ```typescript class ConcreteVisitorX implements Visitor{ public visit(element: ELement) { element.accept(this); } public visit(concreteElementA: ConcreteElementA) { console.log('X 操作 A') } public visit(concreteElementB: ConcreteElementB) { console.log('X 操作 B') } } class ConcreteVisitorY implements Visitor{ public visit(element: ELement) { element.accept(this); } public visit(concreteElementA: ConcreteElementA) { console.log('Y 操作 A') } public visit(concreteElementB: ConcreteElementB) { console.log('Y 操作 B') } } ``` 配合上面已经写过的 `Element`,可以看到,经历了如下过程: ```typescript // 先创建元素 const element = new ConcreteElement() // 访问者 X const visitorX = new ConcreteVisitorX() // 访问者 Y const visitorY = new ConcreteVisitorY() // 然后让访问者 visit 观察一下元素 visitorX.visit(element as Element) visitorY.visit(element as Element) ``` 要注意的是,访问者观察的 Element 一定要是通用类型 Element,而不是一个具体类型 ConcreteElement,否则访问者模式抽象性就无法体现了,因为 Visitor 可以访问任何类型的 Element,所以先把接口传进去。 到这里,我们看看下面经历了什么:首先 Visitor 定义的 `visit` 会被调用,由于符合了 Element 这个通用类型,所以会调用 Element 接口定义的 `accept` 函数,这是所有元素都有的方法。 接下来,每个具体元素都重写了 `accept` 方法: ```typescript public accept(visitor: Visitor) { visitor.visit(this) } ``` 所以又调用了 Visitor 的 `visit` 函数,不同的是,此时的参数是具体 Element 类型,所以可能调用到的是具体对某个元素处理的 `visit` 方法,比如: ```typescript public visit(concreteElementA: ConcreteElementA) { console.log('X 操作 A') } ``` 最终就输出了 “X 操作 A” 这段话。 我们可以看到这样的程序拓展性有这么些: 1. Element 元素的所有子类都不用频繁修改,只要修改 Visitor 即可。 2. 一个 Visitor 可以选择性的操作任何类型的 Element 子类,只要申明了处理函数即可处理,不申明就不会命中,比较方便。在城市建造的例子中,可以提现为锅需要用铁制作,但不需要消耗木材,所以不需要定义木材的 `visit` 方法。 3. 可以定义多种 Visitor,对同一种 Element 子类也可以有不同的操作,这在我们城市建造的例子中,可以体现为门和窗户,对铁矿的使用是不同的。 由此一来,我们就能在城市建造的例子中拓展出任意多种使用资源的场景,而无需让资源感知到这些场景的存在。 ## 弊端 访问者模式使用场景非常有限,请确定你的场景满足以上情况再使用。如果资源并不需要频繁修改和拓展,那么就没必要使用访问者模式。 ## 总结 访问者模式的精髓,就是在不断拓展的业务场景中,防止基础元素代码不断膨胀。 假设我们这款城市建造游戏有 20 人团队开发,每周发布 2 个版本,每个版本新增了几种资源的组合使用方式,由于资源一共就木材、铁矿、铜矿那么几种,如果你作为团队负责人,任大家随意修改这些资源基础类,过不了半年就会发现,木材类的成员方法突破了 100 种,而且以每天新增 2 种的速度不断增加,你会明显发现自己精心打造的程序即将变成一堆屎山。 更要命的是,你还搞不清楚哪些场景的用法是打包的,当一种使用场景下线时,已存在的成员方法还不敢删除。 假设你用了访问者模式,会发现,每天因为迭代而新增的那几个方法,都会放到一个新 Visitor 文件下,比如一种纳米材料的门板在游戏 V1.5 版本被引进,它对材料的使用会体现在新增一个 Visitor 文件,资源本身的类不会被修改,这既不会引发协同问题,也使功能代码按照场景聚合,不论维护还是删除的心智负担都非常小。 访问者模式背后的思考本质还是,基础的元素数量一般不会随着程序迭代产生太大变化,而对这些基础元素的使用方式或组合使用会随着程序迭代不断更新,我们将变化更快的通过 Visitor 打包提取出来,自然会更利于维护。 > 讨论地址是:[精读《设计模式 - Visitor 访问者模式》· Issue #306 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/306) **如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** > 关注 **前端精读微信公众号** > 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh))