![]() |
忐忑的显示器 · mysql获取当天,昨天,本周,本月,上周, ...· 11 月前 · |
![]() |
仗义的单车 · pandas 筛选数据的 8 ...· 1 年前 · |
![]() |
活泼的伤疤 · Javascript读取json文件方法实例 ...· 1 年前 · |
![]() |
闯红灯的馒头 · iis修改请求内容长度_7849800的技术 ...· 2 年前 · |
原文来自我的个人博客1. HTTP 基础1.1 HTTP 协议是什么?HTTP (HyperText Transfer Protocol),即超文本运输协议,是实现网络通信的一种规范在计算机和网络世界有,存在不同的协议,如广播协议、寻址协议、路由协议等等......而 HTTP 是一个传输协议,即将数据由 A 传到 B 或将 B 传输到 A ,并且 A 与 B 之间能够存放很多第三方,如: A<=>X<=>Y<=>Z<=>B传输的数据并不是计算机底层中的二进制包,而是完整的、有意义的数据,如 HTML 文件, 图片文件, 查询结果等超文本,能够被上层应用识别在实际应用中,HTTP 常被用于在 Web浏览器 和 网站服务器 之间传递信息,以明文方式发送内容,不提供任何方式的数据加密1.2 HTTP 协议的特点应用层协议(下面可以是 TCP/IP)信息纯文本传输支持客户/服务器模式简单快速:客户向服务器请求服务时,只需传送请求方法和路径。由于 HTTP 协议简单,使得HTTP 服务器的程序规模小,因而通信速度很快灵活:HTTP 允许传输任意类型的数据对象。正在传输的类型由 Content-Type 加以标记无状态:每次请求独立,请求之间互相不影响(浏览器提供了手段维护状态比如Cookie, Session, Storage 等)1.3 HTTP 协议的历史1991 HTTP 0.9 (实验版本)1996 HTTP 1.0 (有广泛用户)1999 HTTP 1.1 (影响面最大的版本)2015 HTTP 2.0 (大公司基本上都是2.0了)2018 HTTP 3.0 (2022 年 6 月 6 日正式发布了)1.4 Header 和 BodyHTTP 是一个文本传输协议,传输内容是人类可读的文本,大体分成两部分:请求头(Header)/ 返回头消息体(Body)下面演示一个 Node.js 实战 http 请求const net = require("net"); const response = ` HTTP/1.1 200 OK Data: Tue,30 Jun 2022 01:00:00 GMT Content-Type: text/plain Connection: Closed Hello World const server = net.createServer((socket) => { socket.end(response); server.listen(80, () => { console.log("80端口启动"); 从浏览器中观察1.5. HTTPS在上述介绍 HTTP 中,了解到 HTTP 传递信息是以明文的形式发送内容,这并不安全。而 HTTPS 出现正是为了解决 HTTP 不安全的特性为了保证这些隐私数据能加密传输,让 HTTP 运行安全的 SSL/TLS 协议上,即 HTTPS = HTTP + SSL/TLS,通过 SSL 证书来验证服务器的身份,并为浏览器和服务器之间的通信进行加密SSL 协议位于 TCP/IP 协议与各种应用层协议之间,浏览器和服务器在使用 SSL 建立连接时需要选择一组恰当的加密算法来实现安全通信,为数据通讯提供安全支持流程图如下:1.6. HTTP 和 HTTPS 的区别HTTPS 是 HTTP 协议的安全版本,HTTP 协议的数据传输是明文的,是不安全的,HTTPS 使用了 SSL/TLS 协议进行了加密处理,相对更安全HTTP 和 HTTPS 使用连接方式不同,默认端口也不一样,HTTP 是 80,HTTPS 是 443HTTPS 由于需要设计加密以及多次握手,性能方面不如 HTTPHTTPS需要 SSL,SSL 证书需要钱,功能越强大的证书费用越高2. HTTP 详情2.1. HTTP 常见请求头2.1.1 请求头是什么?HTTP 头字段(HTTP header fields),是指在超文本传输协议(HTTP)的请求和响应消息中的消息头部分它们定义了一个超文本传输协议事务中的操作参数HTTP 头部字段可以自己根据需要定义,因此可能在 Web 服务器和浏览器上发现非标准的头字段下面是一个 HTTP 常见的请求头:下面是一个 HTTP 常见的响应头2.2 常见HTTP头2.2.1 Content-Length发送给接受者的 Body 内容长度(字节)一个 byte 是 8bitUTF-8编码的字符1-4个字节、示例:Content-Length: 3482.2.2 User-Agent帮助区分客户端特性的字符串 - 操作系统 - 浏览器 - 制造商(手机类型等) - 内核类型 - 版本号 示例:User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.362.2.3 Content-Type帮助区分资源的媒体类型(Media Type/MIME Type)text/htmltext/cssapplication/jsonimage/jpeg示例:Content-Type: application/x-www-form-urlencoded2.2.4 Origin:描述请求来源地址scheme://host:port不含路径可以是null示例: Origin: https://yewjiwei.com2.2.5 Accept建议服务端返回何种媒体类型(MIME Type)/代表所有类型(默认)多个类型用逗号隔开衍生的还有Accept-Charset能够接受的字符集 示例:Accept-Charset: utf-8Accept-Encoding能够接受的编码方式列表 示例:Accept-Encoding: gzip, deflateAccept-Language能够接受的回应内容的自然语言列表 示例:Accept-Language: en-US示例:Accept: text/plainAccept-Charset: utf-8Accept-Encoding: gzip, deflate2.2.6 Referer告诉服务端打开当前页面的上一张页面的URL;如果是ajax请求那么就告诉服务端发送请求的URL是什么非浏览器环境有时候不发送Referer常常用户行为分析2.2.7 Connection决定连接是否在当前事务完成后关闭HTTP1.0默认是closeHTTP1.1后默认是keep-alive2.2.8 Authorization用于超文本传输协议的认证的认证信息示例: Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==2.2.9 Cache-Control用来指定在这次的请求/响应链中的所有缓存机制 都必须 遵守的指令示例: Cache-Control: no-cache2.2.10 Date发送该消息的日期和时间示例: Date: Tue, 15 Nov 1994 08:12:31 GMT2.3. 基本方法GET 从服务器获取资源(一个网址url代表一个资源)POST 在服务器创建资源PUT 在服务器修改资源DELETE 在服务器删除资源OPTIONS 跟跨域相关TRACE 用于显示调试信息CONNECT 代理PATCH 对资源进行部分更新(极少用)2.4. 状态码1XX: 提供信息100 continue 情景:客户端向服务端传很大的数据,这个时候询问服务端,如果服务端返回100,客户端就继续传 (历史,现在比较少了)101 协议切换switch protocolHTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade告诉客户端把协议切换为Websocket2xx: 成功200 Ok 正常的返回成功 通常用在GET201 Created 已创建 通常用在POST202 Accepted 已接收 比如发送一个创建POST请求,服务端有些异步的操作不能马上处理先返回202,结果需要等通知或者客户端轮询获取203 Non-Authoritative Infomation 非权威内容 原始服务器的内容被修改过204 No Content 没有内容 一般PUT请求修改了但是没有返回内容205 Reset Content 重置内容206 Partial Content 服务端下发了部分内容3XX: 重定向300 Multiple Choices 用户请求了多个选项的资源(返回选项列表)301 Moved Permanently 永久转移302 Found 资源被找到(以前是临时转移)不推荐用了 302拆成了303和307303 See Other 可以使用GET方法在另一个URL找到资源304 Not Modified 没有修改305 Use Proxy 需要代理307 Temporary Redirect 临时重定向 (和303的区别是,307使用原请求的method重定向资源, 303使用GET方法重定向资源)308 Permanent Redirect 永久重定向 (和301区别是 客户端接收到308后,之前是什么method,之后也会沿用这个method到新地址。301,通常给用户会向新地址发送GET请求)4XX: 客户端错误400 Bad Request 请求格式错误401 Unauthorized 没有授权402 Payment Required 请先付费403 Forbidden 禁止访问404 Not Found 没有找到405 Method Not Allowed 方法不允许406 Not Acceptable 服务端可以提供的内容和客户端期待的不一样5XX: 服务端错误500 Internal Server Error 内部服务器错误501 Not Implemented 没有实现502 Bad Gateway 网关错误503 Service Unavailable 服务不可用 (内存用光了,线程池溢出,服务正在启动)504 Gateway Timeout 网关超时505 HTTP Version Not Supported 版本不支持3. TCP vs UDP3.1. TCPTCP(Transmission Control Protocol),传输控制协议,是一种可靠、面向字节流的通信协议,把上面应用层交下来的数据看成无结构的字节流来发送。可以想象成流水形式的,发送方TCP会将数据放入“蓄水池”(缓存区),等到可以发送的时候就发送,不能发送就等着,TCP会根据当前网络的拥塞状态来确定每个报文段的大小。TCP 报文首部有 20 个字节,额外开销大。特点如下:TCP充分地实现了数据传输时各种控制功能,可以进行丢包时的重发控制,还可以对次序乱掉的分包进行顺序控制。而这些在 UDP 中都没有。此外,TCP 作为一种面向有连接的协议,只有在确认通信对端存在时才会发送数据,从而可以控制通信流量的浪费。根据 TCP 的这些机制,在 IP 这种无连接的网络上也能够实现高可靠性的通信( 主要通过检验和、序列号、确认应答、重发控制、连接管理以及窗口控制等机制实现)3.2 UDPUDP(User Datagram Protocol),用户数据包协议,是一个简单的面向数据报的通信协议,即对应用层交下来的报文,不合并,不拆分,只是在其上面加上首部后就交给了下面的网络层也就是说无论应用层交给UDP多长的报文,它统统发送,一次发送一个报文而对接收方,接到后直接去除首部,交给上面的应用层就完成任务UDP报头包括 4 个字段,每个字段占用 2 个字节(即 16 个二进制位),标题短,开销小特点如下:UDP 不提供复杂的控制机制,利用 IP 提供面向无连接的通信服务传输途中出现丢包,UDP 也不负责重发当包的到达顺序出现乱序时,UDP没有纠正的功能。并且它是将应用程序发来的数据在收到的那一刻,立即按照原样发送到网络上的一种机制。即使是出现网络拥堵的情况,UDP 也无法进行流量控制等避免网络拥塞行为3.3 区别UDP 与 TCP 两者的都位于传输层,如下图所示:两者区别如下表所示:标题TCPUDP可靠性可靠不可靠连接性面向连接无连接报文面向字节流面向报文效率传输效率低传输效率高双共性全双工一对一、一对多、多对一、多对多流量控制滑动窗口无拥塞控制慢开始、拥塞避免、快重传、快恢复无传输效率慢快TCP 是面向连接的协议,建立连接3次握手、断开连接四次挥手,UDP是面向无连接,数据传输前后不连接连接,发送端只负责将数据发送到网络,接收端从消息队列读取TCP 提供可靠的服务,传输过程采用流量控制、编号与确认、计时器等手段确保数据无差错,不丢失。UDP 则尽可能传递数据,但不保证传递交付给对方TCP 面向字节流,将应用层报文看成一串无结构的字节流,分解为多个TCP报文段传输后,在目的站重新装配。UDP协议面向报文,不拆分应用层报文,只保留报文边界,一次发送一个报文,接收方去除报文首部后,原封不动将报文交给上层应用TCP 只能点对点全双工通信。UDP 支持一对一、一对多、多对一和多对多的交互通信两者应用场景如下图:可以看到,TCP 应用场景适用于对效率要求低,对准确性要求高或者要求有链接的场景,而UDP 适用场景为对效率要求高,对准确性要求低的场景4. HTTP 1.0 / 1.1 / 2.0 / 3.04.1. HTTP1.0HTTP协议的第二个版本,第一个在通讯中指定版本号的HTTP协议版本HTTP 1.0 浏览器与服务器只保持短暂的连接,每次请求都需要与服务器建立一个TCP连接服务器完成请求处理后立即断开TCP连接,服务器不跟踪每个客户也不记录过去的请求简单来讲,每次与服务器交互,都需要新开一个连接例如,解析html文件,当发现文件中存在资源文件的时候,这时候又创建单独的链接最终导致,一个html文件的访问包含了多次的请求和响应,每次请求都需要创建连接、关系连接这种形式明显造成了性能上的缺陷如果需要建立长连接,需要设置一个非标准的Connection字段 Connection: keep-alive4.2. HTTP1.1在HTTP1.1中,默认支持长连接(Connection: keep-alive),即在一个TCP连接上可以传送多个HTTP请求和响应,减少了建立和关闭连接的消耗和延迟建立一次连接,多次请求均由这个连接完成这样,在加载html文件的时候,文件中多个请求和响应就可以在一个连接中传输同时,HTTP 1.1还允许客户端不用等待上一次请求结果返回,就可以发出下一次请求,但服务器端必须按照接收到客户端请求的先后顺序依次回送响应结果,以保证客户端能够区分出每次请求的响应内容,这样也显著地减少了整个下载过程所需要的时间同时,HTTP1.1在HTTP1.0的基础上,增加更多的请求头和响应头来完善的功能,如下:引入了更多的缓存控制策略,如If-Unmodified-Since, If-Match, If-None-Match等缓存头来控制缓存策略引入range,允许值请求资源某个部分引入host,实现了在一台WEB服务器上可以在同一个IP地址和端口号上使用不同的主机名来创建多个虚拟WEB站点并且还添加了其他的请求方法:put、delete、options...4.3. HTTP2.0HTTP2.0在相比之前版本,性能上有很大的提升,添加了如下特性多路复用二进制分帧首部压缩服务器推送4.3.1 多路复用HTTP/2 复用 TCP 连接,在一个连接里,客户端和浏览器都可以同时发送多个请求或回应,而且不用按照顺序一一对应,这样就避免了”队头堵塞”上图中,可以看到第四步中css、js资源是同时发送到服务端4.3.2 二进制分帧帧是HTTP2通信中最小单位信息HTTP/2 采用二进制格式传输数据,而非 HTTP 1.x的文本格式,解析起来更高效将请求和响应数据分割为更小的帧,并且它们采用二进制编码HTTP2中,同域名下所有通信都在单个连接上完成,该连接可以承载任意数量的双向数据流每个数据流都以消息的形式发送,而消息又由一个或多个帧组成。多个帧之间可以乱序发送,根据帧首部的流标识可以重新组装,这也是多路复用同时发送数据的实现条件4.3.3 首部压缩HTTP/2在客户端和服务器端使用“首部表”来跟踪和存储之前发送的键值对,对于相同的数据,不再通过每次请求和响应发送首部表在HTTP/2的连接存续期内始终存在,由客户端和服务器共同渐进地更新例如:下图中的两个请求, 请求一发送了所有的头部字段,第二个请求则只需要发送差异数据,这样可以减少冗余数据,降低开销4.3.4 服务器推送HTTP2引入服务器推送,允许服务端推送资源给客户端服务器会顺便把一些客户端需要的资源一起推送到客户端,如在响应一个页面请求中,就可以随同页面的其它资源免得客户端再次创建连接发送请求到服务器端获取这种方式非常合适加载静态资源4.4. HTTP3.0我们都知道,HTTP是应用层协议,应用层产生的数据会通过传输层协议作为载体来传输到互联网上的其他主机中,而其中的载体就是 TCP 协议,这是 HTTP 2 之前的主流模式。但是随着 TCP 协议的缺点不断暴露出来, HTTP 3.0 毅然决然切断了和 TCP 的联系,转而拥抱了 UDP 协议,这么说不太准确,其实 HTTP 3.0 其实是拥抱了 QUIC 协议,而 QUIC 又被叫做快速 UDP 互联网连接,QUIC的一个特征就是快。QUIC 具有下面这些优势使用 UDP 协议,不需要三次连接进行握手,而且也会缩短 TLS 建立连接的时间。解决了队头阻塞问题。实现动态可插拔,在应用层实现了拥塞控制算法,可以随时切换。报文头和报文体分别进行认证和加密处理,保障安全性。连接能够平滑迁移。4.5. 总结HTTP 1.0:浏览器与服务器只保持短连接,浏览器的每次请求都需要与服务器建立一个TCP连接。HTTP 1.1:引入了长连接,即TCP连接默认不关闭,可以被多个请求复用引入pipelining管道技术,在同一个TCP连接里面,客户端可以同时发送多个请求,但有队头阻塞问题。新增了一些请求方法新增了一些请求头和响应头HTTP 2.0:采用二进制格式而非文本格式多路复用TCP连接,在一个链接上客户端或服务器可同时发送多个请求或响应,避免了队头阻塞问题(只解决粒度级别为http request的队头阻塞)使用报头压缩,降低开销服务器推送HTTP 3.0:利用QUIC作为底层支撑协议,其融合UDP协议的速度、性能与TCP的安全可靠。彻底解决了队头阻塞问题连接建立更快支持连接迁移
原文来自我的个人博客前言先直接展示下最终效果,代码已上传至码上掘金本章将会主要介绍关于 Canvas 的基础知识,看完之后应该就能理解最终的代码了。1. 什么是 Canvas ?Canvas 最初由 Apple 于 2004 年 引入,用于 Mac OS X Webkit 组件,为仪表盘小组件和 Safari 浏览器等应用程序提供支持。后来,它被 Gecko 内核的浏览器(尤其是 Mozilla Firefox),Opera 和 Chrome 实现,并被网页超文本应用技术工作小组提议为下一代的网络技术的标准元素(HTML5新增元素)。Canvas 提供了非常多的 JavaScript绘图 API (比如:绘制路径、矩形、圆、文本和图像等方法),与 <canvas>元素可以绘制各种 2D 图形。Canvas API 主要聚焦于 2D 图形。当然也可以使用 <canvas> 元素对象的 WebGL API 来绘制 2D 和 3D 图形。Canvas 可用于动画、游戏画面、数据可视化、图片编辑以及实现视频处理等方面。1.1 浏览器兼容性Canvas 的浏览器兼容性还是不错的,能兼容 e9 及其以上版本1.2 Canvas 的优点:Canvas 提供的功能更原始,适合像素处理,动态渲染和数据量大的绘制,如:图片编辑、热力图、炫光尾迹特效等。Canvas 非常适合图像密集的游戏开发,适合频繁重绘许多的对象。Canvas 能够以 .png 或 .jpg 格式保存结果图片,适合对图像进行像素级的处理。1.3 Canvas 的缺点:在移动端可能因为 Canvas 数量多,而导致内存占用超出了手机的承受能力,导致浏览器崩溃。Canvas 绘图只能通过 JavaScript 脚本操作 (all in js)。Canvas 是由一个个像素点构成的图形,放大会使图形变得颗粒状和像素化,导致模糊。2. Canvas 绘制图形Canvas 支持两种方式来绘制矩形:"矩形方法" 和 "路径方法"。2.1 矩形方法路径是通过不同颜色和宽度的线段或曲线相连形成的不同形状的点的集合,除了矩形,其他的图形都是通过一条或者多条路径组合而成的通常我们会通过众多的路径来绘制复杂的图形。下面是常见的绘制方法:fillRect(x, y, width, height): 绘制一个填充的矩形strokeRect(x, y, width, height): 绘制一个矩形的边框clearRect(x, y, width, height): 清除指定矩形区域,让清除部分完全透明。Canvas 绘制一个矩形:<canvas id="tutorial" width="300" height="300px"> 你的浏览器不兼容Canvas,请升级您的浏览器! </canvas> <script> window.onload = function() { let canvasEl = document.getElementById('tutorial') if(!canvasEl.getContext){ return let ctx = canvasEl.getContext('2d') // 2d | webgl ctx.fillRect(10,10, 100, 50) // 单位也是不用写 px </script>效果:代码解析:忽略做兼容性的几行代码,上面的代码最终通过 ctx.fillRect(10,10,100,50) 在坐标为 (10,10)的位置,绘制了一个长 100 宽 50 的实心矩形(默认为黑色)2.2 路径方法使用路径绘制图形的步骤:首先需要创建路径起始点(beginPath)。然后使用画图命令去画出路径( arc 绘制圆弧 、lineTo 画直线 )。之后把路径闭合( closePath , 不是必须)。一旦路径生成,就能通过 描边(stroke) 或 填充路径区域(fill) 来渲染图形。以下是绘制路径时,所要用到的函数beginPath():新建一条路径,生成之后,图形绘制命令被指向到新的路径上绘图,不会关联到旧的路径。closePath():闭合路径之后图形绘制命令又重新指向到 beginPath之前的上下文中。stroke():通过线条来绘制图形轮廓/描边 (针对当前路径图形)。fill():通过填充路径的内容区域生成实心的图形 (针对当前路径图形)。代码实现:<canvas id="tutorial" width="300" height="300px"> 你的浏览器不兼容Canvas,请升级您的浏览器! </canvas> <script> window.onload = function () { let canvasEl = document.getElementById("tutorial"); if (!canvasEl.getContext) { return; let ctx = canvasEl.getContext("2d"); // 2d | webgl // 1.创建一个路径 ctx.beginPath(); // 2.绘图指令 // ctx.moveTo(0, 0) // ctx.rect(100, 100, 100, 50); ctx.moveTo(100, 100); ctx.lineTo(200, 100); ctx.lineTo(200, 150); ctx.lineTo(100, 150); // 3.闭合路径 ctx.closePath(); // 4.填充和描边 ctx.stroke(); </script>lineTo 和 arc 两个函数结合既能绘制直线也能绘制圆弧,因此路径方法还可以绘制许多图形,比如三角形、菱形、梯形、椭圆形、圆形等等。。。效果:3. Canvas 样式和颜色3.1 色彩 Colors如果我们想要给图形上色,有两个重要的属性可以做到:fillStyle = color: 设置图形的填充颜色,需在 fill() 函数前调用。strokeStyle = color: 设置图形轮廓的颜色,需在 stroke() 函数前调用。一旦设置了 strokeStyle 或者 fillStyle 的值,那么这个新值就会成为新绘制的图形的默认值。如果你要给图形上不同的颜色,你需要重新设置 fillStyle 或 strokeStyle 的3.2 透明度 Transparent除了可以绘制实色图形,我们还可以用 canvas 来绘制半透明的图形。1. 方式一:strokeStyle 和 fillStyle 属性结合 RGBA:// 指定透明颜色,用于描边和填充样式 ctx.strokeStyle = "rgba(255,0,0,0.5)"; ctx.fillStyle = "rgba(255,0,0,0.5)";2. 方式二:globalAlpha 属性// 针对于Canvas中所有的图形生效 ctx.globalAlpha = 0.3 // 2.修改画笔的颜色 // ctx.fillStyle = 'rgba(255, 0, 0, 0.3)' ctx.fillRect(0,0, 100, 50) // 单位也是不用写 px ctx.fillStyle = 'blue' ctx.fillRect(200, 0, 100, 50) ctx.fillStyle = 'green' // 关键字, 十六进制, rbg , rgba ctx.beginPath() ctx.rect(0, 100, 100, 50) ctx.fill()globalAlpha = 0 ~ 1✓ 这个属性影响到 canvas 里所有图形的透明度✓ 有效的值范围是 0.0(完全透明)到 1.0(完全不透明),默认是 1.0。3.3 线型 Line styles调用 lineTo()函数绘制的线条,是可以通过一系列属性来设置线的样式。常见的属性有:lineWidth = value: 设置线条宽度。lineCap = type: 设置线条末端样式。lineJoin = type: 设定线条与线条间接合处的样式。......lineWidth设置线条宽度的属性值必须为正数。默认值是 1.0px,不需单位。( 零、负数、Infinity 和 NaN 值将被忽略)线宽是指给定路径的中心到两边的粗细。换句话说就是在路径的两边各绘制线宽的一半。如果你想要绘制一条从 (3,1) 到 (3,5),宽度是 1.0 的线条,你会得到像第二幅图一样的结果。路径的两边各延伸半个像素填充并渲染出 1 像素的线条(深蓝色部分)两边剩下的半个像素又会以实际画笔颜色一半色调来填充(浅蓝部分)实际画出线条的区域为(浅蓝和深蓝的部分),填充色大于 1 像素了,这就是为何宽度为 1.0 的线经常并不准确的原因。要解决这个问题,必须对路径精确的控制。如,1px 的线条会在路径两边各延伸半像素,那么像第三幅图那样绘制从 (3.5 ,1) 到 (3.5, 5) 的线条,其边缘正好落在像素边界,填充出来就是准确的宽为 1.0 的线条。lineCap: 属性的值决定了线段端点显示的样子。它可以为下面的三种的其中之一:butt 截断,默认是 butt。round 圆形square 正方形如下图所示:lineJoin: 属性的值决定了图形中线段连接处所显示的样子。它可以是这三种之一:round 圆形bevel 斜角miter 斜槽规,默认是 miter。如下图所示:3.4 绘制文本canvas 提供了两种方法来渲染文本:fillText(text, x, y [, maxWidth])在 (x,y) 位置,填充指定的文本绘制的最大宽度(可选)。strokeText(text, x, y [, maxWidth])在 (x,y) 位置,绘制文本边框绘制的最大宽度(可选)。文本的样式(需在绘制文本前调用)font = value: 当前绘制文本的样式。这个字符串使用和 CSS font 属性相同的语法。默认的字体是:10px sans-serif。textAlign = value:文本对齐选项。可选的值包括:start, end, left, right or center. 默认值是 starttextBaseline = value:基线对齐选项。可选的值包括:tophangingmiddlealphabeticideographicbottom。**✓ 默认值是 `alphabetic`。** 下面是一个绘制文本你的例子:<canvas id="tutorial" width="300" height="300px"> 你的浏览器不兼容Canvas,请升级您的浏览器! </canvas> <script> window.onload = function () { let canvasEl = document.getElementById("tutorial"); if (!canvasEl.getContext) { return; let ctx = canvasEl.getContext("2d"); // 2d | webgl ctx.font = "60px sen-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.strokeStyle = "red"; ctx.fillStyle = "red"; // 将字体绘制在 100, 100 这个坐标点 ctx.fillText("Ay", 100, 100); // ctx.strokeText("Ay", 100, 100); </script>3.5 绘制图片绘制图片,可以使用 drawImage 方法将它渲染到 canvas 里。drawImage 方法有三种形态:drawImage(image, x, y)其中 image 是 image 或者 canvas 对象,x 和 y 是其在目标 canvas 里的起始坐标。drawImage(image, x, y, width, height)这个方法多了 2 个参数:width 和 height,这两个参数用来控制 当向 canvas 画入时应该缩放的大小drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)第一个参数和其它的是相同的,都是一个图像或者另一个 canvas 的引用。其它 8 个参数最好参照下边的图解来理解,前 4 个是定义图像源的切片位置和大小,后 4 个则是定义切片的目标显示位置和大小。(剪切)HTMLImageElement:这些图片是由 Image() 函数构造出来的,或者任何的 <img> 元素。HTMLVideoElement:用一个 HTML 的 <video> 元素作为你的图片源,可以从视频中抓取当前帧作为一个图像。HTMLCanvasElement:可以使用另一个 <canvas> 元素作为你的图片源。等等...4. Canvas 状态和形变4.1 Canvas 绘画状态-保存和恢复Canvas 绘画状态是当前绘画时所产生的样式和变形的一个快照,Canvas 在绘画时,会产生相应的绘画状态,其实我们是可以将某些绘画的状态存储在栈中来为以后复用,Canvas 绘画状态的可以调用 save 和 restore 方法是用来保存和恢复,这两个方法都没有参数,并且它们是成对存在的。保存和恢复(Canvas)绘画状态save():保存画布 (canvas) 的所有绘画状态restore():恢复画布 (canvas) 的所有绘画状态Canvas 绘画状态包括:当前应用的变形(即移动,旋转和缩放)以及这些属性:strokeStyle, fillStyle, globalAlpha, lineWidth, lineCap, lineJoin, miterLimit, shadowOffsetX, shadowOffsetY, shadowBlur, shadowColor, font, textAlign, textBaseline......当前的裁切路径(clipping path)4.2 移动 - translatetranslate 方法,它用来移动 canvas 和它的原点到一个不同的位置。translate(x, y)x 是左右偏移量,y 是上下偏移量(无需单位)。移动 canvas 原点的好处如不使用 translate 方法,那么所有矩形默认都将被绘制在相同的(0,0)坐标原点。translate 方法可让我们任意放置图形,而不需要手工一个个调整坐标值。移动矩形案例一:形变( 没有保存状态)<script> ///1.形变( 没有保存状态) ctx.translate(100, 100); ctx.fillRect(0, 0, 100, 50); // 单位也是不用写 px ctx.translate(100, 100); ctx.strokeRect(0, 0, 100, 50); </script>效果:移动矩形案例一:形变(保存形变之前的状态)<script> // 2.形变(保存形变之前的状态) ctx.save(); ctx.translate(100, 100); ctx.fillRect(0, 0, 100, 50); // 单位也是不用写 px ctx.restore(); // 恢复了形变之前的状态( 0,0) ctx.save(); // (保存形变之前的状态) ctx.translate(100, 100); ctx.fillStyle = "red"; ctx.fillRect(0, 0, 50, 30); ctx.restore(); </script>4.3 旋转 - rotaterotate方法,它用于以原点为中心旋转 canvas,即沿着 z轴 旋转。rotate(angle)只接受一个参数:旋转的角度 (angle),它是顺时针方向,以弧度为单位的值。角度与弧度的 JS 表达式:弧度= ( Math.PI / 180 ) * 角度 ,即 1 角度 = Math.PI/180 个弧度。比如:旋转 90°:Math.PI / 2;旋转 180°:Math.PI ;旋转 360°:Math.PI * 2;旋转 -90°:-Math.PI / 2;旋转的中心点始终是 canvas 的原坐标点,如果要改变它,我们需要用到 translate方法。<SCRIPT> // 保存形变之前的状态 ctx.save() // 1.形变 ctx.translate(100, 100) // 360 -> Math.PI * 2 // 180 -> Math.PI // 1 -> Math.PI / 180 // 45 -> Math.PI / 180 * 45 ctx.rotate(Math.PI / 180 * 45) ctx.fillRect(0, 0, 50, 50) // ctx.translate(100, 0) // ctx.fillRect(0, 0, 50, 50) // 绘图结束(恢复形变之前的状态) ctx.restore() ctx.save() ctx.translate(100, 0) ctx.fillRect(0, 0, 50, 50) ctx.restore() // ....下面在继续写代码的话,坐标轴就是参照的是原点了 <SCRIPT>4.4 缩放 - scalescale(x, y) 方法可以缩放画布。可用它来增减图形在 canvas 中的像素数目,对图形进行缩小或者放大。x 为水平缩放因子,y 为垂直缩放因子,也支持负数。<script> // 保存形变之前的状态 ctx.save() // 1.形变 ctx.translate(100, 100) // 平移坐标系统 ctx.scale(2, 2) // 对坐标轴进行了放大(2倍) ctx.translate(10, 0) // 10px -> 20px ctx.fillRect(0, 0, 50, 50) // 绘图结束(恢复形变之前的状态) ctx.restore() // ....下面在继续写代码的话,坐标轴就是参照的是原点了 </script>5. 实现太阳系动画代码window.onload = function () { let canvasEl = document.getElementById("tutorial"); if (!canvasEl.getContext) { return; let ctx = canvasEl.getContext("2d"); // 2d | webgl let sun = new Image(); sun.src = "https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/640b4df0a5074e9a9bf4777fdf1fd74e~tplv-k3u1fbpfcp-watermark.image"; let earth = new Image(); earth.src = "https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f4ad71733e934b818a52bcfea56a683f~tplv-k3u1fbpfcp-watermark.image"; let moon = new Image(); moon.src = "https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/05bc3992bd5044448f029b7d68049b38~tplv-k3u1fbpfcp-watermark.image"; requestAnimationFrame(draw); 1秒钟会回调 61次 function draw() { console.log("draw"); ctx.clearRect(0, 0, 300, 300); ctx.save(); // 1.绘制背景 drawBg(); // 2.地球 drawEarth(); ctx.restore(); requestAnimationFrame(draw); function drawBg() { ctx.save(); ctx.drawImage(sun, 0, 0); // 背景图 ctx.translate(150, 150); // 移动坐标 ctx.strokeStyle = "rgba(0, 153, 255, 0.4)"; ctx.beginPath(); // 绘制轨道 ctx.arc(0, 0, 105, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); function drawEarth() { let time = new Date(); let second = time.getSeconds(); let milliseconds = time.getMilliseconds(); ctx.save(); // earth start ctx.translate(150, 150); // 中心点坐标系 // 地球的旋转 // Math.PI * 2 一整个圆的弧度 // Math.PI * 2 / 60 分成 60 份 // Math.PI * 2 / 60 1s // Math.PI * 2 / 60 / 1000 1mm // 1s 1mm // Math.PI * 2 / 60 * second + Math.PI * 2 / 60 / 1000 * milliseconds ctx.rotate( ((Math.PI * 2) / 10) * second + ((Math.PI * 2) / 10 / 1000) * milliseconds ctx.translate(105, 0); // 圆上的坐标系 ctx.drawImage(earth, -12, -12); // 3.绘制月球 drawMoon(second, milliseconds); // 4.绘制地球的蒙版 drawEarthMask(); ctx.restore(); // earth end function drawMoon(second, milliseconds) { ctx.save(); // moon start // 月球的旋转 // Math.PI * 2 一圈 360 // Math.PI * 2 / 10 1s(10s一圈) // Math.PI * 2 / 10 * 2 2s(10s一圈) // Math.PI * 2 / 10 / 1000 1mm 的弧度 // 2s + 10mm = 弧度 // Math.PI * 2 / 10 * second + Math.PI * 2 / 10 / 1000 * milliseconds ctx.rotate( ((Math.PI * 2) / 2) * second + ((Math.PI * 2) / 2 / 1000) * milliseconds ctx.translate(0, 28); ctx.drawImage(moon, -3.5, -3.5); ctx.restore(); // moon end function drawEarthMask() { // 这里的坐标系是哪个? 圆上的坐标系 ctx.save(); ctx.fillStyle = "rgba(0, 0, 0, 0.4)"; ctx.fillRect(0, -12, 40, 24); ctx.restore();
前言原文来自我的个人博客接下来的几章中我们将要在 mini-vue 中 实现一个自己的编译器,在上一章我们了解了 compiler 的作用和大致流程。它主要经历了以下三个大的步骤:解析( parse ) template 模板,生成 AST转化(transform)AST,得到 JavaScript AST生成(generate)render 函数那么本章我们就先来实现编译器的第一步:依据模板生成 AST 抽象语法树ps:compiler 是一个非常复杂的概念,我们不会实现一个完善的编译器,而是会像之前一样严格遵循:没有使用就当做不存在 和 最少代码的实现逻辑 这两个标准。只关注 核心 和 当前业务 相关的内容,而忽略其他。1. 扩展知识:JavaScript与有限自动状态机想要实现 compiler 第一步是构建 AST 对象。那么想要构建 AST,就需要利用到 有限状态机 的概念。有限状态机也被叫做 有限自动状态机,表示:有限个状态以及在这些状态之间的转移和动作等行为的数学计算模型)光看概念,可能难以理解,那么下面我们来看一个具体的例子:根据 packages/compiler-core/src/compile.ts 中的代码可知,ast 对象的生成是通过 baseParse 方法得到的。const ast = isString(template) ? baseParse(template, options) : template而对于 baseParse 方法而言,它接收一个 template 作为参数,返回一个 ast 对象。即:通过 parse 方法,解析 template,得到 ast 对象。 中间解析的过程,就需要使用到 有限自动状态机。比如,vue 想要把如下模板解析成 AST,那么就需要利用有限自动状态机对该模板进行分析<div>hello world</div>分析的过程中主要包含了三个特性:摘自阮一峰:JavaScript与有限状态机状态总数(state)是有限的。初始状态标签开始状态标签名称状态文本状态结束标签状态结束标签名称状态...任一时刻,只处在一种状态之中。某种条件下,会从一种状态转变(transition)到另一种状态。如下图所示:解析 <:由 初始状态 进入 标签开始状态解析 div:由 标签开始状态 进入 标签名称状态解析 >:由 标签名称状态 进入 初始状态解析 hello world:由 初始状态 进入 文本状态解析 <:由 文本状态 进入 标签开始状态解析 /:由 标签开始状态 进入 结束标签状态解析 div:由 结束标签状态 进入 结束标签名称状态解析 >:由 结束标签名称状态 进入 初始状态经过这样一些列的解析,对于:<div>hello world</div>而言,我们将得到三个 token:开始标签:<div> 文本节点:hello world 结束标签:</div>而这样一个利用有限自动状态机的状态迁移,来获取 tokens 的过程,可以叫做:对模板的标记化总结这一小节,我们了解了什么是有限自动状态机,也知道了它的三个特性。vue 利用它来实现了对模板的标记化,得到了对应的 token。关于这些 token 有什么用,我们下一小节说。2. 扩展知识:扫描 tokens 构建 AST 结构的方案在上一小节中,我们已经知道可以通过自动状态机解析模板为 tokens,而解析出来的 tokens 就是生成 AST 的关键。生成 AST 的过程,就是 tokens 扫描的过程。我们以以下 html 结构为例:<div> <p>hello</p> <p>world</p> </div>该 html 可以被解析为如下 tokens:开始标签:<div> 开始标签:<p> 文本节点:hello 结束标签:</p> 开始标签:<p> 文本节点:world 结束标签:</p> 结束标签:</div>具体的扫描过程为:1. 初始状态:2. 扫描开始标签 <div>,在 AST 下 会生成 Element<div>3. <p>hello</p> 标签依次入栈, AST 生成对应 Element<p><p>hello</p> 标签依次出栈,<p>world</p> 标签依次入栈,AST 生成对应 Element<p>5. </div>结束标签入栈再与<div>开始标签组合依次出栈。6. 结束状态在以上的图示中,我们通过 递归下降算法? 这样的一种扫描形式把 tokens 通过 栈 解析成了 AST(抽象语法树)。3. 源码阅读:依据模板生成 AST 抽象语法树前面做了这么多铺垫,我们中行与可以来看一下 vue 中生成 AST 的代码了。vue 的这部分代码全部放在了 packages/compiler-core/src/parse.ts 中:从这个文件可以看出,整体 parse 的逻辑非常复杂,整体文件有 1175 行代码。通过 packages/compiler-core/src/compile.ts 中的 baseCompile 方法可以知道,整个 parse 的过程是从 baseParse 开始的,所以我们可以直接从这个方法开始进行 debugger。创建测试实例:<script> const { compile, h, render } = Vue // 创建 template const template = `<div>hello world</div>` // 生成 render 函数 const renderFn = compile(template) </script>当前的 template 对应的目标极简 AST 为(我们不再关注其他的属性生成):const ast = { "type": 0, "children": [ "type": 1, "tag": "div", "tagType": 0, // 属性,目前我们没有做任何处理。但是需要添加上,否则,生成的 ats 放到 vue 源码中会抛出错误 "props": [], "children": [{ "type": 2, "content": " hello world " }] // loc:位置,这个属性并不影响渲染,但是它必须存在,否则会报错。所以我们给了他一个 {} "loc": {} }模板解析的 token 流程为(以 <div>hello world</div> 为例):1. <div 2. > 3. hello world 4. </div 5. >明确好以上内容之后,我们开始。Here we go进入 baseParse 方法:上图描述的应该比较详细了,在 baseParse 方法中,程序首先会进入 createParserContext 方法中,这个方法会返回一个 ParserContext 类型的对象,这个对象比较复杂,我们只需要关注 source(模板源代码)属性即可此时 context.source = "<div> hello world </div>",程序接着执行:从 createParserContext 方法跳出后,程序接着执行 getCursor 方法,这个方法主要获取 loc(即:location 位置),与我们的极简 `AST 无关,无需关注,接着调试:接着,程序会先后执行 parseChildren、getSelection、createRoot 三个方法,最后返回 ast执行 parseChildren 方法(解析子节点) ,这个方法 非常重要,是生成 AST 的核心方法:parseChildren 方法中的核心逻辑都在 while 循环中,它的目的就是循环解析模板数据,生成 AST 中的 children 的,最后生成的 nodes:(关于 while 循环中的逻辑是很复杂的,碍于篇幅这里做了点省略,具体逻辑可以参考我下面的框架实现)接下来执行 getSelection 方法:这个函数非常简单,也不是那么重要,我们只要知道是关于 location 的生成就行了。最后,执行 createRoot 方法,这个方法也非常简单,就是整合了前两个函数生成的 children 和 loc 成一个 RootNode 对象(最后的 AST)并返回4. 框架实现AST 对象的生成颇为复杂,我们把整个过程分为成三步进行处理。构建 parse 方法,生成 context 实例构建 parseChildren ,处理所有子节点(最复杂)构建有限自动状态机解析模板扫描 token 生成 AST 结构生成 AST,构建测试4.1 构建 parse 函数,生成 context 实例创建 packages/compiler-core/src/compile.ts 模块,写入如下代码:export function baseCompile(template: string, options) { return {} }创建 packages/compiler-dom/src/index.ts 模块,导出 compile 方法:import { baseCompile } from 'packages/compiler-core/src/compile' export function compile(template: string, options) { return baseCompile(template, options) }在 packages/vue/src/index.ts 中,导出 compile 方法:export { render, compile } from '@vue/runtime-dom'创建 packages/compiler-core/src/parse.ts 模块下创建 baseParse 方法:/** * 基础的 parse 方法,生成 AST * @param content tempalte 模板 * @returns export function baseParse(content: string) { return {} }在 packages/compiler-core/src/compile.ts 模块下的 baseCompile 中,使用 baseParse 方法:import { baseParse } from './parse' export function baseCompile(template: string, options) { const ast = baseParse(template) console.log(JSON.stringify(ast)) return {} }至此,我们就成功的触发了 baseParse。接下来我们去生成 context 上下文对象。在 packages/compiler-core/src/parse.ts 中创建 createParserContext 方法,用来生成上下文对象:/** * 创建解析器上下文 function createParserContext(content: string): ParserContext { // 合成 context 上下文对象 return { source: content }创建 ParserContext 接口:/** * 解析器上下文 export interface ParserContext { // 模板数据源 source: string }在 baseParse 中触发该方法:export function baseParse(content: string) { // 创建 parser 对象,未解析器的上下文对象 const context = createParserContext(content) console.log(context) return {} }至此我们成功得到了 context 上下文对象。创建测试实例 packages/vue/examples/compiler/compiler-ast.html:<script> const { compile } = Vue // 创建 template const template = `<div> hello world </div>` // 生成 render 函数 const renderFn = compile(template) </script>可以成功打印 context4.2 构建有限自动状态机解析模板,扫描 token 生成 AST 结构接下来我们通过 parseChildren 方法处理所有的子节点,整个处理的过程分为两大块:构建有限自动状态机解析模板扫描 token 生成 AST 结构接下来我们来进行实现:创建 parseChildren 方法:/** * 解析子节点 * @param context 上下文 * @param mode 文本模型 * @param ancestors 祖先节点 * @returns function parseChildren(context: ParserContext, ancestors) { // 存放所有 node节点数据的数组 const nodes = [] * 循环解析所有 node 节点,可以理解为对 token 的处理。 * 例如:<div>hello world</div>,此时的处理顺序为: * 1. <div * 2. > * 3. hello world * 4. </ * 5. div> while (!isEnd(context, ancestors)) { * 模板源 const s = context.source // 定义 node 节点 let node if (startsWith(s, '{{')) { // < 意味着一个标签的开始 else if (s[0] === '<') { // 以 < 开始,后面跟a-z 表示,这是一个标签的开始 if (/[a-z]/i.test(s[1])) { // 此时要处理 Element node = parseElement(context, ancestors) // node 不存在意味着上面的两个 if 都没有进入,那么我们就认为此时的 token 为文本节点 if (!node) { node = parseText(context) pushNode(nodes, node) return nodes }以上代码中涉及到了 五 个方法:isEnd:判断是否为结束节点startsWith:判断是否以指定文本开头pushNode:为 array 执行 push 方法复杂: parseElement:解析 element复杂: parseText:解析 text我们先实现前三个简单方法:创建 startsWith 方法:/** * 是否以指定文本开头 function startsWith(source: string, searchString: string): boolean { return source.startsWith(searchString) }创建 isEnd 方法:/** * 判断是否为结束节点 function isEnd(context: ParserContext, ancestors): boolean { const s = context.source // 解析是否为结束标签 if (startsWith(s, '</')) { for (let i = ancestors.length - 1; i >= 0; --i) { if (startsWithEndTagOpen(s, ancestors[i].tag)) { return true return !s * 判断当前是否为《标签结束的开始》。比如 </div> 就是 div 标签结束的开始 * @param source 模板。例如:</div> * @param tag 标签。例如:div * @returns function startsWithEndTagOpen(source: string, tag: string): boolean { return ( startsWith(source, '</') && source.slice(2, 2 + tag.length).toLowerCase() === tag.toLowerCase() && /[\t\r\n\f />]/.test(source[2 + tag.length] || '>') }创建 pushNode 方法:/** * nodes.push(node) function pushNode(nodes, node): void { nodes.push(node) }至此三个简单的方法都被构建完成。接下来我们来处理 parseElement,在处理的过程中,我们需要使用到 NodeTypes 和 ElementTypes 这两个 enum 对象,所以我们需要先构建它们(直接从 vue 源码复制):创建 packages/compiler-core/src/ast.ts 模块:/** * 节点类型(我们这里复制了所有的节点类型,但是我们实际上只用到了极少的部分) export const enum NodeTypes { ROOT, ELEMENT, TEXT, COMMENT, SIMPLE_EXPRESSION, INTERPOLATION, ATTRIBUTE, DIRECTIVE, // containers COMPOUND_EXPRESSION, IF_BRANCH, TEXT_CALL, // codegen VNODE_CALL, JS_CALL_EXPRESSION, JS_OBJECT_EXPRESSION, JS_PROPERTY, JS_ARRAY_EXPRESSION, JS_FUNCTION_EXPRESSION, JS_CONDITIONAL_EXPRESSION, JS_CACHE_EXPRESSION, // ssr codegen JS_BLOCK_STATEMENT, JS_TEMPLATE_LITERAL, JS_IF_STATEMENT, JS_ASSIGNMENT_EXPRESSION, JS_SEQUENCE_EXPRESSION, JS_RETURN_STATEMENT * Element 标签类型 export const enum ElementTypes { * element,例如:<div> ELEMENT, COMPONENT, SLOT, * template TEMPLATE }下面就可以构建 parseElement 方法了 ,该方法的作用主要为了解析 Element 元素:创建 parseElement:/** * 解析 Element 元素。例如:<div> function parseElement(context: ParserContext, ancestors) { // -- 先处理开始标签 -- const element = parseTag(context, TagType.Start) // -- 处理子节点 -- ancestors.push(element) // 递归触发 parseChildren const children = parseChildren(context, ancestors) ancestors.pop() // 为子节点赋值 element.children = children // -- 最后处理结束标签 -- if (startsWithEndTagOpen(context.source, element.tag)) { parseTag(context, TagType.End) // 整个标签处理完成 return element }构建 TagType enum:/** * 标签类型,包含:开始和结束 const enum TagType { Start, }处理开始标签,构建 parseTag:/** * 解析标签 function parseTag(context: any, type: TagType): any { // -- 处理标签开始部分 -- // 通过正则获取标签名 const match: any = /^<\/?([a-z][^\r\n\t\f />]*)/i.exec(context.source) // 标签名字 const tag = match[1] // 对模板进行解析处理 advanceBy(context, match[0].length) // -- 处理标签结束部分 -- // 判断是否为自关闭标签,例如 <img /> let isSelfClosing = startsWith(context.source, '/>') // 《继续》对模板进行解析处理,是自动标签则处理两个字符 /> ,不是则处理一个字符 > advanceBy(context, isSelfClosing ? 2 : 1) // 标签类型 let tagType = ElementTypes.ELEMENT return { type: NodeTypes.ELEMENT, tagType, // 属性,目前我们没有做任何处理。但是需要添加上,否则,生成的 ats 放到 vue 源码中会抛出错误 props: [] }解析标签的过程,其实就是一个自动状态机不断读取的过程,我们需要构建 advanceBy 方法,来标记进入下一步:/** * 前进一步。多次调用,每次调用都会处理一部分的模板内容 * 以 <div>hello world</div> 为例 * 1. <div * 2. > * 3. hello world * 4. </div * 5. > function advanceBy(context: ParserContext, numberOfCharacters: number): void { // template 模板源 const { source } = context // 去除开始部分的无效数据 context.source = source.slice(numberOfCharacters) }至此 parseElement 构建完成。此处的代码虽然不多,但是逻辑非常复杂。在解析的过程中,会再次触发 parseChildren,这次触发表示触发 文本解析,所以下面我们要处理 parseText 方法。创建 parseText 方法,解析文本:/** * 解析文本。 function parseText(context: ParserContext) { * 定义普通文本结束的标记 * 例如:hello world </div>,那么文本结束的标记就为 < * PS:这也意味着如果你渲染了一个 <div> hell<o </div> 的标签,那么你将得到一个错误 const endTokens = ['<', '{{'] // 计算普通文本结束的位置 let endIndex = context.source.length // 计算精准的 endIndex,计算的逻辑为:从 context.source 中分别获取 '<', '{{' 的下标,取最小值为 endIndex for (let i = 0; i < endTokens.length; i++) { const index = context.source.indexOf(endTokens[i], 1) if (index !== -1 && endIndex > index) { endIndex = index // 获取处理的文本内容 const content = parseTextData(context, endIndex) return { type: NodeTypes.TEXT, content }解析文本的过程需要获取到文本内容,此时我们需要构建 parseTextData 方法:/** * 从指定位置(length)获取给定长度的文本数据。 function parseTextData(context: ParserContext, length: number): string { // 获取指定的文本数据 const rawText = context.source.slice(0, length) // 《继续》对模板进行解析处理 advanceBy(context, length) // 返回获取到的文本 return rawText }最后在 baseParse 中触发 parseChildren 方法:此时运行测试实例,打印出如下内容:4.3 生成 AST,测试当 parseChildren 处理完成之后,我们可以到 children,那么最后我们就只需要利用 createRoot 方法,把 children 放到 ROOT 节点之下即可。创建 createRoot 方法:/** * 生成 root 节点 export function createRoot(children) { return { type: NodeTypes.ROOT, children, // loc:位置,这个属性并不影响渲染,但是它必须存在,否则会报错。所以我们给了他一个 {} loc: {} }在 baseParse 中使用该方法:/** * 基础的 parse 方法,生成 AST * @param content tempalte 模板 * @returns export function baseParse(content: string) { // 创建 parser 对象,未解析器的上下文对象 const context = createParserContext(content) const children = parseChildren(context, []) return createRoot(children) }至此整个 parse 解析流程完成。我们可以在 packages/compiler-core/src/compile.ts 中打印得到的 AST:export function baseCompile(template: string, options) { const ast = baseParse(template) console.log(JSON.stringify(ast), ast) return {} }得到的内容为:{ "type": 0, "children": [ "type": 1, "tag": "div", "tagType": 0, "props": [], "children": [{ "type": 2, "content": " hello world " }] "loc": {} 我们可以把得到的该 AST 放入到 vue 的源码中进行解析,以此来验证是否正确。在 vue 源码的 packages/compiler-core/src/compile.ts 模块下 baseCompile 方法中:运行源码的 compile 方法,浏览器中依然可以渲染 hello world:<script> const { compile, h, render } = Vue // 创建 template const template = `<div>hello world</div>` // 生成 render 函数 const renderFn = compile(template) // 创建组件 const component = { render: renderFn // 通过 h 函数,生成 vnode const vnode = h(component) // 通过 render 函数渲染组件 render(vnode, document.querySelector('#app')) </script>成功运行,标记着我们的 AST 处理完成。5. 扩展知识:AST 到 JavaScript AST 的转换策略和注意事项
前言原文来自我的 个人博客从这一章开始我们进入到 compiler 编译器模块的实现。在实现 compiler 编译器模块之前,我们先来了解一下 vue 的编译时核心设计原则1. 初探 compiler 编译器编译器是一个非常复杂的概念,在很多语言中均有涉及。不同类型的编译器在实现技术上都会有较大的差异。比如你要实现一个 java 或者 JavaScript 的编译器,那就是一个非常复杂的过程了。但是对于我们而言,我们并不需要设计这种复杂的语言编辑器,我们只需要有一个 领域特定语言(DSL) 的编辑器即可。DSL 并不具备很强的普适性,它是仅为某个适用的领域而设计的,但它也足以用于表示这个领域中的问题以及构建对应的解决方案。我们这里所谓的特定语言指的就是:把 template 模板,编译成 render 函数。这个就是 vue 中 编译器 compiler 的作用。而这也是我们本章所要研究的内容,"vue 编译器是如何将 template 编译成 render 函数的?"明确好以上概念后,我们创建以下实例,以此来看一下 vue 中 compiler 的作用:<script> const { compile } = Vue const template = ` <div>hello world</div> const renderFn = compile(template) console.log(renderFn); </script>查看最终的打印结果可以发现,最终 compile 函数把 template 模板字符串转化为了 render 函数。那么我们可以借此来观察一下 compile 这个方法的内部实现。我们可以在源码packages/compiler-dom/src/index.ts 中的 第40行 查看到该方法。从代码中可以发现,compile 方法,其实是触发了 baseCompile 方法,那么我们可以进入到该方法。该方法的代码比较简单,剔除掉无用的内容之后,我们可以得到上图框框圈出的三块内容总结这段代码(complie),主要做了三件事情:通过 parse 方法进行解析,得到 AST通过 transform 方法对 AST 进行转化,得到 JavaScript AST通过 generate 方法根据 AST 生成 render 函数整体的代码解析,虽然比较清晰,但是里面涉及到的一些概念,我们可能并不了解。比如:什么是 AST?所以接下来我们先花费一些时间,来了解编译器中的一些基础知识,然后再去阅读对应的源码和实现具体的逻辑。2. 模板编译的核心流程我们知道,对于 vue 中的 compiler 而言,它的核心作用就是把 template模板 编译成 render 函数 ,那么在这样的一个编译过程中,它的一个具体流程是什么呢?从上一小节的源码中,我们可以看到 编译器 compiler 本身只是一段程序,它的作用就是:把 A 语言,编译成 B 语言。在这样的一个场景中 A 语言,我们把它叫做 源代码。而 B 语言,我们把它叫做 目标代码。整个的把源代码变为目标代码的过程,叫做 编译 compiler。一个完整的编译过程,非常复杂,下图大致的描述了完整的编译步骤。由图可知,一个完善的编译流程非常复杂。但是对于 vue 的 compiler 而言,因为他只是一个领域特定语言(DSL)编译器,所以它的一个编译流程会简化很多,如下图所示:由上图可知,整个的一个编译流程,被简化为了 4 步。其中的错误分析就包含了词法分析、语法分析。这个我们不需要过于关注。我们的关注点只需要放到 parse、transform、generate 中即可。3. 抽象语法树 AST通过上一小节的内容,我们可以知道,利用 parse 方法可以得到一个 AST ,那么这个 AST 是什么东西呢?这一小节我们就来说一下。抽象语法树(AST) 是一个用来描述模板的 JS 对象,我们以下面的模板为例:<div v-if="isShow"> <p class="m-title">hello world</p> </div>生成的 AST 为:{ "type": 0, "children": [ "type": 1, "ns": 0, "tag": "div", "tagType": 0, "props": [ "type": 7, "name": "if", "exp": { "type": 4, "content": "isShow", "isStatic": false, "isConstant": false, "loc": { "start": { "column": 12, "line": 1, "offset": 11 "end": { "column": 18, "line": 1, "offset": 17 "source": "isShow" "modifiers": [], "loc": { "start": { "column": 6, "line": 1, "offset": 5 "end": { "column": 19, "line": 1, "offset": 18 "source": "v-if=\"isShow\"" "isSelfClosing": false, "children": [ "type": 1, "ns": 0, "tag": "p", "tagType": 0, "props": [ "type": 6, "name": "class", "value": { "type": 2, "content": "m-title", "loc": { "start": { "column": 12, "line": 2, "offset": 31 "end": { "column": 21, "line": 2, "offset": 40 "source": "\"m-title\"" "loc": { "start": { "column": 6, "line": 2, "offset": 25 "end": { "column": 21, "line": 2, "offset": 40 "source": "class=\"m-title\"" "isSelfClosing": false, "children": [ "type": 2, "content": "hello world", "loc": { "start": { "column": 22, "line": 2, "offset": 41 "end": { "column": 33, "line": 2, "offset": 52 "source": "hello world" "loc": { "start": { "column": 3, "line": 2, "offset": 22 "end": { "column": 37, "line": 2, "offset": 56 "source": "<p class=\"m-title\">hello world</p>" "loc": { "start": { "column": 1, "line": 1, "offset": 0 "end": { "column": 7, "line": 3, "offset": 65 "source": "<div v-if=\"isShow\">\n <p class=\"m-title\">hello world</p> \n</div>" "helpers": [], "components": [], "directives": [], "hoists": [], "imports": [], "cached": 0, "temps": 0, "loc": { "start": { "column": 1, "line": 1, "offset": 0 "end": { "column": 1, "line": 4, "offset": 66 "source": "<div v-if=\"isShow\">\n <p class=\"m-title\">hello world</p> \n</div>\n" }对于以上这段 AST 而言,内部包含了一些关键属性,需要我们了解:如上图所示:type:这里的 type 对应一个 enum 类型的数据 NodeTypes,表示 当前节点类型。比如是一个 ELEMENT 还是一个 指令NodeTypes 可在 packages/compiler-core/src/ast.ts 中进行查看 25 行children:表示子节点loc:loction 内容的位置start:开始位置end:结束位置source:原值注意: 不同的 type 类型具有不同的属性值:NodeTypes.ROOT -- 0 :根节点必然包含一个 children 属性,表示对应的子节点NodeTypes.ELEMENT -- 1:DOM 节点tag:标签名称tagType:标签类型,对应 ElementTypesprops:标签属性,是一个数组NodeTypes.DIRECTIVE -- 7:指令节点 节点name:指令名modifiers:修饰符exp:表达式type:表达式的类型,对应 NodeTypes.SIMPLE_EXPRESSION, 共有如下类型:SIMPLE_EXPRESSION:简单的表达式COMPOUND_EXPRESSION:复合表达式JS_CALL_EXPRESSION:JS 调用表达式JS_OBJECT_EXPRESSION:JS 对象表达式JS_ARRAY_EXPRESSION:JS 数组表达式JS_FUNCTION_EXPRESSION:JS 函数表达式JS_CONDITIONAL_EXPRESSION:JS 条件表达式JS_CACHE_EXPRESSION:JS 缓存表达式JS_ASSIGNMENT_EXPRESSION:JS 赋值表达式JS_SEQUENCE_EXPRESSION:JS 序列表达式content:表达式的内容NodeTypes.ATTRIBUTE -- 6:属性节点1. `name`:属性名 2. `value`:属性值NodeTypes.TEXT -- 2:文本节点1. `content`:文本内容 总结:由以上的 AST 解析可知:所谓的 AST 抽象语法树本质上只是一个对象不同的属性下,有对应不同的选项,分别代表了不同的内容。每一个属性都详细描述了该属性的内容以及存在的位置指令的解析也包含在 AST 中所以我们可以说:AST 描述了一段 template 模板的所有内容 。4. AST 转化为 JavaScript AST,获取 codegenNode在上一小节中,我们大致了解了抽象语法树 AST 对应的概念。同时我们也知道,AST 最终会通过 transform 方法转化为 JavaScript AST。那么 JavaScript AST 又是什么样子的呢?我们知道:compiler 最终的目的是吧 template 转化为 render 函数。而整个过程分为三步:生成 AST将 AST 转化为 JavaScript AST根据 JavaScript AST 生成 render所以,生成 JavaScript AST 的目的就是为了最终生成渲染函数最准备的。我们以下面的模板为例:<div>hello world</div>在 vue 的源码中分别打印 AST 和 JavaScript AST,得到如下数据:1. AST "type": 0, "children": [ "type": 1, "ns": 0, "tag": "div", "tagType": 0, "props": [], "isSelfClosing": false, "children": [ "type": 2, "content": "hello world", "loc": { "start": { "column": 6, "line": 1, "offset": 5 }, "end": { "column": 17, "line": 1, "offset": 16 }, "source": "hello world" "loc": { "start": { "column": 1, "line": 1, "offset": 0 }, "end": { "column": 23, "line": 1, "offset": 22 }, "source": "<div>hello world</div>" "helpers": [], "components": [], "directives": [], "hoists": [], "imports": [], "cached": 0, "temps": 0, "loc": { "start": { "column": 1, "line": 1, "offset": 0 }, "end": { "column": 23, "line": 1, "offset": 22 }, "source": "<div>hello world</div>" 2. JavaScript AST{ "type": 0, "children": [ "type": 1, "ns": 0, "tag": "div", "tagType": 0, "props": [], "isSelfClosing": false, "children": [ "type": 2, "content": "hello world", "loc": { "start": { "column": 6, "line": 1, "offset": 5 }, "end": { "column": 17, "line": 1, "offset": 16 }, "source": "hello world" "loc": { "start": { "column": 1, "line": 1, "offset": 0 }, "end": { "column": 23, "line": 1, "offset": 22 }, "source": "<div>hello world</div>" "codegenNode": { "type": 13, "tag": "\"div\"", "children": { "type": 2, "content": "hello world", "loc": { "start": { "column": 6, "line": 1, "offset": 5 }, "end": { "column": 17, "line": 1, "offset": 16 }, "source": "hello world" "isBlock": true, "disableTracking": false, "isComponent": false, "loc": { "start": { "column": 1, "line": 1, "offset": 0 }, "end": { "column": 23, "line": 1, "offset": 22 }, "source": "<div>hello world</div>" "helpers": [xxx, xxx], "components": [], "directives": [], "hoists": [], "imports": [], "cached": 0, "temps": 0, "codegenNode": { "type": 13, "tag": "\"div\"", "children": { "type": 2, "content": "hello world", "loc": { "start": { "column": 6, "line": 1, "offset": 5 }, "end": { "column": 17, "line": 1, "offset": 16 }, "source": "hello world" "isBlock": true, "disableTracking": false, "isComponent": false, "loc": { "start": { "column": 1, "line": 1, "offset": 0 }, "end": { "column": 23, "line": 1, "offset": 22 }, "source": "<div>hello world</div>" "loc": { "start": { "column": 1, "line": 1, "offset": 0 }, "end": { "column": 23, "line": 1, "offset": 22 }, "source": "<div>hello world</div>" }由以上对比可以发现,对于 当前场景下 的 AST 与 JavaScript AST ,相差的就只有 codegenNode 这一个属性。那么这个 codegenNode 是什么呢?codegenNode 是 代码生成节点。根据我们之前所说的流程可知:JavaScript AST 的作用就是用来 生成 render 函数。那么生成 render 函数的关键,就是这个 codegenNode 节点。那么在这一小节我们知道了:AST 转化为 JavaScript AST 的目的是为了最终生成 render 函数而生成 render 函数的核心,就是多出来的 codegenNode 节点codegenNode 节点描述了如何生成 render 函数的详细内容5. JavaScript AST 生成 render 函数代码在上一小节我们已经成功了拿到了对应的 JavaScript AST,那么接下来我们就根据它生成对应的 render 函数。我们知道利用 render 函数可以完成对应的渲染,根据我们之前了解的规则,render 必须返回一个 vnode。例如,我们想要渲染这样的一个结构:<div>hello world</div>,那么可以构建这样的 render 函数:render() { return h('div', 'hello world') }我们可以直接创建如下测试实例,来打印最后生成的 render 函数:<script> const { compile, h, render } = Vue // 创建 template const template = `<div>hello world</div>` // 生成 render 函数 const renderFn = compile(template) // 打印 renderFn console.log(renderFn.toString()); // 创建组件 const component = { render: renderFn // 通过 h 函数,生成 vnode const vnode = h(component) // 通过 render 函数渲染组件 render(vnode, document.querySelector('#app')) </script> renderFn 的值为function render(_ctx, _cache) { with (_ctx) { const { openBlock: _openBlock, createElementBlock: _createElementBlock } = _Vue return (_openBlock(), _createElementBlock("div", null, "hello world")) }对于以上代码,存在一个 with 语法,这个语法是一个 不被推荐 的语法,我们无需太过于关注它,只需要知道它的作用即可:摘自:《JavaScript 高级程序设计》 with 语句的作用是:将代码的作用域设置到一个特定的对象中… 于大量使用with语句会导致性能下降,同时也会给调试代码造成困难,因此在开发大型应用程序时,不建议使用with语句。我们可以把该代码(render)略作改造,直接应用到 render 的渲染中:<script> const { compile, h, render } = Vue // 创建组件 const component = { render: function (_ctx, _cache) { with (_ctx) { const { openBlock: _openBlock, createElementBlock: _createElementBlock } = Vue // 把 _Vue 改为 Vue return (_openBlock(), _createElementBlock("div", null, "hello world")) // 通过 h 函数,生成 vnode const vnode = h(component) // 通过 render 函数渲染组件 render(vnode, document.querySelector('#app')) </script>发现可以得到与:render() { return h('div', 'hello world') }同样的结果。观察两个 render 可以发现:compiler 最终生成的 render 函数,与我们自己的写的 render 会略有区别。它会直接通过 createElementBlock 来渲染 块级元素 的方法,比 h 函数更加 “精确”同时这也意味着,生成的 render 函数会触发更精确的方法,比如:createTextVNodecreateCommentVNodecreateElementBlock…虽然,生成的 render 更加精确,但是本质的逻辑并没有改变,已然是一个:return vnode 进行 render 的过程。6. 总结整个 compiler 的过程,就是一个把:源代码(template)转化为目标代码(render 函数) 的过程。在这个过程中,主要经历了三个大的步骤:解析( parse ) template 模板,生成 AST转化(transform)AST,得到 JavaScript AST生成(generate)render 函数这三步是非常复杂的一个过程,内部的实现涉及到了非常复杂的计算方法,并且会涉及到一些我们现在还没有了解过得概念,比如:自动状态机。这些内容我们都会放到下一章在研究吧~ 本章我们只需要知道 compiler 的作用,以及三大步骤即可都在干什么即可。
theme: devui-bluehighlight: vs2015前言原文来自 我的个人博客webpack 作为前端目前使用最广泛的打包工具,在面试中也是经常会被问到的。比较常见的面试题包括:可以配置哪些属性来进行 webpack 性能优化?前端有哪些常见的性能优化?(除了其他常见的,也完全可以从 webpack 来回答)webpack 的性能优化比较多,我们可以对其进行分类:打包后的结果,上线时的性能优化。(比如分包处理、减小包体积、CDN服务器等)优化打包速度,开发或者构建时优化打包速度。(比如 exclude、cache-loader 等)大多数情况下,我们会更加侧重于 第一种,因为这对线上的产品影响更大。虽然在大多数情况下,webpack 都帮我们做好了该有的性能优化:比如配置 mode 为 production 或者 development 时,默认 webpack 的配置信息;但是我们也可以针对性的进行自己的项目优化;本章,就让我们来学习一下 webpack 性能优化的更多细节1. 代码分离 Code Spliting代码分离(Code Spliting) 是 webpack 一个非常重要的特性,它主要的目的是将代码剥离到不同的 bundle 中,之后我们可以按需加载,或者并行加载这些文件。什么意思呢?举个例子:当 没有使用代码分离 时:webpack 将项目中的所有代码都打包到 一个 index.js 文件中(假如这个文件有 10M)当我们在生产环境去访问页面时,浏览器必须得将这 10M 的 index.js 文件全部下载解析执行后页面才会开始渲染。假如此时的网速是 10M/s,那么光是去下载这个 index.js 文件会花去 1s 。(这 1s 中内页面是白屏的)在改动了部分代码第二次打包后,因为是全新的文件,浏览器又要重新下载一次当 使用代码分离 时:webpack 将项目中的所有代码都打包到是 多个 js 文件中(我们假设每个文件都为 1M)当我们在生产环境去访问页面时,此时浏览器将 1M 的 index.js 文件下载就只需要 0.1s 了,至于其它的文件,可以选择需要用到它们时候加载或者和 index.js 文件并行的下载在改动了部分代码第二次打包后,浏览器可以值下载改动过的代码文件,对于没改动过的文件可以直接从缓存中拿去。通过以上的例子,相信大家应该能理解 代码分离 的好处了,那么在 webpack 如何能实现代码分离呢?webpack 常用的代码分离方式有三种:入口起点:使用 entry 配置手动分离代码;防止重复:使用 EntryDependencies 或者 SplitChunksPlugin 去重和分离代码:动态导入:通过模块的内联函数用来分离代码1.1 方式一:多入口起点这是迄今为止最简单直观的分离代码的方式。不过,这种方式手动配置较多,并有一些隐患,我们将会解决这些问题。先来看看如何从 main bundle 中分离 another module(另一个模块)1.1.1 没有代码分离时创建一个小的 demo:首先我们创建一个目录,初始化 npm,然后在本地安装 webpack、webpack-cli、loadshmkdir webpack-demo cd webpack-demo npm init -y npm install webpack webpack-cli lodash --save-dev创建 src/index.js:import _ from "lodash"; console.log(_);创建 src/another-module.js:import _ from 'lodash'; console.log(_);创建 webpack.config.js:const path = require("path"); module.exports = { mode: "development", entry: "./src/index.js", output: { path: path.resolve(__dirname, "dist"), filename: "main.js", };在 package.json 中添加命令:"scripts": { "build": "webpack" },执行命令进行打包:npm run build生成如下构建结果:可以看到此时生成了一个 554KB 的 main.js 文件1.1.2 有代码分离时接下来我们从 main bundle 中分离出 another module(另一个模块)修改 webpack.config.jsconst path = require("path"); module.exports = { mode: "development", - entry: './src/index', + entry: { + index: './src/index', + another: './src/another-module.js' + }, output: { path: path.resolve(__dirname, "dist"), - filename: "main.js", + filename: "[name].main.js", };打包,生成如下构建结果:我们发现此时已经成功打包出 another.bundle.js 和 index.bundle.js 两个文件了,但是文件的大小似乎有些问题,怎么两个都是 554KB?正如前面提到的,这种方式存在一些隐患:如果入口 chunk 之间包含一些重复的模块,那些重复模块都会被引入到各个 bundle 中。这种方法不够灵活,并且不能动态地将核心应用程序逻辑中的代码拆分出来。以上两点中,第一点对我们的示例来说无疑是个问题,因为之前我们在 ./src/index.js 中也引入过 lodash,这样就在两个 bundle 中造成重复引用。在下一小节我们将移除重复的模块。1.1.3 优化:移除重复的模块在通过多入口分离代码的方式中,我们可以通过配置 dependOn 这个选项来解决重复模块的问题,它的原理就是从两个文件中抽出一个共享的模块,然后再让这两个模块依赖这个共享模块。修改 webpack.config.js 配置文件: const path = require('path'); module.exports = { mode: 'development', entry: { - index: './src/index.js', - another: './src/another-module.js', + index: { + import: './src/index.js', + dependOn: 'shared', + }, + another: { + import: './src/another-module.js', + dependOn: 'shared', + }, + shared: ['lodash'], output: { filename: '[name].bundle.js', path: path.resolve(__dirname, 'dist'), };打包,生成如下构建结果:可以看到 index.mian.js 和 another.mian.js 中重复引用的部分被抽离成了 shared.main.js 文件,且 index.mian.js 和 another.mian.js 文件大小也变小了。1.2 方式二:splitChunks 模式另外一种分包的模式是 splitChunks,它底层是使用 SplitChunksPlugin 来实现的:SplitChunksPlugin 插件可以将公共的依赖模块提取到已有的入口 chunk 中,或者提取到一个新生成的 chunk。因为该插件 webpack 已经默认安装和集成,所以我们并 不需要单独安装和直接使用该插件;只需要提供 SplitChunksPlugin 相关的配置信息即可webpack 提供了 SplitChunksPlugin 默认的配置,我们也可以手动来修改它的配置:比如默认配置中,chunks 仅仅针对于异步(async)请求,我们可以设置为 initial 或者 all ,1.2.1 splitChunk 的配置在 1.1.2 的基础上修改 webpack.cofig.js: const path = require('path'); module.exports = { mode: 'development', entry: { index: './src/index.js', another: './src/another-module.js', output: { filename: '[name].bundle.js', path: path.resolve(__dirname, 'dist'), + optimization: { + splitChunks: { + chunks: 'all', + }, + }, };打包,生成如下构建结果:使用 optimization.splitChunks 配置选项之后,现在应该可以看出,index.bundle.js 和 another.bundle.js 中已经移除了重复的依赖模块。需要注意的是,插件将 lodash 分离到单独的 chunk,并且将其从 main bundle 中移除,减轻了大小。除了 webpack 默认继承的 SplitChunksPlugin 插件,社区中也有提供一些对于代码分离很有帮助的 plugin 和 loader,比如:mini-css-extract-plugin: 用于将 CSS 从主应用程序中分离。1.2.2 SplitChunks 自定义配置解析关于 optimization.splitChunks 文档上有很详细的记载,我这里讲你叫几个常用的:1. Chunks:默认值是 async另一个值是 initial,表示对通过的代码进行处理all 表示对同步和异步代码都进行处理2. minSize :拆分包的大小, 至少为 `minSize;如果一个包拆分出来达不到 minSize ,那么这个包就不会拆分;3. maxSize:将大于maxSize的包,拆分为不小于minSize的包;4. cacheGroups:用于对拆分的包就行分组,比如一个 lodash 在拆分之后,并不会立即打包,而是会等到有没有其他符合规则的包一起来打包;test 属性:匹配符合规则的包;name 属性:拆分包的 name 属性;filename 属性:拆分包的名称,可以自己使用 placeholder 属性;修改 webpack.config.jsconst path = require("path"); module.exports = { mode: "development", entry: { index: "./src/index.js", another: "./src/another-module.js", output: { filename: "[name].bundle.js", path: path.resolve(__dirname, "dist"), optimization: { splitChunks: { chunks: "all", // 拆分包的最小体积 // 如果一个包拆分出来达不到 minSize,那么这个包就不会拆分(会被合并到其他包中) minSize: 100, // 将大于 maxSize 的包,拆分成不小于 minSize 的包 maxSize: 10000, // 自己对需要拆包的内容进行分组 cacheGroups: { 自定义模块的name: { test: /node_modules/, filename: "[name]_vendors.js", };打包,生成如下构建结果:1.3 方式三:动态导入(dynamic import)另外一个代码拆分的方式是动态导入时,webpack 提供了两种实现动态导入的方式:第一种,使用 ECMAScript 中的 import() 语法来完成,也是目前推荐的方式;第二种,使用 webpack 遗留的 require.ensure,目前已经不推荐使用;动态 import 使用最多的一个场景是懒加载(比如路由懒加载)1.3.1 import 方式接着从 1.1.2 小节代码的基础上修改:修改 webpack.confg.js:const path = require("path"); module.exports = { entry: "./src/index.js", mode: "development", entry: { index: "./src/index.js", output: { filename: "[name].bundle.js", path: path.resolve(__dirname, "dist"), };删除 src/another-module.js 文件修改 src/index.js,不再使用 statically import (静态导入) lodash,而是通过 dynamic import(动态导入) 来分离出一个 chunk:const logLodash = function () { import("lodash").then(({ default: _ }) => { console.log(_); logLodash();之所以需要 default,是因为 webpack 4 在导入 CommonJS 模块时,将不再解析为 module.exports 的值,而是为 CommonJS 模块创建一个 artificial namespace 对象。打包,生成如下构建结果:由于 import() 会返回一个 promise,因此它可以和 async 函数一起使用。下面是如何通过 async 函数简化代码:const logLodash = async function () { const { default: _ } = await import("lodash"); console.log(_); logLodash();1.3.2 动态导入的文件命名因为动态导入通常是一定会打包成独立的文件的,所以并不会再 cacheGroups 中进行配置;它的命名我们通常会在 output 中,通过 chunkFilename 属性来命名:修改 webpack.config.jsconst path = require("path"); module.exports = { entry: "./src/index.js", mode: "development", entry: { index: "./src/index.js", output: { filename: "[name].bundle.js", path: path.resolve(__dirname, "dist"), + chunkFilename: "chunk_[name].js"" 打包构建:如果对打包后的 [name] 不满意,还可以通过 magic comments(魔法注释)来修改:1, 修改 src/index.js:const logLodash = async function () { const { default: _ } = await import(/*webpackChunkName: 'lodash'*/ "lodash"); console.log(_); logLodash();打包构建1.4 CDN 加速CDN 称之为 内容分发网络(Content Delivery Network 或 Content Distribution Network,缩写:CDN)它是指通过相互连接的网络系统,利用最靠近每个用户的服务器;更快、更可靠地将音乐、图片、视频、应用程序及其他文件发送给用户;来提供高性能、可扩展性及低成本的网络内容传递给用户;在开发中,我们使用 CDN 主要是两种方式:方式一:打包的所有静态资源,放到 CDN 服务器,用户所有资源都是通过 CDN 服务器加载的;方式二:一些第三方资源放到 CDN 服务器上;1.4.1 配置自己的 CDN 服务器如果所有的静态资源都想要放到 CDN 服务器上,我们需要购买自己的 CDN 服务器;目前阿里、腾讯、亚马逊、Google 等都可以购买 CDN 服务器;我们可以直接修改 publicPath,在打包时添加上自己的 CDN 地址;在 1.3.1 的基础上安装 HtmlWebpackPlugin 插件:npm install --save-dev html-webpack-plugin修改 webpack.config.js 文件:const path = require("path"); const HtmlWebpackPlugin = require("html-webpack-plugin"); module.exports = { entry: "./src/index.js", mode: "development", entry: { index: "./src/index.js", output: { filename: "[name].bundle.js", path: path.resolve(__dirname, "dist"), chunkFilename: "chunk_[name].js", + publicPath: "https://yejiwei.com/cdn/", plugins: [new HtmlWebpackPlugin()], };打包构建可以发现我们打包后的 script 标签自动添加了 CDN 服务器地址的前缀。1.4.2 配置第三方库的CDN服务器通常一些比较出名的开源框架都会将打包后的源码放到一些比较出名的、免费的 CDN 服务器上:国际上使用比较多的是 unpkg、JSDelivr、cdnjs;国内也有一个比较好用的 CDN 是 bootcdn ;在项目中,我们如何去引入这些 CDN 呢?第一,在打包的时候我们不再需要对类似于 lodash 或者 dayjs 这些库进行打包;第二,在 html 模块中,我们需要自己加入对应的 CDN 服务器地址;创建 public/index.html 模版,手动加上对应 CDN 服务器地址<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Document</title> <script src="https://cdn.bootcdn.net/ajax/libs/lodash.js/4.17.21/lodash.core.min.js"></script> </head> <body></body> </html>在 1.3.1 的基础上修改 webpack.config.js配置,来排除一些库的打包并配置 html 模版:const path = require("path"); const HtmlWebpackPlugin = require("html-webpack-plugin"); module.exports = { entry: "./src/index.js", mode: "development", entry: { index: "./src/index.js", output: { filename: "[name].bundle.js", path: path.resolve(__dirname, "dist"), chunkFilename: "chunk_[name].js", plugins: [ new HtmlWebpackPlugin({ + template: "./public/index.html", + externals: { + lodash: "_", 打包构建1.5 补充以下补充了解即可(一些细节)1.5.1 解决注释的单独提取如果将 webpack.config.js 的 mode 改为 production 也就是生产环境时,经常会看到一写 .txt 后缀的注释文件这是因为在 production 默认情况下,webpack 再进行分包时,有对包中的注释进行单独提取。这个包提取是由另一个插件(TerserPlugin 后面会细说) 默认配置的原因,如果想去掉可以做以下配置:1.5.2 chunkIds 的生成方式optimization.chunkIds 配置用于告知 webpack 模块的 id 采用什么算法生成。有三个比较常见的值:natural:按照数字的顺序使用 id;named:development下 的默认值,一个可读的(你能看的懂得)名称的 id;deterministic:确定性的,在不同的编译中不变的短数字 id在 webpack4 中是没有这个值的;那个时候如果使用 natural,那么在一些编译发生变化时,就需要重新进行打包就会有问题;最佳实践:开发过程中,我们推荐使用 named;打包过程中,我们推荐使用 deterministic;1.5.3. runtimeChunk 的配置配置 runtime 相关的代码是否抽取到一个单独的 chunk 中:runtime 相关的代码指的是在运行环境中,对模块进行解析、加载、模块信息相关的代码;比如我们的 index 中通过 import 函数相关的代码加载,就是通过 runtime 代码完成的;抽离出来后,有利于浏览器缓存的策略:比如我们修改了业务代码(main),那么 runtime 和 component、bar的 chunk 是不需要重新加载的;比如我们修改了 component、bar 的代码,那么 main 中的代码是不需要重新加载的;设置的值:true/multiple:针对每个入口打包一个 runtime 文件;single:打包一个 runtime 文件;对象:name 属性决定 runtimeChunk 的名称;对于每个 runtime chunk,导入的模块会被分别初始化,因此如果你在同一个页面中引用多个入口起点,请注意此行为。你或许应该将其设置为 single,或者使用其他只有一个 runtime 实例的配置。1.5.4. Prefetch 和 Preloadwebpack v4.6.0+ 增加了对预获取和预加载的支持。在声明 import 时,使用下面这些内置指令,来告知浏览器:prefetch(预获取):将来某些导航下可能需要的资源preload(预加载):当前导航下可能需要资源与 prefetch 指令相比,preload 指令有许多不同之处:preload chunk 会在父 chunk 加载时,以并行方式开始加载。prefetch chunk 会在父 chunk 加载结束后开始加载。preload chunk 具有中等优先级,并立即下载。prefetch chunk 在浏览器闲置时下载。preload chunk 会在父 chunk 中立即请求,用于当下时刻。prefetch chunk 会用于未来的某个时刻。推荐使用 prefetch ,因为它是在未来闲置的时候下载,有些东西是不需要立即下载的,这样做不会因为请求不重要的资源而占用网络带宽。2. Shimming 预制依赖shimming 是一个概念,是某一类功能的统称:翻译过来我们称之为 垫片,相当于给我们的代码填充一些垫片来处理一些问题;比如我们现在依赖一个第三方的库,这个第三方的库本身依赖 lodash ,但是默认没有对 lodash 进行导入(认为全局存在 lodash),那么我们就可以通过 ProvidePlugin 来实现 shimming 的效果;注意:webpack 并不推荐随意的使用 shimming。Webpack 背后的整个理念是使前端开发更加模块化;也就是说,需要编写具有封闭性的、不存在隐含依赖(比如全局变量)的彼此隔离的模块;2.1 Shimming 预支全局变量假如一个文件中我们使用了 axios,但是没有对它进行引入,那么下面的代码是会报错的;axios.get('XXXXX').then(res => { console.log(res) get('XXXXX').then(res => { console.log(res) })我们可以通过使用 ProvidePlugin 来实现 shimming 的效果:修改 webpack.config.js:new ProvidePlugin({ axios: 'axios', get: ['axios','get'] })ProvidePlugin 能够帮助我们在每个模块中,通过一个变量来获取一个 package;如果 webpack 看到这个模块,它将在最终的 bundle 中引入这个模块;另外 ProvidePlugin 是 webpack默认的一个插件,所以不需要专门导入;这段代码的本质是告诉webpack: 如果你遇到了至少一处用到 axios 变量的模块实例,那请你将 axios package 引入进来,并将其提供给需要用到它的模块。3. TerserPlugin 代码压缩在了解 TerserPlugin 插件前,我们先来认识一下什么是 Terser 。3.1 Terser 介绍什么是 Terser 呢?Terser 是一个 JavaScript 的解释(Parser)、Mangler(绞肉机)/ Compressor(压缩机)的工具集;早期我们会使用 uglify-js 来压缩、丑化我们的 JavaScript 代码,但是目前已经不再维护,并且不支持 ES6+ 的语法;Terser 是从 uglify-es fork 过来的,并且保留它原来的大部分 API 以及适配 uglify-es 和 uglify-js@3 等;也就是说,Terser 可以帮助我们压缩、丑化我们的代码,让我们的 bundle 变得更小。我们现在就来用一下 Terser,因为 Terser 是一个独立的工具,所以它可以单独安装:# 全局安装 npm install terser -g # 局部安装 npm install terser -D可以在命令行中使用 Terser:terser [input files] [options] # 举例说明 terser js/file1.js -o foo.min.js -c -m我们这里来讲解几个 Compress option 和 Mangle(乱砍) option:Compress option:arrows:class或者object中的函数,转换成箭头函数;arguments:将函数中使用 arguments[index]转成对应的形参名称;dead_code:移除不可达的代码(tree shaking);Mangle option:toplevel:默认值是false,顶层作用域中的变量名称,进行丑化(转换);keep_classnames:默认值是false,是否保持依赖的类名称;keep_fnames:默认值是false,是否保持原来的函数名称;3.2 Terser 在 webpack 中配置(JS 的压缩)真实开发中,我们不需要手动的通过 terser 来处理我们的代码,我们可以直接通过 webpack 来处理:在 webpack 中有一个 minimizer 属性,在 production 模式下,默认就是使用TerserPlugin 来处理我们的代码的;如果我们对默认的配置不满意,也可以自己来创建 TerserPlugin 的实例,并且覆盖相关的配置;修改 webpack.config.js 配置:const TerserPlugin = require("terser-webpack-plugin"); optimization: { // 打开minimize,让其对我们的代码进行压缩(默认production模式下已经打 minimize: true, minimizer: [ new TerserPlugin({ // extractComments:默认值为true,表示会将注释抽取到一个单独的文件中; // 在开发中,我们不希望保留这个注释时,可以设置为false; extractComments: false, // parallel:使用多进程并发运行提高构建的速度,默认值是true // 并发运行的默认数量: os.cpus().length - 1; // 我们也可以设置自己的个数,但是使用默认值即可; // parallel: true, // terserOptions:设置我们的terser相关的配置 terserOptions: { // 设置压缩相关的选项; compress: { unused: false, // 设置丑化相关的选项,可以直接设置为true; mangle: true, // 顶层变量是否进行转换; toplevel: true, // 保留类的名称; keep_classnames: true, // 保留函数的名称; keep_fnames: true, },3.3 CSS 的压缩上面我们讲了 JS 的代码压缩,而在我们的前端项目中另一类占大头的代码就是 CSS :CSS 压缩通常是去除无用的空格等,因为很难去修改选择器、属性的名称、值等;CSS 的压缩我们可以使用另外一个插件:css-minimizer-webpack-plugin;css-minimizer-webpack-plugin 是使用 cssnano 工具来优化、压缩 CSS(也可以单独使用);安装 css-minimizer-webpack-plugin:npm install css-minimizer-webpack-plugin -D在 optimization.minimizer 中配置:4. Tree Shaking什么是 Tree Shaking ?Tree Shaking 是一个术语,在计算机中表示消除死代码(dead_code);最早的想法起源于 LISP,用于消除未调用的代码(纯函数无副作用,可以放心的消除,这也是为什么要求我们在进行函数式编程时,尽量使用纯函数的原因之一);后来 Tree Shaking 也被应用于其他的语言,比如 JavaScript、Dart;JavaScript 的 Tree Shaking:对 JavaScript 进行 Tree Shaking 是源自打包工具 rollup;这是因为 Tree Shaking 依赖于 ES Module 的静态语法分析(不执行任何的代码,可以明确知道模块的依赖关系);webpack2 正式内置支持了 ES2015 模块,和检测未使用模块的能力;在 webpack4 正式扩展了这个能力,并且通过 package.json 的 sideEffects 属性作为标记,告知 webpack 在编译时,哪里文件可以安全的删除掉;webpack5 中,也提供了对部分 CommonJS 的 tree shaking 的支持;✓ https://github.com/webpack/changelog-v5#commonjs-tree-shaking4.1 webpack 实现 Tree Shakingwebpack 实现 Tree Shaking 采用了两种不同的方案:usedExports:通过标记某些函数是否被使用,之后通过 Terser 来进行优化的;sideEffects:跳过整个模块/文件,直接查看该文件是否有副作用;usedExports 按 sideEffects 这两个东西的优化是不同的事情。引用官方文档的话: The sideEffects and usedExports(more konwn as tree shaking)optimizations are two different things下面我们分别来演示一下这两个属性的使用4.1.1 usedExports新建一个 webpack-demo。mkdir webpack-demo cd webpack-demo npm init -y npm install webpack webpack-cli lodash --save-dev创建 src/math.js 文件:export const add = (num1, num2) => num1 + num2; export const sub = (num1, num2) => num1 - num2;在这个问价中仅是导出了两个函数方法创建 src/index.js 文件:、import { add, sub } from "./math"; console.log(add(1, 2));在 index.js 中 导入了刚刚创建的两个函数,但是只使用了 add配置 webpack.config.js:const path = require("path"); module.exports = { mode: "development", devtool: false, entry: "./src/index.js", output: { path: path.resolve(__dirname, "dist"), filename: "main.js", optimization: { usedExports: true, };为了可以看到 usedExports 带来的效果,我们需要设置为 development 模式。因为在 production 模式下,webpack 默认的一些优化会带来很大的影响。设置 usedExports 为 true 和 false 对比打包后的代码:仔细观察上面两张图可以发现当设置 usedExports: true 时,sub 函数没有导出了,另外会多出一段注释:unused harmony export mul;这段注释的意义是会告知 Terser 在优化时,可以删除掉这段代码。这个时候,我们将 minimize 设置 true:usedExports 设置为 false 时,sub 函数没有被移除掉;usedExports 设置为 true 时,sub 函数有被移除掉;所以,usedExports 实现 Tree Shaking 是结合 Terser 来完成的。4.1.2 sideEffects在一个纯粹的 ESM 模块世界中,很容易识别出哪些文件有副作用。然而,我们的项目无法达到这种纯度,所以,此时有必要提示 webpack compiler 哪些代码是“纯粹部分”。通过 package.json 的 "sideEffects" 属性,来实现这种方式。{ "name": "your-project", "sideEffects": false }如果所有代码都不包含副作用,我们就可以简单地将该属性标记为 false,来告知 webpack 它可以安全地删除未用到的 export。"side effect(副作用)" 的定义是,在导入时会执行特殊行为的代码,而不是仅仅暴露一个 export 或多个 export。举例说明,例如 polyfill,它影响全局作用域,并且通常不提供 export。如果你的代码确实有一些副作用,可以改为提供一个数组:{ "name": "your-project", "sideEffects": ["./src/some-side-effectful-file.js"] }注意,所有导入文件都会受到 tree shaking 的影响。这意味着,如果在项目中使用类似 css-loader 并 import 一个 CSS 文件,则需要将其添加到 side effect 列表中,以免在生产模式中无意中将它删除:{ "name": "your-project", "sideEffects": ["./src/some-side-effectful-file.js", "*.css"] }4.2 CSS 实现 Tree Shaking上面将的都是关于 JavaScript 的 Tree Shaking ,对于 CSS 同样有对应的 Tree Shaking 操作。在早期的时候,我们会使用 PurifyCss 插件来完成 CSS 的 tree shaking,但是目前该库已经不再维护了(最新更新也是在 4 年前了);目前我们可以使用另外一个库来完成 CSS 的 Tree Shaking:PurgeCSS,也是一个帮助我们删除未使用的 CSS 的工具;安装 PurgeCss 的 webpack 插件:npm install purgecss-webpack-plugin -D在 webpack.config.js 中配置 PurgeCssnew PurgeCSSPlugin({ paths: glob.sync(`${path.resolve(__dirname, '../src')}/**/*`, { nodir: true }), only: ['bundle', 'vendor'] })paths:表示要检测哪些目录下的内容需要被分析,这里我们可以使用 glob;默认情况下,Purgecss 会将我们的 html 标签的样式移除掉,如果我们希望保留,可以添加一个 safelist 的属性;purgecss 也可以对 less、sass文件进行处理(它是对打包后的 css 进行 tree shaking 操作);4.3 Scope HoistingScope Hoisting 是从 webpack3 开始增加的一个新功能,它的功能是对作用域进行提升,并且让 webpack 打包后的代码更小、运行更快;默认情况下 webpack 打包会有很多的函数作用域,包括一些(比如最外层的)IIFE:无论是从最开始的代码运行,还是加载一个模块,都需要执行一系列的函数Scope Hoisting 可以将函数合并到一个模块中来运行;(作用域提升,在主模块里直接运行它,而不是去加载一些单独的模块)使用 Scope Hoisting 非常的简单,webpack 已经内置了对应的模块:在 production 模式下,默认这个模块就会启用;在 development 模式下,我们需要自己来打开该模块;new webpack.optimize.ModuleConcatenationPlugin()5. webpack 对文件压缩经过前几小节的代码压缩优化(Tree Shaking 的优化、Terser 的优化、CSS 压缩的优化),基本上已经没有什么可以通过删除一些代码再压缩文件的方法了(变量、空格、换行符、注释、没用的代码都已经处理了)但是我们还有一种通过压缩算法从对文件压缩的方式来继续减小包的体积(就像在 winodows 将文件夹压缩成 zip 一样,只不过我们这里是对单个js文件进行压缩)目前的压缩格式非常的多:compress – UNIX 的 “compress” 程序的方法(历史性原因,不推荐大多数应用使用,应该使用 gzip或 deflate);deflate – 基于 deflate 算法(定义于RFC 1951)的压缩,使用 zlib 数据格式封装;gzip – GNU zip 格式(定义于RFC 1952),是目前使用比较广泛的压缩算法;br – 一种新的开源压缩算法,专为 HTTP 内容的编码而设计;在 webpack 中的配置:安装 CompressionPluginnpm install compression-webpack-plugin -D配置 webpack.config.js:new CompressionPlugin({ test: /].(css|js)$/, // 匹配哪些文件需要压缩 // threshold: 500, // 设置文件从多大开始压缩 minRatio: 0.7, // 至少的压缩比例 algorithm: "gzip, // 才用的压缩算法 // include // exclude })6. HTML 文件中代码的压缩我们之前使用了 HtmlWebpackPlugin 插件来生成 HTML 的模板,事实上它还有一些其他的配置:inject:设置打包的资源插入的位置true、 false 、body、headcache:设置为true,只有当文件改变时,才会生成新的文件(默认值也是true)minify:默认会使用一个插件html-minifier-terser
前言在实现了 ELEMENT、COMMENT、TEXT 节点的挂载后,我们最后再来实现一下组件的挂载与更新开始实现组件之前,我们需要明确 vue 中一些关于组件的基本概念:组件本身是一个对象(仅考虑对象的情况,忽略函数式组件)。它必须包含一个 render 函数,该函数决定了它的渲染内容。如果我们想要定义数据,那么需要通过 data 选项进行注册。data 选项应该是一个 函数,并且 renturn 一个对象,对象中包含了所有的响应性数据。除此之外,我们还可以定义例如 生命周期、计算属性、watch 等对应内容。1. 无状态组件的挂载Vue 中通常把 状态 比作 数据 的意思。我们所谓的无状态,指的就是 无数据 的意思。我们先定一个小目标,本小节 仅关注无状态组件的处理逻辑 。创建以下测试实例:<script> const { h, render } = Vue const component = { render() { return h('div', 'hello component') const vnode = h(component) // 挂载 render(vnode, document.querySelector('#app')) </script>上面的代码很简单:使用 h 函数 生成组件的 vnode使用 render 函数将组件 挂载到 dom 上下面我们先从 vue 源码中分析它是如何处理的:1.1 源码阅读根据之前的经验,我们知道 vue 的渲染逻辑,都会从 render 函数进入 patch 函数,所以我们可以直接在 patch 函数中进入 debugger:可以看到在 patch 方法中,最终会进入到 processComponent 方法去:接着在 processComponent 方法中,代码最终会进入到 mountComponent 方法去挂载组件,我们进入到 mountComponent 中:在 mountComponent 中代码会先执行 createComponentInstance 方法创建 instance,这个 instance 就是组件实例const instance: ComponentInternalInstance = { ... }通过以上代码最终 生成了 component 组件实例,并且把 组件实例绑定到了 vnode.component 中 ,即:initialVNode.component = instance = 组件实例,接下来我们从 createComponentInstance 方法返回到 mountComponent 方法中执行代码:根据上图可知在 setupComponent 方法中执行了 setupStatefulComponent(instance, isSSR),我们进入 setupStatefulComponent 方法:在 setupStatefulComponent 方法中最终会执行 finishComponentSetup 方法,我们进入 finishComponentSetup 方法:可以看到,在 finishComponentSetup 方法中,最终使 instance 具备了 render 属性。我们目前只关注渲染的逻辑,接着 setupStatefulComponent 会返回到 setupComponent,setupComponent 再返回到 mountComponent 方法继续执行:我们总结一下上图的逻辑,在 mountComponent 方法中程序接着会进入到一个 很重要 的方法: setupRenderEffect。这个方法内部主要做了以下几件事:定义了一个更新函数 componentUpdateFn创建了一个 ReactiveEffect 实例将 update 和 instance.update 都绑定到一个匿名函数,而这个函数就是用来执行上面的 componentUpdateFn 函数。最后执行 update 函数。'我们现在进入 componentUpdateFn 看看里面到底执行了什么:根据上图可知当前 instance.isMounted === false 表示组件没挂载。会执行 patch 方法进行挂载操作,而这个 patch 方法我们也很熟悉了。它是一个 打补丁 函数,我们知道对于 patch 函数来说,第一个参数是 n1,第二个参数是 n2,此时的 n1 为 null,当第一个函数为 null 时就会去挂载 n2,而此时的 n2(subTree) 又是什么呢?往上翻一下代码,我们找到了 subTree 的创建:根据上图我们知道了 subTree 实际上就是 render 调用返回的 vnode,最终执行 patch 函数将 vnode 挂载到 dom 上去,patch 的逻辑就不在过了,之前过很多遍了。至此,我们的组件 挂载成功。总结:重新捋一遍整个组件的挂载过程首先整个组件的挂载开始于 mountComponent 方法在 mountComponent 方法的内部会通过 createComponentInstance 得到一个组件的实例。组件的实例会和 vnode 进行一个双向绑定的关系。vnode.component = instance instance.vnode = initialVNode接着,代码执行 setupComponent,在这里会初始化 prop slot 等等属性。对于我们当前测试实例而言,最重要的就是执行了 setupStatefulComponent 方法为 instance.render 赋值接着执行 setupRenderEffect 方法,在 setupRenderEffect 中创建了一个 ReactiveEffect 对象,利用 update 方法 触发了 componentUpdateFn 方法在 componentUpdateFn 方法中,根据当前的状态 isMounted,生成了 subTree。subTree 本质上就是 render 函数生成的 vnode,最后通过 patch 函数进行挂载1.2 代码实现明确好了源码的无状态组件挂载之后,那么接下来我们来进行一下对应实现。在 packages/runtime-core/src/renderer.ts 的 patch 方法中,创建 processComponent 的触发:else if (shapeFlag & ShapeFlags.COMPONENT) { // 组件 processComponent(oldVNode, newVNode, container, anchor) }创建 processComponent 函数:/** * 组件的打补丁操作 const processComponent = (oldVNode, newVNode, container, anchor) => { if (oldVNode == null) { // 挂载 mountComponent(newVNode, container, anchor) }创建 mountComponent 方法:const mountComponent = (initialVNode, container, anchor) => { // 生成组件实例 initialVNode.component = createComponentInstance(initialVNode) // 浅拷贝,绑定同一块内存空间 const instance = initialVNode.component // 标准化组件实例数据 setupComponent(instance) // 设置组件渲染 setupRenderEffect(instance, initialVNode, container, anchor) }创建 packages/runtime-core/src/component.ts 模块,构建 createComponentInstance 函数逻辑:\let uid = 0 * 创建组件实例 export function createComponentInstance(vnode) { const type = vnode.type const instance = { uid: uid++, // 唯一标记 vnode, // 虚拟节点 type, // 组件类型 subTree: null!, // render 函数的返回值 effect: null!, // ReactiveEffect 实例 update: null!, // update 函数,触发 effect.run render: null // 组件内的 render 函数 return instance 在 packages/runtime-core/src/component.ts 模块,创建 setupComponent 函数逻辑:/** * 规范化组件实例数据 export function setupComponent(instance) { // 为 render 赋值 const setupResult = setupStatefulComponent(instance) return setupResult function setupStatefulComponent(instance) { finishComponentSetup(instance) export function finishComponentSetup(instance) { const Component = instance.type instance.render = Component.render }在 packages/runtime-core/src/renderer.ts 中,创建 setupRenderEffect 函数:/** * 设置组件渲染 const setupRenderEffect = (instance, initialVNode, container, anchor) => { // 组件挂载和更新的方法 const componentUpdateFn = () => { // 当前处于 mounted 之前,即执行 挂载 逻辑 if (!instance.isMounted) { // 从 render 中获取需要渲染的内容 const subTree = (instance.subTree = renderComponentRoot(instance)) // 通过 patch 对 subTree,进行打补丁。即:渲染组件 patch(null, subTree, container, anchor) // 把组件根节点的 el,作为组件的 el initialVNode.el = subTree.el } else { // 创建包含 scheduler 的 effect 实例 const effect = (instance.effect = new ReactiveEffect( componentUpdateFn, () => queuePreFlushCb(update) // 生成 update 函数 const update = (instance.update = () => effect.run()) // 触发 update 函数,本质上触发的是 componentUpdateFn update() }创建 packages/runtime-core/src/componentRenderUtils.ts 模块,构建 renderComponentRoot 函数: import { ShapeFlags } from 'packages/shared/src/shapeFlags' * 解析 render 函数的返回值 export function renderComponentRoot(instance) { const { vnode, render } = instance let result try { // 解析到状态组件 if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) { // 获取到 result 返回值 result = normalizeVNode(render!()) } catch (err) { console.error(err) return result * 标准化 VNode export function normalizeVNode(child) { if (typeof child === 'object') { return cloneIfMounted(child) * clone VNode export function cloneIfMounted(child) { return child 至此代码完成。创建 packages/vue/examples/runtime/render-component.html 测试实例:<script> const { h, render } = Vue const component = { render() { return h('div', 'hello component') const vnode = h(component) // 挂载 render(vnode, document.querySelector('#app')) </script>此时,组件渲染完成。2 无状态组件的更新与卸载此时我们的无状态组件挂载已经完成,接下来我们来看一下 无状态组件更新 的处理逻辑。创建测试实例:<script> const { h, render } = Vue const component = { render() { return h('div', 'hello component') const vnode = h(component) render(vnode, document.querySelector('#app')) setTimeout(() => { const component2 = { render() { return h('div', 'update component') const vnode2 = h(component2) render(vnode2, document.querySelector('#app')) }, 2000); </script>2.1 源码阅读在 render 中进入 debugger:第一次 进入 render 方法,执行组件挂载,这里不在复述。第二次 进入 render 方法,此时是第二个 component 的挂载,即: 更新同样进入 patch,此时的参数为:此时存在两个不同的 VNode,所以 if (n1 && !isSameVNodeType(n1, n2)) 判断为 true,此时将执行 卸载旧的 VNode 逻辑执行 ·unmount(n1, parentComponent, parentSuspense, true)· ,触发 卸载逻辑代码继续执行,经过 switch,再次执行 processComponent ,因为 旧的 VNode 已经被卸载,所以此时 n1 = null代码继续执行,发现 再次触发 mountComponent ,执行 挂载操作后续省略至此,组件更新完成。由以上代码可知:所谓的组件更新,其实本质上就是一个 卸载、挂载 的逻辑对于这样的卸载逻辑,我们之前已经完成过。所以,目前我们的代码 支持 组件的更新操作。2.2 代码实现因为目前我们的代码 支持 组件的更新操作所以可以直接可创建测试实例 packages/vue/examples/imooc/runtime/render-component-update.html:<script> const { h, render } = Vue const component = { render() { return h('div', 'hello component') const vnode = h(component) render(vnode, document.querySelector('#app')) setTimeout(() => { const component2 = { render() { return h('div', 'update component') const vnode2 = h(component2) render(vnode2, document.querySelector('#app')) }, 2000) </script>测试通过‘3. 局部总结那么到现在我们已经完成了 无状态组件的挂载、更新、卸载 操作。从以上的内容中我们可以发现:所谓组件渲染,本质上指的是 render 函数返回值的渲染组件渲染的过程中,会生成 ReactiveEffect 实例 effect额外还存在一个 instance 的实例,该实例表示 组件本身,同时 vnode.component 指向它组件本身额外提供了很多的状态,比如:sMounted但是以上的内容,全部都是针对于 无状态 组件来看的。在我们的实际开发中,组件通常是 有状态(即:存在 data 响应性数据 ) 的,那么有状态的组件和无状态组件他们之间的渲染存在什么差异呢?让我们继续来往下看。4. 有状态的响应性组件和之前一样,我们先创建一个 有状态的组件 测试实例,从源码上分析:<script> const { h, render } = Vue const component = { data() { return { msg: 'hello component' render() { return h('div', this.msg) const vnode = h(component) // 挂载 render(vnode, document.querySelector('#app')) </script>该组件存在一个 data 选项,data 选项对外 return 了一个包含 msg 数据的对象。然后我们可以在 render 中通过 this.msg 来访问到 msg 数据。这样的一种包含 data 选项的组件,我们就把它叫做有状态的组件。4.1 源码阅读那么下面,我们对当前实例进行 debugger 操作。剔除掉之前的重复逻辑,我们之前的关注点在 渲染,现在我们把关注点放在 data 上。直接从 mountComponent 方法开始进入 debugger:进入 mountComponent 方法,首先会通过 createComponentInstance 生成 instance 实例,代码继续执行:接着会执行 setupComponent,这个方法是用来初始化组件实例 instance 的,我们跳过 props 和 slots,程序会进入到 setupStatefulComponent 方法:可以看到 setupStatefulComponent 方法执行了 finishComponentSetup,进入 finishComponentSetup 方法:我们在第 842 行看了一个 applyOptions,很明显的名称告诉我们就是我们想要找的方法,直接进入:根据上图可以看到,在 applyOptions 方法中,首先将 data 和 render 取了出来,还有很多我们熟悉的属性比如生命周期等等,应该可以意识到 vue 会在这个方法中对他们进行一一处理。代码接着执行:接着调用了通过 const data = dataOptions.call(publicThis, publicThis) 调用 data 函数返回了对象,而且还将 this 传给了 data,最后将 data 通过 reactive 转换为响应式的 proxy 代理对象至此 setupComponent 完成。完成之后 instance 将具备 data 属性,值为 proxy,被代理对象为 {msg: 'hello component'}代码继续执行,触发 setupRenderEffect 方法,我们知道该方法为组件的渲染方法,最终会通过 renderComponentRoot 生成的 subTree(一个 vnode) patch 到 dom 上。setupRenderEffect 这里的逻辑就不在多复述。到这里 我们已经成功解析了 render,把 this.msg 成功替换为了 hello component后面的逻辑,就与 无状态组件 挂载完全相同了。至此,代码解析完成。总结:由以上代码可知:有状态的组件渲染,核心的点是:让 render 函数中的 this.xx 得到真实数据那么想要达到这个目的,我们就必须要 改变 this 的指向改变的方式就是在:生成 subTree 时,通过 call 方法,指定 this4.2 代码实现明确好了有状态组件的挂载逻辑之后,我们接下里就进行对应的实现。在 packages/runtime-core/src/component.ts 中,新增 applyOptions 方法,为 instance 赋值 data:function applyOptions(instance: any) { const { data: dataOptions } = instance.type // 存在 data 选项时 if (dataOptions) { // 触发 dataOptions 函数,拿到 data 对象 const data = dataOptions() // 如果拿到的 data 是一个对象 if (isObject(data)) { // 则把 data 包装成 reactiv 的响应性数据,赋值给 instance instance.data = reactive(data) }在 finishComponentSetup 方法中,触发 applyOptions:export function finishComponentSetup(instance) { const Component = instance.type instance.render = Component.render // 改变 options 中的 this 指向 applyOptions(instance) }在 packages/runtime-core/src/componentRenderUtils.ts 中,为 render 的调用,通过 call 方法修改 this 指向: /** * 解析 render 函数的返回值 export function renderComponentRoot(instance) { + const { vnode, render, data } = instance let result try { // 解析到状态组件 if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) { // 获取到 result 返回值,如果 render 中使用了 this,则需要修改 this 指向 + result = normalizeVNode(render!.call(data)) } catch (err) { console.error(err) return result 至此,代码完成。我们可以创建对应测试实例 packages/vue/examples/runtime/render-comment-data.html:<script> const { h, render } = Vue const component = { data() { return { msg: 'hello component' render() { return h('div', this.msg) const vnode = h(component) // 挂载 render(vnode, document.querySelector('#app')) </script>5. 组件生命周期的回调处理在前面几节,我们其实已经在源码中查看到了对应的一些生命周期处理逻辑。我们知道 vue 把生命周期叫做生命周期回调钩子,说白了就是一个:在指定时间触发的回调方法。我们查看 packages/runtime-core/src/component.ts 中 第 213 行可以看到 ComponentInternalInstance 接口,该接口描述了组件的所有选项,其中包含: /** * @internal [LifecycleHooks.BEFORE_CREATE]: LifecycleHook * @internal [LifecycleHooks.CREATED]: LifecycleHook * @internal [LifecycleHooks.BEFORE_MOUNT]: LifecycleHook * @internal [LifecycleHooks.MOUNTED]: LifecycleHook * @internal [LifecycleHooks.BEFORE_UPDATE]: LifecycleHook * @internal [LifecycleHooks.UPDATED]: LifecycleHook * @internal [LifecycleHooks.BEFORE_UNMOUNT]: LifecycleHook * @internal [LifecycleHooks.UNMOUNTED]: LifecycleHook * @internal [LifecycleHooks.RENDER_TRACKED]: LifecycleHook * @internal [LifecycleHooks.RENDER_TRIGGERED]: LifecycleHook * @internal [LifecycleHooks.ACTIVATED]: LifecycleHook * @internal [LifecycleHooks.DEACTIVATED]: LifecycleHook * @internal [LifecycleHooks.ERROR_CAPTURED]: LifecycleHook * @internal [LifecycleHooks.SERVER_PREFETCH]: LifecycleHook<() => Promise<unknown>>以上全部都是 vue 生命周期回调钩子的选项描述,大家可以在 官方文档 中查看到详细的生命周期钩子描述。这些生命周期全部都指向 LifecycleHooks 这个 enum 对象:export const enum LifecycleHooks { BEFORE_CREATE = 'bc', CREATED = 'c', BEFORE_MOUNT = 'bm', MOUNTED = 'm', BEFORE_UPDATE = 'bu', UPDATED = 'u', BEFORE_UNMOUNT = 'bum', UNMOUNTED = 'um', DEACTIVATED = 'da', ACTIVATED = 'a', RENDER_TRIGGERED = 'rtg', RENDER_TRACKED = 'rtc', ERROR_CAPTURED = 'ec', SERVER_PREFETCH = 'sp' }在 LifecycleHooks 中,对生命周期的钩子进行了简化的描述,比如:created 被简写为 c。即:c 方法触发,就意味着 created 方法被回调。那么明确好了这个之后,我们来看一个测试实例:<script> const { h, render } = Vue const component = { data() { return { msg: 'hello component' render() { return h('div', this.msg) // 组件初始化完成之后 beforeCreate() { alert('beforeCreate') // 组件实例处理完所有与状态相关的选项之后 created() { alert('created') // 组件被挂载之前 beforeMount() { alert('beforeMount') // 组件被挂载之后 mounted() { alert('mounted') const vnode = h(component) // 挂载 render(vnode, document.querySelector('#app')) </script>5.1 源码阅读根据之前的经验,我们知道,vue 对 options 选项的处理都在全部都是在位于 packages/runtime-core/src/componentOptions.ts 这个文件第 549 行的 applyOptions 方法中处理的,在执行的执行顺序为 mountComponent() -> setupComponent() -> setupStatefulComponent() -> finishComponentSetup() -> applyOptions(instance),我们直接跳到 applyOptions 进行调试代码:可以看到代码首先会进入 callHook 方法中:关于 callHook 里面的执行,上图应该讲得很清楚了,在 cakkWithErrorHandling 中执行了 fn 函数,也就代表此时我们的 beforeCreate 钩子函数执行,执行了 alert('beforeCreate'),页面弹出弹框。此时 if (options.beforeCreate) 中的 callHook 代码执行完成,我们继续回到 applyOptions 中:我们忽略其他属性的设置,直接来到 第 745 行,可以看到此时代码触发 if (created) {...},和刚才的 beforeCreate 触发一样,此时 在组件实例处理完所有与状态相关的选项之后,触发了 create 生命周期回调。至此,我们在 applyOptions 方法中,触发了 beforeCreate 和 created,代码继续执行~~~代码接着会执行 11 个 registerLifeHook,我们先从第一个进去看,由上图可知最终会执行 injectHook 方法,我们再进入这个方法看下:到这已经清楚了 injectHook 方法的作用了,它的最终目的就是在当前组件的实例上的生命周期钩子上注入一个 wrappedHook 函数,至于这个函数里面的逻辑我们可以先不分析,但是我们应该也能清楚它是会执行我们在 beforeMounted 的代码的,后面的 10 个 registerLifeHook 原理相同我们就直接跳过了。至此我们当前实例整个 setupComponent 方法执行完成,接下来会执行 setupRenderEffect 渲染 dom,我们再次进入分析一下,现在重点关注钩子函数的执行时期吗,我们直接来到 componentUpdateFn 函数:在 setupRenderEffect 方法最后调用 update 触发的 compoentUpdateFn 方法中,程序先是从 instance 中拿出了 bm 和 m 也就是 beforeMounted 和 Mounted 两个钩子,然后执行了 invokeArrayFns 方法,而 invokeArrayFns 方法很简单就是循环调用了 bm 数组中的函数,此时调用的函数也就是我们在第 7 步 创建的 wrappedhook,在 wrappedhook 中 主要就是通过执行 callWithAsyncErrorHandling,这个方法我们在 beforeCreated 时就碰到过。至此 beforeMounted 生命周期函数执行完成,执行了 alert('beforeMount'),页面显示弹窗。我们接着执行代码:接着程序会在 patch 方法后面之后判断 m 也就是 created 钩子是否存在,我们当前肯定是存在的,所以会执行 queuePostRenderEffect,而在 queuePostRenderEffect 中最终执行了 queuePostFlushCb,而这个函数我们之前也是接触过的,它是一个 Promise 的任务队列,最终函数会循环执行钩子函数的。最终执行了 mounted 中的代码,执行 alert('mounted'),页面显示弹窗。至此,代码执行完成。总结:由以上源码阅读可知:整个源码可以分为两大块:第一块是 beforeCreated 和 created,它俩的执行主要是在 applyOptions 中执行的,我们直接通过 options.beforeCretad 或 options.created 来判断是否有这两个钩子,在通过 callHook 执行。第二块是对于其余的 11 个生命周期,我们都是通过 registerLifecycleHook 方法将这些生命周期注入到 instance 里面,然后在合适的时机去触发5.2 代码实现明确好了源码的生命周期处理之后,那么接下来我们来实现一下对应的逻辑。我们本小节要处理的生命周期有四个,首先我们先处理前两个 beforeCreate 和 created,我们知道这两个回调方法是在 applyOptions 方法中回调的:在 packages/runtime-core/src/component.ts 的 applyOptions 方法中:function applyOptions(instance: any) { const { data: dataOptions, beforeCreate, created, beforeMount, mounted } = instance.type // hooks if (beforeCreate) { callHook(beforeCreate) // 存在 data 选项时 if (dataOptions) { // hooks if (created) { callHook(created) }创建对应的 callHook:/** * 触发 hooks function callHook(hook: Function) { hook() }至此, beforeCreate 和 created 完成。接下来我们来去处理 beforeMount 和 mounted,对于这两个生命周期而言,他需要先注册,在触发。那么首先我们先来处理注册的逻辑:首先我们需要先创建 LifecycleHooks在 packages/runtime-core/src/component.ts 中:/** * 生命周期钩子 export const enum LifecycleHooks { BEFORE_CREATE = 'bc', CREATED = 'c', BEFORE_MOUNT = 'bm', MOUNTED = 'm' }在生成组件实例时,提供对应的生命周期相关选项:/** * 创建组件实例 export function createComponentInstance(vnode) { const type = vnode.type const instance = { + // 生命周期相关 + isMounted: false, // 是否挂载 + bc: null, // beforeCreate + c: null, // created + bm: null, // beforeMount + m: null // mounted return instance }创建 packages/runtime-core/src/apiLifecycle.ts 模块,处理对应的 hooks 注册方法:import { LifecycleHooks } from './component' * 注册 hook export function injectHook( type: LifecycleHooks, hook: Function, target ): Function | undefined { // 将 hook 注册到 组件实例中 if (target) { target[type] = hook return hook * 创建一个指定的 hook * @param lifecycle 指定的 hook enum * @returns 注册 hook 的方法 export const createHook = (lifecycle: LifecycleHooks) => { return (hook, target) => injectHook(lifecycle, hook, target) export const onBeforeMount = createHook(LifecycleHooks.BEFORE_MOUNT) export const onMounted = createHook(LifecycleHooks.MOUNTED)这样我们注册 hooks 的一些基础逻辑完成。那么下面我们就可以 applyOptions 方法中,完成对应的注册:function applyOptions(instance: any) { function registerLifecycleHook(register: Function, hook?: Function) { register(hook, instance) // 注册 hooks registerLifecycleHook(onBeforeMount, beforeMount) registerLifecycleHook(onMounted, mounted) }将 bm 和 m 注册到组件实例之后,下面就可以在 componentUpdateFn 中触发对应 hooks 了:// 组件挂载和更新的方法 const componentUpdateFn = () => { // 当前处于 mounted 之前,即执行 挂载 逻辑 if (!instance.isMounted) { // 获取 hook const { bm, m } = instance // beforeMount hook if (bm) { // 从 render 中获取需要渲染的内容 const subTree = (instance.subTree = renderComponentRoot(instance)) // 通过 patch 对 subTree,进行打补丁。即:渲染组件 patch(null, subTree, container, anchor) // mounted hook if (m) { // 把组件根节点的 el,作为组件的 el initialVNode.el = subTree.el } else { }至此,生命周期逻辑处理完成。可以创建对应测试实例 packages/vue/examples/runtime/redner-component-hook.html:<script> const { h, render } = Vue const component = { data() { return { msg: 'hello component' render() { return h('div', this.msg) // 组件初始化完成之后 beforeCreate() { alert('beforeCreate') // 组件实例处理完所有与状态相关的选项之后 created() { alert('created') // 组件被挂载之前 beforeMount() { alert('beforeMount') // 组件被挂载之后 mounted() { alert('mounted') const vnode = h(component) // 挂载 render(vnode, document.querySelector('#app')) </script>测试成功6. 生命回调钩子中访问响应性数据对于我们当前的代码,还不能生命周期钩子中访问响应式数据,那么要如何解决这个问题呢?我们从源码中分析一下:<script> const { h, render } = Vue const component = { data() { return { msg: 'hello component' render() { return h('div', this.msg) // 组件实例处理完所有与状态相关的选项之后 created() { console.log('created', this.msg) // 组件被挂载之后 mounted() { console.log('mounted', this.msg) const vnode = h(component) // 挂载 render(vnode, document.querySelector('#app')) </script>6.1 源码阅读created通过之前的代码我们已经知道,created 的回调是在 applyOptions 中触发的,所以我们可以直接在这里进行 debugger:进入 applyOptions剔除之前相同的逻辑,代码执行 if (created) {...}通过上他我们很容易分析 created 能获取到响应数据的原因。mounted对于 mounted 而言,我们知道它的 生命周期注册 是在 applyOptions 方法内的 registerLifecycleHook 方法中,我们可以直接来看一下源码中的 registerLifecycleHoo 方法:function registerLifecycleHook( register: Function, hook?: Function | Function[] if (isArray(hook)) { hook.forEach(_hook => register(_hook.bind(publicThis))) } else if (hook) { register((hook as Function).bind(publicThis)) }该方法中的逻辑非常简单,可以看到它和 created 的处理几乎一样,都是通过 bind 方法来改变 this 指向总结:无论是 created 也好,还是 mounted 也好,本质上都是通过 bind 方法来修改 this 指向,以达到在回调钩子中访问响应式数据的目的。6.2 代码实现根据上一小节的描述,我们只需要 改变生命周期钩子的 this 指向即可在 packages/runtime-core/src/component.ts 中为 callHook 方法增加参数,以此来改变 this 指向:/** * 触发 hooks function callHook(hook: Function, proxy) { hook.bind(proxy)() }在 applyOptions 方法中为 callHoo 的调用,传递第二个参数:// hooks if (beforeCreate) { callHook(beforeCreate, instance.data) // hooks if (created) { callHook(created, instance.data) 在 registerLifecycleHook 中,为 hook 修改 this 指向function registerLifecycleHook(register: Function, hook?: Function) { register(hook?.bind(instance.data), instance) }至此,代码完成。创建对应测试实例 packages/vue/examples/runtime/redner-component-hook-data.html:<script> const { h, render } = Vue const component = { data() { return { msg: 'hello component' render() { return h('div', this.msg) // 组件实例处理完所有与状态相关的选项之后 created() { console.log('created', this.msg) // 组件被挂载之后 mounted() { console.log('mounted', this.msg) const vnode = h(component) // 挂载 render(vnode, document.querySelector('#app')) </script>数据访问成功7. 响应性数据改变,触发组件的响应性变化虽然目前我们已经完成了在生命周期中访问响应性数据,但是还有个问题就是:响应性数据改变,没有触发组件发生变化。再来看这一块内容之前,首先我们需要先来明确一些基本的概念:组件的渲染,本质上是 render 函数返回值的渲染。所谓响应性数据,指的是:getter 时收集依赖setter 时触发依赖那么根据以上概念,我们所需要做的就是:在组件的数据被触发 getter 时,我们应该收集依赖。那么组件什么时候触发的 getter 呢?在 packages/runtime-core/src/renderer.ts 的 setupRenderEffect 方法中,我们创建了一个 effect,并且把 effect 的 fn 指向了 componentUpdateFn 函数。在该函数中,我们触发了 getter,然后得到了 subTree,然后进行渲染。所以依赖收集的函数为 componentUpdateFn。在组件的数据被触发 setter 时,我们应该触发依赖。我们刚才说了,收集的依赖本质上是 componentUpdateFn 函数,所以我们在触发依赖时,所触发的也应该是 componentUpdateFn 函数。明确好了以上内容之后,我们就去分析一下源码是怎么做的:<script> const { h, render } = Vue const component = { data() { return { msg: 'hello component' render() { return h('div', this.msg) // 组件实例处理完所有与状态相关的选项之后 created() { setTimeout(() => { this.msg = '你好,世界' }, 2000) const vnode = h(component) // 挂载 render(vnode, document.querySelector('#app')) </script>7.1 源码阅读在 componentUpdateFn 中进行 debugger,等待 第二次 进入 componentUpdateFn 函数(注意: 此时我们仅关注依赖触发,生命周期的触发不再关注对象,会直接跳过):第二次进入 componentUpdateFn,因为这次组件已经挂载过了,所以会执行 else,在 else 中将下一次要渲染的 vnode 赋值给 next,我们继续往下执行:在 else 中,代码最终会执行 renderComponentRoot, 而对于 renderComponentRoot 方法,我们也很熟悉了,它内部会调用result = normalizeVNode( render!.call( proxyToUse, proxyToUse!, renderCache, props, setupState, data, )同样通过 call 方法,改变 this 指向,触发 render。然后通过 normalizeVNode 得到 vnode,这次得到的 vnode 就是 下一次要渲染的 subTree。接着跳出renderComponentRoot 方法继续执行代码:可以看到,最终触发 patch(...) 方法,完成 更新操作至此,整个 组件视图的更新完成。总结:所谓的组件响应性更新,本质上指的是: componentUpdateFn 的再次触发,根据新的 数据 生成新的 subTree,再通过 path 进行 更新 操作## 7.2 代码实现在 packages/runtime-core/src/renderer.ts 的 componentUpdateFn 方法中,加入如下逻辑:// 组件挂载和更新的方法 const componentUpdateFn = () => { // 当前处于 mounted 之前,即执行 挂载 逻辑 if (!instance.isMounted) { // 修改 mounted 状态 instance.isMounted = true } else { let { next, vnode } = instance if (!next) { next = vnode // 获取下一次的 subTree const nextTree = renderComponentRoot(instance) // 保存对应的 subTree,以便进行更新操作 const prevTree = instance.subTree instance.subTree = nextTree // 通过 patch 进行更新操作 patch(prevTree, nextTree, container, anchor) // 更新 next next.el = nextTree.el }至此,代码完成。创建对应测试实例 packages/vue/examples/runtime/redner-component-hook-data-change.html:<body> <div id="app"></div> </body> <script> const { h, render } = Vue const component = { data() { return { msg: 'hello component' render() { return h('div', this.msg) // 组件实例处理完所有与状态相关的选项之后 created() { setTimeout(() => { this.msg = '你好,世界' }, 2000) const vnode = h(component) // 挂载 render(vnode, document.querySelector('#app')) </script>得到响应性的组件更新。8. composition API ,setup 函数挂载逻辑到现在我们已经处理好了组件非常多的概念,但是我们还知道对于 vue3 而言,提供了 composition API,即 setup 函数的概念。那么如果我们想要通过 setup 函数来进行一个响应性数据的挂载,那么又应该怎么做呢?我们继续从源码中找答案:<script> const { reactive, h, render } = Vue const component = { setup() { const obj = reactive({ name: '张三' return () => h('div', obj.name) const vnode = h(component) // 挂载 render(vnode, document.querySelector('#app')) </script>在上面的代码中,我们构建了一个 setup 函数,并且在 setup 函数中 return 了一个函数,函数中返回了一个 vnode。上面的代码运行之后,浏览器会在一个 div 中渲染 张三。8.1 源码阅读我们知道,vue 对于组件的挂载,本质上是触发 mountComponent,在 mountComponent 中调用了 setupComponent 函数,通过此函数来对组件的选项进行标准化。那么 setup 函数本质上就是一个 vue 组件的选项,所以对于 setup 函数处理的核心逻辑,就在 setupComponent 中。我们在这个函数内部进行 debugger。由上图我们看到了setup 函数最终被执行了,由此得到 setupResult 的值为 () => h('div', obj.name)。即:setup 函数的返回值。我们代码继续执行:可以看到,先是触发了 handleSetupResult 方法, 在 handleSetupResult 方法中会将 setupResult 赋值给 instance.render,最后进行了 finishComponentSetup。后面的逻辑就是 有状态的响应性组件挂载逻辑 的逻辑了。这里就不再详细说了。总结:对于 setup 函数的 composition API 语法的组件挂载,本质上只是多了一个 setup 函数的处理因为 setup 函数内部,可以完成对应的 自洽 ,所以我们 无需 通过 call 方法来改变 this 指向,即可得到真实的 render得到真实的 render 之后,后面就是正常的组件挂载了8.2 代码实现明确好了 setup 函数的渲染逻辑之后,那么下面我们就可以进行对应的实现了。在 packages/runtime-core/src/component.ts 模块的 setupStatefulComponent 方法中,增加 setup 判定:function setupStatefulComponent(instance) { const Component = instance.type const { setup } = Component // 存在 setup ,则直接获取 setup 函数的返回值即可 if (setup) { const setupResult = setup() handleSetupResult(instance, setupResult) } else { // 获取组件实例 finishComponentSetup(instance) }创建 handleSetupResult 方法:export function handleSetupResult(instance, setupResult) { // 存在 setupResult,并且它是一个函数,则 setupResult 就是需要渲染的 render if (isFunction(setupResult)) { instance.render = setupResult finishComponentSetup(instance) }在 finishComponentSetup 中,如果已经存在 render,则不需要重新赋值:export function finishComponentSetup(instance) { const Component = instance.type // 组件不存在 render 时,才需要重新赋值 if (!instance.render) { instance.render = Component.render // 改变 options 中的 this 指向 applyOptions(instance) }至此,代码完成。创建对应测试实例 packages/vue/examples/runtime/redner-component-setup.html:<script> const { reactive, h, render } = Vue const component = { setup() { const obj = reactive({ name: '张三' setTimeout(() => { obj.name = '李四' }, 2000) return () => h('div', obj.name) const vnode = h(component) // 挂载 render(vnode, document.querySelector('#app')) </script>挂载 和 更新 都可成功9. 总结在本章中,我们处理了 vue 中组件对应的 挂载、更新 逻辑。我们知道组件本质上就是一个对象(或函数),组件的渲染本质上是 render 函数返回值的渲染。组件渲染的内部,构建了 ReactiveEffect 的实例,其目的是为了实现组件的响应性渲染。而当我们期望在组件中访问响应性数据时,分为两种情况:通过 this 访问:对于这种情况我们需要改变 this 指向,改变的方式是通过 call 方法或者 bind 方法通过 setup 访问:这种方式因为不涉及到 this 指向问题,反而更加简单当组件内部的响应性数据发生变化时,会触发 componentUpdateFn 函数,在该函数中根据 isMounted 的值的不同,进行了不同的处理。组件的生命周期钩子,本质上只是一些方法的回调,当然,如果我们希望在生命周期钩子中通过 this 访问响应式数据,那么一样需要改变 this 指向。
theme: juejinhighlight: vs2015前言原文来自 我的个人博客最近一直在学习 vue3 源码,搞的头有点大,用这篇文章来换换脑子~PS:面试题下附有解答,解答会结合我的思考以及扩展,仅供参考,如果有误麻烦指出,好了就酱~1. HTML在面试中,HTML 的面试题相对来说比较简单,一般面试官不会花太多时间去关注 HTML 的细节。1.1 如何理解 HTML 语义化语义化的含义就是用正确的标签做正确的事情,html 语义化就是让页面的内容结构化。打个比方就是,如果我要实现一个一级标题,可以用 div+css 设置样式字体来达到效果,也可以用 h1,前者当然也能实现效果,但是语义化的方式还是使用 h1 标签,因为我们一看到 h1 标签就会知道他是一个 一级标题,这就是 html 语义化。语义化不仅能方便我们开发人员阅读维护理解还有利于搜索引擎的建立索引、抓取(SEO 优化)而且有还利于不同设备的解析(屏幕阅读器,盲人阅读器等)1.2 谈谈 SEO这个问题可以从三个方向回答:SEO 是什么?它的原理是?SEO 优化方法有哪些?回答:SEO(Search Engine Optimization),意思就是搜索引擎优化。通俗点讲就是提高你的网页在搜索结果中的排名(排名越高越靠前)SEO 的原理可以大致分为四个步骤:爬行和抓取:搜索引擎派出称为爬虫的程序,从一只网页出发,就像正常用户的浏览器一样访问这些网页抓取文件,并且会跟踪网页上的链接,访问更多的网页。索引:搜索引擎将爬取的网页文件分解、分析存入数据库,这个过程就是索引。搜索词处理:用户输入关键词,点击搜索后,搜索引擎会进行 分词 检查拼音 错别字 等等。。。排序:搜索引擎从索引数据库中找出所有包含搜索词的网页,并按照计算法进行排序返回。SEO 优化可以分为内部优化和外部优化内部优化标签优化:语义化标签、META 标签网站内容更新:每天保持站内的更新(主要是文章的更新等)服务器端渲染(SSR)内部链接的优化,包括相关性链接(Tag 标签),锚文本链接,各导航链接,及图片链接外部优化:外部链接类别:博客、论坛、B2B、新闻、分类信息、贴吧、知道、百科、相关信息网等尽量保持链接的多样性外链运营:每天添加一定数量的外部链接,使关键词排名稳定提升。外链选择:与一些和你网站相关性比较高,整体质量比较好的网站交换友情链接,巩固稳定关键词排名1.3 HTML 有哪些块级元素,有哪些行内元素以及他们的区别display: block/inline/inline-block常用的块级元素:div、p、h1-6、table、form、ul、dl常用的行内元素:a、span、br、label、strong常用的内联块状元素有:img、input默认情况下块级元素会独占一行而行内元素不会块级元素可以设置 width, height 属性而行内元素设置无效块级元素可以设置 margin 和 padding,而行内元素只有水平方向有效,竖直方向无效块级元素可以包含行内元素和块级元素。行内元素不能包含块级元素1.4 什么是 HTML5,和 HTML 的区别是?HTML5 是 HTML 的新标准,其主要目标是无需任何额外的插件如 Flash、Silverlight 等,就可以传输所有内容。它囊括了动画、视频、丰富的图形用户界面等。区别:从文档声明类型上看:HTML 是很长的一段代码,很难记住。HTML5 却只有简简单单的声明,方便记忆。<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" " http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <html xmlns="http://www.w3.org/1999/xhtml"> <!DOCTYPE html>从语义结构上看:HTML4.0 没有体现结构语义化的标签,通常都是这样来命名的<div id="header"></div>,这样表示网站的头部。HTML5 在语义上却有很大的优势。提供了一些新的标签,比如:<header><article><footer>。1.5 HTML5 有哪些新特性?新增语义化标签:nav、header、footer、aside、section、article音频、视频标签:audio、video数据存储:localStorage、sessionStoragecanvas(画布)、Geolocation(地理定位)、websocket(通信协议)input 标签新增属性:placeholder、autocomplete、autofocus、requiredhistory API:go、forward、back、pushstate2. CSS不多说了,前端面试 CSS 是必考知识,不过关直接回家2.1 谈谈你对盒模型的理解思路:先讲盒模型是什么再介绍两种盒模型的区别可以再提一下 box-sizing 这个属性回答:当对一个文档进行布局(layout)的时候,浏览器的渲染引擎会根据标准之一的 CSS基础框盒模型(CSS basic box model),将所有元素表示为一个个矩形的盒子(box)。通俗来讲就是网页是由一个一个盒子组成的,而这个盒子是有自己的标准的,一个盒子由以下四个部分组成:content(盒子的内容)padding(盒子的内边距)border(盒子的边框)margin(盒子的外边距)在 CSS 中,盒子模型可以分成:标准盒子模型 和 怪异盒子模型标准盒子模型:浏览器默认的盒子模型盒子总宽度 = width + padding + border + margin盒子总高度 = height + padding + border + margin **`width/height` 只是内容高度,不包含 `padding` 和 `border` 值** - **怪异盒子模型** 盒子总宽度 = `width` + `margin`盒子总高度 = height + margin **`width/height` 包含了 `padding` 和 `border` 值** CSS 中的 box-sizing 属性定义了引擎应该如何计算一个元素的总宽度和总高度content-box 默认值,元素的 width/height 不包含padding,border,与 标准盒子模型表现一致border-box 元素的 width/height 包含 padding,border,与怪异盒子模型表现一致inherit 指定 box-sizing 属性的值,应该从父元素继承2.2 margin 的纵向重叠问题下面我们来看一段代码:<body> <div style="margin-top: 20px"> <div style="margin-top: 10px">aaa</div> </div> <p style="margin-bottom: 50px">bbb</p> <p style="margin-top: 10px">ccc</p> <p style="margin-bottom: 50px">ddd</p> <p></p> <p></p> <p></p> <p style="margin-top: 10px">eee</p> </body>请问:aaa 距离最顶部多少 px?bbb 距离 ccc 多少 px?ddd 距离 eee 多少 px?公布答案: 分别是 20px 50px 50px常见的重叠现象同一层相邻元素之间,相邻元素外边距重叠父元素与子元素重叠空的块级元素重叠解决方法:相邻元素之间:底部元素设置为浮动 float:left;底部元素的 position 的值为 absolute/fixed在设置 margin-top/bottom 值时统一设置上或下(推荐)父子元素之间:外层元素添加 padding外层元素 overflow:hidden/auto(推荐);外层元素透明边框 border:1px solid transparent;内层元素绝对定位 postion:absolute:内层元素加 float:left;或 display:inline-block;2.3 margin-left/right/bottom/top 分别设为负值会怎么样?margin-top 和 margin-left 负值,元素向上、向左移动margin-right 负值,右侧元素左移,自身不受影响margin-bottom 负值,下方元素上移,自身不受影响第一点应该很好理解,下面这张图分别展示了第二和第三点。2.4 BFC 的理解1. 定义:BFC 全称:Block Formatting Context, 名为 "块级格式化上下文"。(全程说出来也能加分)W3C 官方解释为:BFC 它决定了元素如何对其内容进行定位,以及与其它元素的关系和相互作用,当涉及到可视化布局时,Block Formatting Context 提供了一个环境,HTML 在这个环境中按照一定的规则进行布局。简单来说就是,BFC 就是一块独立渲染区域,内部元素的渲染不会影响边界以外的元素。BFC 有自己的规则和触发条件。2. 规则BFC 就是一个块级元素,块级元素会在垂直方向一个接一个的排列BFC 就是页面中的一个隔离的独立容器,容器里的标签不会影响到外部标签垂直方向的距离由 margin 决定, 属于同一个 BFC 的两个相邻的标签外边距会发生重叠计算 BFC 的高度时,浮动元素也参与计算3. 怎样触发 BFCoverflow: hiddendisplay: flex / inline-block / table-cellposition: absolute / fixed4. BFC 解决了什么问题1. 使用 float 脱离文档流,造成高度塌陷<style> .box { margin: 100px; width: 100px; height: 100px; background: red; float: left; .container { border: 1px solid #000; </style> <div class="container"> <div class="box"></div> <div class="box"></div> </div>效果:给 container 触发 BFC 就可解决问题,例如 给 container 元素 添加 display:inline-block。效果:2. margin 边距重叠问题这个问题 2.2 已经讲过:<style> .box { margin: 10px; width: 100px; height: 100px; background: #000; .container { margin-top: 20px; overflow: hidden; </style> <div class="container"> <div class="box"></div> </div>效果以及触发 BFC 后的效果如下:2.5 css 如何画三角形原理:css 画三角形的原理就是通过 border 画的,因为在 css 中 border 并不是一个矩形,而是一个梯形,在 box 的内容越来越小时,border 的一条底边也会越来越小,直到 box 的宽高都是 0 时,此时的四条 border 就组成了四个三角形,将其他三个隐藏,就能得到一个三角形。<style> .box { /* 内部大小 */ width: 0px; ; /* 边框大小 只设置两条边*/ border-top: #4285f4 solid; border-right: transparent solid; border-width: 85px; /* 其他设置 */ margin: 50px; </style> <div class="box"></div>效果:2.6 absolute 和 relative 分别依据什么定位?relative 依据自身定位absolute 依据最近一层的定位元素2.8 居中对齐的实现方式居中的方式有很多,我最常用的还是 flexbox:display:flex; justify-content: center; // 水平居中 align-items: center; // 垂直居中另外常见的还有:text-align: centerline-height 和 height 设置成一样的高度margin: auto 0绝对定位 + margin 负值绝对定位 + transform 负值2.9 line-height 的继承问题请问下面代码 p 标签的行高是多少<style> body { font-size: 20px; line-height: 200%; font-size: 16px; </style> <p>AAA</p>因为上面的代码中 p 标签没有自己的行高,所以会继承 body 的行高,这题考的就是行高是如何继承的。规则:写具体数值,如 30px, 则直接继承该值写比例,如 2 / 1.5,则继承该比例写百分比,如 200%, 则继承计算出来的值答案:40px2.10 rem 是什么?rem 是一个长度单位:px,绝对长度单位,最常用em,相对长度单位,相对于父元素,不常用rem,相对长度单位,相对于 html 根元素,常用于响应式布局2.11 CSS3 新增了哪些特性?1. css3 中新增了一些选择器,主要为下图:2. css3 中新增了一些样式边框:border-radius box-shadow border-image背景:background-clip、background-origin、background-size 和 background-break文字:word-wrap text-overflow text-shadow text-decoration颜色:rgba 与 hsla动画相关: transition transform animation 渐变:linear-gradient radial-gradient其他: flex布局 grid布局(上面提及的属性都应该去了解!)3. JS 基础-变量类型和计算不会变量,别说会 JS3.1 JavaScript 中的数据类型有哪些?JS 中的数据类型分为 值类型(基本类型) 和 引用类型(对象类型)基本类型有:undefined、null、boolean、string、number、symbol、BigInt 七种引用类型有:Object、Function、Array、RegExp、Date 等等3.2 null 和 undefined 有什么区别?首先 undefined 和 null 都是基本数据类型。undefined 翻译过来是 未定义。表示 此处应该有一个值,但是还没有定义null 翻译过来是 空的。表示 没有对象,即此处不应该有值典型用法:一般变量声明了但还没有定义的时候会返回 undefined,函数中没有返回值时默认返回undefined,以及函数参数没提供时这个参数也是 undefinednull 作为对象原型链的终点。null 作为函数的参数,表示该函数的参数不是对象。typeof undefined 值为 undefined,typeof null 是 'object'undefined == null // true undefined === null // false至于为什么在 js 中会有 null 和 undefined 吗,就要从历史谈起了,建议阅读一下阮一峰老师的文章 undefined与null的区别我的总结就是,JS 最初是只设计了一种表示 “无” 的值的就是 null,这点很好理解就像 java 以及其他语言中的 null 一样,但是在 JS 中的数据类型分为 基本类型 和 对象类型,JS 的作者认为表示 “无” 的变量最好不是一个对象,另外 JS 早期没有错误处理机制,有时null 自动转为 0,很不容易发现错误,而 undefined 会转换成 NaN。3.3 谈谈 Javascript 中的类型转换机制JS 的类型转换分为:显式转换 和 隐式转换1. 显式转换显式转换常见的方法有:Number:将任意类型的值转化为数值Number(324) // 324 // 字符串:如果可以被解析为数值,则转换为相应的数值 Number('324') // 324 // 字符串:如果不可以被解析为数值,返回 NaN Number('324abc') // NaN // 空字符串转为0 Number('') // 0 // 布尔值:true 转成 1,false 转成 0 Number(true) // 1 Number(false) // 0 // undefined:转成 NaN Number(undefined) // NaN // null:转成0 Number(null) // 0 // 对象:通常转换成NaN(除了只包含单个数值的数组) Number({a: 1}) // NaN Number([1, 2, 3]) // NaN Number([5]) // 5parseInt:和 Number 相比,Number 会更严格一些,只要有一个字符无法转换成数值,整个字符串就会被转为 NaN,而 parseInt 函数逐个解析字符,遇到不能转换的字符就停下来例如:Number('32a3') // NaN parseInt('32a3') // 32String:可以将任意类型的值转化成字符串// 数值:转为相应的字符串 String(1) // "1" //字符串:转换后还是原来的值 String("a") // "a" //布尔值:true转为字符串"true",false转为字符串"false" String(true) // "true" //undefined:转为字符串"undefined" String(undefined) // "undefined" //null:转为字符串"null" String(null) // "null" String({a: 1}) // "[object Object]" String([1, 2, 3]) // "1,2,3"Boolean:可以将任意类型的值转为布尔值Boolean(undefined) // false Boolean(null) // false Boolean(0) // false Boolean(NaN) // false Boolean('') // false Boolean({}) // true Boolean([]) // true Boolean(new Boolean(false)) // true隐式转换在 JS 中,很多时候会发生隐式转换,我们可以归纳为两种场景:比较运算(==、!=、>、<)、if、while 需要布尔值的地方算术运算(+、-、*、/、%)上面的场景有个前提就是运算符两边的操作数要是不同一类型的自动转换为布尔值在需要布尔值的地方,就会将非布尔值的参数自动转为布尔值,系统内部会调用Boolean函数。undefined null false +0 -0 NaN "" 这些都会被转化成 false,其他都换被转化成 true自动转换成字符串遇到预期为字符串的地方,就会将非字符串的值自动转为字符串常发生在 + 运算中,一旦存在字符串,则会进行字符串拼接操作'5' + 1 // '51' '5' + true // "5true" '5' + false // "5false" '5' + {} // "5[object Object]" '5' + [] // "5" '5' + function (){} // "5function (){}" '5' + undefined // "5undefined" '5' + null // "5null"自动转换成数值除了 + 有可能把运算子转为字符串,其他运算符都会把运算子自动转成数值'5' - '2' // 3 '5' * '2' // 10 true - 1 // 0 false - 1 // -1 '1' - 1 // 0 '5' * [] // 0 false / '5' // 0 'abc' - 1 // NaN null + 1 // 1 undefined + 1 // NaN3.4 == 和 ===有什么区别,分别在什么情况使用?== 叫 等于操作符=== 叫 全等操作符== 在比较时会进行隐式的类型转换'' == '0' // false 0 == '' // true 0 == '0' // true false == 'false' // false false == '0' // true false == undefined // false false == null // false null == undefined // true ' \t\r\n' == 0 // true比较 null 的情况的时候,我们一般使用相等操作符 ==const obj = {}; if(obj.x == null){ console.log("1"); //执行 }等同于下面写法if(obj.x === null || obj.x === undefined) { }使用相等操作符 (==) 的写法明显更加简洁了所以,除了在比较对象属性为 null 或者 undefined 的情况下,我们可以使用相等操作符 (==),其他情况建议一律使用全等操作符 (===)3.5 let、var、const 的区别let 和 const 都是 ES6 新增加了两个重要的关键字,var 是 ES6 之前就有的,他们都是用来声明变量的,const 是用来声明一个只读的常量。var、let、const 三者区别可以围绕下面五点展开:变量提升:var 可以在声明之前调用,let 和 const 不行(会报错)块级作用域:var 不存在跨级作用于,let 和 const 有重复声明:var 允许重复声明变量,let 和 const 不行修改声明的变量:var 和 let 可以,const 不行使用:能用 const 的情况尽量使用 const,其他情况下大多数使用 let,避免使用 var3.6 const 声明了数组,还能 push 元素吗,为什么?可以数组是引用类型,const 声明的引用类型变量,不可以变的是变量引用始终指向某个对象,不能指向其他对象,但是所指向的某个对象本身是可以变的3.7 0.1 + 0.2 == 0.3?答案是 false 不相等。原因:0.1 和 0.2 在转换成二进制后会无限循环,由于标准位数的限制后面多余的位数会被截掉,此时就已经出现了精度的损失,相加后因浮点数小数位的限制而截断的二进制数字在转换为十进制就会变成 0.30000000000000004。总结一句话就是:二进制模拟十进制进行计算时的精度问题3.8 值类型 和 引用类型 的值得计算1. 第一道var a = {"x": 1}; var b = a; a.x = 2; console.log(b.x); a = {"x": 3}; console.log(b.x); a.x = 4; console.log(b.x);答案放在了下面,思考一下哦~原理:首先:第一个 log 打印 2 大家应该都没问题然后 a 指向了栈内存中的另一块地址,而 b 没变,所以 b.x 仍然为 2接着修改 a.x = 4 ,因为此时 b 仍然指向之前的地址,所以修改 a.x 并不会去影响 b,所以打印仍然为 2答案:2 2 22. 第二道:var a = {n:1}; var b = a; a.x = a = {n:2}; console.log(a.x); console.log(b.x);答案在下面思考下哦~这次我尝试一张图解释:答案: undefined {n: 2}4. JS 基础-原型和原型链三座大山之一,必考!!!4.1 JavaScript 中的原型,原型链分别是什么?在 JS 中,原型和原型链是一个很重要的概念,可以说原型本质就是一个对象。它分为两种: 对象的原型和 函数的原型对象的原型:任何对象都有自己默认的原型(隐式原型),它的作用就是在当前对象查找某一个属性时, 如果找不到, 会在原型上面查找获取隐式原型的方法:__proto__(这个不是规范,是浏览器加的,因为早期没有获取原型对象的方法)Object.getPrototypeOf(obj)函数的原型:首先,因为函数也是一个对象,所以他会有一个 __proto__ 隐示原型其次任何一个函数(非箭头函数)还会有自己的 prototype 属性(显式原型)获取显示原型的方法:prototype作用:当通过 new 操作符调用函数时, 创建一个新的对象这个新的对象的 隐式原型 会指向这个函数的 显式原型obj.__proto__ = F.prototype原型链:上面讲过,隐式原型的作用是 当前对象查找某一个属性时, 如果找不到, 会在原型上面查找,而原型也是一个对象,所以在原型对象上找不到的话,还会去原型对象的对象上找,这样一层一层、以此类推就形成了原型链(prototype chain)4.2 如何准确判断一个变量不是数组?1. instanceofinstanceof 运算符用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上如果让你实现一个 instanceof 应该就很简单了吧?(循环遍历对象的隐式原型知道为 null 或者为 Array)let arr = [1, 2]; arr instanceof Array // true2. toStringlet arr = [1, 2]; Object.prototype.toString.call(arr) === '[object Array]'3. constructorlet arr = [1,2]; arr.constructor === Array; // true4. isArraylet arr = [1,2]; Array.isArray(arr) // true4.3 JS 如何实现继承在 ES6 中可以使用 class + extends 的方式很容易实现继承,而它的本质其实就是通过原型链来实现的ES6 实现继承:class Person { constructor(name) { this.name = name; sayHello() { console.log("你好,我是" + this.name); class Student extends Person { constructor(name, sno) { super(name); this.sno = sno; readingBooks() { console.log("我正在读书"); const stu = new Student("张三", "001"); // Student 子类能调用父类的 sayHello 方法 stu.sayHello(); stu.readingBooks();ES5 实现继承:function Person(name) { this.name = name; Person.prototype.sayHello = function () { console.log("你好,我是", this.name); function Student(name, sno) { this.name = name; this.sno = sno; const per = new Person() Student.prototype = perper; Student.prototype.readingBooks = function () { console.log("我正在读书"); const stu = new Student("张三", "001"); // Student 子类能调用父类的 sayHello 方法 stu.sayHello(); stu.readingBooks();ES5 中的继承还会复杂一些,初级面知道这些就够了,如果感兴趣,还可以看下我的这篇文章 js 常见手写题汇总 第 14 章实现继承。下面我们用一张图来解释原型链继承的原理:5. JS 基础-作用域和闭包三座大山之二,不会闭包,基本不会通过5.1 什么是作用域?什么是自由变量?作用域 就是即变量(变量作用域又称上下文)和函数生效(能被访问)的区域或集合。我们一般将作用域分成:全局作用域:任何不在函数中或是大括号中声明的变量,都是在全局作用域下,全局作用域下声明的变量可以在程序的任意位置访问函数作用域:函数作用域也叫局部作用域,如果一个变量是在函数内部声明的它就在一个函数作用域下面。这些变量只能在函数内部访问,不能在函数以外去访问块级作用域:ES6 引入了 let 和 const 关键字,和 var 关键字不同,在大括号中使用 let 和 const 声明的变量存在于块级作用域中。在大括号之外不能访问这些变量。自由变量 就是一个变量在当前作用域没有定义,但被使用了。那这个时候怎么办呢?它会向上级作用域,一层一层依次寻找,知道找到为止。如果到全局作用域都没找到,则报错 xx is not defined5.2 什么是闭包?闭包会用在哪儿?闭包是什么?一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来,作为函数内部与外部连接起来的一座桥梁下面给出一个简单的例子:function init() { var name = "Mozilla"; // name 是一个被 init 创建的局部变量 function displayName() { // displayName() 是内部函数,一个闭包 alert(name); // 使用了父函数中声明的变量 displayName(); init();使用场景闭包常常作为函数 参数 和 返回值:作为函数参数:function print(fn) { let a = 200; fn(); let a = 100; function fn() { console.log(a); print(fn); // 100 作为函数的返回值function create() { let a = 100; return function () { console.log(a); let fn = create(); let a = 200; fn(); // 100任何闭包的使用场景都离不开这两点:创建私有变量延长变量的生命周期一般函数的词法环境在函数返回后就被销毁,但是闭包会保存对创建时所在词法环境的引用,即便创建时所在的执行上下文被销毁,但创建时所在词法环境依然存在,以达到延长变量的生命周期的目的使用闭包的注意点由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在 IE 中可能导致内存泄露。解决方法是,在退出函数之前,将不使用的局部变量全部删除。闭包会在父函数外部,改变父函数内部变量的值。所以,如果你把父函数当作对象(object)使用,把闭包当作它的公用方法(Public Method),把内部变量当作它的私有属性(private value),这时一定要小心,不要随便改变父函数内部变量的值。5.3 this 的绑定规则有哪些?优先级?this 关键字是函数运行时自动生成的一个内部对象,只能在函数内部使用,总指向调用它的对象。根据不同的使用场合,this 有不同的值,主要分为下面几种情况:1. 默认绑定:什么情况下使用默认绑定呢?独立函数调用。独立的函数调用我们可以理解成函数没有被绑定到某个对象上进行调用;案例一:普通函数调用该函数直接被调用,并没有进行任何的对象关联;这种独立的函数调用会使用默认绑定,通常默认绑定时,函数中的 this 指向全局对象(window);function foo() { console.log(this); // window foo();案例二:函数调用链(一个函数又调用另外一个函数)所有的函数调用都没有被绑定到某个对象上;// 2.案例二: function test1() { console.log(this); // window test2(); function test2() { console.log(this); // window test3() function test3() { console.log(this); // window test1();案例三:将函数作为参数,传入到另一个函数中function foo(func) { func() function bar() { console.log(this); // window foo(bar);我们对案例进行一些修改,考虑一下打印结果是否会发生变化:这里的结果依然是 window,为什么呢?原因非常简单,在真正函数调用的位置,并没有进行任何的对象绑定,只是一个独立函数的调用;function foo(func) { func() var obj = { name: "why", bar: function() { console.log(this); // window foo(obj.bar);2. 隐式绑定:另外一种比较常见的调用方式是通过某个对象进行调用的:也就是它的调用位置中,是通过某个对象发起的函数调用。案例一:通过对象调用函数foo 的调用位置是 obj.foo() 方式进行调用的那么 foo 调用时 this 会隐式的被绑定到 obj 对象上function foo() { console.log(this); // obj对象 var obj = { name: "why", foo: foo obj.foo();案例二:案例一的变化我们通过 obj2 又引用了 obj1 对象,再通过 obj1 对象调用 foo 函数;那么 foo 调用的位置上其实还是 obj1 被绑定了 this;function foo() { console.log(this); // obj1 对象 var obj1 = { name: "obj1", foo: foo var obj2 = { name: "obj2", obj1: obj1 obj2.obj1.foo();案例三:隐式丢失结果最终是 window,为什么是 window 呢?因为 foo 最终被调用的位置是 bar ,而 bar 在进行调用时没有绑定任何的对象,也就没有形成隐式绑定;相当于是一种默认绑定;function foo() { console.log(this); var obj1 = { name: "obj1", foo: foo // 讲obj1的foo赋值给bar var bar = obj1.foo; bar();3. 显示绑定隐式绑定有一个前提条件:必须在调用的 对象内部 有一个对函数的引用(比如一个属性);如果没有这样的引用,在进行调用时,会报找不到该函数的错误;正是通过这个引用,间接的将this绑定到了这个对象上;如果我们不希望在 对象内部 包含这个函数的引用,同时又希望在这个对象上进行强制调用,该怎么做呢?JavaScript 所有的函数都可以使用 call 和 apply 方法(这个和 Prototype 有关)。它们两个的区别这里不再展开;其实非常简单,第一个参数是相同的,后面的参数,apply 为数组,call 为参数列表;这两个函数的第一个参数都要求是一个对象,这个对象的作用是什么呢?就是给 this 准备的。在调用这个函数时,会将 this 绑定到这个传入的对象上。因为上面的过程,我们明确的绑定了 this 指向的对象,所以称之为 显示绑定。案例一:call、apply通过 call 或者 apply 绑定 this 对象显示绑定后,this 就会明确的指向绑定的对象function foo() { console.log(this); foo.call(window); // window foo.call({name: "why"}); // {name: "why"} foo.call(123); // Number对象,存放 123案例二:bind 函数如果我们希望一个函数总是显示的绑定到一个对象上,我们可以使用 bind 函数:function foo() { console.log(this); var obj = { name: "why" var bar = foo.bind(obj); bar(); // obj对象 bar(); // obj对象 bar(); // obj对象案例三:内置函数有些时候,我们会调用一些 JavaScript 的内置函数,或者一些第三方库中的内置函数。这些内置函数会要求我们传入另外一个函数;我们自己并不会显示的调用这些函数,而且 JavaScript 内部或者第三方库内部会帮助我们执行;这些函数中的 this 又是如何绑定的呢?// 1. setTimeout中会传入一个函数,这个函数中的this通常是window setTimeout(function() { console.log(this); // window }, 1000); // 2. forEach map filter 等高阶函数 this 通常指向 window对象,但我们可以通过第二个参数改变 var names = ["abc", "cba", "nba"]; names.forEach(function(item) { console.log(this); // 三次window var names = ["abc", "cba", "nba"]; var obj = {name: "why"}; names.forEach(function(item) { console.log(this); // 三次obj对象 }, obj);4. new 绑定JavaScript 中的函数可以当做一个类的构造函数来使用,也就是使用 new 关键字。使用 new 关键字来调用函数时,会执行如下的操作:1.创建一个全新的对象;2.这个新对象会被执行 Prototype 连接;3.这个新对象会绑定到函数调用的 this 上 (this 的绑定在这个步骤完成);4.如果函数没有返回其他对象,表达式会返回这个新对象;// 创建Person function Person(name) { console.log(this); // Person {} this.name = name; // Person {name: "why"} var p = new Person("why"); console.log(p);5. 优先级new绑定 > 显示绑定(bind) > 隐式绑定 > 默认绑定PS: new 绑定后可以使用 bind 但是 bind 不会生效。 new 绑定后使用 call 和 apply 会报错。5. 规则之外bind 绑定一个 null 或者 undefined 无效间接函数引用function foo() { console.log(this); var obj1 = { name: "obj1", foo: foo var obj2 = { name: "obj2" obj1.foo(); // obj1对象 // 赋值(obj2.foo = obj1.foo)的结果是foo函数 // foo函数被直接调用,那么是默认绑定; (obj2.foo = obj1.foo)(); // window ES6 箭头函数:箭头函数不使用 this 的四种标准规则(也就是不绑定 this ),而是根据外层作用域来决定 this。6. JS 基础-异步三座大山之三,必考!!!6.1 同步 和 异步 的区别JS 是一门单线程的编程语言,这就意味着一个时间里只能处理一件事,也就是说 JS 引擎一次只能在一个线程里处理一条语句。(浏览器和 nodejs 已经支持 js 启动进程,如 web worker)虽然单线程简化了编程代码,因为这样咱们不必太担心并发引出的问题,这也意味着在阻塞主线程的情况下执行长时间的操作,如网络请求。想象一下从 API 请求一些数据,根据具体的情况,服务器需要一些时间来处理请求,同时阻塞主线程,使网页长时间处于无响应的状态。这就是引入异步 JS 的原因。使用异步 (如 回调函数、promise、async/await),可以不用阻塞主线程的情况下长时间执行网络请求。总结:基于 js 单线程本质,同步会阻塞代码执行,异步不会阻塞代码执行。6.2 异步的应用场景网络请求,如 ajax 加载图片定时任务,setTimeout6.3 你是怎么理解ES6中 Promise的?1. 介绍Promise,译为承诺,是异步编程的一种解决方案,比传统的解决方案(回调函数)更加合理和更加强大。Promise 的出现主要解决了用回调函数处理多层异步操作出现的回调地狱问题// 1. 经典的回调地狱 doSomething(function(result) { doSomethingElse(result, function(newResult) { doThirdThing(newResult, function(finalResult) { console.log('得到最终结果: ' + finalResult); }, failureCallback); }, failureCallback); }, failureCallback); // 2. 链式操作减低了编码难度 代码可读性明显增强 doSomething().then(function(result) { return doSomethingElse(result); .then(function(newResult) { return doThirdThing(newResult); .then(function(finalResult) { console.log('得到最终结果: ' + finalResult); .catch(failureCallback);promise 对象仅有三种状态:pending(进行中) fulfilled(已成功) rejected(已失败)对象的状态不受外界影响,只有异步操作的结果,可以决定当前是哪一种状态一旦状态改变(从 pending 变为 fulfilled 和从 pending 变为 rejected ),就不会再变,任何时候都可以得到这个结果2. 用法创建一个 Promise// 1. Promise对象是一个构造函数,用来生成Promise实例 // resolve函数的作用是,将Promise对象的状态从“未完成”变为“成功” // reject函数的作用是,将Promise对象的状态从“未完成”变为“失败” const promise = new Promise(function(resolve, reject) {});实例方法:then():then 是实例状态发生改变时的回调函数,第一个参数是 resolved 状态的回调函数,第二个参数是 rejected 状态的回调函数catch():catch() 方法是 .then(null, rejection) 或 .then(undefined, rejection) 的别名,用于指定发生错误时的回调函数finally():finally() 方法用于指定不管 Promise 对象最后状态如何,都会执行的操作构造函数方法:all()race()allSettled()resolve():将现有对象转为 Promise 对象reject():返回一个新的 Promise 实例,该实例的状态为rejectedps:all race allSettled 这三个方法都能将多个 promise实例转换为 1 个 promise 实例,区别就是 all 只有当所有实例状态都变为 fulfilled 最终返回的实例状态才会变为 fulfilled。race 则返回最快改变状态的实例,allSettled 则会等所有实例状态改变才会改变。6.4 手写 Promise 加载一张图片const preloadImage = function (path) { return new Promise(function (resolve, reject) { const image = new Image(); image.onload = () => { resolve(image) }; image.onerror = () => { const err = new Error(`图片加载失败${path}`) reject(err) image.src = path; };7. JS 异步进阶关于异步还有更多的问题,很重要7.1 什么是事件循环 event loop?因为 js 是单线程的,为了防止代码阻塞会将代码分成同步和异步同步代码交给 js 引擎执行,异步代码交给宿主环境(浏览器、node)同步代码放入执行栈中,异步代码等待时机成熟送入任务队列排队执行栈执行完毕,会去任务队列看是否有异步任务,有就送到执行栈执行,反复循环查看执行代码,这个过程就是事件循环(event loop)7.2 什么是宏任务和微任务,它们的区别?上题讲到 js 将代码分成同步和异步,而在异步任务中又分 宏任务(macro-task)和微任务(micro-task)。 宏任务是由宿主(浏览器、node)发起的,微任务是由 js 引擎发起的宏任务大概包括script(整体代码)setTimeoutsetIntervalsetImmediateI/OUI render微任务大概包括process.nextTickPromiseAsync/Await(实际就是promise)MutationObserver(html5新特性)宏任务和微任务的执行过程先执行同步代码后执行微任务的异步代码再执行宏任务的异步代码如下图所示:下面有个小例子:setTimeout(() => { console.log(1); Promise.resolve().then(() => { console.log(2); console.log(3);代码最终执行: 3 2 1考异步代码执行顺序的题目有很多,这里推荐一个 练习事件循环的网站7.3 async/await 语法用法很简单,这里不说了。你可以把 await 后面的代码理解为是放在 Promise.then 中,看上去相当于将链式的调用变成了同步的执行关于async/await 的本质 最好去理解一下,其实就是 Promise 和 Generator 的语法糖7.4 手写 Promise详见 JavaScript 常见手写题汇总 第 15 章8. JS-Web-API-DOM学会DOM,才能具备网页开发的基础8.1 DOM 是什么?DOM:Document Object Model 指的是文档对象模型,它指的是把文档当做一个对象,这个对象主要定义了处理网页内容的方法和接口。DOM 的本质就是一颗树8.2 DOM 节点的操作获取 DOM 节点getElementByIdgetElementByClassNamegetElementByTagNamequerySeletorAllDOM 节点的 property通过 js 对象属性的方式来获取或修改 DOM 节点const pList = document.querySelectorAll('p') const p = pList[0] console.log(p.style.width) // 获取样式 p.style.width = '100px' // 修改样式 console.log(p.className) // 获取 class p.className = 'p1' // 修改 class // 获取 nodeName 和 nodeType console.log(p.nodeName) console.log(p.nodeType)DOM 节点的 attribute通过 getAttribute setAttribute 这种 API 的方式来修改 DOM 节点const pList = document.querySelectorAll('p') const p = pList[0] p.setAttribute('data-name', 'coder') // 设置值 console.log(p.getAttribute('data-name')) // 获取值property 和 attribute 形式都可以修改节点的属性,但是对于新增或删除的自定义属性,能在 html 的 dom 树结构上体现出来的,就必须要用到 attribute 形式了。8.3 DOM 结构操作新增插入节点:<div id="div1">div1</div> <div id="div2">div2</div> <p id="p2">这是 p2 标签</p> <script> const div1 = document.getElementById("div1"); const div2 = document.getElementById("div2"); // 新建节点 const p1 = document.createElement("p"); p1.innerHTML = "这是新的 p 标签"; // 插入节点 div1.appendChild(p1); // 移动节点 const p2 = document.getElementById("p2"); div2.appendChild(p2); </script>获取子元素列表,获取父元素// 获取父元素 console.log(p1.parentNode); // 获取子元素列表 console.log(div2.childNodes);删除子元素// 删除子元素 div2.removeChild(div2.childNodes[0]);8.4 如何优化 DOM 性能DOM 操作非常昂贵,避免频繁的 DOM 操作对 DOM 操作做缓存将频繁 DOM 操作改为一次性操作9. JS-Web-API-BOM内容虽然不多,但是你不能不会9.1 什么 BOM ?BOM:Browser Object Model 指的是浏览器对象模型,它指的是把浏览器当做一个对象来对待,这个对象主要定义了与浏览器进行交互的法和接口。BOM 的核心是 window,而 window 对象具有双重角色,它既是通过 js 访问浏览器窗口的一个接口,又是一个 Global(全局)对象。这意味着在网页中定义的任何对象,变量和函数,都作为全局对象的一个属性或者方法存在。window 对象含有 location 对象、navigator 对象、screen 对象等子对象,并且 DOM 的最根本的对象 document 对象也是 BOM 的 window 对象的子对象。9.2 如何识别浏览器类型navigator.userAgent 简称 ua,可以从 ua 里拿到浏览器信息9.3 拆解 url 各部分location:hrefprotocolpathnamesearchhash10. JS-Web-API-事件事件不会,等于残废,必考!必考!10.1 谈谈你对事件冒泡和捕获的理解事件冒泡和事件捕获分别由微软和网景公司提出,这两个概念都是为了解决页面中事件流(事件发生顺序)的问题。<div id="outer"> <p id="inner">Click me!</p> </div>上面的的两个元素,如果点击 inner,他们的执行顺序是什么呢?事件冒泡:先执行 inner 的监听事件在执行 outer 的监听事件事件捕获:先执行 outer 的监听事件在执行 inner 的监听事件其实这个很好理解,冒泡就是从一个泡泡从水底往上冒当然是里面的先执行啦。至于为什么会有这两种情况,这就要谈到网景和微软的战争了,两家公司的理念不同。网景主张捕获方式,微软主张冒泡方式。后来 w3c 将两者都保留了下来。addEventListener 的第三个参数就是为冒泡和捕获准备的。第三个参数设置为 true 可以将让当前元素绑定的事件先于里面的元素绑定事件执行。默认是 false10.2 什么是 事件代理事件代理(Event Delegation)也称之为事件委托。是 JavaScript 中常用绑定事件的常用技巧。顾名思义,事件代理 即是把原本需要绑定在子元素的响应事件委托给父元素,让父元素担当事件监听的职务。事件代理的原理是DOM元素的事件冒泡。11. JS-Web-API-Ajax每个工程师必须熟练掌握的技能11.1 什么是 Ajax ?Ajax(全称 Asynchronous JavaScript And XML) 翻译过来就是 异步的 Javascript 和 XMLAJAX 是一种在无需重新加载整个网页的情况下,能够更新部分网页的技术。随着谷歌搜索建议功能在 2005 的发布,AJAX 开始流行起来。11.2 手写一个简易的 Ajax网页中实现 Ajax 最核心的 API 就是 XMLHttpRequest,如果不知道这个就别谈实现了。function ajax(url) { const xhr = new XMLHttpRequest(); xhr.open("get", url, true); xhr.onreadystatechange = function () { // 异步回调函数 if (xhr.readyState === 4) { if (xhr.status === 200) { console.info("响应结果", xhr.response) xhr.send(null); }11.3 浏览器的同源策略是什么??同源策略(Same origin policy)是一种约定,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,则浏览器的正常功能可能都会受到影响。可以说 Web 是构建在同源策略基础之上的,浏览器只是针对同源策略的一种实现。它的核心就在于它认为自任何站点装载的信赖内容是不安全的。当被浏览器半信半疑的脚本运行在沙箱时,它们应该只被允许访问来自同一站点的资源,而不是那些来自其它站点可能怀有恶意的资源。所谓同源是指:域名、协议、端口相同。另外,同源策略又分为以下两种:DOM 同源策略:禁止对不同源页面 DOM 进行操作。这里主要场景是 iframe 跨域的情况,不同域名的 iframe 是限制互相访问的。XMLHttpRequest 同源策略:禁止使用 XHR 对象向不同源的服务器地址发起 HTTP 请求。11.4 什么是跨域?跨域本质是浏览器基于同源策略的一种安全手段同源策略(Sameoriginpolicy),是一种约定,它是浏览器最核心也最基本的安全功能所谓同源(即指在同一个域)具有以下三个相同点协议相同(protocol)主机相同(host)端口相同(port)反之非同源请求,也就是协议、端口、主机其中一项不相同的时候,这时候就会产生跨域一定要注意跨域是浏览器的限制,你用抓包工具抓取接口数据,是可以看到接口已经把数据返回回来了,只是浏览器的限制,你获取不到数据。用 postman 请求接口能够请求到数据。这些再次印证了跨域是浏览器的限制。11.5 如何实现跨域请求首先跨域是因为浏览器的同源策略造成的,他是浏览器的一种安全机制。跨域并不是请求发不出去,请求能发出去,服务端能收到请求并正常返回结果,只是结果被浏览器拦截了实现跨域常见的方案:jsonpnginx反向代理webpack devServer代理cors跨源资源共享其中 jsonp 由于其局限性,以及对比其他方案的效果。此处不做介绍。1. nginx方向代理nginx 反向代理常用在开发环境及线上环境。通过拦截转发请求来处理跨域问题。假如现在前端项目运行在 8080 端口,而实际后端项目的地址为 https://1.1.1.1:9000 ,需要拦截前缀为 api 的请求,此时 nginx 配置为:server { listen 8080 default_server; location /api { proxy_pass https://1.1.1.1:9000; }假如现在有个接口为 /api/test,在没有做转发前为 http://localhost:8080/api/test ,实际接口位置为 https://1.1.1.1:9000/api/test.结果转发为实际接口位置。2. webpack devserver代理webpack devserver 代理用在开发环境。配置如下:devServer({ proxy: { '/api': { target: 'https://1.1.1.1:9000', changeOrigin: true, pathRewrite: { '^/api': 'api' }, })3. cors跨源资源共享(服务端设置) 跨源资源共享 (CORS) (或通俗地译为跨域资源共享)是一种基于 HTTP 头的机制,该机制通过允许服务器标示除了它自己以外的其它 origin(域,协议和端口),这样浏览器可以访问加载这些资源。Access-Control-Allow-Origin: 'xxx'可以通过服务端设置 Access-Control-Allow-Origin 字段的白名单来处理跨域问题。如果在此情况下,发送请求时需要带上cookie的话,则需要配置Access-Control-Allow-Credentials,同时客户端需要同步设置xhr.withCredentials = true;,两者缺一不可11.6 ajax fetch axios 的区别ajax 是 js 异步技术的术语,早期相关的 api 是 xhr ,它是一个术语。fetch 是 es6 新增的用于网络请求标准 api,它是一个 api。(是 es6 用来代替 xhr 的, xhr 很不好用)axios 是用于网络请求的第三方库,它是一个库。12. JS-Web-API-存储内容虽然不多,但不可不会12.1 描述 cookie sessionStorage localStorage 的区别cookie 本身是用于浏览器和 server 通讯的,他是被借用到本地用于存储的,因为后两者是在 H5 后才提出来的( 2010年左右),我们可以通过 document.cookie = 'xxx' 来改变 cookie。cookie 的缺点:存储大小最大 4kb (因为 cookie 本身就不是用来做存储的)http 请求时需要发送到服务端,增加请求数据量只能用 document.cookie 来修改,太过简陋localStorage 和 sessionStorage :它俩是 H5 专门为了存储设计的,最大可存 5M 左右API 更简洁:getItem setItem不会随着 http 被发送出去localStorage 数据会永久存储,除非代码或者手动删除, sessionStorage 数据只存在于房钱会话,浏览器关闭则清除。一般 localStorage 会更多一些13. HTTP 面试题前后端分离的时代,网络请求是前端的生命线13.1 http 常见的状态码有哪些?分类:1xx 服务器收到请求2xx 请求成功,如 2003XX 重定向,如 3024xx 客户端错误,如 4045xx 服务端错误,如 500常见状态码:1XX: 提供信息100 Continue 情景:客户端向服务端传很大的数据,这个时候询问服务端,如果服务端返回100,客户端就继续传 (历史,现在比较少了)101 Switching Protocols 协议切换。比如下面这种:HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade告诉客户端把协议切换为 Websocket2xx: 成功200 Ok 正常的返回成功 通常用在 GET201 Created 已创建 通常用在 POST202 Accepted 已接收 比如发送一个创建 POST 请求,服务端有些异步的操作不能马上处理先返回 202,结果需要等通知或者客户端轮询获取203 Non-Authoritative Infomation 非权威内容 原始服务器的内容被修改过204 No Content 没有内容 一般 PUT 请求修改了但是没有返回内容205 Reset Content 重置内容206 Partial Content 服务端下发了部分内容3XX: 重定向300 Multiple Choices 用户请求了多个选项的资源(返回选项列表)301 Moved Permanently 永久转移302 Found 资源被找到(以前是临时转移)不推荐用了 302 拆成了 303 和 307303 See Other 可以使用 GET 方法在另一个 URL 找到资源304 Not Modified 没有修改305 Use Proxy 需要代理307 Temporary Redirect 临时重定向 (和 303 的区别是,307 使用原请求的method 重定向资源, 303 使用 GET 方法重定向资源)308 Permanent Redirect 永久重定向 (和 301 区别是 客户端接收到 308 后,之前是什么 method,之后也会沿用这个 method 到新地址。301,通常给用户会向新地址发送 GET 请求)4XX: 客户端错误400 Bad Request 请求格式错误401 Unauthorized 没有授权402 Payment Required 请先付费403 Forbidden 禁止访问404 Not Found 没有找到405 Method Not Allowed 方法不允许406 Not Acceptable 服务端可以提供的内容和客户端期待的不一样5XX: 服务端错误500 Internal Server Error 内部服务器错误501 Not Implemented 没有实现502 Bad Gateway 网关错误503 Service Unavailable 服务不可用 (内存用光了,线程池溢出,服务正在启动)504 Gateway Timeout 网关超时505 HTTP Version Not Supported 版本不支持面试的时候常见该记住的有:101 200 201 301 302 304 403 404 500 502 504规范就是一个约定,要求大家跟着执行,不要违反规范,例如 IE 浏览器13.2 http 常见的 header 头1. Content-Length发送给接收者的 Body 内容长度(字节)一个 byte 是 8bitUTF-8 编码的字符 1-4 个字节、示例:Content-Length: 3482. User-Agent帮助区分客户端特性的字符串 - 操作系统 - 浏览器 - 制造商(手机类型等) - 内核类型 - 版本号 示例:User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.363. Content-Type帮助区分资源的媒体类型(Media Type/MIME Type)text/htmltext/cssapplication/jsonimage/jpeg示例:Content-Type: application/x-www-form-urlencoded4. Origin描述请求来源地址scheme://host:port不含路径可以是null示例: Origin: https://yewjiwei.com5. Accept建议服务端返回何种媒体类型(MIME Type)/代表所有类型(默认)多个类型用逗号隔开衍生的还有Accept-Charset 能够接受的字符集 示例:Accept-Charset: utf-8Accept-Encoding 能够接受的编码方式列表 示例:Accept-Encoding: gzip, deflateAccept-Language 能够接受的回应内容的自然语言列表 示例:Accept-Language: en-US示例:Accept: text/plainAccept-Charset: utf-8Accept-Encoding: gzip, deflate6. Referer告诉服务端打开当前页面的上一张页面的URL;如果是ajax请求那么就告诉服务端发送请求的URL是什么非浏览器环境有时候不发送Referer常常用户行为分析7. Connection决定连接是否在当前事务完成后关闭HTTP1.0默认是 closeHTTP1.1后默认是 keep-alive13.3 什么是 RESTFUL API ?Restful API 是一种新的 API 设计方法(早已推广)传统 API 设计:把每一个 url 当做一个功能Restful API 设计:把每个 url 当做一个唯一的资源传统/api/list?pageIndex=2Restful API/api/list/213.4 描述一下 http 缓存机制HTTP 缓存即是浏览器第一次向一个服务器发起 HTTP 请求后,服务器会返回请求的资源,并且在响应头中添加一些有关缓存的字段如:cache-control,expires, last-modifed,ETag, Date,等,之后浏览器再向该服务器请求资源就可以视情况使用强缓存和协商缓存,强缓存:浏览器直接从本地缓存中获取数据,不与服务器进行交互,协商缓存:浏览器发送请求到服务器,服务器判断是否可使用本地缓存,
highlight: vs2015theme: juejin前言原文来自 我的个人博客在前几章中,我们实现了 ELEMENT 节点的挂载、更新以及删除等操作。但是我们的代码现在还只能挂载 class 属性,而不能挂载其他属性。本章我们就来实现一下其他属性的挂载( style 属性,事件 属性)1. 源码阅读:vue3 是如何挂载其他属性的我们从下面的测试实例开始阅读源码:<script> const { h, render } = Vue const vnode = h('textarea', { class: 'test-class', value: 'textarea value', type: 'text' // 挂载 render(vnode, document.querySelector('#app')) </script>在这个测试实例中,我们为 textarea 挂载了三个属性 class、value 和 type,根据之前的源码阅读我们知道,属性的挂载是在 packages/runtime-dom/src/patchProp.ts 中的 patchProp 方法处进行的。所以我们可以直接在这里进行 debugger,因为我们设置了三个属性,所以会 执行三次,我们一个一个来看:第一次进入 patchProp:可以看到,代码首先会执行 patchClass,而在 patchClass 中最终会执行 el.className = value。至此 class 设置完成。第二次进入 patchProp:可以看到代码前三个 if 都会跳过,而在第四个 if 时,会执行 shouldSetAsProp(el, key, nextValue, isSVG),其实这个方法会返回 false,最终还是执行 else 中的代码,在 else 中最终会执行 patchAttr 方法:patchAttr 最终执行 el.setAttribute(key, isBoolean ? '' : value) 设置 type至此 type 设置完成第三次进入 patchProp:可以看到第三次进入最后执行的是 patchDOMProp,这个方法最后是通过 执行 el[key] = value 设置 value,完成 value 属性的设置的至此 value 设置完成至此三个属性全部设置完成。总结:由以上代码可知:针对于三个属性,vue 通过了 三种不同的方式 来进行了设置:class 属性:通过 el.className 设定textarea 的 type 属性:通过 el.setAttribute 设定textarea 的 value 属性:通过 el[key] = value 设定至于 vue 为什么要通过三种不同的形式挂载属性,主要有以下两点原因:首先 HTML Attributes 和 DOM Properties 想要成功的进行各种属性的设置,就需要 针对不同属性,通过不同方式 完成,例如:// 修改 class el.setAttribute('class', 'm-class') // 成功 el['class'] = 'm-class' // 失败 el.className = 'm-class' // 成功上面同样是修改 class,通过 HTML Attributes 的方式使用 setAttribute 就可以成功,通过 el['class'] 就会失败,因为在 DOM Properties 中,修改 class 要通过 el['className']`还有出于性能的考虑,比如 className 和 setAttribute('class', ''),className 的性能会更高2. 代码实现:区分处理 ELEMENT 节点的各种属性挂载在 packages/runtime-dom/src/patchProp.ts 中,增加新的判断条件:export const patchProp = (el, key, prevValue, nextValue) => { else if (shouldSetAsProp(el, key)) { // 通过 DOM Properties 指定 patchDOMProp(el, key, nextValue) } else { // 其他属性 patchAttr(el, key, nextValue) 在 packages/runtime-dom/src/patchProp.ts 中,创建 shouldSetAsProp 方法:/** * 判断指定元素的指定属性是否可以通过 DOM Properties 指定 function shouldSetAsProp(el: Element, key: string) { // #1787, #2840 表单元素的表单属性是只读的,必须设置为属性 attribute if (key === 'form') { return false // #1526 <input list> 必须设置为属性 attribute if (key === 'list' && el.tagName === 'INPUT') { return false // #2766 <textarea type> 必须设置为属性 attribute if (key === 'type' && el.tagName === 'TEXTAREA') { return false return key in el }在 packages/runtime-dom/src/modules/props.ts 中,增加 patchDOMProp 方法:/** * 通过 DOM Properties 指定属性 export function patchDOMProp(el: any, key: string, value: any) { try { el[key] = value } catch (e: any) {} 在 packages/runtime-dom/src/modules/attrs.ts 中,增加 patchAttr 方法:/** * 通过 setAttribute 设置属性 export function patchAttr(el: Element, key: string, value: any) { if (value == null) { el.removeAttribute(key) } else { el.setAttribute(key, value) 至此,代码完成。创建测试实例 packages/vue/examples/runtime/render-element-props.html:<script> const { h, render } = Vue const vnode = h('textarea', { class: 'test-class', value: 'textarea value', type: 'text' // 挂载 render(vnode, document.querySelector('#app')) </script>测试渲染成功。3. 源码阅读:style 属性的挂载和更新创建测试实例阅读源码:<script> const { h, render } = Vue const vnode = h( 'div', style: { color: 'red' '你好,世界' // 挂载 render(vnode, document.querySelector('#app')) setTimeout(() => { const vnode2 = h( 'div', style: { fontSize: '32px' '你好,世界' // 挂载 render(vnode2, document.querySelector('#app')) }, 2000) </script>我们继续在 patchProp 方法中,跟踪源码实现:第一次进入 patchProp,执行 挂载 操作:可以看到在 patchProp 方法中会进入 patchStyle 方法,而 patchStyle 经过判断会进入 setStyle ,我们进入 setStyle 方法:在 setStyle 中,最后执行 style[prefixed as any] = val ,直接为 style 对象进行赋值操作,至此 style 属性 挂载完成接下来延迟两秒之后就开始 style 的 更新操作:忽略掉相同的挂载逻辑,代码执行到 patchStyle 方法下:可以看到此时 会执行 setStyle(style,key,''),再次进入 setStyle,此时 val 为 '',最后会执行 style['color'] = '',完成 清理旧样式 操作。至此 更新 操作完成总结:由以上代码可知:整个 style 赋值的逻辑还是比较简单的在 不考虑边缘情况 的前提下,vue 只是对 style 进行了 缓存 和 赋值 两个操作缓存是通过 prefixCache = {} 进行赋值则是直接通过 style[xxx] = val 进行4. 代码实现:style 属性的更新和挂载在 packages/runtime-dom/src/patchProp.ts 中,处理 style 情况:/** * 为 prop 进行打补丁操作 export const patchProp = (el, key, prevValue, nextValue) => { ...... else if (key === 'style') { // style patchStyle(el, prevValue, nextValue) ...... 在 packages/runtime-dom/src/modules/style.ts 中,新建 patchStyle 方法:import { isString } from '@vue/shared' * 为 style 属性进行打补丁 export function patchStyle(el: Element, prev, next) { // 获取 style 对象 const style = (el as HTMLElement).style // 判断新的样式是否为纯字符串 const isCssString = isString(next) if (next && !isCssString) { // 赋值新样式 for (const key in next) { setStyle(style, key, next[key]) // 清理旧样式 if (prev && !isString(prev)) { for (const key in prev) { if (next[key] == null) { setStyle(style, key, '') * 赋值样式 function setStyle( style: CSSStyleDeclaration, name: string, val: string | string[] style[name] = val 代码完成创建测试实例 packages/vue/examples/runtime/render-element-style.html:<script> const { h, render } = Vue const vnode = h( 'div', style: { color: 'red' '你好,世界' // 挂载 render(vnode, document.querySelector('#app')) setTimeout(() => { const vnode2 = h( 'div', style: { fontSize: '32px' '你好,世界' // 挂载 render(vnode2, document.querySelector('#app')) }, 2000) </script>效果:页面刷新,两秒钟后样式更新。5. 源码阅读:事件的挂载和更新我们通过如下测试用例来阅读 vue 源码:<script> const { h, render } = Vue const vnode = h( 'button', onClick() { alert('点击') // 挂载 render(vnode, document.querySelector('#app')) setTimeout(() => { const vnode2 = h( 'button', onDblclick() { alert('双击') // 挂载 render(vnode2, document.querySelector('#app')) }, 2000) </script>上面代码很简单就是页面刚渲染时挂载 点击事件,两秒钟之后更新为 双击事件我们依然来到 patchProps 方法:此时会进入 patchEvent 方法中:在 patchEvent 中,首先创建了一个 invokers 对象并绑定到了 el._wei 上,这是个用于缓存的对象,我们可以不用管,他目前只是一个空对象。然后又执行 existingInvoker = invokers[rawName],rawName 此时为 'onClick' 这就是想从缓存中取出之前已经缓存过得 onClick 事件函数,我们目前没缓存过,所以是 undefined,所以程序会执行下面的 else可以看到最后会执行 addEventListener 的方法,这个方法就是最终挂载事件的方法。但是我们会有个疑问这个 invoker 是什么东西呢?我们代码进入 82 行 createInvoker:由上图我们知道 invoker 就是一个函数,它的 value 属性是当前 onClick 函数创建完 invoker 对象后,会执行 invokers[rawName],也就是缓存下来。至此,支持事件 挂载 完成等待两秒之后,执行 更新 操作:第二次 进入 patchEvent,会再次挂载 onDblclick 事件与 第一次 相同,此时的 invokers 值为:但是,到这还没完,我们知道 属性的挂载 其实是在 packages/runtime-core/src/renderer.ts 中的 patchProps 中进行的,观察内部方法,我们可以发现 内部进行了两次 for 循环:所以此时还会执行下面的 for 循环来卸载之前的 onClick 事件,我们 第三次 进入到 patchEvent 方法中:这次因为 nextValue 为 null 且 存在 existingInvoker,所以会执行最后的 removeEventListener 即卸载 onClick 事件,最后执行 invokers[rawName] = undefined,删除 onClick 事件的缓存。至此 卸载旧事件 完成总结:我们一共三次进入 patchEvent 方法第一次进入为 挂载 onClick 行为第二次进入为 挂载 onDblclick 行为第三次进入为 卸载 onClick 行为挂载事件,通过 el.addEventListener 完成卸载事件,通过 el.removeEventListener 完成除此之外,还有一个 _vei 即 invokers 对象 和 invoker 函数,我们说两个东西需要重点关注,那么这两个对象有什么意义呢?深入事件更新在 patchEvent 方法中有一行代码是我们没有讲到的,那就是:// patch existingInvoker.value = nextValue这行代码是用来更新事件的,vue 通过这种方式而不是调用 addEventListener 和 removeEventListener 解决了频繁的删除、新增事件时非常消耗性能的问题。6. 代码实现:事件的挂载和更新在 packages/runtime-dom/src/patchProp.ts 中,增加 patchEvent 事件处理} else if (isOn(key)) { // 事件 patchEvent(el, key, prevValue, nextValue) }在 packages/runtime-dom/src/modules/events.ts 中,增加 patchEvent、parseName、createInvoker 方法:/** * 为 event 事件进行打补丁 export function patchEvent( el: Element & { _vei?: object }, rawName: string, prevValue, nextValue // vei = vue event invokers const invokers = el._vei || (el._vei = {}) // 是否存在缓存事件 const existingInvoker = invokers[rawName] // 如果当前事件存在缓存,并且存在新的事件行为,则判定为更新操作。直接更新 invoker 的 value 即可 if (nextValue && existingInvoker) { // patch existingInvoker.value = nextValue } else { // 获取用于 addEventListener || removeEventListener 的事件名 const name = parseName(rawName) if (nextValue) { // add const invoker = (invokers[rawName] = createInvoker(nextValue)) el.addEventListener(name, invoker) } else if (existingInvoker) { // remove el.removeEventListener(name, existingInvoker) // 删除缓存 invokers[rawName] = undefined * 直接返回剔除 on,其余转化为小写的事件名即可 function parseName(name: string) { return name.slice(2).toLowerCase() * 生成 invoker 函数 function createInvoker(initialValue) { const invoker = (e: Event) => { invoker.value && invoker.value() // value 为真实的事件行为 invoker.value = initialValue return invoker 支持事件的打补丁处理完成。可以创建如下测试实例 packages/vue/examples/runtime/render-element-event.html:<script> const { h, render } = Vue const vnode = h( 'button', onClick() { alert('点击') // 挂载 render(vnode, document.querySelector('#app')) setTimeout(() => { const vnode2 = h( 'button', onDblclick() { alert('双击') // 挂载 render(vnode2, document.querySelector('#app')) }, 2000) </script>效果:7. 渲染器模块的局部总结目前我们已经完成了针对于 ELEMENT 的:挂载更新卸载patch props 打补丁classstyleeventattr等行为的处理。针对于 挂载、更新、卸载 而言,我们主要使用了 packages/runtime-dom/src/nodeOps.ts 中的浏览器兼容方法进行的实现,比如:doc.createElementparent.removeChild等等。而对于 patch props 的操作而言,因为 HTML Attributes 和 DOM Properties 不同的问题,所以我们需要针对不同的 props 进行分开的处理。而最后的 event,本身并不复杂,但是 vei 的更新思路也是非常值得学习的一种事件更新方案。至此,针对于 ELEMENT 的处理终于完成啦~接下来是 Text 、Comment 以及 Component 的渲染行为。
highlight: vs2015theme: juejin前言原文来自 我的个人博客自上一章我们成功构建了 h 函数创建 VNode 后,这一章的目标就是要在 VNode 的基础上构建 renderer 渲染器。根据上一章的描述,我们知道在 packages/runtime-core/src/renderer.ts 中存放渲染器相关的内容。Vue 提供了一个 baseCreateRenderer 的函数(这个函数很长有 2000 多行代码~),它会返回一个对象,我们把返回的这个对象叫做 renderer 渲染器。对于该对象而言,提供了三个方法:render:渲染函数hydrate:服务端渲染相关createApp:初始化方法因为这里代码实在太长了,所以我们将会以下面两个思想来阅读以及实现:阅读:没有使用的代码就当做不存在实现:用最少的代码来实现接下来就让我们开始吧,Here we go~1. 案例分析我们依然从上一章的测试案例开始讲:<script> const { h, render } = Vue const vnode = h( 'div', class: 'test' 'hello render' console.log(vnode) render(vnode, document.querySelector('#app')) </script>上一章中我们跟踪了 h 函数的创建,但是并没有提 render 函数。实际上在 h 函数创建了 VNode 后,就是通过 render 渲染函数将 VNode 渲染成真实 DOM 的。至于其内部究竟是如何工作的,我们从源码中去找答案吧~2. 源码阅读:初见 render 函数,ELEMENT 的挂载操作我们直接到源码 packages/runtime-core/src/renderer.ts 的第 2327 行进行debugger:可以看到 render 函数内部很简单,对 vnode 进行判断是否为 null,此时我们的vnode 是从 h 函数得到的 vnode 肯定不为空,所以会执行 patch 方法,最后将 vnode 赋值到 container._vnode 上。我们进入到 patch 方法。patch 的是贴片、补丁的意思,在这里 patch 表示 更新 节点。这里传递的参数我们主要关注 前三个。container._vnode 表示 旧节点(n1),vnode 表示 新节点(n2),container 表示 容器。我们进入 patch 方法:上图讲得很明白了,我们进入 processElement 方法:因为当前为 挂载操作,所以 没有旧节点,即:n1 === null,进入 mountElement 方法:在 mountElement 方法中,代码首先会进入到 hostCreateElement 方法中,根据上图我们也知道,hostCreateElement 方法实际上就是调用了 document.createElement 方法创建了 Element 并返回,但是有个点可以提的是,这个方法在 packages/runtime-dom/src/nodeOps.ts,我们之前调试的代码都在 packages/runtime-core/src/renderer.ts。这是因为 vue 为了保持兼容性,把所有和浏览器相关的 API 封装到了 runtime-dom 中。此时 el 和 vnode.el 的值为 createElement 生成的 div 实例。我们代码接着往下跑:进入 hostSetElementText,而 hostSetElementText 实际上就是执行 el.textContent = text,hostSetElementText 同样 在 packages/runtime-dom/src/nodeOps.ts 中(和浏览器有关的 API 都在 runtime-dom,下面不再将)。我们接着调试:因为此时我们的 prop 有值, 所以会进入这个 for 循环,看上面的图应该很明白了,就是添加了 class 属性,接着程序跳出 patchClass ,跳出 patchProp ,跳出 for 循环,if 结束。如果此时触发 div 的 outerHTML 方法,就会得到 <div class="test">hello render</div>到现在 dom 已经构建好了,最后就只剩下 挂载 操作了继续执行代码将进入 hostInsert(el, container, anchor) 方法:可以看到 hostInsert 方法就是执行了 insertBefore,而我们知道 insertBefore 可以将 ·dom· 插入到执行节点那么到这里,我们已经成功的把 div 插入到了 dom 树中,执行完成 hostInsert 方法之后,浏览器会出现对应的 div.至此,整个 render 执行完成总结:由以上代码可知:整个挂载 Element | Text_Children 的过程分为以下步骤:触发 patch 方法根据 shapeFlag 的值,判定触发 processElement 方法在 processElement 中,根据 是否存在 旧VNode 来判定触发 挂载 还是 更新 的操作挂载中分成了4大步:生成 div处理 textContent处理 props挂载 dom通过 container._vnode = vnode 赋值 旧 VNode3. 代码实现:构建 renderer 基本架构整个 基本架构 应该分为 三部分 进行处理:renderer 渲染器本身,我们需要构建出 baseCreateRenderer 方法我们知道所有和 dom 的操作都是与 core 分离的,而和 dom 的操作包含了 两部分:Element 操作:比如 insert、createElement 等,这些将被放入到 runtime-dom 中props 操作:比如 设置类名,这些也将被放入到 runtime-dom 中renderer 渲染器本身创建 packages/runtime-core/src/renderer.ts 文件:import { ShapeFlags } from 'packages/shared/src/shapeFlags' import { Fragment } from './vnode' * 渲染器配置对象 export interface RendererOptions { * 为指定 element 的 prop 打补丁 patchProp(el: Element, key: string, prevValue: any, nextValue: any): void * 为指定的 Element 设置 text setElementText(node: Element, text: string): void * 插入指定的 el 到 parent 中,anchor 表示插入的位置,即:锚点 insert(el, parent: Element, anchor?): void * 创建指定的 Element createElement(type: string) * 对外暴露的创建渲染器的方法 export function createRenderer(options: RendererOptions) { return baseCreateRenderer(options) * 生成 renderer 渲染器 * @param options 兼容性操作配置对象 * @returns function baseCreateRenderer(options: RendererOptions): any { * 解构 options,获取所有的兼容性方法 const { insert: hostInsert, patchProp: hostPatchProp, createElement: hostCreateElement, setElementText: hostSetElementText } = options const patch = (oldVNode, newVNode, container, anchor = null) => { if (oldVNode === newVNode) { return const { type, shapeFlag } = newVNode switch (type) { case Text: // TODO: Text break case Comment: // TODO: Comment break case Fragment: // TODO: Fragment break default: if (shapeFlag & ShapeFlags.ELEMENT) { // TODO: Element } else if (shapeFlag & ShapeFlags.COMPONENT) { // TODO: 组件 * 渲染函数 const render = (vnode, container) => { if (vnode == null) { // TODO: 卸载 } else { // 打补丁(包括了挂载和更新) patch(container._vnode || null, vnode, container) container._vnode = vnode return { render 封装 Element 操作创建 packages/runtime-dom/src/nodeOps.ts 模块,对外暴露 nodeOps 对象:const doc = document export const nodeOps = { * 插入指定元素到指定位置 insert: (child, parent, anchor) => { parent.insertBefore(child, anchor || null) * 创建指定 Element createElement: (tag): Element => { const el = doc.createElement(tag) return el * 为指定的 element 设置 textContent setElementText: (el, text) => { el.textContent = text 封装 props 操作创建 packages/runtime-dom/src/patchProp.ts 模块,暴露 patchProp 方法:const doc = document export const nodeOps = { * 插入指定元素到指定位置 insert: (child, parent, anchor) => { parent.insertBefore(child, anchor || null) * 创建指定 Element createElement: (tag): Element => { const el = doc.createElement(tag) return el * 为指定的 element 设置 textContent setElementText: (el, text) => { el.textContent = text }创建 packages/runtime-dom/src/modules/class.ts 模块,暴露 patchClass 方法:/** * 为 class 打补丁 export function patchClass(el: Element, value: string | null) { if (value == null) { el.removeAttribute('class') } else { el.className = value }在 packages/shared/src/index.ts 中,写入 isOn 方法:const onRE = /^on[^a-z]/ * 是否 on 开头 export const isOn = (key: string) => onRE.test(key)三大块 全部完成,标记着整个 renderer 架构设计完成。4. 代码实现:基于 renderer 完成 ELEMENT 节点挂载在 packages/runtime-core/src/renderer.ts 中,创建 processElement 方法:/** * Element 的打补丁操作 const processElement = (oldVNode, newVNode, container, anchor) => { if (oldVNode == null) { // 挂载操作 mountElement(newVNode, container, anchor) } else { // TODO: 更新操作 * element 的挂载操作 const mountElement = (vnode, container, anchor) => { const { type, props, shapeFlag } = vnode // 创建 element const el = (vnode.el = hostCreateElement(type)) if (shapeFlag & ShapeFlags.TEXT_CHILDREN) { // 设置 文本子节点 hostSetElementText(el, vnode.children as string) } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { // TODO: 设置 Array 子节点 // 处理 props if (props) { // 遍历 props 对象 for (const key in props) { hostPatchProp(el, key, null, props[key]) // 插入 el 到指定的位置 hostInsert(el, container, anchor) const patch = (oldVNode, newVNode, container, anchor = null) => { if (oldVNode === newVNode) { return const { type, shapeFlag } = newVNode switch (type) { case Text: // TODO: Text break case Comment: // TODO: Comment break case Fragment: // TODO: Fragment break default: if (shapeFlag & ShapeFlags.ELEMENT) { processElement(oldVNode, newVNode, container, anchor) } else if (shapeFlag & ShapeFlags.COMPONENT) { // TODO: 组件 }根据源码的逻辑,在这里主要做了五件事情:区分挂载、更新创建 Element设置 text设置 class插入 DOM 树5. 代码实现:合并渲染架构我们知道,在源码中,我们可以直接:const { render } = Vue render(vnode, document.querySelector('#app'))但是在我们现在的代码,发现是 不可以 直接这样导出并使用的。所以这就是本小节要做的 得到可用的 render 函数创建 packages/runtime-dom/src/index.ts:import { createRenderer } from '@vue/runtime-core' import { extend } from '@vue/shared' import { nodeOps } from './nodeOps' import { patchProp } from './patchProp' const rendererOptions = extend({ patchProp }, nodeOps) let renderer function ensureRenderer() { return renderer || (renderer = createRenderer(rendererOptions)) export const render = (...args) => { ensureRenderer().render(...args) }在 packages/runtime-core/src/index.ts 中导出 createRenderer在 packages/vue/src/index.ts 中导出 render创建测试实例 packages/vue/examples/runtime/render-element.html :`<script> const { h, render } = Vue const vnode = h( 'div', class: 'test' 'hello render' console.log(vnode) render(vnode, document.querySelector('#app')) </script>成功渲染出 hello render!
前言终于来到渲染系统啦~在 vue3 渲染系统学习的第一章,我们先来处理 h 函数的构建,关于 h 函数的介绍我这里就不多讲了,具体可以查询文档 h() 以及 创建VNode我们知道 h 函数核心是用来:创建 vnode 的。但是对于 vnode 而言,它存在很多种不同的节点类型。查看 packages/runtime-core/src/renderer.ts 中第 354 行 patch 方法的代码可知,Vue 总共处理了:Text:文本节点Comment:注释节点Static:静态 DOM 节点Fragment:包含多个根节点的模板被表示为一个片段 (fragment)ELEMENT: DOM 节点COMPONENT:组件TELEPORT:新的 内置组件SUSPENSE:新的 内置组件…各种不同类型的节点,而每一种类型的处理都对应着不同的 VNode。所以我们在本章中,就需要把各种类型的 VNode 构建出来(不会全部处理所有类型,只会选择比较有代表性的部分),以便,后面进行 render 渲染。1. 构建 h 函数,处理 ELEMENT + TEXT_CHILDREN老样子,我们从下面这段代码的调试 开始 vue3 的源码阅读<script> const { h } = Vue const vnode = h( 'div', class: 'test' 'hello render' console.log(vnode) </script>这段代码很简单,就是使用 h 函数 创建了一个类型为 ELEMENT 子节点为 TEXT 的 vnode。1.1 源码阅读我们直接跳到源码 packages/runtime-core/src/h.ts 中的第 174 行,为 h 函数增加 debugger:通过源码可知,h 函数接收三个参数:type:类型。比如当前的 div 就表示 Element 类型propsOrChildren:props 或者 childrenchildren:子节点而且最终代码将会触发 createVNode 方法,createVNode 方法实际就是调用了 _createVnode 方法 我们进入 _createVNode 方法:3、 这里 _createVNode 对 type 做了一些条件判断,我们的 type 为 div 可以先跳过接着调试:_createVNode 接着对 props 做了 class 和 style 的增强,我们也可以先不管,最终得到 shapeFlag 的值为 1,shapeFlag 为当前的 类型标识: shapeFlag。查看 packages/shared/src/shapeFlags.ts 的代码,根据 enum ShapeFlags 可知:1 代表为 Element即当前 shapeFlag = ShapeFlags.Element,代码继续执行:可以看到 _craeteVNode 最终是调用了 createBaseVNode 方法,我们进入到 createBaseVNode 方法:createBaseVnode 方法首先创建了一个 vnode,此时的 vnode 为上图右侧所示。我们做些简化,剔除对我们无用的属性之后,得到:children: "hello render props: {class: 'test'} shapeFlag: 1 // 表示为 Element type: "div" __v_isVNode: true在 createBaseVnode 中继续执行代码,会进入到 normalizeChildren 的方法中:在 normalizeChildren 的方法中,会执行最后的 else 以及一个 按位或赋值运算 最后得到 shapeFlag 的最终值为 9normalizeChildren 方法 结束, craeteBaseVNode 返回 vnode至此,整个 h 函数执行完成,最终得到的打印有效值为:children: "hello render props: {class: 'test'} shapeFlag: 9 // 表示为 Element | ShapeFlags.TEXT_CHILDREN 的值 type: "div" __v_isVNode: true 总结:h 函数内部本质上只处理了参数的问题createVNode 是生成 vnode 的核心方法在 createVNode 中第一次生成了 shapeFlag = ShapeFlags.ELEMENT,表示为:是一个 Element 类型在 createBaseVNode 中,生成了 vnode 对象,并且对 shapeFlag 的进行 |= 运算,最终得到的 shapeFlag = 9,表示为:元素为 ShapeFlags.ELEMENT,children 为 TEXT1.2 代码实现创建 packages/shared/src/shapeFlags.ts ,写入所有的对应类型:export const enum ShapeFlags { * type = Element ELEMENT = 1, * 函数组件 FUNCTIONAL_COMPONENT = 1 << 1, * 有状态(响应数据)组件 STATEFUL_COMPONENT = 1 << 2, * children = Text TEXT_CHILDREN = 1 << 3, * children = Array ARRAY_CHILDREN = 1 << 4, * children = slot SLOTS_CHILDREN = 1 << 5, * 组件:有状态(响应数据)组件 | 函数组件 COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT 创建 packages/runtime-core/src/h.ts ,构建 h 函数:import { isArray, isObject } from '@vue/shared' import { createVNode, isVNode, VNode } from './vnode' export function h(type: any, propsOrChildren?: any, children?: any): VNode { // 获取用户传递的参数数量 const l = arguments.length // 如果用户只传递了两个参数,那么证明第二个参数可能是 props , 也可能是 children if (l === 2) { // 如果 第二个参数是对象,但不是数组。则第二个参数只有两种可能性:1. VNode 2.普通的 props if (isObject(propsOrChildren) && !isArray(propsOrChildren)) { // 如果是 VNode,则 第二个参数代表了 children if (isVNode(propsOrChildren)) { return createVNode(type, null, [propsOrChildren]) // 如果不是 VNode, 则第二个参数代表了 props return createVNode(type, propsOrChildren, []) // 如果第二个参数不是单纯的 object,则 第二个参数代表了 props else { return createVNode(type, null, propsOrChildren) // 如果用户传递了三个或以上的参数,那么证明第二个参数一定代表了 props else { // 如果参数在三个以上,则从第二个参数开始,把后续所有参数都作为 children if (l > 3) { children = Array.prototype.slice.call(arguments, 2) // 如果传递的参数只有三个,则 children 是单纯的 children else if (l === 3 && isVNode(children)) { children = [children] // 触发 createVNode 方法,创建 VNode 实例 return createVNode(type, propsOrChildren, children) }创建 packages/runtime-core/src/vnode.ts,处理 VNode 类型和 isVNode 函数:export interface VNode { __v_isVNode: true type: any props: any children: any shapeFlag: number export function isVNode(value: any): value is VNode { return value ? value.__v_isVNode === true : false }在 packages/runtime-core/src/vnode.ts 中,构建 createVNode 函数: /** * 生成一个 VNode 对象,并返回 * @param type vnode.type * @param props 标签属性或自定义属性 * @param children 子节点 * @returns vnode 对象 export function createVNode(type, props, children): VNode { // 通过 bit 位处理 shapeFlag 类型 const shapeFlag = isString(type) ? ShapeFlags.ELEMENT : 0 return createBaseVNode(type, props, children, shapeFlag) * 构建基础 vnode function createBaseVNode(type, props, children, shapeFlag) { const vnode = { __v_isVNode: true, type, props, shapeFlag } as VNode normalizeChildren(vnode, children) return vnode export function normalizeChildren(vnode: VNode, children: unknown) { let type = 0 const { shapeFlag } = vnode if (children == null) { children = null } else if (isArray(children)) { // TODO: array } else if (typeof children === 'object') { // TODO: object } else if (isFunction(children)) { // TODO: function } else { // children 为 string children = String(children) // 为 type 指定 Flags type = ShapeFlags.TEXT_CHILDREN // 修改 vnode 的 chidlren vnode.children = children // 按位或赋值 vnode.shapeFlag |= type 在 index 中导出 h 函数下面我们可以创建对应的测试实例,packages/vue/examples/runtime/h-element.html:<script> const { h } = Vue const vnode = h( 'div', class: 'test' 'hello render' console.log(vnode) </script>最终打印的结果为:children: "hello render" props: {class: 'test'} shapeFlag: 9 type: "div" __v_isVNode: true至此,我们就已经构建好了:type = Element,children = Text 的 VNode 对象2. 构建 h 函数,处理 ELEMENT + ARRAY_CHILDREN将测试用例改为下面的代码:<script> const { h } = Vue const vnode = h( 'div', class: 'test' [h('p', 'p1'), h('p', 'p2'), h('p', 'p3')] console.log(vnode) </script>我们很容易能看出上面的代码执行了四次 h 函数,分别为:h('p', 'p1')h('p', 'p2')h('p', 'p3')以及最外层的 h(...)前三次触发代码的流程和第一个节中相似,我们直接将代码 debugger 到第四次 h 函数2.1 源码阅读此时进入到 _createVNode 时的参数为:代码继续,计算 shapeFlag = 1(与第一节一样)_createVNode 返回一个 createBaseVNode 方法, 进入 createBaseVNodecreateBaseVNode 创建 vnode, 接着执行 normalizeChildren(vnode, children):normalizeChildren 我们之前跟踪过得,这次 vnode.shapeFlag 计算出来是 17。我们最终将不重要的属性剔除,打印出的 vnode 结构为:{ "__v_isVNode": true, "type": "div", "props": { "class": "test" }, "children": [ "__v_isVNode": true, "type": "p", "children": "p1", "shapeFlag": 9 "__v_isVNode": true, "type": "p", "children": "p2", "shapeFlag": 9 "__v_isVNode": true, "type": "p", "children": "p3", "shapeFlag": 9 "shapeFlag": 17 总结处理 ELEMENT + ARRAY_CHILDREN 的过程整体的逻辑并没有变得复杂第一次计算 shapeFlag,依然为 Element第二次计算 shapeFlag,因为 children 为 Array,所以会进入 else if (array) 判断2.2 代码实现根据上一小节的源码阅读可知,ELEMENT + ARRAY_CHILDREN 场景下的处理,我们只需要在 packages/runtime-core/src/vnode.ts 中,处理 isArray 场景即可:在 packages/runtime-core/src/vnode.ts 中,找到 normalizeChildren 方法: else if (isArray(children)) { // TODO: array + type = ShapeFlags.ARRAY_CHILDREN }创建测试实例 packages/vue/examples/runtime/h-element-ArrayChildren.html :<script> const { h } = Vue const vnode = h( 'div', class: 'test' [h('p', 'p1'), h('p', 'p2'), h('p', 'p3')] console.log(vnode) </script>2.3 总结到现在我们可以先做一个局部的总结。对于 vnode 而言,我们现在已经知道,它存在一个 shapeFlag 属性,该属性表示了当前 VNode 的 “类型” ,这是一个非常关键的属性,在后面的 render 函数中,还会再次看到它。shapeFlag 分成两部分:createVNode:此处计算 “DOM” 类型,比如 ElementcreateBaseVNode:此处计算 “children” 类型,比如 Text || Array3. 构建 h 函数,处理组件组件是 vue 中非常重要的一个概念,这一小节我们就来分析一下 组件 生成 VNode 的情况。在 vue 中,组件本质上就是 一个对象或一个函数(Function Component)我们这里 不考虑 组件是函数的情况,因为这个比较少见。在 vue3 中,我们可以直接利用 h 函数 + render 函数渲染出一个基本的组件,就像下面这样:<script> const { h, render } = Vue const component = { render() { const vnode1 = h('div', '这是一个 component') console.log(vnode1) return vnode1 const vnode2 = h(component) console.log(vnode2) render(vnode2, document.querySelector('#app')) </script>3.1 案例分析在当前代码中共触发了两次 h 函数,第一次是在 component 对象中的 render 函数内,我们可以把 component 对象看成一个组件,实际上在 vue3 中你打印一个组件对象它的内部就有一个 render 函数,下面是我打印的一个 App 组件第二次是在将 component 作为参数生成的 vnode2 时最后将生成的 vnode2 通过 render 渲染函数 渲染到页面上(关于 render 函数我们之后在讲)最终打印的 vnode2 如下图所示:shapeFlag:这个是当前的类型表示,4 表示为一个 组件type:是一个 对象,它的值包含了一个 render 函数,这个就是 component 的 真实渲染 内容__v_isVNode:VNode 标记vnode1:与 ELEMENT + TEXT_CHILDREN 相同{ __v_isVNode: true, type: "div", children: "这是一个 component", shapeFlag: 9 总结:那么由此可知,对于 组件 而言,它的一个渲染,与之前不同的地方主要有两个:shapeFlag === 4type:是一个 对象(组件实例),并且包含 render 函数仅此而已,那么依据这样的概念,我们可以通过如下代码,完成同样的渲染:const component = { render() { return { __v_isVNode: true, type: 'div', children: '这是一个 component', shapeFlag: 9 render( __v_isVNode: true, type: component, shapeFlag: 4 document.querySelector('#app') )3.2 代码实现在我们的代码中,处理 shapeFlag 的地方有两个:createVNode:第一次处理,表示 node 类型(比如:Element)createBaseVNode:第二次处理,表示 子节点类型(比如:Text Children)因为我们这里不涉及到子节点,所以我们只需要在 createVNode 中处理即可: // 通过 bit 位处理 shapeFlag 类型 const shapeFlag = isString(type) ? ShapeFlags.ELEMENT : isObject(type) ? ShapeFlags.STATEFUL_COMPONENT : 0此时创建测试实例 packages/vue/examples/runtime/h-component.html:<script> const { h, render } = Vue const component = { render() { const vnode1 = h('div', '这是一个 component') console.log(vnode1) return vnode1 const vnode2 = h(component) console.log(vnode2) </script>可以得到相同的打印结果:4. 构建 h 函数,处理 Text / Comment/ Fragment当组件处理完成之后,最后我们来看下 Text 、 Comment、Fragment 这三个场景下的 VNode。 <script> const { h, render, Text, Comment, Fragment } = Vue const vnodeText = h(Text, '这是一个 Text') console.log(vnodeText) // 可以通过 render 进行渲染 render(vnodeText, document.querySelector('#app1')) const vnodeComment = h(Comment, '这是一个 Comment') console.log(vnodeComment) render(vnodeComment, document.querySelector('#app2')) const vnodeFragment = h(Fragment, '这是一个 Fragment') console.log(vnodeFragment) render(vnodeFragment, document.querySelector('#app3')) </script>查看打印:可以看到 Text、Comment、Fragment 三个的 type 分别为 Symbol(Text)、Symbol(Comment)、Symbol(Fragment),还是比较简单的。实现:直接在 packages/runtime-core/src/vnode.ts 中创建三个 Symbol:export const Fragment = Symbol('Fragment') export const Text = Symbol('Text') export const Comment = Symbol('Comment')然后导出即可。创建测试实例 packages/vue/examples/runtime/h-other.html:<script> const { h, Text, Comment, Fragment } = Vue const vnodeText = h(Text, '这是一个 Text') console.log(vnodeText) const vnodeComment = h(Comment, '这是一个 Comment') console.log(vnodeComment) const vnodeFragment = h(Fragment, '这是一个 Fragment') console.log(vnodeFragment) </script>测试打印即可。5. 构建 h 函数,完成虚拟节点下 class 和 style 的增强我们在第一节中有讲过, vue 在 _createVNode 的方法中对 class 和 style 做了专门的增强,使其可以支持 Object 和 Array 。比如说:<script> const { h, render } = Vue const vnode = h( 'div', class: { red: true '增强的 class' render(vnode, document.querySelector('#app')) </script>这样,我们可以得到一个 class: red 的 div。这样的 h 函数,最终得到的 vnode 如下:{ __v_isVNode: true, type: "div", shapeFlag: 9, props: {class: 'red'}, children: "增强的 class" 由以上的 VNode 可以发现,最终得出的 VNode 与 const vnode = h('div', { class: 'red' }, 'hello render') 是完全相同的。那么 vue 是如何来处理这种增强的呢?我们一起从源码中一探究竟(style 的增强处理与 class 非常相似,所以我们只看 class 即可)5.1 源码阅读我们直接来到在第一节阅读源码有讲过的对 prop 进行处理的地方,也就是 packages/runtime-core/src/vnode.ts 文件中 _createVNode 方法内:执行 props.class = normalizeClass(klass),这里的 normalizeClass 方法就是处理 class 增强的关键,进入 normalizeClass:总结:对于 class 的增强其实还是比较简单的,只是额外对 class 和 style 进行了单独的处理。整体的处理方式也比较简单:针对数组:进行迭代循环针对对象:根据 value 拼接 name5.2 代码实现创建 packages/shared/src/normalizeProp.ts :import { isArray, isObject, isString } from '.' * 规范化 class 类,处理 class 的增强 export function normalizeClass(value: unknown): string { let res = '' // 判断是否为 string,如果是 string 就不需要专门处理 if (isString(value)) { res = value // 额外的数组增强。官方案例:https://cn.vuejs.org/guide/essentials/class-and-style.html#binding-to-arrays else if (isArray(value)) { // 循环得到数组中的每个元素,通过 normalizeClass 方法进行迭代处理 for (let i = 0; i < value.length; i++) { const normalized = normalizeClass(value[i]) if (normalized) { res += normalized + ' ' // 额外的对象增强。官方案例:https://cn.vuejs.org/guide/essentials/class-and-style.html#binding-html-classes else if (isObject(value)) { // for in 获取到所有的 key,这里的 key(name) 即为 类名。value 为 boolean 值 for (const name in value as object) { // 把 value 当做 boolean 来看,拼接 name if ((value as object)[name]) { res += name + ' ' // 去左右空格 return res.trim() 在 packages/runtime-core/src/vnode.ts 的 createVNode 增加判定:if (props) { // 处理 class let { class: klass, style } = props if (klass && !isString(klass)) { props.class = normalizeClass(klass) }至此代码完成。可以创建 packages/vue/examples/runtime/h-element-class.html 测试用例:<script> const { h, render } = Vue const vnode = h( 'div', class: { red: true '增强的 class' render(vnode, document.querySelector('#app')) </script>打印可以获取到正确的 vnode。6. 总结在本章中,完成了对:ElementComponentTextCommentFragment5 个标签类型的处理。同时处理了:Text ChildrenArray chiLdren两个子节点类型。在这里渲染中,我们可以发现,整个 Vnode 生成,核心的就是几个属性:typechildrenshapeFlag__v_isVNode另外,还完成了 class 的增强逻辑,对于 class 的增强其实是一个额外的 class 和 array 的处理,把复杂数据类型进行解析即可。对于 style 的增强逻辑本质上和 class 的逻辑是一样的所以没有去实现。它的源码是在 packages/shared/src/normalizeProp.ts 中的 normalizeStyle 方法,本身的逻辑也非常简单。
1. 前言这是 《vue3 源码学习,实现一个 mini-vue》 系列文章 响应式模块 的最后一章,在前面几章我们分别介绍了 reactive、ref 以及 computed 这三个方法,阅读了 vue 源码并且实现了它们,那么本章我们最后来实现一下 watch 吧~2. watch 源码阅读我们可以点击 这里 来查看 watch 的官方文档。watch 的实现和 computed 有一些相似的地方,但是作用却与 computed 大有不同。watch 可以监听响应式数据的变化,从而触发指定的函数。2.1 基础的 watch 实例我们直接从下面的代码开始 vue 源码调试:<script> const { reactive, watch } = Vue const obj = reactive({ name: '张三' watch(obj, (value, oldValue) => { console.log('watch 监听被触发') console.log('value', value) setTimeout(() => { obj.name = '李四' }, 2000) </scri以上代码分析:首先通过 reactive 函数构建了响应性的实例然后触发 watch最后触发 proxy 的 setter摒弃掉之前熟悉的 reactive,我们从 watch 函数开始源码跟踪:2.2 watch 函数我们直接来到 packages/runtime-core/src/apiWatch.ts 中找到 watch 函数,开始 debugger:可以看到 watch 接受三个参数 source cb options,最后返回并调用了 doWatch,我们进入到 doWatch:doWatch 方法代码很多,上面有一些警告打印的 if,我们直接来到第 207 行。因为 source 为 reactive 类型数据,所以会执行 getter = () => source,目前 source 为 proxy 实例,即:getter = () => Proxy{name: '张三'}。紧接着,指定 deep = true,即:source 为 reactive 时,默认添加 options.deep = true。我们继续调试 doWatch 这个方法:执行 if (cb && deep),条件满足:创建新的常量 baseGetter = getter,我们继续调试 doWatch 这个方法:执行 let oldValue = isMultiSource ? [] : INITIAL_WATCHER_VALUE,将 INITIAL_WATCHER_VALUE 赋值给 oldValue,INITIAL_WATCHER_VALUE = {}执行 const job: SchedulerJob = () => {...},我们知道 Scheduler 是一个调度器,SchedulerJob 其实就是一个调度器的处理函数,在之前我们接触了一下 Scheduler 调度器,但是并没有进行深入了解,那么这里将涉及到调度器的比较复杂的一些概念,所以后面我们想要实现 watch,还需要 深入的了解下调度器的概念,现在我们暂时先不需要管它。我们继续调试 doWatch 这个方法:直接执行:let scheduler: EffectScheduler = () => queuePreFlushCb(job),这里通过执行 queuePreFlushCb 函数,将上一步的 job 作为传参,来得到一个完整的调度器函数 scheduler。我们继续调试 doWatch 这个方法:代码继续执行得到一个 ReactiveEffect 的实例,注意: 该实例包含一个完善的调度器 scheduler,接着调用了 effect 的 run 方法,实际上是调用了 getter 方法,获取到了 oldValue,最后返回一个回调函数。至此 watch 函数的逻辑执行完成。总结:watch 函数的代码很长,但是逻辑还算清晰调度器 scheduler 在 watch 中很关键scheduler 、ReactiveEffect 两者之间存在互相作用的关系,一旦 effect 触发了 scheduler 那么会导致 queuePreFlushCb(job) 执行只要 job() 触发,那么就表示 watch 触发了一次2.3 reactive 触发 setter等待两秒,reactive 实例将触发 setter 行为,setter 行为的触发会导致 trigger 函数的触发,所以我们可以直接在 trigger 中进行 debugger我们直接来到 packages/reactivity/src/effect.ts 中找到 trigger,进行 debugger:根据我们之前的经验可知,trigger 最终会触发到 triggerEffect,所以我们可以 省略中间 步骤,直接进入到 triggerEffect 中:我们主要来看 triggerEffect:因为 scheduler 存在,所以会直接执行 scheduler,即等同于直接执行 queuePreFlushCb(job)。所以接下来我们 进入 queuePreFlushCb 函数,看看 queuePreFlushCb 做了什么:触发 queueCb(cb, ..., pendingPreFlushCbs, ...) 函数,此时 cb = job,即:cb() 触发一次,意味着 watch 触发一次,进入 queueCb 函数:执行 pendingQueue.push(cb),pendingQueue 从语义中看表示 队列 ,为一个 数组,接着执行了 queueFlush 函数,我们进入 queueFlush() 函数:queueFlush 函数内部做了两件事:1. 执行了 isFlushPending = true isFlushPending 是一个 标记,表示 promise 进入 pending 状态。2. 通过 Promise.resolve().then() 这样一种 异步微任务的方式 执行了 flushJobs 函数,flushJobs 是一个 异步函数,它会等到 同步任务执行完成之后 被触发,我们可以 给 flushJobs 函数内部增加一个断点至此整个 trigger 就执行完成总结:整个 trigger 的执行核心是触发了 scheduler 调度器,从而触发 queuePreFlushCb 函数queuePreFlushCb 函数主要做了以下几点事情:构建了任务队列 pendingQueue通过 Promise.resolve().then 把 flushJobs 函数扔到了微任务队列中同时因为接下来 同步任务已经执行完成,所以 异步的微任务 马上就要开始执行,即接下来我们将会进入 flushJobs 中。2.4 flushJobs 函数进入 flushJobs 函数代码:执行 flushPreFlushCbs(seen) 函数,这个函数非常关键,我们来看一下:通过截图代码可知,pendingPreFlushCbs 为一个数组,其中第一个元素就是 job 函数(通过 2.2 watch 函数 第 4 步 下面的截图可以看到传参)执行 for 循环,执行 activePreFlushCbs[preFlushIndex](),即从 activePreFlushCbs 这个数组中,取出一个函数,并执行(就是 job 函数!)到这里,job 函数被成功执行,我们知道 job 执行意味着 watch 执行,即当前 watch 的回调 即将被执行总结:flushJobs 的主要作用就是触发 job,即:触发 watch2.5 job 函数进入 job 的执行函数,执行 const newValue = effect.run(),此时 effect 为 :我们知道执行 run,本质上是执行 fn,而 traverse(baseGetter()) 即为 traverse(() => Proxy{name: 'xx'}),结合代码获取到的是 newValue,所以我们可以大胆猜测,测试 fn 的结果等同于:`fn: () => ({name: '李四'})。 接下来执行:callWithAsyncErrorHandling(cb ......):函数接收的第一个参数 fn 的值为 watch 的第二个参数 cb。接下来执行 callWithErrorHandling(fn ......)。这里的代码就比较简单了,其实就是触发了 fn(...args),即:watch 的回调被触发,此时 args 的值为:截止到此时 watch 的回调终于 被触发了。总结:job 函数的主要作用其实就是有两个:拿到 newValue 和 oldValue触发 fn 函数执行2.6 总结到目前为止,整个 watch 的逻辑就已经全部理完了。整体氛围了四大块:watch 函数本身reactive 的 setterflushJobsjob整个 watch 还是比较复杂的,主要是因为 vue 在内部进行了很多的 兼容性处理,使代码的复杂度上升了好几个台阶,我们自己去实现的时候 会简单很多 的。3. 代码实现3.1 scheduler 调度系统机制实现经过了 computed 的代码和 watch 的代码之后,其实我们可以发现,在这两块代码中都包含了同样的一个概念那就是:调度器 scheduler。完整的来说,我们应该叫它:调度系统整个调度系统其实包含两部分实现:lazy:懒执行scheduler:调度器3.1.1 懒执行懒执行相对比较简单,我们来看 packages/reactivity/src/effect.ts 中第 183 - 185 行的代码:if (!options || !options.lazy) { _effect.run() }这段代码比较简单,其实就是如果存在 options.lazy 则 不立即 执行 run 函数。我们可以直接对这段代码进行实现:export interface ReactiveEffectOptions { lazy?: boolean scheduler?: EffectScheduler * effect 函数 * @param fn 执行方法 * @returns 以 ReactiveEffect 实例为 this 的执行函数 export function effect<T = any>(fn: () => T, options?: ReactiveEffectOptions) { // 生成 ReactiveEffect 实例 const _effect = new ReactiveEffect(fn) // !options.lazy 时 if (!options || !options.lazy) { // 执行 run 函数 _effect.run() 那么此时,我们就可以新建一个测试案例来测试下 lazy,创建 packages/vue/examples/reactivity/lazy.html:<script> const { reactive, effect } = Vue const obj = reactive({ count: 1 // 调用 effect 方法 effect( () => { console.log(obj.count) lazy: true obj.count = 2 console.log('代码结束') </script>当不存在 lazy 时,打印结果为:1 代码结束当 lazy 为 true 时,因为不在触发 run,所以不会进行依赖收集,打印结果为:代码结束3.1.2 scheduler:调度器调度器比懒执行要稍微复杂一些,整体的作用分成两块:控制执行顺序控制执行规则1. 控制执行顺序在 packages/reactivity/src/effect.ts 中:export function effect<T = any>(fn: () => T, options?: ReactiveEffectOptions) { // 生成 ReactiveEffect 实例 const _effect = new ReactiveEffect(fn) // 存在 options,则合并配置对象 + if (options) { + extend(_effect, options) // !options.lazy 时 if (!options || !options.lazy) { // 执行 run 函数 _effect.run() }在 packages/shared/src/index.ts 中,增加 extend 函数:/** * Object.assign export const extend = Object.assign创建测试案例 packages/vue/examples/reactivity/scheduler.html:<script> const { reactive, effect } = Vue const obj = reactive({ count: 1 // 调用 effect 方法 effect( () => { console.log(obj.count) scheduler() { setTimeout(() => { console.log(obj.count) obj.count = 2 console.log('代码结束') </script>最后执行结果为:1 2说明我们实现了 控制执行顺序2. 控制执行规则创建 packages/runtime-core/src/scheduler.ts :// 对应 promise 的 pending 状态 let isFlushPending = false * promise.resolve() const resolvedPromise = Promise.resolve() as Promise<any> * 当前的执行任务 let currentFlushPromise: Promise<void> | null = null * 待执行的任务队列 const pendingPreFlushCbs: Function[] = [] * 队列预处理函数 export function queuePreFlushCb(cb: Function) { queueCb(cb, pendingPreFlushCbs) * 队列处理函数 function queueCb(cb: Function, pendingQueue: Function[]) { // 将所有的回调函数,放入队列中 pendingQueue.push(cb) queueFlush() * 依次处理队列中执行函数 function queueFlush() { if (!isFlushPending) { isFlushPending = true currentFlushPromise = resolvedPromise.then(flushJobs) * 处理队列 function flushJobs() { isFlushPending = false flushPreFlushCbs() * 依次处理队列中的任务 export function flushPreFlushCbs() { if (pendingPreFlushCbs.length) { let activePreFlushCbs = [...new Set(pendingPreFlushCbs)] pendingPreFlushCbs.length = 0 for (let i = 0; i < activePreFlushCbs.length; i++) { activePreFlushCbs[i]() }创建 packages/runtime-core/src/index.ts ,导出 queuePreFlushCb 函数:export { queuePreFlushCb } from './scheduler'在 packages/vue/src/index.ts 中,新增导出函数:export { queuePreFlushCb } from '@vue/runtime-core'创建测试案例 packages/vue/examples/reactivity/scheduler-2.html :<script> const { reactive, effect, queuePreFlushCb } = Vue const obj = reactive({ count: 1 // 调用 effect 方法 effect( () => { console.log(obj.count) scheduler() { queuePreFlushCb(() => { console.log(obj.count) obj.count = 2 obj.count = 3 </script>最后执行结果为:1 3说明我们实现了 控制执行规则3.2.3 总结懒执行相对比较简单,所以我们的总结主要针对调度器来说明。调度器是一个相对比较复杂的概念,但是它本身并不具备控制 执行顺序 和 执行规则 的能力。想要完成这两个能力,我们需要借助一些其他的东西来实现,这整个的一套系统,我们把它叫做 调度系统那么到目前,我们调度系统的代码就已经实现完成了,这个代码可以在我们将来实现 watch 的时候直接使用。3.2 初步实现 watch 数据监听器创建 packages/runtime-core/src/apiWatch.ts 模块,创建 watch 与 doWatch 函数:/** * watch 配置项属性 export interface WatchOptions<Immediate = boolean> { immediate?: Immediate deep?: boolean * 指定的 watch 函数 * @param source 监听的响应性数据 * @param cb 回调函数 * @param options 配置对象 * @returns export function watch(source, cb: Function, options?: WatchOptions) { return doWatch(source as any, cb, options) function doWatch( source, cb: Function, { immediate, deep }: WatchOptions = EMPTY_OBJ // 触发 getter 的指定函数 let getter: () => any // 判断 source 的数据类型 if (isReactive(source)) { // 指定 getter getter = () => source // 深度 deep = true } else { getter = () => {} // 存在回调函数和deep if (cb && deep) { // TODO const baseGetter = getter getter = () => baseGetter() // 旧值 let oldValue = {} // job 执行方法 const job = () => { if (cb) { // watch(source, cb) const newValue = effect.run() if (deep || hasChanged(newValue, oldValue)) { cb(newValue, oldValue) oldValue = newValue // 调度器 let scheduler = () => queuePreFlushCb(job) const effect = new ReactiveEffect(getter, scheduler) if (cb) { if (immediate) { job() } else { oldValue = effect.run() } else { effect.run() return () => { effect.stop() 在 packages/reactivity/src/reactive.ts 为 reactive 类型的数据,创建 标记: export const enum ReactiveFlags { IS_REACTIVE = '__v_isReactive' function createReactiveObject( // 未被代理则生成 proxy 实例 const proxy = new Proxy(target, baseHandlers) // 为 Reactive 增加标记 proxy[ReactiveFlags.IS_REACTIVE] = true * 判断一个数据是否为 Reactive export function isReactive(value): boolean { return !!(value && value[ReactiveFlags.IS_REACTIVE]) 在 packages/shared/src/index.ts 中创建 EMPTY_OBJ:/** * 只读的空对象 export const EMPTY_OBJ: { readonly [key: string]: any } = {}在 packages/runtime-core/src/index.ts 和 packages/vue/src/index.ts 中导出 watch 函数创建测试实例 packages/vue/examples/reactivity/watch.html:<script> const { reactive, watch } = Vue const obj = reactive({ name: '张三' watch( (value, oldValue) => { console.log('watch 监听被触发') console.log('value', value) setTimeout(() => { obj.name = '李四' }, 2000) </script>此时运行项目,却发现,当前存在一个问题,那就是 watch 监听不到 reactive 的变化。这个问题的原因是 我们在 setTimeout 中,触发了 触发依赖 操作。但是我们并没有做 依赖收集 的操作导致的。不知道大家还记不记得,我们之前在看源码的时候,看到过一个 traverse 方法。之前的时候,我们一直没有看过该方法,那么现在我们可以来说一下它了。它的源码在 packages/runtime-core/src/apiWatch.ts 中:查看源代码可以发现,这里面的代码其实有些 莫名其妙,他好像什么都没有做,只是在 循环的进行 xxx.value 的形式,我们知道 xxx.value 这个行为,我们把它叫做 getter 行为。并且这样会产生 副作用,那就是 依赖收集!。所以我们知道了,对于 traverse 方法而言,它就是一个不断在触发响应式数据 依赖收集 的方法。我们可以通过该方法来触发依赖收集,然后在两秒之后,触发依赖,完成 scheduler 的回调。3.3 完成 watch 数据监听器的依赖收集在 packages/runtime-core/src/apiWatch.ts 中,创建 traverse 方法:/** * 依次执行 getter,从而触发依赖收集 export function traverse(value: unknown) { if (!isObject(value)) { return value for (const key in value as object) { traverse((value as any)[key]) return value }在 doWatch 中通过 traverse 方法,构建 getter:// 存在回调函数和deep if (cb && deep) { // TODO const baseGetter = getter getter = () => traverse(baseGetter()) }此时再次运行测试实例, watch 成功监听。同时因为我们已经处理了 immediate 的场景:if (cb) { if (immediate) { job() } else { oldValue = effect.run() } else { effect.run() }所以,目前 watch 也支持 immediate 的配置选项。3.4 总结对于 watch 而言本质上还是依赖于 ReactiveEffect 来进行的实现。本质上依然是一个 依赖收集、触发依赖 的过程。只不过区别在于此时的依赖收集是被 “被动触发” 的。除此之外,还有一个调度器的概念,对于调度器而言,它起到的的主要作用就是 控制执行顺序、控制执行规则 ,但是大家也需要注意调度器本身只是一个函数,想要完成调度功能,还需要其他的东西来配合才可以。4. 最后总结到这里,mini-vue 的整个 响应系统 就完成了,响应系统分成了:reactiverefcomputedwatch四大块来进行分别的实现。通过之前的学习可以知道,响应式的核心 API 为 Proxy。整个 reactive 都是基于此来进行实现。但是 Porxy 只能代理 复杂数据类型,所以延伸除了 get value 和 set value 这样 以属性形式调用的方法, ref 和 computed 之所以需要 .value 就是因为这样的方法。响应系统 终于结束,接下来可以开始学习新的模块 渲染系统 喽~
前言对于响应性系统而言,除了前两章接触的 ref 和 reactive 之外,还有另外两个也是我们经常使用到的,那就是:计算属性:computed侦听器:watch本章我们先来实现一下 computed 这个 API1. computed 计算属性计算属性 computed 会 基于其响应式依赖被缓存,并且在依赖的响应式数据发生变化时 重新计算我们来看下面这段代码:<div id="app"></div> <script> const { reactive, computed, effect } = Vue const obj = reactive({ name: '张三' const computedObj = computed(() => { return '姓名:' + obj.name effect(() => { document.querySelector('#app').innerHTML = computedObj.value setTimeout(() => { obj.name = '李四' }, 2000) </script>上面的代码,程序主要执行了 5 个步骤:使用 reactive 创建响应性数据通过 computed 创建计算属性 computedObj,并且触发了 obj 的 getter通过 effect 方法创建 fn 函数在 fn 函数中,触发了 computed 的 getter延迟触发了 obj 的 setter接下来我们将从源码中研究 computed 的实现:2. computed 源码阅读因为研究过了 reactive 的实现,所以我们直接来到 packages/reactivity/src/computed.ts 中的第 84 行,在 computed 函数出打上断点:可以看到 computed 方法其实很简单,主要就是创建并返回了一个 ComputedRefImpl 对象,我们将代码跳转进 ComputedRefImpl 类。在 ComputedRefImpl 的构造函数中 创建了 ReactiveEffect 实例,并且传入了两个参数:getter:触发 computed 函数时,传入的第一个参数匿名函数:当 this._dirty 为 false 时,会触发 triggerRefValue,我们知道 triggerRefValue 会 依次触发依赖 (_dirty 在这里以为 脏 的意思,可以理解为 《依赖的数据发生变化,计算属性就需要重新计算了》)对于 ReactiveEffect 而言,我们之前是有了解过的,生成的实例,我们一般把它叫做 effect,他主要提供两个方法:run 方法:触发 fn,即传入的第一个参数stop 方法:语义上为停止的意思,我这里目前还没有实现至此,我们已经执行完了 computed 函数,我们来总结一下做了什么:定义变量 getter 为我们传入的回调函数生成了 ComputedRefImpl 实例,作为 computed 函数的返回值ComputedRefImpl 内部,利用了 ReactiveEffect 函数,并且传入了 第二个参数当 computed 代码执行完成之后,我们在 effect 中触发了 computed 的 getter:computedObj.value根据我们之前在学习 ref 的时候可知,.value 属性的调用本质上是一个 get value 的函数调用,而 computedObj 作为 computed 的返回值,本质上是 ComputedRefImpl 的实例, 所以此时会触发 ComputedRefImpl 下的 get value 函数。在 get value 中,做了两件事:做了trackRefVale 依赖收集。执行了之前存在 computed 中的函数 () => return '姓名' + obj.name,并返回了结果这里可以提一下第 59 行中的判断条件,_dirty 初始化是 ture(_cacheable 初始化 false),所以会执行这个 if, 在 if 中将 _dirty 改为了 false,也就是说只要不改这个 _dirty,下次再去获取 computedObj.value 值时,不会重新执行 fn 。effect 函数执行完成,页面显示 姓名:张三,延迟两秒之后,会触发 obj.name 即 reactive 的 setter 行为,所以我们可以在 packages/reactivity/src/baseHandlers.ts 中为 set 增加一个断点:可以发现因为之前 oldValue 是张三 ,现在 value 是李四,hasChange 方法为 true,进入到 trigger 方法同样跳过之前相同逻辑,可知,最后会触发:triggerEffects(deps[0], eventInfo) 方法。进入 triggerEffects 方法:这里要注意:因为我们在 ComputedRefImpl 的构造函数中,执行了 this.effect.computed = this,所以此时的 if (effect.computed) 判断将会为 true。此时我们注意看 effects,此时 effect 的值为 ReactiveEffect 的实例,同时 scheduler 存在值;接下来进入 triggerEffect:不知道大家还有没有印象,在 ComputedRefImpl 的构造函数创建 ReactiveEffect 实例时传进去的第二个参数,那个参数就是这里 scheduler。我们进入 scheduler 回调:此时的 _dirty 是 false,所以会执行 triggerRefValue 函数,我们进入 triggerRefValue:triggerRefValue 会再次触发 triggerEffects 依赖触发函数,把当前的 this.dep 作为参数传入。注意此时的 effect 是没有 computed 和 scheduler 属性的。fn 函数的触发,标记着 computedObj.value 触发,而我们知道 computedObj.value 本质上是 get value 函数的触发,所以代码接下来会触发 ComputedRefImpl 的 get value获取到 computedObj.value 后 通过 ocument.querySelector('#app').innerHTML = computedObj.value 修改视图。至此,整个过程结束。梳理一下修改 obj.name 到修改视图的过程:整个事件有 obj.name 开始触发 proxy 实例的 setter执行 trigger,第一次触发依赖注意,此时 effect 包含 scheduler 调度器属性,所以会触发调度器调度器指向 ComputedRefImpl 的构造函数中传入的匿名函数在匿名函数中会:再次触发依赖即:两次触发依赖最后执行 :() => { return '姓名:' + obj.name }得到值作为 computedObj 的值总结:到这里我们基本上了解了 computed 的执行逻辑,里面涉及到了一些我们之前没有了解过的概念,比如 调度器 scheduler ,并且整体的 computed 的流程也相当复杂。对于 computed 而言,整体比较复杂,所以我们将分步进行实现3. 构建 ComputedRefImpl ,读取计算属性的值我们的首先的目标是:构建 ComputedRefImpl 类,创建出 computed 方法,并且能够读取值创建 packages/reactivity/src/computed.ts :import { isFunction } from '@vue/shared' import { Dep } from './dep' import { ReactiveEffect } from './effect' import { trackRefValue } from './ref' * 计算属性类 export class ComputedRefImpl<T> { public dep?: Dep = undefined private _value!: T public readonly effect: ReactiveEffect<T> public readonly __v_isRef = true constructor(getter) { this.effect = new ReactiveEffect(getter) this.effect.computed = this get value() { // 触发依赖 trackRefValue(this) // 执行 run 函数 this._value = this.effect.run()! // 返回计算之后的真实值 return this._value * 计算属性 export function computed(getterOrOptions) { let getter // 判断传入的参数是否为一个函数 const onlyGetter = isFunction(getterOrOptions) if (onlyGetter) { // 如果是函数,则赋值给 getter getter = getterOrOptions const cRef = new ComputedRefImpl(getter) return cRef as any }在 packages/shared/src/index.ts 中,创建工具方法:/** * 是否为一个 function export const isFunction = (val: unknown): val is Function => typeof val === 'function'在 packages/reactivity/src/effect.ts 中,为 ReactiveEffect 增加 computed 属性: /** * 存在该属性,则表示当前的 effect 为计算属性的 effect computed?: ComputedRefImpl<T>在 packages/reactivity/src/index.ts 和 packages/vue/src/index.ts 导出创建测试实例:packages/vue/examples/reactivity/computed.html: <body> <div id="app"></div> </body> <script> const { reactive, computed, effect } = Vue const obj = reactive({ name: '张三' const computedObj = computed(() => { return '姓名:' + obj.name effect(() => { document.querySelector('#app').innerHTML = computedObj.value setTimeout(() => { obj.name = '李四' }, 2000) </script>此时,我们可以发现,计算属性,可以正常展示。但是: 当 obj.name 发生变化时,我们可以发现浏览器 并不会 跟随变化,即:计算属性并非是响应性的。那么想要完成这一点,我们还需要进行更多的工作才可以。4. 初见调度器,处理脏的状态如果我们想要实现 响应性,那么必须具备两个条件:收集依赖:该操作我们目前已经在 get value 中进行。触发依赖:该操作我们目前尚未完成,而这个也是我们本小节主要需要做的事情。代码实现:在 packages/reactivity/src/computed.ts 中,处理脏状态和 scheduler:export class ComputedRefImpl<T> { * 脏:为 false 时,表示需要触发依赖。为 true 时表示需要重新执行 run 方法,获取数据。即:数据脏了 public _dirty = true constructor(getter) { this.effect = new ReactiveEffect(getter, () => { // 判断当前脏的状态,如果为 false,表示需要《触发依赖》 if (!this._dirty) { // 将脏置为 true,表示 this._dirty = true triggerRefValue(this) this.effect.computed = this get value() { // 触发依赖 trackRefValue(this) // 判断当前脏的状态,如果为 true ,则表示需要重新执行 run,获取最新数据 if (this._dirty) { this._dirty = false // 执行 run 函数 this._value = this.effect.run()! // 返回计算之后的真实值 return this._value 在 packages/reactivity/src/effect.ts 中,添加 scheduler 的处理:export type EffectScheduler = (...args: any[]) => any * 响应性触发依赖时的执行类 export class ReactiveEffect<T = any> { * 存在该属性,则表示当前的 effect 为计算属性的 effect computed?: ComputedRefImpl<T> constructor( public fn: () => T, public scheduler: EffectScheduler | null = null 最后不要忘记,触发调度器函数/** * 触发指定的依赖 export function triggerEffect(effect: ReactiveEffect) { // 存在调度器就执行调度函数 if (effect.scheduler) { effect.scheduler() // 否则直接执行 run 函数即可 else { effect.run() 此时,重新执行测试实例,则发现 computed 已经具备响应性。5. computed 的 缓存问题 和 死循环问题到目前为止,我们的 computed 其实已经具备了响应性,但是还存在一点问题。我们来看下下面的代码5.1 存在的问题我们来看下面的代码:<body> <div id="app"></div> </body> <script> const { reactive, computed, effect } = Vue const obj = reactive({ name: '张三' const computedObj = computed(() => { console.log('计算属性执行计算') return '姓名:' + obj.name effect(() => { document.querySelector('#app').innerHTML = computedObj.value document.querySelector('#app').innerHTML = computedObj.value setTimeout(() => { computedObj.value = '李四' }, 2000) </script>结果报错了:调用了两次 computedObj.value 按理说 computed 只会执行一次才对,但是却提示 超出最大调用堆栈大小。5.2 为什么会出现死循环我们继续从源码中找问题,我们接着从两秒之后的 obj.name = '李四' 开始调试。修改 obj.name = '李四',此时会进行 obj 的依赖处理 trigger 函数中代码继续向下进行,进入 triggerEffects(dep) 方法在 triggerEffects(dep) 方法中,继续进入 triggerEffect(effect)在 triggerEffect 中接收到的 effect,即为刚才查看的 计算属性的 effect此时因为 effect 中存在 scheduler,所以会执行该计算属性的 scheduler 函数在 scheduler 函数中,会触发 triggerRefValue(this)而 triggerRefValue 则会再次触发 triggerEffects。特别注意: 此时 effects 的值为 计算属性实例的 dep:循环 effects,从而再次进入 triggerEffect 中。再次进入 triggerEffect,此时 effect 为 非计算属性的 effect,即 fn 函数(修改 DOM 的函数)因为他 不是 计算属性的 effect ,所以会直接执行 run 方法。而我们知道 run 方法中,其实就是触发了 fn 函数,所以最终会执行:document.querySelector('#app').innerHTML = computedObj.value document.querySelector('#app').innerHTML = computedObj.value但是在这个 fn 函数中,是有触发 computedObj.value 的,而 computedObj.value 其实是触发了 computed 的 get value 方法。那么这次 run 的执行会触发 两次 computed 的 get value第一次进入:进入 computed 的 get value :首先收集依赖接下来检查 dirty 脏的状态,执行 this.effect.run()!获取最新值,返回第二次进入:进入 computed 的 get value :首先收集依赖接下来检查 dirty 脏的状态,因为在上一次中 dirty 已经为 false,所以本次 不会在触发 this.effect.run()!直接返回结束按说代码应该到这里就结束了,但是不要忘记,在刚才我们进入到 triggerEffects 时,effets 是一个数组,内部还存在一个 computed 的 effect,所以代码会 继续 执行,再次来到 triggerEffect 中:此时 effect 为 computed 的 effect:这会导致,再次触发 scheduler,scheduler 中还会再次触发 triggerRefValue,triggerRefValue 又触发 triggerEffects ,再次生成一个新的 effects 包含两个 effect,就像 第五、第六、第七步 一样从而导致 死循环5.3 解决方法想要解决这个死循环的问题,其实比较简单,我们只需要 packages/reactivity/src/effect.ts 中的 triggerEffects 中修改如下代码:export function triggerEffects(dep: Dep) { // 把 dep 构建为一个数组 const effects = isArray(dep) ? dep : [...dep] // 依次触发 // for (const effect of effects) { // triggerEffect(effect) // 不在依次触发,而是先触发所有的计算属性依赖,再触发所有的非计算属性依赖 for (const effect of effects) { if (effect.computed) { triggerEffect(effect) for (const effect of effects) { if (!effect.computed) { triggerEffect(effect) 查看测试实例的打印,此时 computed 只计算了一次。5.4 解决方法的原理原理就是将具有 computed 属性的 effect 放在前面,先执行有 computed 属性的 effect,再执行没有 computed 属性的 effect第一个执行的有 computed 属性的 effect:第二个执行的没有 computed 属性的 effect:6. 总结计算属性实现的重点:计算属性的实例,本质上是一个 ComputedRefImpl 的实例ComputedRefImpl 中通过 dirty 变量来控制 run 的执行和 triggerRefValue 的触发想要访问计算属性的值,必须通过 .value ,因为它内部和 ref 一样是通过 get value 来进行实现的每次 .value 时都会触发 trackRefValue 即:收集依赖在依赖触发时,需要谨记,先触发 computed 的 effect,再触发非 computed 的 effect
前言在上一章中我们完成了 reactive 函数,同时也知道了 reactive 函数的局限性,知道了只靠 reactive 函数,vue 是没有办法构建出完善的响应式系统的。所以我们还需要另外一个函数 ref。本章我们将致力于解决以下三个问题:ref 函数是如何进实现的?ref 是如何构建简单数据类型的?为什么 ref 类型的数据,必须要通过 .value 访问?1. ref 复杂类型数据的响应性我们知道 ref 其实也是可以实现复杂类型数据的响应性的,那么它是如何实现的呢?我们从下面这段程序开始研究<div id="app"></div> <script> const { ref, effect } = Vue const obj = ref({ name: '张三' // 调用 effect 方法 effect(() => { document.querySelector('#app').innerText = obj.value.name setTimeout(() => { obj.value.name = '李四' }, 2000) </script>1.1 源码阅读我们直接进入源码 packages/reactivity/src/ref.ts 之下,找的 ref 函数的实现,并在这里打下断点。可以看到 ref 函数中最后就是返回了一个 RefImpl 对象,我们进到 RefImpl 类中。RefImpl 类的构造函数中 执行了一个 toReactive 的方法,传入了 value 并把返回值赋值给了 this._value,那么我们来看看 toReactive 的作用toReactive 方法把数据分成了两种类型:1. 复杂类型调用了 reactive 函数,即把 value 变为响应性的。2.简单数据类型:直接把 value 原样返回。而且,RefImpl 类 还 提供了一个分别被 get 和 set 标记的函数 value。1.当执行 xxx.value 时,会触发 get 标记。2.当执行 xxx.value = xxx 时,会触发 set 标记。至此 ref 函数执行完成。接下来开始执行 effect 函数。effect 函数我们在上一张的时候跟踪过它的执行流程。我们知道整个 effect 主要做了 3 件事情:1.生成 ReactiveEffect 实例。2.触发 fn 方法,从而激活 getter。3. 建立了 targetMap 和 activeEffect 之间的联系。通过上述可知,在执行 obj.value.name = '张三' 时,会执行 RefImpl 类中的 get value 方法,而 get value 方法中 实际执行的是 trackRefValue,我们直接跳到 trackRefValue 中在 trackRefValue 中,触发了 trackEffects 函数,并且在此时为 ref 新增了一个 dep 属性。而 trackEffects 其实我们是有过了解的,我们知道 trackEffects 主要的作用就是:收集所有的依赖至此 get value 执行完成接着,在两秒之后,修改数据源了:obj.value.name = '李四',这里的步骤可以拆分成两步const value = obj.value value.name = '李四'第一步 const value = obj.value,此时还会触发一遍 get value 中的 trackRefValue 函数。但是这次不一样了, 这次 activeEffect 为 undefined,所以不会执行后续逻辑,直接返回 this._value第二步 value.name = '李四', 因为 这里的 value 是 toReactive 转化而来的 proxy 对象,根据 reactive 的执行逻辑可知,此时会触发 trigger 触发依赖。至此,视图上的文字改为 李四 ,程序结束总结:对于 ref 函数,会返回 RefImpl 类型的实例在该实例中,会根据传入的数据类型进行分开处理复杂数据类型:转化为 reactive 返回的 proxy 实例简单数据类型:不做处理无论我们执行 obj.value.name 还是 obj.value.name = xxx 本质上都是触发了 get value4,之所以会进行 响应性 是因为 obj.value 是一个 reactive 函数生成的 proxy1.2 代码实现创建 packages/reactivity/src/ref.ts 模块:import { createDep, Dep } from './dep' import { activeEffect, trackEffects } from './effect' import { toReactive } from './reactive' export interface Ref<T = any> { value: T * ref 函数 * @param value unknown export function ref(value?: unknown) { return createRef(value, false) * 创建 RefImpl 实例 * @param rawValue 原始数据 * @param shallow boolean 形数据,表示《浅层的响应性(即:只有 .value 是响应性的)》 * @returns function createRef(rawValue: unknown, shallow: boolean) { if (isRef(rawValue)) { return rawValue return new RefImpl(rawValue, shallow) class RefImpl<T> { private _value: T public dep?: Dep = undefined // 是否为 ref 类型数据的标记 public readonly __v_isRef = true constructor(value: T, public readonly __v_isShallow: boolean) { // 如果 __v_isShallow 为 true,则 value 不会被转化为 reactive 数据,即如果当前 value 为复杂数据类型,则会失去响应性。对应官方文档 shallowRef :https://cn.vuejs.org/api/reactivity-advanced.html#shallowref this._value = __v_isShallow ? value : toReactive(value) * get语法将对象属性绑定到查询该属性时将被调用的函数。 * 即:xxx.value 时触发该函数 get value() { trackRefValue(this) return this._value set value(newVal) {} * 为 ref 的 value 进行依赖收集工作 export function trackRefValue(ref) { if (activeEffect) { trackEffects(ref.dep || (ref.dep = createDep())) * 指定数据是否为 RefImpl 类型 export function isRef(r: any): r is Ref { return !!(r && r.__v_isRef === true) 在 packages/reactivity/src/reactive.ts 中,新增 toReactive 方法:/** * 将指定数据变为 reactive 数据 export const toReactive = <T extends unknown>(value: T): T => isObject(value) ? reactive(value as object) : value 在 packages/shared/src/index.ts 中,新增 isObject 方法:/** * 判断是否为一个数组 export const isArray = Array.isArray * 判断是否为一个对象 export const isObject = (val: unknown) => val !== null && typeof val === 'object'在 packages/reactivity/src/index.ts 中,导出 ref 函数:在 packages/vue/src/index.ts 中,导出 ref 函数::至此,ref 函数构建完成。测试我们可以增加测试案例 packages/vue/examples/reactivity/ref.html 中:<script> const { ref, effect } = Vue const obj = ref({ name: '张三' // 调用 effect 方法 effect(() => { document.querySelector('#app').innerText = obj.value.name setTimeout(() => { obj.value.name = '李四' }, 2000) </script>可以发现代码测试成功。2. ref 简单数据类型的响应性我们继续从下面的代码研究 ref 是如何实现简单数据类型的响应性的<script> const { ref, effect } = Vue const obj = ref('张三') // 调用 effect 方法 effect(() => { document.querySelector('#app').innerText = obj.value setTimeout(() => { obj.value = '李四' }, 2000) </script>2.1 源码阅读ref 函数整个 ref 初始化的流程和上一小节完全相同,但是有一个不同的地方,需要 特别注意:因为当前不是复杂数据类型,所以在 toReactive 函数中,不会通过 reactive 函数处理 value。所以 this._value 不是 一个 proxy。即:无法监听 setter 和 getter。effect 函数整个 effect 函数的流程与上一小节完全相同。get value()整个 effect 函数中引起的 get value() 的流程与上一小节完全相同。大不同:set value()延迟两秒钟,我们将要执行 obj.value = '李四' 的逻辑。我们知道在复杂数据类型下,这样的操作(obj.value.name = '李四'),其实是触发了 get value 行为。但是,此时,在 简单数据类型之下,obj.value = '李四' 触发的将是 set value 形式,这里也是 ref 可以监听到简单数据类型响应性的关键。跟踪代码,进入到 set value(newVal):由以上代码可知:简单数据类型的响应性,不是基于 proxy 或 Object.defineProperty 进行实现的,而是通过:set 语法,将对象属性绑定到查询该属性时将被调用的函数 上,使其触发 xxx.value = '李四' 属性时,其实是调用了 xxx.value('李四') 函数。在 value 函数中,触发依赖总结:简单数据类型,不具备数据件监听的概念,即本身并不是响应性的。只是因为 vue 通过了 set value() 的语法,把 函数调用变成了属性调用的形式,让我们通过主动调用该函数,来完成了一个 “类似于” 响应性的结果。2.2 代码实现在 packages/reactivity/src/ref.ts 中,完善 set value 函数:class RefImpl<T> { private _value: T private _rawValue: T constructor(value: T, public readonly __v_isShallow: boolean) { // 原始数据 this._rawValue = value set value(newVal) { * newVal 为新数据 * this._rawValue 为旧数据(原始数据) * 对比两个数据是否发生了变化 if (hasChanged(newVal, this._rawValue)) { // 更新原始数据 this._rawValue = newVal // 更新 .value 的值 this._value = toReactive(newVal) // 触发依赖 triggerRefValue(this) * 为 ref 的 value 进行触发依赖工作 export function triggerRefValue(ref) { if (ref.dep) { triggerEffects(ref.dep) }在 packages/shared/src/index.ts 中,新增 hasChanged 方法/** * 对比两个数据是否发生了改变 export const hasChanged = (value: any, oldValue: any): boolean => !Object.is(value, oldValue) 至此,简单数据类型的响应性处理完成。测试创建对应测试实例:packages/vue/examples/reactivity/ref-shallow.html<script> const { ref, effect } = Vue const obj = ref('张三') // 调用 effect 方法 effect(() => { document.querySelector('#app').innerText = obj.value setTimeout(() => { obj.value = '李四' }, 2000) </script> 测试成功,表示代码完成。3. 总结我们现在来回答一下 前言中的三个问题ref 函数是如何进实现的?ref 函数本质上是生成了一个 RefImpl 类型的实例对象,通过 get 和 set 标记处理了 value 函数ref 是如何构建简单数据类型的?ref 通过 get value() 和 set value() 定义了两个属性函数,通过 主动 触发这两个函数(属性调用)的形式来进行 依赖收集 和 触发依赖为什么 ref 类型的数据,必须要通过 .value 访问值呢?因为 ref 需要处理简单数据类型的响应性,但是对于简单数据类型而言,它无法通过 proxy 建立代理。只能通过 get value() 和 set value() 的方式来处理对依赖的收集和触发,所以我们必须通过 .value 来保证响应性。
前言从本章开始我们将开始实现 Vue3 中的 reactivity 模块接下来我们看一段代码:<body> <div id="app"></div> </body> <script> // 从 Vue 中结构出 reactie、effect 方法 const { reactive, effect } = Vue // 声明响应式数据 obj const obj = reactive({ name: '张三' // 调用 effect 方法 effect(() => { document.querySelector('#app').innerText = obj.name // 定时修改数据,视图发生变化 setTimeout(() => { obj.name = '李四' }, 2000) </script>上面的代码很简单大家应该也都会写,最终的效果就是视图中的 张三 在 2 秒钟之后变成了 李四。但是大家有没有想过这是为什么呢?让我们来从源码中一探究竟吧~1. 源码阅读重要提示:我这里的 vue 的版本是 3.2.27,并且本系列中用的都是这个版本1.1 reactive 部分我们直接到 vue 的源码路径 /packages/reactivity/src/reactive.ts 中的第 90 行找到 reactive 方法,并打上断点。发现 reactive 其实直接返回了一个 createReactiveObject ,听名字就这个方法是在创建一个 reactive 对象,接着跳转进这个方法。可以看到 createReactiveObject 这个方法其实就是返回了一个 Proxy 对象。有两个点可以提的是:一个点是这里维护了一个 proxyMap 对象用来缓存之前已经创建过的响应式对象,而他是一个 WeakMap 类型; 另一个点是在源码第 214 行 创建 proxy 的 baseHandler,它来自上面 reactive 方法中返回的 mutableHandlers,而 mutableHandlers 导入自 baseHandlers.ts 文件,这个我们后面说。至此 reactive 方法执行完成。总结 reactive 的逻辑:1. 创建了 proxy。 2.把 proxy 加到了 proxyMap 里面。3. 返回了 proxy1.2 effect 部分接着我们来到 effect 方法,我们直接到 vue 的源码路径 /packages/reactivity/src/effect.ts 中的第 170 行找到 effect 方法,并打上断点。可以发现 effect 方法内其实就只是创建了一个 ReactiveEffect 对象,并且执行了一次它的 run 方法,再将 run 方法返回。我们直接跳到 run 看代码。调试发现,run 方法里只做了上图框框圈出来的两件事。但是大家不要忘记,fn 函数 中的代码为 document.querySelector('#app').innerText = obj.name ,obj 是个 proxy, obj.name 会触发 getter,所以接下来我们就会进入到 mutableHandlers 的 get 中, 而 get 为 createGetter 函数的调用返回值,所以我们直接跳到 createGetter 中调试得知,createGetter 方法中最主要做了两件事,一是调用 const res = Reflect.get(target, key, receiver), res 此时是 张三, 然后将 res 返回。二是触发了 track 函数,这个函数是一个重点函数, track 在此为跟踪的意思。接下来我们看看里面发生了什么。可以看到 track 里面主要做了两件事,一是为 targetMap 赋值,targetMap 的结构是一个 Map 套 Set 的结构(createDep 方法实际是返回了一个 Set);二是执行了 trackEffects 方法。我们来看一下这个方法里做了什么。可以看到在 trackEffects 函数内部,核心也是做了两件事情:一是为 dep(targetMap[target][key] 得到的 Set 实例) 添加了 activeEffect,这个 activeEffect 第 6 步有讲,就一个 ReactiveEffect 对象,里面存了 fn 函数;二是为 activeEffect 函数的 静态属性 deps,增加了一个值 dep,即建立起了 dep 和 activeEffect的联系.至此,整个 track 的核心逻辑执行完成。我们可以把整个 track 的核心逻辑说成:收集了 activeEffect(即:fn)最后在 createGetter 函数中返回了 res(即:张三)至此,整个 effect 执行完成。总结 effect 的逻辑:1.生成 ReactiveEffect 实例。2.触发 fn 方法,从而激活 getter。3.建立了 targetMap 和 activeEffect 之间的联系1.3 obj.name = xx 部分接着我们继续调试程序,两秒钟之后,setTimeout 触发,会执行 obj.name = '李四',从而触发 proxy 的 set。所以接下来我们就会进入到 mutableHandlers 的 set 中, 而 set 为 createSetter 函数的调用返回值,所以我们直接跳到 createSetter 中createSetter 中主要做是有:1.创建变量: oldValue = 张三。2.创建变量:value = 李四。3.执行 const result = Reflect.set(target, key, value, receiver),即:修改了 obj 的值为 “李四”。4.触发:trigger(target, TriggerOpTypes.SET, key, value, oldValue)。trigger 在这里为 触发 的意思,我们来看看 trigger 里面做了什么trigger 主要做了上图中框起来的三件事,我们再来看看 triggerEffect 做了什么?可以看到 triggerEffect 其实就是调用了 run 方法,这一次进入 run 方法,执行了一下步骤:1. 首先还是为 activeEffect = this 赋值。2.最后执行 this.fn() 即:effect 时传入的匿名函数。3.至此,fn 执行,意味着: document.querySelector('#app').innerText = 李四,页面将发生变化。triggerEffect完成 triggerEffects完成 trigger完成 setter回调完成至此,整个 setter 执行完成。总结 setter:1.修改 obj 的值。2.触发 targetMap 下保存的 fn 函数1.4 总结到这里,我们在前言中的代码已经从源码层面上全部分析完了,我们现在总结一下:reactive 函数effect 函数obj.name = xx 表达式这三块代码背后,vue 究竟都做了什么。虽然整个的过程比较复杂,但是如果我们简单来去看,其实内部的完成还是比较简单的:创建 proxy收集 effect 的依赖触发收集的依赖接下来,我们的实现,就将会围绕着这三个核心的理念进行。2. 框架实现2.1 构建 reactive 函数,获取 proxy 实例创建 packages/reactivity/src/reactive.ts 模块:import { mutableHandlers } from './baseHandlers' * 响应性 Map 缓存对象 * key:target * val:proxy export const reactiveMap = new WeakMap<object, any>() * 为复杂数据类型,创建响应性对象 * @param target 被代理对象 * @returns 代理对象 export function reactive(target: object) { return createReactiveObject(target, mutableHandlers, reactiveMap) * 创建响应性对象 * @param target 被代理对象 * @param baseHandlers handler function createReactiveObject( target: object, baseHandlers: ProxyHandler<any>, proxyMap: WeakMap<object, any> // 如果该实例已经被代理,则直接读取即可 const existingProxy = proxyMap.get(target) if (existingProxy) { return existingProxy // 未被代理则生成 proxy 实例 const proxy = new Proxy(target, baseHandlers) // 缓存代理对象 proxyMap.set(target, proxy) return proxy }创建 packages/reactivity/src/baseHandlers.ts 模块:/** * 响应性的 handler export const mutableHandlers: ProxyHandler<object> = {}此时我们就已经构建好了一个基本的 reactive 方法,接下来我们可以通过 测试案例 测试一下。创建 packages/reactivity/src/index.ts 模块,作为 reactivity 的入口模块export { reactive } from './reactive'在 packages/vue/src/index.ts 中,导入 reactive 模块export { reactive } from '@vue/reactivity'执行 npm run build 进行打包,生成 vue.js创建 packages/vue/examples/reactivity/reactive.html 文件,作为测试实例:<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <script src="../../dist/vue.js"></script> </head> <script> const { reactive } = Vue const obj = reactive({ name: '张三' console.log(obj) </script> </html>运行到 Live Server 可见打印了一个 proxy 对象实例至此我们已经得到了一个基础的 reactive 函数2.2 createGetter && createSetter接下来我们需要创建对应的 get 和 set 监听:/** * 响应性的 handler export const mutableHandlers: ProxyHandler<object> = { }getter/** * getter 回调方法 const get = createGetter() * 创建 getter 回调方法 function createGetter() { return function get(target: object, key: string | symbol, receiver: object) { // 利用 Reflect 得到返回值 const res = Reflect.get(target, key, receiver) // 收集依赖 track(target, key) return res }setter/** * setter 回调方法 const set = createSetter() * 创建 setter 回调方法 function createSetter() { return function set( target: object, key: string | symbol, value: unknown, receiver: object // 利用 Reflect.set 设置新值 const result = Reflect.set(target, key, value, receiver) // 触发依赖 trigger(target, key, value) return result }track && trigger在 getter 和 setter 中分别调用了 track && trigger 方法,所以我们需要分别创建对应方法:创建 packages/reactivity/src/effect.ts:/** * 用于收集依赖的方法 * @param target WeakMap 的 key * @param key 代理对象的 key,当依赖被触发时,需要根据该 key 获取 export function track(target: object, key: unknown) { console.log('track: 收集依赖') * 触发依赖的方法 * @param target WeakMap 的 key * @param key 代理对象的 key,当依赖被触发时,需要根据该 key 获取 * @param newValue 指定 key 的最新值 * @param oldValue 指定 key 的旧值 export function trigger(target: object, key?: unknown, newValue?: unknown) { console.log('trigger: 触发依赖') }至此我们就可以:在 getter 时,调用 track 收集依赖在 setter 时,调用 trigger 触发依赖我们可以在两个方法中分别进行一下打印,看看是否可以成功回调。测试在 packages/vue/examples/reactivity/reactive.html 中:<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <script src="../../dist/vue.js"></script> </head> <script> const { reactive } = Vue const obj = reactive({ name: '张三' console.log(obj.name) // 此时应该触发 track obj.name = '李四' // 此时应该触发 trigger </script> </html>2.3 构建 effect 函数,生成 ReactiveEffect 实例根据之前的测试实例我们知道,在创建好了 reactive 实例之后,接下来我们需要触发 effect:在 packages/reactivity/src/effect.ts 中,创建 effect 函数:/** * effect 函数 * @param fn 执行方法 * @returns 以 ReactiveEffect 实例为 this 的执行函数 export function effect<T = any>(fn: () => T) { // 生成 ReactiveEffect 实例 const _effect = new ReactiveEffect(fn) // 执行 run 函数 _effect.run() }接下来我们来实现 ReactiveEffect 的基础逻辑:/** * 单例的,当前的 effect export let activeEffect: ReactiveEffect | undefined * 响应性触发依赖时的执行类 export class ReactiveEffect<T = any> { constructor(public fn: () => T) {} run() { // 为 activeEffect 赋值 activeEffect = this // 执行 fn 函数 return this.fn() }在 packages/reactivity/src/index.ts 导出export { effect } from './effect'在 packages/vue/src/index.ts 中 导出export { reactive, effect } from '@vue/reactivity'根据以上代码可知,最终 vue 会执行 effect 传入的 回调函数,即:document.querySelector('#app').innerText = obj.name那么此时,obj.name 的值,应该可以被渲染到 html 中。所以,我们可以到测试实例中,完成一下测试<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <script src="../../dist/vue.js"></script> </head> <body> <div id="app"></div> </body> <script> const { reactive, effect } = Vue const obj = reactive({ name: '张三' // 调用 effect 方法 effect(() => { document.querySelector('#app').innerText = obj.name </script> </html>此时,我们成功 渲染了数据到 html 中,那么接下来我们需要做的就是:当 obj.name 触发 setter 时,修改视图,以此就可实现 响应性数据变化。所以,下面我们就需要分别处理 getter 和 setter 对应的情况了。2.4 构建 track 依赖收集在 packages/reactivity/src/effect.ts 写入如下代码:type KeyToDepMap = Map<any, ReactiveEffect> * 收集所有依赖的 WeakMap 实例: * 1. `key`:响应性对象 * 2. `value`:`Map` 对象 * 1. `key`:响应性对象的指定属性 * 2. `value`:指定对象的指定属性的 执行函数 const targetMap = new WeakMap<any, KeyToDepMap>() * 用于收集依赖的方法 * @param target WeakMap 的 key * @param key 代理对象的 key,当依赖被触发时,需要根据该 key 获取 export function track(target: object, key: unknown) { // 如果当前不存在执行函数,则直接 return if (!activeEffect) return // 尝试从 targetMap 中,根据 target 获取 map let depsMap = targetMap.get(target) // 如果获取到的 map 不存在,则生成新的 map 对象,并把该对象赋值给对应的 value if (!depsMap) { targetMap.set(target, (depsMap = new Map())) //为指定 map,指定key 设置回调函数 depsMap.set(key, activeEffect) // 临时打印 console.log(targetMap) }此时运行测试函数,查看打印的 targetMap,可得以下数据:2.5 构建 trigger 触发依赖在 packages/reactivity/src/effect.ts 中/** * 触发依赖的方法 * @param target WeakMap 的 key * @param key 代理对象的 key,当依赖被触发时,需要根据该 key 获取 export function trigger(target: object, key?: unknown) { // 依据 target 获取存储的 map 实例 const depsMap = targetMap.get(target) // 如果 map 不存在,则直接 return if (!depsMap) { return // 依据 key,从 depsMap 中取出 value,该 value 是一个 ReactiveEffect 类型的数据 const effect = depsMap.get(key) as ReactiveEffect // 如果 effect 不存在,则直接 return if (!effect) { return // 执行 effect 中保存的 fn 函数 effect.fn() }此时,我们就可以在触发 setter 时,执行保存的 fn 函数了。那么接下来我们实现对应的测试实例,在 packages/vue/examples/reactivity/reactive.html 中:<script> const { reactive, effect } = Vue const obj = reactive({ name: '张三' // 调用 effect 方法 effect(() => { document.querySelector('#app').innerText = obj.name setTimeout(() => { obj.name = '李四' }, 2000) </script>运行测试实例,等待两秒,发现 视图发生变化那么,至此我们就已经完成了一个简单的 响应式依赖数据处理2.6 构建 Dep 模块,处理一对多的依赖关系在我们之前的实现中,还存在一个小的问题,那就是:每个响应性数据属性只能对应一个 effect 回调现象:如果我们新增了一个 effect 函数,即:name 属性对应两个 DOM 的变化。更新渲染就会变无效。原因:因为我们在构建 KeyToDepMap 对象时,它的 Value 只能是一个 ReactiveEffect,所以这就导致了 一个 key 只能对应一个有效的 effect 函数。解决方法:将 value 变为一个 Set 类型。可以把它叫做 Dep ,通过 Dep 来保存 指定 key 的所有依赖创建 packages/reactivity/src/dep.ts 模块:import { ReactiveEffect } from './effect' export type Dep = Set<ReactiveEffect> * 依据 effects 生成 dep 实例 export const createDep = (effects?: ReactiveEffect[]): Dep => { const dep = new Set<ReactiveEffect>(effects) as Dep return dep }在 packages/reactivity/src/effect.ts 修改 KeyToDepMap 的泛型:import { Dep } from './dep' type KeyToDepMap = Map<any, Dep>修改 track 方法,处理 Dep 类型数据:/** * 用于收集依赖的方法 * @param target WeakMap 的 key * @param key 代理对象的 key,当依赖被触发时,需要根据该 key 获取 export function track(target: object, key: unknown) { // 如果当前不存在执行函数,则直接 return if (!activeEffect) return // 尝试从 targetMap 中,根据 target 获取 map let depsMap = targetMap.get(target) // 如果获取到的 map 不存在,则生成新的 map 对象,并把该对象赋值给对应的 value if (!depsMap) { targetMap.set(target, (depsMap = new Map())) // 获取指定 key 的 dep let dep = depsMap.get(key) // 如果 dep 不存在,则生成一个新的 dep,并放入到 depsMap 中 if (!dep) { depsMap.set(key, (dep = createDep())) trackEffects(dep) * 利用 dep 依次跟踪指定 key 的所有 effect * @param dep export function trackEffects(dep: Dep) { dep.add(activeEffect!) }此时,我们已经把指定 key 的所有依赖全部保存到了 dep 函数中,那么接下来我们就可以在 trigger 函数中,依次读取 dep 中保存的依赖。在 packages/reactivity/src/effect.ts 中:export function trigger(target: object, key?: unknown) { // 依据 target 获取存储的 map 实例 const depsMap = targetMap.get(target) // 如果 map 不存在,则直接 return if (!depsMap) { return // 依据指定的 key,获取 dep 实例 let dep: Dep | undefined = depsMap.get(key) // dep 不存在则直接 return if (!dep) { return // 触发 dep triggerEffects(dep) * 依次触发 dep 中保存的依赖 export function triggerEffects(dep: Dep) { // 把 dep 构建为一个数组 const effects = Array.isArray(dep) ? dep : [...dep] // 依次触发 for (const effect of effects) { triggerEffect(effect) * 触发指定的依赖 export function triggerEffect(effect: ReactiveEffect) { effect.run() }至此,我们即可在 trigger 中依次触发 dep 中保存的依赖测试创建 packages/vue/examples/reactivity/reactive-dep.html<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Document</title> <script src="../../dist/vue.js"></script> </head> <body> <div id="app"> <p id="p1"></p> <p id="p2"></p> </div> </body> <script> const { reactive, effect } = Vue const obj = reactive({ name: '张三' // 调用 effect 方法 effect(() => { document.querySelector('#p1').innerText = obj.name effect(() => { document.querySelector('#p2').innerText = obj.name setTimeout(() => { obj.name = '李四' }, 2000) </script> </html>发现两个 p 标签中的内容最后都变成了 李四3. 总结在本章,我们初次了解了 reactivity 这个模块,并且在该模块中构建了 reactive 响应性函数。对于 reactive 的响应性函数而言,我们知道它:是通过 proxy 的 setter 和 getter 来实现的数据监听需要配合 effect 函数进行使用基于 WeakMap 完成的依赖收集和处理可以存在一对多的依赖关系但同时 reactive 函数也存在一些不足,比如:reactive 只能对 复杂数据 类型进行使用reactive 的响应性数据,不可以进行解构因为 reactive 的不足,所以 vue 3 又为我们提供了 ref 函数构建响应性。关于 ref 函数是如何实现的,就留到下一章去学习吧~
前言本系列文章旨在通过学习阅读解析 vue3 源码,来实现并产出一个 精简版的 vue 库小励志一下:阅读源码的过程会是痛苦的,但这一步总是要迈出去的,如果我们能咬牙坚持到最后,回过头会发现,其实我们已经走出去了很远很远。就酱,Here we go!1. 搭建 mini-vue 项目基本结构创建 mini-vue 文件夹通过 VSCode 打开在终端中,通过npm init -y创建 package.json 模块创建 packages 文件夹,作为:核心代码 区域创建 packages/vue 文件夹:打包、测试实例、项目整体入口模块创建 packages/shared 文件夹:共享公共方法模块创建 packages/compiler-core 文件夹:编译器核心模块创建 packages/compiler-dom 文件夹:浏览器部分编译器模块创建 packages/reactivity 文件夹:响应性模块创建 packages/runtime-core 文件夹:运行时核心模块创建 packages/runtime-dom 文件夹:浏览器部分运行时模块在每个模块下面创建 /src/index.ts 和 README.md 文件README.md 内容示例:# reactivity 响应性核心2. 导入 TS 配置想要在项目中使用 ts 构建(这里我使用的 ts 版本为 4.7.4),那么首先我们在项目中创建对应的 tsconfig.json 配置文件。在项目根目录中,创建 tsconfig.json 文件。该 tsconfig.json 文件指定编译项目所需的 入口文件 和 编译器 配置也可以通过以下指令来生成 包含默认配置 的 tsconfig.json 文件:// 需要先安装 typescript npm install -g typescript@4.7.4 // 生成默认配置 tsc -init在 tsconfig.json 中指定如下配置:// https://www.typescriptlang.org/tsconfig,也可以使用 tsc -init 生成默认的 tsconfig.json 文件进行属性查找 // 编辑器配置 "compilerOptions": { // 根目录 "rootDir": ".", // 严格模式标志 "strict": true, // 指定类型脚本如何从给定的模块说明符查找文件。 "moduleResolution": "node", // https://www.typescriptlang.org/tsconfig#esModuleInterop "esModuleInterop": true, // JS 语言版本 "target": "es5", // 允许未读取局部变量 "noUnusedLocals": false, // 允许未读取的参数 "noUnusedParameters": false, // 允许解析 json "resolveJsonModule": true, // 支持语法迭代:https://www.typescriptlang.org/tsconfig#downlevelIteration "downlevelIteration": true, // 允许使用隐式的 any 类型(这样有助于我们简化 ts 的复杂度,从而更加专注于逻辑本身) "noImplicitAny": false, // 模块化 "module": "esnext", // 转换为 JavaScript 时从 TypeScript 文件中删除所有注释。 "removeComments": false, // 禁用 sourceMap "sourceMap": false, // https://www.typescriptlang.org/tsconfig#lib "lib": ["esnext", "dom"], // 入口 "include": ["packages/*/src"] }3. 引入代码格式化工具: Prettier 让代码结构更加规范这里没有引入 eslint 因为 mini-vue 这并不是一个开源的代码仓库,所以我们无需专门导入 eslint 增加项目的额外复杂度,只需要导入 prettier 帮助我们控制代码格式即可。在 VScode 扩展中,安装 prettier 辅助插件在项目根目录下,创建 .prettierrc.js 文件:module.exports = { // 结尾无分号 semi: false, // 全部使用单引号 singleQuote: true, // 每行长度为 80 printWidth: 80, // 不添加尾随 , 号 trailingComma: 'none', // 省略箭头函数括号 arrowParens: 'avoid' };4. 模块打包器:rolluprollup 是一个模块打包器,和 [webpack](https://webpack.docschina.org/concepts/) 一样可以将 JavaScript 打包为指定的模块。但是不同的是,对于 webpack 而言,它在打包的时候会产生许多 冗余的代码,这样的一种情况在我们开发大型项目的时候没有什么影响,但是如果我们是开发一个 库 的时候,那么这些冗余的代码就会大大增加库体积,这就不好美好了。所以说我们需要一个 小而美 的模块打包器,这就是 rollup:Rollup 是一个 JavaScript 模块打包器,可以将小块代码编译成大块复杂的代码,例如 library 或应用程序。rollup我们可以在项目根目录下,创建 rollup.config.js 文件作为 rollup 的配置文件(就像 webpack.config.js 一样 ):import resolve from '@rollup/plugin-node-resolve' import commonjs from '@rollup/plugin-commonjs' import typescript from '@rollup/plugin-typescript' * 默认导出一个数组,数组的每一个对象都是一个单独的导出文件配置,详细可查:https://www.rollupjs.com/guide/big-list-of-options export default [ // 入口文件 input: 'packages/vue/src/index.ts', // 打包出口 output: [ // 导出 iife 模式的包 // 开启 SourceMap sourcemap: true, // 导出的文件地址 file: './packages/vue/dist/vue.js', // 生成的包格式:一个自动执行的功能,适合作为<script>标签 format: 'iife', // 变量名 name: 'Vue' // 插件 plugins: [ // ts 支持 typescript({ sourceMap: true // 模块导入的路径补全 resolve(), // 将 CommonJS 模块转换为 ES2015 commonjs() ]安装插件依赖npm i @rollup/plugin-commonjs@22.0.1 rollup/plugin-node-resolve@13.3.0 rollup/plugin-typescript@8.3.4 -D npm i tslib@2.4.0 typescript@4.7.4 -D在 package.json 中新增一个 scripts:"build": "rollup -c -w"随便在 packages/vue/src/idnex.ts 中导出个变量,再执行 npm run build,出现下图结果即说明打包成功。5. 配置路径映射在 tsconfig.json 中添加如下代码:{ // 编辑器配置 "compilerOptions": { // 设置快捷导入 "baseUrl": ".", "paths": { "@vue/*": ["packages/*/src"] }6. 总结这一章应该是比较简单,只是搭建了框架雏形,并且对项目进行了结构和配置上的初始化。做完了这些之后,接下来我们终于可以开始 源码的阅读 和 模块的实现了,嗨起来!
1. SVG 邂逅1.1 什么是 SVG ?什么是SVG?维基百科介绍:SVG 全称为(Scalable Vector Graphics),即可缩放矢量图形。(矢量定义:既有大小又有方向的量。在物理学中称作矢量,如一个带箭头线段:长度表示大小,箭头表示方向;在数学中称作向量。在计算机中,矢量图可无限放大而不变形)SVG 是一种基于XML格式的矢量图,主要用于定义二维图形,支持交互和动画。SVG 规范是万维网联盟(W3C) 自 1998 年以来开发的标准。SVG 图像可在不损失质量的情况下按比例缩放,并支持压缩。基于XML的SVG可轻松的用文本编辑器或矢量图形编辑器创建和编辑,并可以直接在浏览器显示。1.2 SVG 的历史SVG1.x 版本SVG 是 W3C SVG工作组于 1998 年开始开发 ,而 SVG 1.0于 2001 年 9 月 4 日成为W3C 推荐的标准。SVG 1.1 于 2003 年 1 月 14 日成为 W3C 推荐的标准。 该版本增加了模块化规范的内容。除此之外,1.1 和 1.0 几乎没有区别。SVG Tiny 1.2 于 2008 年 12 月 22 日成为 W3C 推荐标准,主要是为性能低的小设备生成图形,但是后来被 SVG 2 所弃用了。SVG 1.1 第二版 于 2011 年 8 月 16 日发布,这次只是更新了勘误表和说明,并没有增加新功能 。SVG 2.0 版本(推荐)SVG 2.0于2016 年 9 月 15 日成为W3C 候选推荐标准,最新草案于2020年5月26日发布。1.3 SVG 的优缺点优点:扩展好:矢量图像在浏览器中放大缩小不会失真,可被许多设备和浏览器中使用。而光栅图像(PNG 、JPG)放大缩小会失真。矢量图像是基于矢量的点、线、形状和数学公式来构建的图形,该图形是没有像素的,放大缩小是不会失真的。光栅图像是由像素点构建的图像——微小的彩色方块,大量像素点可以形成高清图像,比如照片。图像像素越多,质量越高。灵活:SVG是W3C开发的标准,可结合其它的语言和技术一起使用,包括 CSS、JavaScript、 HTML 和 SMIL 。SVG图像可以直接使用JS和CSS进行操作,使用时非常方便和灵活,因为SVG也是可集成到 DOM 中的。3、 可以动画:SVG 图像可以使用 JS 、 CSS 和 SMIL 进行动画处理。对于 Web 开发人员来说非常的友好。轻量级:与其它格式相比,SVG 图像的尺寸非常小。根据图像的不同,PNG 图像质量可能是 SVG 图像的 50 倍。可打印:SVG 图像可以以任何分辨率打印,而不会损失图像质量。利于SEO:SVG 图像被搜索引擎索引。因此,SVG 图像非常适合 SEO(搜索引擎优化)目的。可压缩:与其它图像格式一样,SVG 文件支持压缩。易于编辑:只需一个文本编辑器就可以创建 SVG 图像。设计师通常会使用 Adobe Illustrator (AI)等矢量图形工具创建和编辑。缺点:不适和高清图片制作SVG 格式非常适合用于徽标和图标(ICON)等 2D 图形,但不适用于高清图片,不适合进行像素级操作。SVG 的图像无法显示与标准图像格式一样多的细节,因为它们是使用点和路径而不是像素来渲染的。SVG 图像变得复杂时,加载会比较慢不完全扩平台尽管 SVG 自 1998 年以来就已经存在,并得到了大多数现代浏览器(桌面和移动设备)的支持,但它不适用于 IE8 及更低版本的旧版浏览器。根据caniuse的数据,大约还有 5% 的用户在使用不支持 SVG 的浏览器。1.4 应用场景SVG 非常适合显示矢量徽标(Logo)、图标(ICON)和其他几何设计。SVG 适合应用在需适配多种尺寸的屏幕上展示,因为SVG的扩展性更好。当需要创建简单的动画时,SVG 是一种理想的格式。SVG 可以与 JS 交互来制作线条动画、过渡和其他复杂的动画。SVG 可以与 CSS 动画交互,也可以使用自己内置的 SMIL 动画。SVG 也非常适合制作各种图表(条形图、折线图、饼图、散点图等等),以及大屏可视化页面开发。1.5 SVG 和 Canvas 的区别可扩展性:SVG 是基于矢量的点、线、形状和数学公式来构建的图形,该图形是没有像素的,放大缩小不会失真。Canvas 是由一个个像素点构成的图形,放大会使图形变得颗粒状和像素化(模糊)。SVG可以在任何分辨率下以高质量的打印。Canvas 不适合在任意分辨率下打印。渲染能力:当 SVG 很复杂时,它的渲染就会变得很慢,因为在很大程度上去使用 DOM 时,渲染会变得很慢。Canvas 提供了高性能的渲染和更快的图形处理能力,例如:适合制作H5小游戏。当图像中具有大量元素时,SVG 文件的大小会增长得更快(导致DOM变得复杂),而Canvas并不会增加太多。灵活度:SVG 可以通过JavaScript 和 CSS 进行修改,用SVG来创建动画和制作特效非常方便。Canvas只能通过JavaScript进行修改,创建动画得一帧帧重绘。使用场景:Canvas 主要用于游戏开发、绘制图形、复杂照片的合成,以及对图片进行像素级别的操作,如:取色器、复古照片。SVG 非常适合显示矢量徽标(Logo)、图标(ICON)和其他几何设计。1.6 一份 SVG 代码<?xml version="1.0" standalone="no" ?> <svg width="100" height="100" xmlns="http://www.w3.org/2000/svg" <rect x="0" y="0" width="100" height="100"></rect> </svg>2. SVG 基础2.1 SVG Grid 和 坐标系SVG 使用的 坐标系统(网格系统) 和 Canvas的差不多。坐标系是 以左上角为 (0,0) 坐标原点,坐标以像素为单位,x 轴正方向是向右,y 轴正方向是向下。SVG Grid(坐标系)<svg> 元素默认宽为 300px, 高为 150px。通常来说网格中的一个单元相当于 svg 元素中的一像素。基本上在 SVG 文档中的 1 个像素对应输出设备(比如显示屏)上的 1 个像素(除非缩放)。<svg> 元素和其它元素一样也是有一个坐标空间的,其原点位于元素的左上角,被称为初始视口坐标系<svg>的 transform 属性可以用来移动、旋转、缩放SVG中的某个元素,如<svg>中某个元素用了变形,该元素内部会建立一个新的坐标系统,该元素默认后续所有变化都是基于新创建的坐标系统。2.2 SVG 坐标系单位SVG坐标系统,在没有明确指定单位时,默认以像素为单位。比如:<rect x="0" y="0" width="100" height="100" />定义一个矩形,即从左上角开始,向右延展 100px,向下延展 100px,形成一个 100*100 大的矩形。当然我们也可以手动指明坐标系的单位,比如:2.3 视口-viewport视口(viewport)视口是 SVG 可见的区域(也可以说是SVG画布大小)。可以将视口视为可看到特定场景的窗口。可以使用 <svg> 元素的width和height属性指定视口的大小。一旦设置了最外层 SVG 元素的宽度和高度,浏览器就会建立初始视口坐标系和初始用户坐标系。视口坐标系视口坐标系是在视口上建立的坐标系,原点在视口左上角的点(0, 0),x轴正向向右,y轴正向下。初始视口坐标系中的一个单位等于视口中的一个像素,该坐标系类似于 HTML 元素的坐标系。用户坐标系( 也称为当前坐标系或正在使用的用户空间,后面绘图都是参照该坐标系 )用户坐标系是建立在 SVG 视口上的坐标系。该坐标系最初与视口坐标系相同——它的原点位于视口的左上角。使用viewBox属性,可以修改初始用户坐标系,使其不再与视口坐标系相同。为什么要有两个坐标系?因为SVG是矢量图,支持任意缩放。在用户坐标系统绘制的图形,最终会参照视口坐标系来进行等比例缩放。2.4 视图框-viewBox视图框(viewBox)viewport是 SVG 画布的大小,而 viewBox 是用来定义用户坐标系中的位置和尺寸 (该区域通常会被缩放填充视口)。viewBox 也可理解为是用来指定用户坐标系大小。因为SVG的图形都是绘制到该区域中。用户坐标系可以比视口坐标系更小或更大,也可以在视口内完全或部分可见。一旦创建了视口坐标系(<svg>使用width和height),浏览器就会创建一个与其相同的默认用户坐标系。我们可以使用 viewBox 属性指定用户坐标系的大小。✓ 如果用户坐标系与视口坐标系具有相同的高宽比,它将viewBox区域拉伸以填充视口区域。✓ 如果用户坐标系和视口坐标系没有相同的宽高比,可用 preserveAspectRatio 属性来指定整个用户坐标系统是否在视口内可见。viewBox语法viewBox = <min-x> <min-y> <width> <height>,比如:viewBox =' 0 0 100 100'<min-x> 和 <min-y> 确定视图框的左上角坐标(不是修改用户坐标系的原点,绘图还是从原来的 0, 0 开始)<width> <height>确定该视图框的宽度和高度。➢ 宽度和高度不必与父 <svg> 元素上设置的宽度和高度相同。➢ 宽度和高度负值无效,为 0 是禁用元素的显示。viewport和viewBox有相同的宽高比<svg width="400" height="400" viewBox="0 0 100 100" > <circle cx="50" cy="50" r="50"></circle> </svg>viewport和viewBox有相同的宽高比-指定viewBox最小的x和y<svg width="400" height="400" viewBox="50 50 100 100" > <circle cx="50" cy="50" r="50"></circle> </svg>viewport和viewBox不同的宽高比<svg width="400" height="400" viewBox="0 0 200 100" preserveAspectRatio="xMinYMin" <circle cx="50" cy="50" r="50"></circle> </svg>关于viewBox属性,可以参考这篇文章,非常容易理解如何理解SVG中的viewport、viewBox和preserveAspectRatio2.5 绘制基本图形矩形 rect<rect> 元素6 个基本属性 x y width height rx ryx :矩形左上角的 x 轴位置y :矩形左上角的 y轴位置width :矩形的宽度height :矩形的高度rx :圆角的 x 轴方位的半径ry :圆角的 y 轴方位的半径 。<rect x="60" y="10" rx="10" ry="10" width="30" height="30"></rect>圆形 circle<circle> 元素3 个基本属性。 r cx cyr:圆的半径cx:圆心的 x 轴位置cy:圆心的 y 轴位置<circle cx="100" cy="100" r="50" fill="red"></circle>椭圆 ellipse<ellipse> 元素4 个基本属性 rx ry cx cyrx:椭圆的 x轴半径ry:椭圆的 y轴半径cx:椭圆中心的 x轴位置cy:椭圆中心的 y轴位置<ellipse cx="100" cy="100" rx="25" ry="50" fill="red"></ellipse>线条 line<line> 元素4 个基本属性x1:起点的 x 轴位置y1:起点的 y轴位置x2:终点的 x轴位置y2:终点的 y轴位置<!-- stroke , 而不是 fill --> <line x1="100" y1="100" x2="200" y2="100" stroke="red" stroke-width="5"></line>折线 polyline<polyline> 元素1 个基本属性points : 点集数列。每个数字用空白、逗号、终止命令符或者换行符分隔开。每个点必须包含 2 个数字,一个是 x 坐标,一个是 y 坐标。所以点列表 (0,0), (1,1) 和 (2,2) 可以写成这样:“0 0, 1 1, 2 2”。✓ 支持格式: “0 0, 1 1, 2 2”或 “0 ,0 , 1, 1, 2, 2”或 “0 0 1 1 2 2”<!-- 第1种写法 --> <!-- <polyline points="20 0, 80 50, 20, 100"></polyline> --> <polyline points="20 0, 80 50, 20, 100" fill="transparent" stroke="red" ></polyline> <!-- 第2种写法 --> <polyline points="20 0 80 50 20 100"></polyline> <!-- 第3种写法 --> <polyline points="20 ,0 ,80 ,50 ,20, 100"></polyline>多边形 polygon<polygon> 元素1 个基本属性points :点集数列。每个数字用空白符、逗号、终止命令或者换行符分隔开。每个点必须包含 2 个数字,一个是 x 坐标,一个是 y 坐标。所以点列表 (0,0), (1,1) 和 (2,2) 推荐写成这样:“0 0, 1 1, 2 2”。路径绘制完后闭合图形,所以最终的直线将从位置 (2,2) 连接到位置 (0,0)。<polygon points="20 0, 80 50, 20 100" fill="transparent" stroke="red"></polygon>路径 path<path> 元素1 个基本属性d :一个点集数列,以及其它关于如何绘制路径的信息,必须M命令开头。✓ 所以点列表 (0,0), (1,1) 和 (2,2) 推荐写成这样:“M 0 0, 1 1, 2 2”。✓ 支持格式: “M 0 0, 1 1, 2 2”或 “M0 0, 1 1, 2 2” 或 “M 0 ,0 , 1, 1, 2, 2”或 “M 0 0 1 1 2 2”<!-- 1.使用path 绘制一个三角形 --> <!-- <path d="M 20 0, 80 50, 20 100" fill="transparent" stroke="red"></path> --> <!-- 1.使用path 绘制一个闭合的三角形 --> <!-- <path d="M 20 0, 80 50, 20 100 Z" fill="transparent" stroke="red"></path> --> <!-- 1.使用 path 绘图的命令: M moveTo Z close path L lineTo --> <path d="M 20 0,L 80 50,L 20 100 Z" fill="transparent" stroke="red"></path>上面的 M Z L 都是一条命令表示 移动到某处 关闭路径 以及 连线到某处,还有很多其他的命令,这里不讲了,遇到去查即可。图片 image在SVG中绘制一张图片,在<image>元素的 href 属性引入图片URL<!-- svg 1.0版本的语法 --> <svg version="1.0" baseProfile="full" width="300" height="300" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" <image x="0" y="0" xlink:href="../images/googlelogo_color_92x30dp.png" width="100" height="100" </image> </svg> <!-- svg 2.0 + 1.0 版本的语法( 为了兼容以前的浏览器的写法 ) --> <svg version="1.0" baseProfile="full" width="300" height="300" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" <image x="0" y="0" xlink:href="../images/googlelogo_color_92x30dp.png" href="../images/googlelogo_color_92x30dp.png" width="100" height="100" </image> </svg>绘制文字 text<text> 元素的基本属性x 和 y 属性决定了文本在用户坐标系中显示的位置。text-anchor 文本流方向属性,可以有 start、middle、end 或 inherit 值,默认值 startdominant-baseline 基线对齐属性 : 有 auto 、middle 或 hanging 值, 默认值:auto<text> 元素的字体属性文本的一个至关重要的部分是它显示的字体。SVG 提供了一些属性,类似于CSS 。下列的属性可以被设置为一个 SVG 属性或一个 CSS 属性:✓ font-family、font-style、font-weight、font-variant、font-stretch、font-size、font-size-adjust、kerning、letter-spacing、word-spacing和textdecoration。其它文本相关的元素:<tspan> 元素用来标记大块文本的子部分,它必须是一个text元素或别的tspan元素的子元素。✓ x 和 y 属性决定了文本在视口坐标系中显示的位置。✓ alignment-baseline 基线对齐属性:auto 、baseline、middle、hanging、top、bottom ... ,默认是 auto<!-- 1.在svg中绘制一个文字 --> <!-- <text x="100" y="100" font-size="50" fill="red">Ay</text> --> <!-- 2.文本的对齐方式 --> <!-- <text x="100" y="100" text-anchor="middle" font-size="50" fill="red">Ay</text> --> <!-- 3.基线对齐方式 : 有 auto 、middle 或 hanging 值, 默认值:auto --> <text x="100" y="100" dominant-baseline="middle" font-size="50" fill="red">Ay</text> <!-- 4.在svg中使用tspan绘制一个文字 --> <text x="40" y="100" font-size="20"> iPhone14 <tspan fill="red">¥100</tspan> </text>2.6 SVG 的组合和复用1. 元素的组合 g<g>元素的属性(该元素只包含全局属性)核心属性:id样式属性:class 、stylePresentation Attributes(也可说是 CSS 属性,这些属性可写在CSS中,也可作为元素的属性用):✓ cursor, display, fill, fill-opacity, opacity,…✓ stroke, stroke-dasharray, stroke-dashoffset, stroke-linecap, stroke-linejoin✓ 更多表示属性:https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/Presentation事件属性:onchange, onclick, ondblclick, ondrag…动画属性:transform更多:https://developer.mozilla.org/en-US/docs/Web/SVG/Element/g<!-- <circle cx="50" cy="50" r="25" fill="transparent" stroke="red"></circle> <circle cx="80" cy="50" r="25" fill="transparent" stroke="red"></circle> <circle cx="110" cy="50" r="25" fill="transparent" stroke="red"></circle> <circle cx="140" cy="50" r="25" fill="transparent" stroke="red"></circle> --> <!-- g 元素没有专有的属性,只有全局的属性 全局属性:id class style fill stroke onclick --> <g fill="transparent" stroke="red"> <circle cx="50" cy="50" r="25"></circle> <circle cx="80" cy="50" r="25"></circle> <circle cx="110" cy="50" r="25"></circle> <circle cx="140" cy="50" r="25"></circle> </g>2. 元素的复用和引入 defs 和 use<defs>元素,定义可复用元素。例如:定义基本图形、组合图形、渐变、滤镜、样式等等。在< defs >元素中定义的图形元素是不会直接显示的。可在视口任意地方用<use>来呈现在defs中定义的元素。<defs>元素没有专有属性,使用时通常也不需添加任何属性。<use> 元素的属性href: 需要复制元素/片段的 URL 或 ID(支持跨SVG引用)。默认值:无xlink:href:(SVG2.0已弃用)需要复制的元素/片段的 URL 或 ID 。默认值:无x / y :元素的 x / y 坐标(相对复制元素的位置)。 默认值:0width / height :元素的宽和高(在引入svg或symbol元素才起作用)。 默认值:0<svg width="300" height="300" xmlns="http://www.w3.org/2000/svg"> <defs> <!-- 0.样式 --> <style> rect{ fill: green; </style> <!-- 1.定义了一个矩形 --> <rect id="rectangle" x="0" y="0" width="100" height="50"></rect> <!-- 2.定义了一个组合图形 --> <g id="logo" fill="transparent" stroke="red"> <circle cx="50" cy="50" r="25"></circle> <circle cx="80" cy="50" r="25"></circle> <circle cx="110" cy="50" r="25"></circle> <circle cx="140" cy="50" r="25"></circle> </g> <!-- 定义渐变 --> <!-- 滤镜 --> </defs> <!-- 在这里进行图形的复用 --> <!-- <use href="#rectangle"></use> --> <!-- <use x="100" y="100" href="#rectangle"></use> --> <!-- <use href="#logo"></use> --> <!-- <use x="100" y="100" href="#logo"></use> --> </svg> <svg width="300" height="300" xmlns="http://www.w3.org/2000/svg" > <!-- 他的宽和高是没有生效的 ???? 只用use引用的图形是 svg 或 symbol 才会起作用 --> <use href="#rectangle" width="200" height="100" ></use> </svg> <svg width="300" height="300" xmlns="http://www.w3.org/2000/svg" > <use href="#logo"></use> </svg>图形元素复用 symbols<symbol> 元素和 <defs> 元素类似,也是用于定义可复用元素,然后通过 <use> 元素来引用显示。在 <symbol> 元素中定义的图形元素默认也是不会显示在界面上。<symbol>元素常见的应用场景是用来定义各种小图标,比如:icon、logo、徽章等<symbol>元素的属性viewBox:定义当前 <symbol> 的视图框。x / y :symbol元素的 x / y坐标。 ;默认值:0width / height:symbol元素的宽度。 默认值:0<symbol>和<defs> 的区别<defs>元素没有专有属性,而<symbol>元素提供了更多的属性✓ 比如: viewBox、 preserveAspectRatio 、x、y、width、height等。<symbol>元素有自己用户坐标系,可以用于制作SVG精灵图。<symbol>元素定义的图形增加了结构和语义性,提高文档的可访问性。SVG ICON文件-合并成SVG精灵图:https://www.zhangxinxu.com/sp/svgo<svg width="300" height="300" xmlns="http://www.w3.org/2000/svg" > <!-- 1.ICON previous--> <symbol id="previous" viewBox="0 0 100 100"> <path fill="currentColor" d="M 80 0,L 20 50, L 80 100 Z"></path> </symbol> <!-- 2.ICON next --> <symbol id="next" viewBox="0 0 100 100"> <polygon points="20 0, 80 50, 20 100"></polygon> </symbol> <!-- 复用 --> <!-- <use href="#previous" width="100" height="100"></use> --> </svg> <!-- 复用 --> <svg width="300" height="300" xmlns="http://www.w3.org/2000/svg" > <!-- 直接在use上指定ICON的 width和 hegiht --> <use href="#previous" width="50" height="50"></use> </svg> <!-- 这个属于缩小 --> <svg width="30" height="30" xmlns="http://www.w3.org/2000/svg" > <use href="#previous" ></use> </svg> <!-- 属于放大 --> <svg width="200" height="200" xmlns="http://www.w3.org/2000/svg" > <use href="#previous" ></use> </svg>3. SVG 高级3.1 填充和描边如果想要给SVG中的元素上色,一般有两种方案可以实现:第一种:直接使用元素的属性,比如:填充(fill)属性、描边(stroke)属性等。<rect x="10" y="10" width="100" height="100" fill="currentColor" fill-opacity="0.4"></rect> <rect x="10" y="10" width="100" height="100" fill="transparent" stroke="red" stroke-width="3" stroke-opacity="1" ></rect>第二种:直接编写CSS样式,因为SVG也是HTML中的元素,也支持用CSS的方式来编写样式。直接编写CSS样式实现填充和描边除了定义元素的属性外,你也可以通过CSS来实现填充和描边(CSS样式可写在defs中,也可写在HTML头部或外部等)。语法和 HTML 里使用 CSS 一样,需要注意的是:需要把 background-color、border 改成 fill 和 stroke不是所有的属性都能用 CSS 来设置,上色和填充的部分是可以用 CSS 来设置。✓ 比如,fill,stroke,stroke-dasharray 等可以用CSS设置;比如,路径的命令则不能用 CSS 设置。哪些属性可以使用CSS设置,哪些不能呢?SVG规范中将属性区分成 Presentation Attributes 和 Attributes 属性。✓ Presentation Attributes 属性( 支持CSS和元素用 ):https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/Presentation✓ Attributes 属性(只能在元素用): https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute✓ 提示:这些属性是不需要去记的,用多了就记住了,在忘记时测试一下就知道了。CSS给SVG中的元素填充、描边和上色,支持如下4种编写方式:方式一:内联(行内) CSS 样式,写在元素的style属性上方式二:内嵌(内部) CSS 样式,写在 <defs>中的 <style>标签中方式三:内嵌(内部) CSS 样式,写在<head>中的<style>标签中方式四:外部 CSS 样式文件,写在 .css 文件中CSS样式优先级别:内联的 style > defs中的style > 外部 / head内部 > 属性 fill<!-- 1.行内的样式 --> <rect x="10" y="10" width="100" height="100" style="fill:red;" ></rect> <!-- 2.行内的样式 --> <defs> <style> .rectangle{ fill: green; stroke: red; </style> </defs> <rect class="rectangle" x="10" y="10" width="100" height="100"></rect> /* 3.行内的样式 */ <style> .rectangle{ fill: blue; </style> <rect class="rectangle" x="10" y="10" width="100" height="100"></rect> /* 4. 引入css文件*/ <link rel="stylesheet" href="./style.css"> <rect class="rectangle" x="10" y="10" width="100" height="100"></rect>3.2 渐变和滤镜SVG除了可以简单的填充和描边,还支持在填充和描边上应用渐变色。渐变有两种类型:线性渐变 和 径向渐变。编写渐变时,必须给渐变内容指定一个 id 属性,use引用需用到。建议渐变内容定义在<defs>标签内部,渐变通常是可复用的。线性渐变,是沿着直线改变颜色。下面看一下线性渐变的使用步骤:第1步:在 SVG 文件的 defs 元素内部,创建一个 <linearGradient> 节点,并添加 id 属性。第2步:在 <linearGradient> 内编写几个 <stop> 结点。✓ 给 <stop> 结点指定位置 offset属性和 颜色stop-color属性,用来指定渐变在特定的位置上应用什么颜色➢ offset 和 stop-color 这两个属性值,也可以通过 CSS 来指定。✓ 也可通过 stop-opacity 来设置某个位置的半透明度。第3步:在一个元素的 fill 属性或 stroke 属性中通过ID来引用 <linearGradient> 节点。✓ 比如:属性fill属性设置为url( #Gradient2 )即可。第4步(可选):控制渐变方向,通过 ( x1, y1 ) 和 ( x2, y2 ) 两个点控制。✓ (0, 0) (0, 1)从上到下;(0, 0)(1, 0)从左到右。✓ 当然也可以通过 gradientTransform 属性 设置渐变形变。比如: gradientTransform=“rotate(90)” 从上到下。<svg width="300" height="300" xmlns="http://www.w3.org/2000/svg"> <!-- 定义可以复用的元素: 样式, 渐变, 图形, 滤镜... --> <defs> <!-- 默认的渐变色 --> <linearGradient id="gradient1"> <stop offset="0%" stop-color="red"></stop> <stop offset="50%" stop-color="green"></stop> <stop offset="100%" stop-color="blue"></stop> </linearGradient> <!-- 这个是制定渐变的方向 --> <linearGradient id="gradient2" x1="0" y1="0" x2="1" y2="1"> <stop offset="0%" stop-color="red"></stop> <stop offset="50%" stop-color="green"></stop> <stop offset="100%" stop-color="blue"></stop> </linearGradient> <!-- 通过形变 渐变色(了解 ) --> <linearGradient id="gradient3" gradientTransform="rotate(0)"> <stop offset="0%" stop-color="red"></stop> <stop offset="50%" stop-color="green"></stop> <stop offset="100%" stop-color="blue"></stop> </linearGradient> </defs> <rect x="0" y="0" width="100" height="50" fill="url(#gradient3)"></rect> </svg>在前端开发中,毛玻璃效果有几种方案来实现:方案一:使用CSS的 backdrop-filter 或 filter 属性backdrop-filter:可以给一个元素后面区域添加模糊效果。适用于元素背后的所有元素。为了看到效果,必须使元素或其背景至少部分透明。filter:直接将模糊或颜色偏移等模糊效果应用于指定的元素。方案二:使用SVG的 filter 和 feGaussianBlur 元素(建议少用)<filter>:元素作为滤镜操作的容器,该元素定义的滤镜效果需要在SVG元素上的 filter 属性引用。✓ x , y, width, height 定义了在画布上应用此过滤器的矩形区域。x, y 默认值为 -10%(相对自身);width ,height 默认值为 120% (相对自身) 。<feGaussianBlur>:该滤镜专门对输入图像进行高斯模糊✓ stdDeviation 熟悉指定模糊的程度<feOffset> :该滤镜可以对输入图像指定它的偏移量。<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg" > <defs> <!-- 高斯模糊的 效果 --> <filter id="blurFilter"> <!-- ...... --> <feGaussianBlur stdDeviation="8"></feGaussianBlur> </filter> </defs> <image href="../images/avatar.jpeg" width="200" height="200" filter="url(#blurFilter)" </image> </svg>3.3 SVG 形变 transformtransform 属性支持的函数有 translate rotate scale skew matrix1. 平移 translate与 CSS 的 translate 相似但有区别,这里只支持 2D 变换,不需单位。<rect x="0" y="0" width="100" height="50" transform="translate(200, 200)" ></rect>2. 旋转 rotate与CSS的rotate相似但有区别。区别是:支持2D变换,不需单位,可指定旋转原点。<rect transform="translate(100, 0) rotate(45, 50, 25)" x="0" y="0" width="100" height="50" </rect>3. 缩放 scale与CSS的scale相似但有区别,这只支持2D变换,不需单位。 <rect transform="translate(100, 100) scale(1, 2)" x="0" y="0" width="100" height="50" ></rect>3.4 路径描边动画stroke 是描边属性,专门给图形描边。如果想给各种描边添加动画效果,需用到下面两个属性:stroke-dasharray =“number [, number , ….]”: 将虚线类型应用在描边上。✓ 该值必须是用逗号分割的数字组成的数列,空格会被忽略。比如 3,5 :➢ 第一个表示填色区域的长度为 3➢ 第二个表示非填色区域的长度为 5stroke-dashoffset:指定在dasharray模式下路径的偏移量。✓ 值为number类型,除了可以正值,也可以取负值。描边动画实现步骤:1.先将描边设置为虚线2.接着将描边偏移到不可见处3.通过动画让描边慢慢变为可见,这样就产生了动画效果了。<style> #line1 { /* 指定为虚线 */ stroke-dasharray: 100px; /* 可见 */ stroke-dashoffset: 20px; /* animation: line1Move 2s linear; */ @keyframes line1Move { /* 不可见 */ stroke-dashoffset: 100px; 100% { /* 可见 */ stroke-dashoffset: 0px; </style> <svg width="300" height="300" xmlns="http://www.w3.org/2000/svg"> <!-- stroke , 而不是 fill --> <line id="line1" x1="100" y1="70" x2="200" y2="70" stroke="red" stroke-width="10" ></line> </svg>3.5 SMIL 动画SMIL(Synchronized Multimedia Integration Language 同步多媒体集成语言)是W3C推荐的可扩展标记语言,用于描述多媒体演示。SMIL 标记是用 XML 编写的,与HTML有相似之处。SMIL 允许开发多媒体项目,例如:文本、图像、视频、音频等。SMIL 定义了时间、布局、动画、视觉转换和媒体嵌入等标记,比如:<head> <body> <seq> <par> <excl> 等元素SMIL的应用目前最常用的Web浏览器基本都支持 SMIL 语言。SVG 动画元素是基于SMIL实现(SVG中使用SMIL实现元素有:<set>、<animate>、<animateMotion>...)。Adobe Media Player implement SMIL playback。QuickTime Player implement SMIL playback。SVG动画实现方式用JS脚本实现:可以直接通过 JavaScript 在来给 SVG 创建动画和开发交互式的用户界面。用CSS样式实现:自 2008 年以来,CSS动画已成为WebKit中的一项功能,使得我们可以通过CSS动画的方式来给文档对象模型(DOM) 中的 SVG 文件编写动态效果。用SMIL实现:一种基于SMIL语言实现的SVG动画。SMIL动画的优势只需在页面放几个animate元素就可以实现强大的动画效果,无需任何CSS和JS代码。SMIL支持声明式动画。声明式动画不需指定如何做某事的细节,而是指定最终结果应该是什么,将实现细节留给客户端软件在 JavaScript 中,动画通常使用 setTimeout() 或 setInterval() 等方法创建,这些方法需要手动管理动画的时间。而SMIL 声明式动画可以让浏览器自动处理,比如:动画轨迹直接与动画对象相关联、物体和运动路径方向、管理动画时间等等。SMIL 动画还有一个令人愉快的特点是,动画与对象本身是紧密集成的,对于代码的编写和阅读性都非常好。SVG 中支持SMIL动画的元素:<set> <animate> <animateColor> <animateMotion>更多 https://www.w3.org/TR/SVG11/animate.html#AnimationElements1. set 元素set元素是最简单的 SVG 动画元素。它是在经过特定时间间隔后,将属性设置为某个值(不是过度动画效果)。因此,图像不是连续动画,而是改变一次属性值。它支持所有属性类型,包括那些无法合理插值的属性类型,例如:字符串 和 布尔值。而对于可以合理插值的属性通常首选<animate>元素。常用属性有 attributeName to begin<!-- 1. 在3秒后自动将长方形瞬间移到右边 --> <svg width="300" height="300" xmlns="http://www.w3.org/2000/svg" > <rect x="0" y="0" width="100" height="50" fill="red"> <set attributeName ='x' to="200" begin="3s" </set> </rect> </svg> <!-- 点击长方形后,长方形瞬间移到右边 --> <svg width="300" height="300" xmlns="http://www.w3.org/2000/svg" > <rect id="rectangle" x="0" y="0" width="100" height="50" fill="green"> <set attributeName ='x' to="200" begin="rectangle.click" </set> </rect> </svg>2. animate 元素常用属性有:attributeName from values begin dur fill repeatCount <svg width="300" height="200" xmlns="http://www.w3.org/2000/svg" > <rect x="0" y="0" width="100" height="50" fill="green"> <!-- form: 0 to: 200 --> <animate attributeName="x" values="0; 170; 200" dur="3s" repeatCount="indefinite" </animate> <animate attributeName="fill" values="red;green" dur="3s" repeatCount="indefinite" </animate> </rect> </svg>
1. 什么是 Canvas ?Canvas 最初由 Apple 于 2004 年 引入,用于 Mac OS X Webkit 组件,为仪表盘小组件和 Safari 浏览器等应用程序提供支持。后来,它被 Gecko 内核的浏览器(尤其是 Mozilla Firefox),Opera 和 Chrome 实现,并被网页超文本应用技术工作小组提议为下一代的网络技术的标准元素(HTML5新增元素)。Canvas 提供了非常多的 JavaScript绘图 API (比如:绘制路径、举行、圆、文本和图像等方法),与 <canvas>元素可以绘制各种 2D 图形。Canvas API 主要聚焦于 2D 图形。当然也可以使用 <canvas> 元素对象的 WebGL API 来绘制 2D 和 3D 图形。Canvas 可用于动画、游戏画面、数据可视化、图片编辑以及实现视频处理等方面。浏览器兼容性Canvas 优点:Canvas 提供的功能更原始,适合像素处理,动态渲染和数据量大的绘制,如:图片编辑、热力图、炫光尾迹特效等。Canvas 非常适合图像密集的游戏开发,适合频繁重绘许多的对象。Canvas能够以 .png 或 .jpg 格式保存结果图片,适合对图像进行像素级的处理。Canvas 缺点:在移动端可能因为 Canvas 数量多,而导致内存占用超出了手机的承受能力,导致浏览器崩溃。Canvas 绘图只能通过 JavaScript 脚本操作 all in js。Canvas 是由一个个像素点构成的图形,放大会使图形变得颗粒状和像素化,导致模糊。2. Canvas 绘制图形Canvas 支持两种方式来绘制矩形:矩形方法 和 路径方法。2.1 矩形方法路径是通过不同颜色和宽度的线段或曲线相连形成的不同形状的点的集合。除了矩形,其他的图形都是通过一条或者多条路径组合而成的。通常我们会通过众多的路径来绘制复杂的图形。fillRect(x, y, width, height): 绘制一个填充的矩形strokeRect(x, y, width, height): 绘制一个矩形的边框clearRect(x, y, width, height): 清除指定矩形区域,让清除部分完全透明。Canvas 绘制一个矩形:<canvas id="tutorial" width="300" height="300px"> 你的浏览器不兼容Canvas,请升级您的浏览器! </canvas> <script> window.onload = function() { let canvasEl = document.getElementById('tutorial') if(!canvasEl.getContext){ return let ctx = canvasEl.getContext('2d') // 2d | webgl ctx.fillRect(0,0, 100, 50) // 单位也是不用写 px </script>2.2 路径方法使用路径绘制图形的步骤:首先需要创建路径起始点(beginPath)。然后使用画图命令去画出路径( arc绘制圆弧 、lineTo画直线 )。之后把路径闭合( closePath , 不是必须)。一旦路径生成,就能通过 描边(stroke) 或 填充路径区域(fill) 来渲染图形。以下是绘制路径时,所要用到的函数beginPath():新建一条路径,生成之后,图形绘制命令被指向到新的路径上绘图,不会关联到旧的路径。closePath():闭合路径之后图形绘制命令又重新指向到 beginPath之前的上下文中。stroke():通过线条来绘制图形轮廓/描边 (针对当前路径图形)。fill():通过填充路径的内容区域生成实心的图形 (针对当前路径图形)。<canvas id="tutorial" width="300" height="300px"> 你的浏览器不兼容Canvas,请升级您的浏览器! </canvas> <script> window.onload = function () { let canvasEl = document.getElementById("tutorial"); if (!canvasEl.getContext) { return; let ctx = canvasEl.getContext("2d"); // 2d | webgl // 1.创建一个路径 ctx.beginPath(); // 2.绘图指令 // ctx.moveTo(0, 0) // ctx.rect(100, 100, 100, 50); ctx.moveTo(100, 100); ctx.lineTo(200, 100); ctx.lineTo(200, 150); ctx.lineTo(100, 150); // 3.闭合路径 ctx.closePath(); // 4.填充和描边 ctx.stroke(); </script>lineTo 和 arc 两个函数结合既能绘制直线也能绘制圆弧,因此路径方法还可以绘制许多图形,比如三角形、菱形、梯形、椭圆形、圆形等等。。。3. Canvas 样式和颜色3.1 色彩 Colors如果我们想要给图形上色,有两个重要的属性可以做到:fillStyle = color: 设置图形的填充颜色,需在 fill() 函数前调用。strokeStyle = color: 设置图形轮廓的颜色,需在 stroke() 函数前调用。:::warning{title="注意"}一旦设置了 strokeStyle 或者 fillStyle 的值,那么这个新值就会成为新绘制的图形的默认值。如果你要给图形上不同的颜色,你需要重新设置 fillStyle 或 strokeStyle 的:::3.2 透明度 Transparent除了可以绘制实色图形,我们还可以用 canvas 来绘制半透明的图形。方式一:strokeStyle 和 fillStyle属性结合RGBA:// 指定透明颜色,用于描边和填充样式 ctx.strokeStyle = "rgba(255,0,0,0.5)"; ctx.fillStyle = "rgba(255,0,0,0.5)";方式二:globalAlpha 属性// 针对于Canvas中所有的图形生效 ctx.globalAlpha = 0.3 // 2.修改画笔的颜色 // ctx.fillStyle = 'rgba(255, 0, 0, 0.3)' ctx.fillRect(0,0, 100, 50) // 单位也是不用写 px ctx.fillStyle = 'blue' ctx.fillRect(200, 0, 100, 50) ctx.fillStyle = 'green' // 关键字, 十六进制, rbg , rgba ctx.beginPath() ctx.rect(0, 100, 100, 50) ctx.fill():::warning{title="注意"}globalAlpha = 0 ~ 1✓ 这个属性影响到 canvas 里所有图形的透明度✓ 有效的值范围是 0.0(完全透明)到 1.0(完全不透明),默认是 1.0。:::3.3 线型 Line styles调用lineTo()函数绘制的线条,是可以通过一系列属性来设置线的样式。lineWidth = value: 设置线条宽度。lineCap = type: 设置线条末端样式。lineJoin = type: 设定线条与线条间接合处的样式。......lineWidth设置线条宽度的属性值必须为正数。默认值是 1.0px,不需单位。( 零、负数、Infinity和NaN值将被忽略)线宽是指给定路径的中心到两边的粗细。换句话说就是在路径的两边各绘制线宽的一半。如果你想要绘制一条从 (3,1) 到 (3,5),宽度是 1.0 的线条,你会得到像第二幅图一样的结果。✓ 路径的两边个各延伸半个像素填充并渲染出1像素的线条(深蓝色部分)✓ 两边剩下的半个像素又会以实际画笔颜色一半色调来填充(浅蓝部分)✓ 实际画出线条的区域为(浅蓝和深蓝的部分),填充色大于1像素了,这就是为何宽度为 1.0 的线经常并不准确的原因。要解决这个问题,必须对路径精确的控制。如,1px的线条会在路径两边各延伸半像素,那么像第三幅图那样绘制从 (3.5 ,1) 到 (3.5, 5) 的线条,其边缘正好落在像素边界,填充出来就是准确的宽为 1.0 的线条。lineCap: 属性的值决定了线段端点显示的样子。它可以为下面的三种的其中之一:butt 截断,默认是 butt。round 圆形square 正方形lineJoin: 属性的值决定了图形中线段连接处所显示的样子。它可以是这三种之一:round 圆形bevel 斜角miter 斜槽规,默认是 miter。3.4 绘制文本canvas 提供了两种方法来渲染文本:fillText(text, x, y [, maxWidth])✓ 在 (x,y) 位置,填充指定的文本✓ 绘制的最大宽度(可选)。strokeText(text, x, y [, maxWidth])✓ 在 (x,y) 位置,绘制文本边框✓ 绘制的最大宽度(可选)。文本的样式(需在绘制文本前调用)font = value: 当前绘制文本的样式。这个字符串使用和 CSS font 属性相同的语法。默认的字体是:10px sans-serif。textAlign = value:文本对齐选项。可选的值包括:start, end, left, right or center. 默认值是 starttextBaseline = value:基线对齐选项。可选的值包括:top, hanging, middle, alphabetic, ideographic, bottom。✓ 默认值是 alphabetic。<canvas id="tutorial" width="300" height="300px"> 你的浏览器不兼容Canvas,请升级您的浏览器! </canvas> <script> window.onload = function () { let canvasEl = document.getElementById("tutorial"); if (!canvasEl.getContext) { return; let ctx = canvasEl.getContext("2d"); // 2d | webgl ctx.font = "60px sen-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.strokeStyle = "red"; ctx.fillStyle = "red"; // 将字体绘制在 100, 100 这个坐标点 ctx.fillText("Ay", 100, 100); // ctx.strokeText("Ay", 100, 100); </script>3.5 绘制图片绘制图片,可以使用 drawImage 方法将它渲染到 canvas 里。drawImage 方法有三种形态:drawImage(image, x, y)其中 image 是 image 或者 canvas 对象,x 和 y 是其在目标 canvas 里的起始坐标。drawImage(image, x, y, width, height)这个方法多了 2 个参数:width 和 height,这两个参数用来控制 当向 canvas 画入时应该缩放的大小drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)第一个参数和其它的是相同的,都是一个图像或者另一个 canvas 的引用。其它 8 个参数最好是参照右边的图解,前 4 个是定义图像源的切片位置和大小,后 4 个则是定义切片的目标显示位置和大小。:::info{title="图片的来源"}HTMLImageElement:这些图片是由Image()函数构造出来的,或者任何的 <img> 元素。HTMLVideoElement:用一个 HTML 的 <video> 元素作为你的图片源,可以从视频中抓取当前帧作为一个图像。HTMLCanvasElement:可以使用另一个 <canvas> 元素作为你的图片源。等等:::4. Canvas 状态和形变4.1 Canvas 绘画状态-保存和恢复Canvas 绘画状态是当前绘画时所产生的样式和变形的一个快照,Canvas 在绘画时,会产生相应的绘画状态,其实我们是可以将某些绘画的状态存储在栈中来为以后复用,Canvas 绘画状态的可以调用 save 和 restore 方法是用来保存和恢复,这两个方法都没有参数,并且它们是成对存在的。保存和恢复(Canvas)绘画状态save():保存画布 (canvas) 的所有绘画状态restore():恢复画布 (canvas) 的所有绘画状态Canvas绘画状态包括:当前应用的变形(即移动,旋转和缩放)以及这些属性:strokeStyle, fillStyle, globalAlpha, lineWidth, lineCap, lineJoin, miterLimit, shadowOffsetX, shadowOffsetY, shadowBlur, shadowColor, font, textAlign, textBaseline......当前的裁切路径(clipping path)4.2 移动 - translatetranslate方法,它用来移动 canvas 和它的原点到一个不同的位置。translate(x, y)x 是左右偏移量,y 是上下偏移量(无需单位)。移动 canvas 原点的好处如不使用 translate方法,那么所有矩形默认都将被绘制在相同的(0,0)坐标原点。translate方法可让我们任意放置图形,而不需要手工一个个调整坐标值。移动矩形案例第一步:先保存一下canvas当前的状态第二步:在绘制图形前translate移动画布第三步:开始绘制图形,并填充颜色<script> ///1.形变( 没有保存状态) ctx.translate(100, 100); ctx.fillRect(0, 0, 100, 50); // 单位也是不用写 px ctx.translate(100, 100); ctx.strokeRect(0, 0, 100, 50); </script><script> // 2.形变(保存形变之前的状态) ctx.save(); ctx.translate(100, 100); ctx.fillRect(0, 0, 100, 50); // 单位也是不用写 px ctx.restore(); // 恢复了形变之前的状态( 0,0) ctx.save(); // (保存形变之前的状态) ctx.translate(100, 100); ctx.fillStyle = "red"; ctx.fillRect(0, 0, 50, 30); ctx.restore(); </script>4.3 移动 - rotaterotate方法,它用于以原点为中心旋转 canvas,即沿着 z轴 旋转。rotate(angle)只接受一个参数:旋转的角度 (angle),它是顺时针方向,以弧度为单位的值。角度与弧度的 JS 表达式:弧度=( Math.PI / 180 ) * 角度 ,即 1角度 = Math.PI/180 个弧度。比如:旋转90°:Math.PI / 2; 旋转180°:Math.PI ; 旋转360°:Math.PI * 2; 旋转-90°:-Math.PI / 2;旋转的中心点始终是 canvas 的原坐标点,如果要改变它,我们需要用到 translate方法。<SCRIPT> // 保存形变之前的状态 ctx.save() // 1.形变 ctx.translate(100, 100) // 360 -> Math.PI * 2 // 180 -> Math.PI // 1 -> Math.PI / 180 // 45 -> Math.PI / 180 * 45 ctx.rotate(Math.PI / 180 * 45) ctx.fillRect(0, 0, 50, 50) // ctx.translate(100, 0) // ctx.fillRect(0, 0, 50, 50) // 绘图结束(恢复形变之前的状态) ctx.restore() ctx.save() ctx.translate(100, 0) ctx.fillRect(0, 0, 50, 50) ctx.restore() // ....下面在继续写代码的话,坐标轴就是参照的是原点了 <SCRIPT>4.4 移动 - scalescale(x, y) 方法可以缩放画布。可用它来增减图形在 canvas 中的像素数目,对图形进行缩小或者放大。x 为水平缩放因子,y 为垂直缩放因子,也支持负数。<script> // 保存形变之前的状态 ctx.save() // 1.形变 ctx.translate(100, 100) // 平移坐标系统 ctx.scale(2, 2) // 对坐标轴进行了放大(2倍) ctx.translate(10, 0) // 10px -> 20px ctx.fillRect(0, 0, 50, 50) // 绘图结束(恢复形变之前的状态) ctx.restore() // ....下面在继续写代码的话,坐标轴就是参照的是原点了 </script>5. Canvas 动画和案例5.1 Canvas 动画Canvas绘图都是通过JavaScript 去操控的,如要实现一些交互性动画是相当容易的。我们可以使用 setInterval 、 setTimeout 和 requestAnimationFrame 三种方法来定期执行指定函数进行重绘。Canvas 画出一帧动画的基本步骤(如要画出流畅动画,1s 需绘60帧):第一步:用 clearRect 方法清空 canvas ,除非接下来要画的内容会完全充满 canvas(例如背景图),否则你需要清空所有。第二步:保存 canvas 状态,如果加了 canvas 状态的设置(样式,变形之类的),又想在每画一帧之时都是原始状态的话,你需要先保存一下,后面再恢复原始状态。第三步:绘制动画图形(animated shapes) ,即绘制动画中的一帧。第四步:恢复 canvas 状态,如果已经保存了 canvas 的状态,可以先恢复它,然后重绘下一帧。5.2 案例:太阳系动画效果代码<script> window.onload = function () { let canvasEl = document.getElementById("tutorial"); if (!canvasEl.getContext) { return; let ctx = canvasEl.getContext("2d"); // 2d | webgl let sun = new Image(); sun.src = "../../images/canvas_sun.png"; // sun.onload = function() { // // draw let earth = new Image(); earth.src = "../../images/canvas_earth.png"; let moon = new Image(); moon.src = "../../images/canvas_moon.png"; requestAnimationFrame(draw); 1秒钟会回调 61次 function draw() { console.log("draw"); ctx.clearRect(0, 0, 300, 300); ctx.save(); // 1.绘制背景 drawBg(); // 2.地球 drawEarth(); ctx.restore(); requestAnimationFrame(draw); function drawBg() { ctx.save(); ctx.drawImage(sun, 0, 0); // 背景图 ctx.translate(150, 150); // 移动坐标 ctx.strokeStyle = "rgba(0, 153, 255, 0.4)"; ctx.beginPath(); // 绘制轨道 ctx.arc(0, 0, 105, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); function drawEarth() { let time = new Date(); let second = time.getSeconds(); let milliseconds = time.getMilliseconds(); ctx.save(); // earth start ctx.translate(150, 150); // 中心点坐标系 // 地球的旋转 // Math.PI * 2 一整个圆的弧度 // Math.PI * 2 / 60 分成 60 份 // Math.PI * 2 / 60 1s // Math.PI * 2 / 60 / 1000 1mm // 1s 1mm // Math.PI * 2 / 60 * second + Math.PI * 2 / 60 / 1000 * milliseconds ctx.rotate( ((Math.PI * 2) / 10) * second + ((Math.PI * 2) / 10 / 1000) * milliseconds ctx.translate(105, 0); // 圆上的坐标系 ctx.drawImage(earth, -12, -12); // 3.绘制月球 drawMoon(second, milliseconds); // 4.绘制地球的蒙版 drawEarthMask(); ctx.restore(); // earth end function drawMoon(second, milliseconds) { ctx.save(); // moon start // 月球的旋转 // Math.PI * 2 一圈 360 // Math.PI * 2 / 10 1s(10s一圈) // Math.PI * 2 / 10 * 2 2s(10s一圈) // Math.PI * 2 / 10 / 1000 1mm 的弧度 // 2s + 10mm = 弧度 // Math.PI * 2 / 10 * second + Math.PI * 2 / 10 / 1000 * milliseconds ctx.rotate( ((Math.PI * 2) / 2) * second + ((Math.PI * 2) / 2 / 1000) * milliseconds ctx.translate(0, 28); ctx.drawImage(moon, -3.5, -3.5); ctx.restore(); // moon end function drawEarthMask() { // 这里的坐标系是哪个? 圆上的坐标系 ctx.save(); ctx.fillStyle = "rgba(0, 0, 0, 0.4)"; ctx.fillRect(0, -12, 40, 24); ctx.restore(); </script>
1. 可视化介绍数据可视化(英语:Data visualization),主要旨在借助于图形化手段,清晰有效地传达与沟通信息。 为了清晰有效地传递信息,数据可视化通常使用柱状图、折线图、饼图、玫瑰图、散点图等图形来传递信息,也可以使用点、线、面、地图来对数字数据进行编码展示,以便在视觉上快速传达关键信息,可视化可以帮助用户分析和推理数据,让复杂的数据更容易理解和使用,有利于做出决策。就像你看下图中的表格很难看出什么,但是你看下面的可视化图标,就能很轻易的分辨出各类数据间的比较以及趋势。2. 可视化的历史1. 萌芽阶段早在 17 世纪以前,可视化就开始萌芽了,其中最早的地图在公元前 6200 年于土耳其地区出现。现代考古发现我国最早的地图实物,是出土于甘肃天水放马滩战国墓地一号墓中的《放马滩地图》。17 世纪末随着几何兴起、坐标系、以及人口统计学开端,人类开始了可视化思考的新模式,从此标记可视化的开端。1800-1849年:随着工艺设计的完善,统计图形爆炸性增长,包括柱状图, 饼图, 直方图, 折线图等。1826 年,查尔斯·杜品发明了使用连续黑白底纹来显示法国识字分布,这可能是第一张现代形式主题统计地图。2. 黄金阶段1850-1899:人们开始认识到数字信息对社会计划,工业化,商业和运输的重要性,此时统计理论开始诞生。1869年查尔斯·约瑟夫·米纳德,发布的拿破仑对 1812 年俄罗斯东征事件流图,被誉为有史以来最好的数据可视化。他的流图呈现了拿破仑军队的位置和行军方向、军队汇集、分散和重聚的时间和地点等信息。1879年 Luigi Perozzo 绘制立体图(三维人口金字塔)。标记着可视化开始进入了三维立体图。3. 重生阶段1950-1974 年:引领这次大潮的,首先是一个划时代的事件——计算机的诞生。计算机的出现彻底地改变了数据分析工作,计算机高分辨率和交互式的图形分析,提供了手绘时代无法实现的表现能力。随着统计应用的发展,数理统计把数据可视化变成了一门科学(如:计算机图形学、统计学、分析学),并运用到各行各业。1969年 John W. Tukey 在探索数据分析的图形时,发明箱型图。1982年乔治·罗里克(George Rorick)绘制彩色天气图开创了报纸上的彩色信息图形时代。1996年 Jason Dykes 发明了制图工具:一种地图可视化工具包,可以实时查看数据的图形工具。4. 分析学阶段2004 年至今以前可视化难以应对海量、高维、多源的动态数据的分析,进入21世纪,随着计算机的升级,对于以前难以应对数据,可以借用计算机来综合可视化、图形学、数据挖掘理论与方法来研究新的科学理论模型。通过这种模型来辅助用户从海量、复杂、矛盾的数据中快速挖掘出有用的数据,做出有效决策,这门新兴学科称为可视化分析学。可视化分析现在已大量应用在地图、物流、电力、水利、环保、交通、医学、监控、预警等领域。可视化分析降低了数据理解的难度,突破了常规统计分析的局限性。如下交通拥挤分析图。随着大数据的应用,如今可视化开发也变得越来越重要了。3. 可视化应用场景随着近几年大数据的快速发展,数据可视化技术也迅速被普及。目前数据可视化的应用非常广:如淘宝双十一活动时,借助于数据可视化展示公司实时交易数额,并可以实时动态观察。交管部门可实现对交通形态、卡口数据统计、违章分析、警力部署、出警分析、行车轨迹分析等智能交通大数据分析。企业各层可以借助数据可视化工具,可以直接在手机等设备上远程查看业务运营数据状况和关键指标。医院可以利用数据可视化工具,对医疗卫生数据进行可视化分析和研究应用,进而获取医疗卫生数据隐藏的价值。等等4. 可视化的解决方案前端可视化技术底层图形引擎:Skia 、OpenGL 等。W3C提供:CSS3、Canvas、SVG、WebGL。第三方的可视化库: ZRender、Echarts、 AntV 、Highcharts、D3.js 、three.js 和 百度地图、高德地图 等等。低代码可视化平台:阿里云(DataV)、腾讯云图、网易有数(EasyScreen)、帆软 等。
1. 适配方案1:rem + font-size我们都知道,在 css 中 1rem 等于 html 根元素设定的 font-size 的 px 值,通过动态的修改html 根元素的 font-size 大小就能动态的改变 rem 的大小,从而实现适配。原理动态设置 HTML 根字体大小将 px 转成 rem实现引入 lib-flexible 动态设置 HTML 根字体大小和 body 字体大小。(function flexible(window, document) { var docEl = document.documentElement; var dpr = window.devicePixelRatio || 1; // 调整 body 字体大小 function setBodyFontSize() { if (document.body) { // body 字体大小默认为 16px document.body.style.fontSize = 16 * dpr + "px"; } else { document.addEventListener("DOMContentLoaded", setBodyFontSize); setBodyFontSize(); // 移动端默认平均分成 10 等分(适用移动端) // pc端默认平均分成 24 等分(适用 pc 端) function setRemUnit() { var splitNum = /Mobi|Android|iPhone/i.test(navigator.userAgent) ? 10 : 24; var rem = docEl.clientWidth / splitNum; // 1920 / 24 = 80 docEl.style.fontSize = rem + "px"; // 设置 html 字体的大小 80px setRemUnit(); // 页面调整大小时重置 rem 单位 window.addEventListener("resize", setRemUnit); window.addEventListener("pageshow", function (e) { if (e.persisted) { setRemUnit(); // 检测 0.5px 支持 if (dpr >= 2) { var fakeBody = document.createElement("body"); var testElement = document.createElement("div"); testElement.style.border = ".5px solid transparent"; fakeBody.appendChild(testElement); docEl.appendChild(fakeBody); if (testElement.offsetHeight === 1) { docEl.classList.add("hairlines"); docEl.removeChild(fakeBody); })(window, document);将 px 转 rempx 转 rem 的方式有很多种:手动、less/scss 函数、cssrem 插件、webpack 插件、**Vite 插件。cssrem 插件转换vscode `root font-size` 设置为 80px。这个是 `px` 单位转 `rem` 的参考值。  接着就可以按照 1920px * 1080px 的设计稿愉快开发,此时页面已经是响应式,并且宽高比不变  webpack 插件转换安装 ```shell npm i webpack webpack-cli -D npm i style-loader css-loader html-webpack-plugin -D npm i postcss-pxtorem autoprefixer postcss-loader postcss -D 配置 `webpack.config.js` ```js const HtmlWebpackPlugin = require("html-webpack-plugin"); const path = require("path"); module.exports = { entry: "./src/index.js", mode: "development", output: { filename: "[name].[contenthash].bundle.js", path: path.resolve("./dist"), module: { rules: [ test: /\.css$/i, use: ["style-loader", "css-loader", "postcss-loader"], plugins: [ new HtmlWebpackPlugin({ template: "./index.html", 配置 `postcss.config.js` 文件,`postcss-pxtorem 的配置` 可以查询 [文档](https://github.com/cuth/postcss-pxtorem) ```js module.exports = { plugins: { autoprefixer: {}, "postcss-pxtorem": { rootValue: 80, // 根元素的字体大小 unitPrecision: 5, // 小数点后精度 propList: ["*"], // 可以从px改变为rem的属性 exclude: /node_modules/i, // 要忽略并保留为px的文件路径 minPixelValue: 0, // 最小的px转化值(小于这个值的不转化) mediaQuery: false, // 允许在媒体查询中转换px selectorBlackList: [], // 要忽略并保留为px的选择器 replace: true, // 直接在css规则上替换值而不是添加备用 在 `main.js` 中引入`lib_flexible.js` `index.js` `index.css` ,最后重启项目即可。 :::tip{title="提示"} 这里我为了回顾一下 `webpack` 配置,就从 0 开始配置了。一般通过脚手架创建的项目会有集成webpack以及postcss的,只需要 安装一下 `postcss postcss-pxtorem` 与配置 `postcss.config.js` 即可 :::warning{title="注意"} 由于 `viewport` 单位得到众多浏览器的兼容,`lib-flexible` 这个过渡方案已经可以放弃使用,不管是现在的版本还是以前的版本,都存有一定的问题。下面就讲介绍 `viewport` 的方案。 2. 适配方案2:vw 单位(推荐)直接使用 vw 单位。屏幕的宽默认为 100vw,那么100vw = 1920px, 1vw = 19.2px 。实现将 px 转 vwcssrem 插件方式转换接着就可以按照 1920px * 1080px 的设计稿愉快开发,此时的页面已经是响应式,并宽高比不变webpack 插件转换安装npm i webpack webpack-cli -D npm i style-loader css-loader html-webpack-plugin -D npm i postcss-px-to-viewport autoprefixer postcss-loader postcss -Dwebpack.config.js 配置不变配置 postcss.config.jsmodule.exports = { plugins: { '@our-patches/postcss-px-to-viewport': { unitToConvert: 'px', // 要转化的单位 viewportWidth: 1920, // UI设计稿的宽度 unitPrecision: 6, // 转换后的精度,即小数点位数 propList: ['*'], // 指定转换的css属性的单位,*代表全部css属性的单位都进行转换 viewportUnit: 'vw', // 指定需要转换成的视窗单位,默认vw fontViewportUnit: 'vw', // 指定字体需要转换成的视窗单位,默认vw selectorBlackList: [], // 指定不转换为视窗单位的类名, minPixelValue: 1, // 默认值1,小于或等于1px则不进行转换 mediaQuery: true, // 是否在媒体查询的css代码中也进行转换,默认false replace: true, // 是否转换后直接更换属性值 include: /\/src\/views\/pc\/layoutMapBS\//, exclude: [/node_modules/], // 设置忽略文件,用正则做目录名匹配 landscape: false // 是否处理横屏情况 }:::warning{title="注意"}postcss-pxtoviewport 这个插件在文档中有 include 这个选项,但是作者一直没更新代码,导致这个选项一直无效,而且作者已经很久没改了。可以使用 @our-patches/postcss-px-to-viewport。安装npm i @our-patches/postcss-px-to-viewport -D配置只需要在 postcss.config.js 中将 postcss-px-to-viewport 改为 postcss-px-to-viewport 即可:::3. 适配方案3:scale(推荐)使用CSS3中的scale函数来缩放网页,这里我们将使用两种方案来实现:方案一:直接根据宽度的比率进行缩放。(宽度比率=网页当前宽 / 设计稿宽)<script> window.onload = function () { triggerScale(); window.addEventListener("resize", function () { triggerScale(); function triggerScale() { var targetX = 1920; var targetY = 1080; // 获取html的宽度和高度(不包含滚动条) var currentX = document.documentElement.clientWidth || document.body.clientWidth; // https://developer.mozilla.org/en-US/docs/Web/API/Element/clientWidth var currentY = document.documentElement.clientHeight || document.body.clientHeight; // 1.缩放比例 3840 / 2160 => 2 var ratio = currentX / targetX; var bodyEl = document.querySelector("body"); // 2.需要修改缩放的原点 body { transform-origin: left top; } bodyEl.setAttribute("style", `transform:scale(${ratio})`); </script>方案二:动态计算网页宽高比,决定是是否按照宽度的比率进行缩放。<script> window.onload = function () { triggerScale(); window.addEventListener("resize", function () { triggerScale(); function triggerScale() { var targetX = 1920; var targetY = 1080; var targetRatio = 16 / 9; var currentX = document.documentElement.clientWidth || document.body.clientWidth; var currentY = document.documentElement.clientHeight || document.body.clientHeight; // 1.缩放比例 3840 / 2160 => 2 var ratio = currentX / targetX; var currentRatio = currentX / currentY; var transformStr = ""; if (currentRatio > targetRatio) { ratio = currentY / targetY; transformStr = `transform:scale(${ratio}) translateX(-${ targetX / 2 }px); left:50%;`; } else { transformStr = `transform:scale(${ratio})`; var bodyEl = document.querySelector("body"); // 2.需要修改缩放的原点 body { transform-origin: left top; } bodyEl.setAttribute("style", transformStr); </script>4. 总结vw 相比于 rem 的优势:优势一:不需要去计算 html 的 font-size 大小,不需要给 html 设置 font-size,也不需要设置 body 的 font-size ,防止继承;优势二:因为不依赖 font-size 的尺寸,所以不用担心某些原因 html 的 font-size 尺寸被篡改,页面尺寸混乱;优势三:vw 相比于 rem 更加语义化,1vw 是 1/100 的 viewport 大小(即将屏幕分成 100 份); 并且具备 rem 之前所有的优点;vw 和 rem 存在问题如果使用 rem 或 vw 单位时,在 JS 中添加样式时,单位需要手动设置 rem 或 vw 。第三方库的字体等默认的都是 px 单位,比如:element、echarts,因此通常需要层叠第三方库的样式。当大屏比例更大时,有些字体还需要相应的调整字号。scale 相比 vw 和 rem 的优势优势一:相比于 vw 和 rem,使用起来更加简单,不需要对单位进行转换。优势二:因为不需要对单位进行转换,在使用第三方库时,不需要考虑单位转换问题。优势三:由于浏览器的字体默认最小是不能小于 12px ,导致 rem 或 vw 无法设置小于 12 px的字体,缩放没有这个问题。大屏开发 注意事项字体大小设置问题(非 scale 方案需要考虑)如果使用 rem 或 vw 单位时,在 JS 中添加样式时,单位需要手动设置 rem或 vw。第三方库的字体等默认的都是 px单位,比如:element、echarts,因此通常需要层叠第三方库的样式。当大屏比例更大时,有些字体还需要相应的调整字号。图片模糊问题切图时切 1 倍图、2 倍图,大屏用大图,小屏用小图。建议都使用SVG矢量图,保证放大缩小不会失真。Echarts 渲染引擎的选择使用 SVG 渲染引擎,SVG 图扩展性更好动画卡顿优化创建新的渲染层、启用 GPU 加速、善用 CSS3 形变动画少用渐变和高斯模糊、当不需要动画时,及时关闭动画
1. Git flow 规范Git 作为一个源码管理的工具,不可避免的会涉及到多人的协作,协作必须有一个规范的工作流程,让大家有效的合作使得项目井井有条的发展下去。工作流程在英语中叫 Workflow。原意是水流,比喻项目想水流哪像自然的流动,不会发生冲击、对撞甚至旋涡。Git flow 是最早诞生,并广泛采用的一种工作流,它采用了功能驱动开发(Feature-driven development,简称FDD),它指的是需求是开发的起点,现有需求再有功能分支Git flow 最主要的特点有两个项目始终存在两个长期分支 master(主分支) 和 develop(开发分支)除此之外,还有三个短期分支 feature(功能分支) hotfix(补丁分支) release(预发分支)分支描述master产品分支:只能从其他分支合并内容,不能再这个分支直接修改。合并到 maters 上的 commit 只能来自 release 分支或 hotfix 分支。develop开发主干分支:基于 master 的 tag 建立,主要用来暂时保存开发完成而又未发布的 feature 分支内容,以及 release 和 hotfix 的补充内容feature功能分支:一般一个新功能对应一个功能分支,从而和已经完成的功能隔离开来,而且只有在新功能完成开发的情况下,其对应的 feature 分支才会合并到主开发分支( develop 分支)上release预发分支:当需要发布时,我们从 develop 分支创建一个 release 分支,然后这个release 分支会发布到测试环境进行测试,如果发现问题就在这个分支直接进行修复。发布结束后,这个 release 分支会合并到 develop 和 master 分支,从而保证不会有代码丢失。hotfix补丁分支:主要用于紧急修复一些 Bug。会从 master 分支上的某个 tag 建立,修复结束后再合并到 develop 和 master 分支上2. Git commit 规范写好 Git commit 能提供更多的而历史信息,方便快速浏览,还能过滤某些 commit(比如文档改动),便于快速查找信息。如何优雅写好Git commit规范呢? 现在业界使用比较广泛的是 Angular规范<type>(<scope>):<subject> # 标题行:必填描述主要修改类型和内容。 // 空行 <body> # 主题内容:描述为什么修改,做了什么样的修改,以及开发的思路等等。 // 空行 <footer> 放 Breaking Changes 或 Closed Issued1. type 类型有:Type作用feat新增特性 (feature)fix修复 Bug(bug fix)docs修改文档 (documentation)style代码格式修改(white-space, formatting, missing semi colons, etc)refactor代码重构(refactor)perf改善性能(A code change that improves performance)test测试(when adding missing tests)build变更项目构建或外部依赖(例如 scopes: webpack、gulp、npm 等)ci更改持续集成软件的配置文件和 package 中的 scripts 命令,例如 scopes: Travis, Circle 等chore变更构建流程或辅助工具(比如更改测试环境)revert代码回退scope 影响范围比如L route,component,utils,buildsubject:commit的描述body:commit具体修改内容,可以分为多行footer: 一些备注2. commitizen 工具1. 简介commitizen git commit 的格式化工具,为我们提供标准化的 commit 信息。 帮助我们统一项目commit , 便于信息的回溯或日志的生成。# commit message 格式2. 安装npm install -g commitizen cz-conventional-changelog echo '{"path": "cz-conventional-changelog"}' > ~/.czrc3. 使用在文件夹中修改内容git add * git cz具体操作步骤可以参考 规范(三):从 0 搭建 React+TS 项目 第七章 Git Commit 规范
1. 通过 Create-React-App 创建项目创建一个 TypeScript 模版的 React 项目npx create-react-app react-app --template typescript运行项目cd react-app npm start输入 localhost:3000 显示如下如即成功2. 配置 CRACOCRACO 全称 Create React App Configuration Override,取首字母即组成了工具名称。是为了无 eject 、可配置式的去修改 CRA 默认提供的工程配置,这样既能享受 CRA 带来的便利和后续升级,也能自己去自定义打包配置完成项目需要,一举两得。从npm安装最新版本的包作为开发依赖项:npm i -D @craco/craco在项目的根目录中创建一个CRACO配置文件并配置: react-app ├── node_modules + ├── craco.config.js └── package.json更新 package.json 的脚本部分中对 react 脚本的现有调用以使用 CRACO CLI:"scripts": { - "start": "react-scripts start" + "start": "craco start" - "build": "react-scripts build" + "build": "craco build" - "test": "react-scripts test" + "test": "craco test" }支持 TypeScript ,使用 CRACO 提供的类型包npm i -D @craco/typescraco.config.js 配置因为不同的项目有不同的需求和业务,配置文件也会不同,根据自己需求配置即可,遇到问题可到找craco官方文档 查看下面我以配置 less 和别名为例:安装craco-lessnpm install -D craco-less如果上面 craco-less 因为版本原因报错,在命令后面加 @alpha 。配置craco-less插件和别名const path = require("path"); const CracoLessPlugin = require("craco-less"); const resolve = (pathname) => path.resolve(__dirname, pathname); module.exports = { plugins: [ /* less */ plugin: CracoLessPlugin, webpack: { /* 别名 */ alias: { "@": resolve("src"), };在 tsconfig.json 的 compilerOptions 添加配置 "baseUrl": ".", "paths": { "@/*": ["src/*"] }运行 npm run start ,项目能正常跑起来就OK。3. 集成 EditorConfig 配置EditorConfig 有助于为不同 IDE 编辑器上处理同一项目的多个开发人员维护一致的编码风格。# http://editorconfig.org root = true [*] # 表示所有文件适用 charset = utf-8 # 设置文件字符集为 utf-8 indent_style = space # 缩进风格(tab | space) indent_size = 2 # 缩进大小 end_of_line = lf # 控制换行类型(lf | cr | crlf) trim_trailing_whitespace = true # 去除行尾的任意空白字符 insert_final_newline = true # 始终在文件末尾插入一个新行 [*.md] # 表示仅 md 文件适用以下规则 max_line_length = off trim_trailing_whitespace = falseVSCode 需要安装一个插件:EditorConfig for VS Code4. 使用 Prettier 工具Prettier 是一款强大的代码格式化工具,支持 JavaScript、TypeScript、CSS 、SCSS、Less、JSX、Angular、Vue、GraphQL、JSON、Markdown 等语言,基本上前端能用到的文件格式它都可以搞定,是当下最流行的代码格式化工具。1.安装 Prettiernpm install prettier -D2.配置 .prettierrc 文件:useTabs:使用tab缩进还是空格缩进,选择false;tabWidth:tab是空格的情况下,是几个空格,选择2个;printWidth:当行字符的长度,推荐80,也有人喜欢100或者120;singleQuote:使用单引号还是双引号,选择true,使用单引号;trailingComma:在多行输入的尾逗号是否添加,设置为 none,比如对象类型的最后一个属性后面是否加一个,;semi:语句末尾是否要加分号,默认值true,选择false表示不加;{ "useTabs": false, "tabWidth": 2, "printWidth": 80, "singleQuote": true, "trailingComma": "none", "semi": false }3.创建 .prettierignore 忽略文件/dist/* .local .output.js /node_modules/** **/*.svg **/*.sh /public/*VSCode 需要安装 Prettier 的插件VSCode 中的配置settings =>format on save => 勾选上settings => editor default format => 选择 prettier6.测试 Prettier 是否生效测试一:在代码中保存代码;测试二:配置一次性修改的命令;在package.json中配置一个scripts:"prettier": "prettier --write ."5. 使用 ESLint 检测安装 ESLintnpm install eslint -D配置 ESLintnpx eslint --init第一步选择如何使用 ESLint ,选第二个第二部模块化选择 ESModule第三步选择框架,根据实际情况选择 React第四步选择是否 TypeScript ,根据实际情况选择第五步选择代码运行的环境,两个可以同时选择第六步,配置文件类型, 选 js第七步,根据你上面的选择询问你要不要安装包,我上面选了React和TypeScriptVSCode 需要安装 ESLint 插件:解决 ESLint 和 Prettier 冲突的问题:安装插件:npm install eslint-plugin-prettier eslint-config-prettier -D添加 Prettier 插件:plugin:prettier/recommended// .eslintrc.js module.exports = { env: { browser: true, es2021: true, node: true extends: [ 'eslint:recommended', 'plugin:react/recommended', 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended' overrides: [], parser: '@typescript-eslint/parser', parserOptions: { ecmaVersion: 'latest', sourceType: 'module' plugins: ['react', '@typescript-eslint'], rules: { '@typescript-eslint/no-var-requires': 'off', 'prettier/prettier': 'warn', '@typescript-eslint/no-explicit-any': 'off' settings: { react: { version: 'detect' VSCode中eslint的配置"eslint.alwaysShowStatus": true,在package.json中配置一个scripts:"lint": "eslint ."6. Git Husky和 ESLint (可选)虽然现在已经要求项目使用 ESLint 了,但是不能保证组员提交代码之前都将 ESLint 中的问题解决掉了:也就是希望保证代码仓库中的代码都是符合 ESLint 规范的;那么需要在组员执行 git commit 命令的时候对其进行校验,如果不符合eslint规范,那么自动通过规范进行修复;那么如何做到这一点呢?可以通过 Husky 工具:husky 是一个 git hook 工具,可以帮助我们触发 git 提交的各个阶段:pre-commit、commit-msg、pre-push如何使用 husky 呢?这里我们可以使用自动配置命令:npx husky-init && npm install这里会做三件事:1.安装husky相关的依赖:2.在项目目录下创建 .husky 文件夹:3.在package.json中添加一个脚本:4.接下来,我们需要去完成一个操作:在进行commit时,执行lint脚本:这个时候我们执行 git commit 的时候会自动对代码进行 lint 校验。7. Git Commit 规范 (可选)7.1 代码提交风格通常我们的 git commit 会按照统一的风格来提交,这样可以快速定位每次提交的内容,方便之后对版本进行控制。但是如果每次手动来编写这些是比较麻烦的事情,我们可以使用一个工具:CommitizenCommitizen 是一个帮助我们编写规范 commit message 的工具;1.安装 Commitizennpm install commitizen -D2.安装 cz-conventional-changelog,并且初始化 cz-conventional-changelog:npx commitizen init cz-conventional-changelog --save-dev --save-exact这个命令会帮助我们安装cz-conventional-changelog:并且在package.json中进行配置:这个时候我们提交代码需要使用 npx cz:第一步是选择type,本次更新的类型Type作用feat新增特性 (feature)fix修复 Bug(bug fix)docs修改文档 (documentation)style代码格式修改(white-space, formatting, missing semi colons, etc)refactor代码重构(refactor)perf改善性能(A code change that improves performance)test测试(when adding missing tests)build变更项目构建或外部依赖(例如 scopes: webpack、gulp、npm 等)ci更改持续集成软件的配置文件和 package 中的 scripts 命令,例如 scopes: Travis, Circle 等chore变更构建流程或辅助工具(比如更改测试环境)revert代码回退第二步选择本次修改的范围(作用域)第三步选择提交的信息第四步提交详细的描述信息第五步是否是一次重大的更改第六步是否影响某个open issue我们也可以在scripts中构建一个命令来执行 cz:7.2 代码提交验证如果我们按照 cz 来规范了提交风格,但是依然有同事通过 git commit 按照不规范的格式提交应该怎么办呢?我们可以通过 commitlint 来限制提交;1.安装 @commitlint/config-conventional 和 @commitlint/clinpm i @commitlint/config-conventional @commitlint/cli -D2.在根目录创建commitlint.config.js文件,配置commitlintmodule.exports = { extends: ['@commitlint/config-conventional'] }3.使用husky生成commit-msg文件,验证提交信息:npx husky add .husky/commit-msg "npx --no-install commitlint --edit $1"8. 文件目录结构划分对项目进行目录结构划分: react-app + |- /src + |- /assets 存放资源 + |- /img + |- /css + |- /font + |- /data + |- base-ui 存放多个项目中都会用到的公共组件 + |- components 存放这个项目用到的公共组件 + |- hooks 存放自定义hook + |- views 视图 + |- store 状态管理 + |- router 路由 + |- service 网络请求 + |- utils 工具 + |- global 全局注册、全局常量... - |- App.css - |- App.test.tsx - |- index.css - |- logo.svg - |- reportWebVitals.ts - |- setupTest.tsApp.tsximport React from 'react' function App() { return ( <div className="App"> <h1>React App</h1> </div> export default App index.tsximport React from 'react' import ReactDOM from 'react-dom/client' import App from './App' const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement) root.render(<App />) 9. CSS 重置添加 common.less index.less reset.lesssrc/assets/css + |- common.less 公共样式 + |- index.less + |- reset.less 自定义重置样式reset.less/* reset.css样式重置文件 */ /* margin/padding重置 */ body, h1, h2, h3, ul, ol, li, p, dl, dt, dd { padding: 0; margin: 0; /* a元素重置 */ text-decoration: none; color: #333; /* img的vertical-align重置 */ img { vertical-align: top; /* ul, ol, li重置 */ ul, ol, li { list-style: none; /* 对斜体元素重置 */ i, em { font-style: normal; }index.less@import './reset.less'; @import './common.less';安装 normalize.css 包npm install normalize.css在 index.tsx 中引入 normalize.css 和 index.less... import 'normalize.css' import './src/assets/index.less' ...10. 设置代码片段为了方便开发,我们可以设置一份通用的 React 组件代码模板。创建一份自己常用的模板import React, { memo } from 'react' import type { FC, ReactNode } from 'react' interface IProps { children?: ReactNode const Template: FC<IProps> = () => { return <div>Template</div> export default memo(Template)复制到 snippet-generator网站 ,生成对应vscode的配置vscode 中点击 文件->首选项->配置用户代码片段, 选择 typescriptreact.json 配置文件将生成的代码 变量名修改为 ${1:Template} 复制进去之后只要在文件中输入 tsreact 即可创建模板11. 配置 React-Router安装 react-router-dom 包npm i react-router-dom 在 src/index.tsx 中导入 HashRouter 对App组件进行包裹import { HashRouter } from 'react-router-dom' root.render( <HashRouter> <App /> </HashRouter> )在 src/router/index.tsx 中配置路由映射表import React, { lazy } from 'react' import type { RouteObject } from 'react-router-dom' import { Navigate } from 'react-router-dom' /* 路由懒加载 */ const Home = lazy(() => import('@/views/home')) const Mine = lazy(() => import('@/views/mine')) const routes: RouteObject[] = [ path: '/', element: <Navigate to="/home" /> path: '/home', element: <Home /> path: '/mine', element: <Mine /> export default routes在 App.tsx 中使用路由import React, { Suspense } from 'react' import { useRoutes, Link } from 'react-router-dom' import routes from './router' function App() { return ( <div className="App"> <div className="nav"> <Link to="/home">菜单一</Link> <Link to="/mine">菜单二</Link> </div> <Suspense fallback="loading..."> <div className="main">{useRoutes(routes)}</div> </Suspense> </div> export default App12. 配置 Redux 状态管理安装 react-redux 和 @reduxjs/toolkit 两个包npm install react-redux @reduxjs/toolkit在 src/store 中创建storestore/index.tsimport { configureStore } from '@reduxjs/toolkit' import couterReducer from './modules/counter' import { useSelector, TypedUseSelectorHook, useDispatch, shallowEqual } from 'react-redux' const store = configureStore({ reducer: { counter: couterReducer type GetStateFnType = typeof store.getState type IRootState = ReturnType<GetStateFnType> type DispatchType = typeof store.dispatch export const useAppSelector: TypedUseSelectorHook<IRootState> = useSelector export const useAppDispatch: () => DispatchType = useDispatch export const shallowEqualApp = shallowEqual export default storestore/modules/counter.tsimport { createSlice } from '@reduxjs/toolkit' const counterSlice = createSlice({ name: 'counter', initialState: { count: 0, message: 'Hello Redux' reducers: {} export default counterSlice.reducer 在 src/index.tsx 中提供storeimport { Provider } from 'react-redux' import store from './store' root.render( <Provider store={store}> <HashRouter> <App /> </HashRouter> </Provider> )在 App.tsx 测试使用获取并且显示 stateimport { useAppSelector, shallowEqualApp } from './store' const { count, message } = useAppSelector( (state) => ({ count: state.counter.count, message: state.counter.message shallowEqualApp return ( <div>count:{count}</div> <div>message:{message}</div> 修改 stateimport { useAppDispatch } from './store' const dispatch = useAppDispatch() function handleChangeMessage() { dispatch(changeMessage('哈哈哈哈哈哈')) return ( <button onClick={handleChangeMessage}>changeMessage</button> )13. 环境配置在根目录下添加两个文件用以配置不同环境下的环境变量.env.developmentREACT_APP_BASE_URL = 'www.development.com'.env.productionREACT_APP_BASE_URL = 'www.production.com'同时需要在 react-app-env.d.ts 声明变量类型/// <reference types="react-scripts" /> declare namespace NodeJS { interface ProcessEnv { readonly REACT_APP_BASE_URL: string 14. axios 网络请求封装修改 service 的目录结构为+ |- /config + |- index.ts + |- /request + |- index.ts + |- types.ts + |- index.ts/service/config.tsconst BASE_URL = process.env.REACT_APP_BASE_URL export const TIME_OUT = 1000 export { BASE_URL }/service/request/index.tsimport axios from 'axios' import type { AxiosInstance } from 'axios' import type { RequestConfig } from './type' // 拦截器: 蒙版Loading/token/修改配置 * 两个难点: * 1.拦截器进行精细控制 * > 全局拦截器 * > 实例拦截器 * > 单次请求拦截器 * 2.响应结果的类型处理(泛型) class Request { instance: AxiosInstance // request实例 => axios的实例 constructor(config: RequestConfig) { this.instance = axios.create(config) // 每个instance实例都添加拦截器 this.instance.interceptors.request.use( (config) => { // loading/token return config (err) => { return err this.instance.interceptors.response.use( (res) => { return res.data (err) => { return err // 针对特定的Request实例添加拦截器 this.instance.interceptors.request.use( config.interceptors?.requestSuccessFn, config.interceptors?.requestFailureFn this.instance.interceptors.response.use( config.interceptors?.responseSuccessFn, config.interceptors?.responseFailureFn // 封装网络请求的方法 request<T = any>(config: RequestConfig<T>) { // 单次请求的成功拦截处理 if (config.interceptors?.requestSuccessFn) { config = config.interceptors.requestSuccessFn(config) // 返回Promise return new Promise<T>((resolve, reject) => { this.instance .request<any, T>(config) .then((res) => { // 单词响应的成功拦截处理 if (config.interceptors?.responseSuccessFn) { res = config.interceptors.responseSuccessFn(res) resolve(res) .catch((err) => { reject(err) get<T = any>(config: RequestConfig<T>) { return this.request({ ...config, method: 'GET' }) post<T = any>(config: RequestConfig<T>) { return this.request({ ...config, method: 'POST' }) delete<T = any>(config: RequestConfig<T>) { return this.request({ ...config, method: 'DELETE' }) patch<T = any>(config: RequestConfig<T>) { return this.request({ ...config, method: 'PATCH' }) export default Request/service/request/type.tsimport type { AxiosRequestConfig, AxiosResponse } from 'axios' // 针对AxiosRequestConfig配置进行扩展 export interface Interceptors<T = AxiosResponse> { requestSuccessFn?: (config: AxiosRequestConfig) => AxiosRequestConfig requestFailureFn?: (err: any) => any responseSuccessFn?: (res: T) => T responseFailureFn?: (err: any) => any export interface RequestConfig<T = AxiosResponse> extends AxiosRequestConfig { interceptors?: Interceptors<T> }/service/index.tsimport { BASE_URL, TIME_OUT } from './config' import Request from './request' const request = new Request({ baseURL: BASE_URL, timeout: TIME_OUT export default request15. 引入 styled-components除了 styled-components本身之外还要安装它的类型声明npm i -D styled-components @types/styled-components
1. 建立代码规范的意义和原则为什么要建立代码规范呢?增强团队协作效率每个工程师都有自己主观的编程风格,但作为一个团队,必须在可读性上找到最大公约数。提高代码质量很多优秀的编码习惯,应该沉淀下来成为一个团队的【军规】而不是工程师个人的选择减缓系统腐化的速度一个工程总会腐化,但在保持可读性和代码质量的情况下,我们可以减慢它的速度建立代码规范需要建立什么原则?代码规范是一个找公约数的过程需要听取团队每一位成员的意见,除了会引起质量问题的编码习惯,其他意见都值得被尊重本着可读性第一的目标代码规范是为了帮助人与人之间的协作,可读性应该是第一目标循序渐进的建立规范代码规范不应成为工程师工作之外的负担,建立规范的过程可以求同存异,小步快跑2. 社区中成熟的规范在 HTML 和 CSS 方面比较著名的有:Google HTML/CSS/JS 规范 (著名的谷歌前端规范,大而全)Airbnb Style规范(Airbnb的样式规范,不仅包含CSS规范,也包含Sass的规范)但是,由于 MVC/MVVM 框架的的出现,比如说 Vue 和 React ,纯的 HTML/CSS 的我们已经很少写了,我们现在一般只把它们当做最佳实践。不需要严格的遵守,可以了解一下其中背后的思想和原理JavaScript 方面规范有:Airbnb JavaScript 规范JavaScript Standard Style框架相关:V0ue Style Guide (VueJS官方推荐的编码规范)Airbnb React/JSX Style Guide (Airbnb JavaScript规范的React/JSX部分)3. 利用各种工具建立规范3.1 ESLintESLint 是一款高度可配置的 JavaScript 静态代码检验工具,已成为 JS 代码检查的事实标准特性完全的可插拔,一切行为都通过配置产生任一 rule 之间都是独立的原理先通过解析器(parser)将 JavaScript 代码解析为抽象语法树(AST),再调用规则(rule)对 AST 进行检查,从而实现对代码的检查AST浅析AST 是一种可遍历的、描述代码的树状结构,利用AST可以方便地分析代码的结构和内容。可以从这个网站查看AST长什么样 AST ExploreESLint的使用ESLint 的使用可以通过 ESLint CLI# 全局安装 npm i -g eslint # -h参数查看用法 eslint -h除了CLI之外,ESLint还提供了编辑器的集成以及构建工具的集成编辑器集成VS Code / Atom / Vim / Sublime Text 提供了在写代码的同时就可以实时进行代码检查构件工具集成Webpack / Rollup / Gulp / Grunt 提供了在构建过程中进行代码检查ESLint的配置配置文件格式JavaScript,JSON 或者 YAML,也可以在 package, json 中的 eslintConfig 字段配置ESLint配置的主要内容Parser: ESLint使用哪种解析器Environments:选择代码跑在什么环境中(browser/node/commonjs/es6...)Globals:除了Env之外,其他需要额外指定的全局变量Rules: 规则Plugins:一组以上配置选项以及processor的集合,往往用于特定类型文件的代码检查,如.md文件Extends:继承的配置3.2 PrettierPrettier介绍Prettier 是一个流行的代码格式化工具Prettier 称,自己最大的作用是:可以让大家停止对“代码格式”的无意义的辩论Prettier 在一众工程化工具中非常特殊,它毫不掩饰地称自己是“有主见的”,且严格控制配置项的数量,它对默认格式的选择,完全遵循【让可读性更高】这一标准。Prettier 认为,在代码格式化方面牺牲一些灵活性,可以为开发者带来更多的收益。不得不承认,Prettier是对的。Prettier vs LintersLinters规则分两类 1. 格式优化类,max-len,no-mided-spaces-and-tabs,keyword-spacing,comman-style... 2. 代码质量类:no-unused-vars,no-extra-bind,no-implicit-globals,prefer-promise-reject-errors...Prettier 只关注第一类,且不会以报错的形式告知格式问题,而是允许开发者按自己的方式编写代码,但是会在特定时机(save,commit),将代码格式化为可读性最好的形式。prettier的配置prettier 可以通过 .prettierc .prettierrc.json .prettierrc.js 或者 .prettierrc.yml 配置{ "useTabs": false, // 使用tab缩进还是空格缩进,选择false; "tabWidth": 2, // tab是空格的情况下,是几个空格,选择2个; "printWidth": 80, // 当行字符的长度,推荐80,也有人喜欢100或者120; "singleQuote": true, // 使用单引号还是双引号,选择true,使用单引号; "trailingComma": "none", // 在多行输入的尾逗号是否添加,设置为 `none`,比如对象类型的最后一个属性后面是否加一个; "semi": false // 语句末尾是否要加分号,默认值true,选择false表示不加; }prettier的使用有很多方式可以去触发 Prettier 的格式化行为:CLI、Watch Changes、git hook、与 Linter 集成Watch Changes{ "scripts": { "prettier-watch": "onchange '**/*.js -- prettier --write {{changed}}'" }与ESLint集成npm install -D eslint-config-prettier eslint-plugin-prettiereslint-config-prettier会禁止 ESLint 中与 prettier 相冲突的规则eslint-plugin-prettier 让 ESLint 根据 prettier 的规则去检查代码,所有与代码格式有关的错误,ESLint全听 prettier 的。{ "extends": ["prettier"], "plugins": ["prettier"], "rules": { "prettier/prettier": "error"
1. 前端发布策略前端发布的本质,其实是静态资源的发布,一般是 js、css,而不包括动态渲染出来的html模版。野生状态下的前端资源有一个HTMLHTML中引入一个CSSCSS和HTML模版都有服务器反向代理这时候他们的网络时序图会如下图所示,html和css会依此加载:这种情况下我们可能会想到一些优化手段。比如说能否用HTTP的缓存提高资源的加载速度呢?我们知道HTTP的缓存有两种协商缓存使用协商缓存的话,浏览器去请求资源,服务器会告诉浏览器这个资源已经多久没有改变过了,浏览器发现这个时间内自己请求过这个资源,于是就把缓存的资源拿出来直接使用。 这种方式省去了重新下载整个资源的时间,但是仍然需要一次协商缓存的过程。 本地缓存另一种是本地缓存,在这种方式下,浏览器在发起请求之前,会对比请求的url,如果发现和之前一致的话,就从硬盘或是内存中,找到对应的缓存,直接使用。显然本地缓存对加载速度更好,但是本地缓存带来了新的问题,如果资源更新了怎么办?用户一直使用老的缓存,即使发布了新的版本,用户不是也用不上吗?有人想到了一个办法,既然本地缓存更请求的url有关,那么我们在请求资源的后面多加个版本的参数不就好了么,每次更新资源的时候也同步的更新参数。比如说:所有的资源都加上一个v.x.x.x的版本号,在下次更新的时候修改版本号,让用户的缓存失效。但是在实际情况中,我们还发现了新的问题,对于一个大型网站来说,静态资源可能非常非常多,每天都会有新的前端代码被修改,如果其中每一个资源被修改了,我们就全量修改所有的版本号的话。那缓存的意义也就不大了。针对这个问题,我们可以用hash来解决。hash是一串字符串,它像是文件的一种特质,只和文件的内容有关,如果一个文件的内容变了,那么它的hash值也会变化。利用hash的特性,我们可以把上一步的版本号改为hash值,这样的话只有被我们改动过的静态资源的缓存才会失效。看起来这是一种比较完美的解决方案。不过,对于大公司来说,为了进一步提升网站性能,一般都会把静态资源和动态网页分集群部署,静态资源会被部署到CDN节点上,网页中引用的资源也会变成对应的部署路径:好了,当我要更新静态资源的时候,同时也会更新html中的引用吧,就好像这样:页面和静态资源不在同一个机器上,我们就要面临一个问题,他们两者的部署,就必然会有一个时间差,那么是先上线页面呢,还是先上线静态资源呢?先部署页面,再部署资源:在二者部署的时间间隔内,如果有用户访问页面,就会在新的页面结构中加载旧的资源,并且把这个旧版本的资源当做新版本缓存起来,其结果就是:用户访问到了一个样式错乱的页面,除非手动刷新,否则在资源缓存过期之前,页面会一直执行错误。先部署资源,再部署页面:在部署时间间隔之内,有旧版本资源本地缓存的用户访问网站,由于请求的页面是旧版本的,资源引用没有改变,浏览器将直接使用本地缓存,这种情况下页面展现正常;但没有本地缓存或者缓存过期的用户访问网站,就会出现旧版本页面加载新版本资源的情况,导致页面执行错误,但当页面完成部署,这部分用户再次访问页面又会恢复正常了。好想都不太好...最终方案:非覆盖式发布之前的方案之所以有问题,是因为在CDN上同名的文件只能有一份,它要么是老的要么是新的,不能同时存在,这种方式叫做覆盖式发布。非覆盖式发布就是指我们不通过url参数带hash的方法解决缓存问题,而是直接将hash写入到静态资源的文件名中,这样的话不同版本的同一资源,文件名也不会重复,我们在发布时,将新的资源推送到CDN之后并不会覆盖老的资源,它们两者在CDN上同时存在,这个时候访问新页面的用户加载新的资源,访问老的页面的用户加载老的资源,各不冲突,这样就可以完美的解决资源更新的问题了。2. 后端发布策略后端发布的本质是后端服务再多台服务器上的分发。对于大公司,往往不可能用单机承载所有的流量,而是有一个服务器集群,分摊高并发服务的成本,也挺高了服务的可用性。后端发布关心的问题如何减少用户感知上的新旧版本不一致如何让发布过程中的服务尽可能的稳定如何保证发布的结果最终一致性下面介绍后端的集中部署方式滚动部署滚动更新的发布过程,是让应用的新版本逐步替换旧版本。在此期间,新旧版本会共存。下图显示了该更新策略:旧版本显示为蓝色,新版本显示为绿色,它们部署在集群中的每一台服务器上。三台服务器一开始都是老版本,然后在state 1, state 2阶段开始逐渐替换到新版本,最终完全替换成功,这个过程服务不会中断,一个请求可能会落在老版本中,也可能会落在新版本中。这种部署方式的特点:节约资源(不需要额外服务器资源)流量冲击小(应用逐步的被替换,过程是渐进式的,每台服务器承受的压力理论上是相同的)回滚不及时存在中间状态,可能会导致不一致蓝绿部署不停止老版本,部署新版本并进行测试,确认OK后,将流量切到新版本。这种部署方式的特点:风险小,新的版本有问题不会影响线上便于快速回滚切换流量要妥善处理未完成请求整体切换流量冲击较大灰度发布/金丝雀发布灰度发布是滚动发布的改良,在原有软件生产版本可用的情况下,同时部署一个新的版本,两个版本同时存在于线上,为新版本分配少量流量,线上验证完毕后再将新版本推广。这种部署方式的特点渐进式,风险较小需配合复杂的路由策略参考文章:大公司里怎样开发和部署前端代码?部署策略对比:蓝绿部署、金丝雀发布及其他
1. 前后端分离的意义1.1 分离前的前后端开发后端程序渲染 HTML 模板,代理 JS / CSS / SVG / PNG 等前端静态资源。前后端不分离的问题前后端开发协作困难发布频率耦合联调效率低性能低下:前端资源的请求耗尽连接1.2 分离后的前后端开发主要包含两个方面工程拆分:不在同一工程中开发,有个字独立的开发节奏、构建发布策略技术拆分:使用各自熟悉和易用的技术,两者完全通过 RESTFUL API 交流前后端分离的好处解耦前后端架构分工明确、界限清晰提高开发效率支持前后端不同的迭代频率支持各自更适合的构建发布策略为什么现在可以实现前后端分离因为前端技术的工程化、模块化、组件化的方案已经进入成熟期,工具链完善,不需要作为后端的附庸了。现状现在前后端分离的方案可以说是百花齐放了。从 SSR 同构到 BFF 到 Serverless 到 GraphQL...2. 前后端分离的实现2.1 洪荒时代:不分离在前后端不分离的时代,最典型的就是 MVC 架构了在 MVC 架构中,前端作为 View 层通过后端的 Controller 去渲染,构建和发布的流程上,前端是后端的复用2.2 探索时代:Nginx代理静态资源为了实现前后端分离,有人提出了采用 Nginx 代理静态资源这种方案。这种方案不使用页面模板,把 HTML 直接作为静态资源,通过 Nginx 或者 Apache HTTP Server 代理 HTML 和静态资源。如下图的 Nginx 配置,它直接代理了所有对 HTML 、CSS 、JS 的请求,对 /api 的请求会转发到真正的服务上。Nginx代理静态资源是一个比较激进的方案,这种方式可以实现前后端分离,不过它的问题也不少:只适合纯客户端渲染(CSR),只适合 SPA这种单页面应用(页面的本身就是一个空的div,具体内容由js代码在浏览器中生成)首屏性能差(浏览器下载JS、执行JS都需要过程),对 C 端用户不友好(白屏)SEO 效果约等于02.3 探索时代:后端渲染页面 + 静态资源代理为了解决 SEO 的问题,又有人提出了 后端渲染页面 + 静态资源代理 这种方案。这种方案的特点是依然由后端渲染 HTML 模板,但是其他静态资源从 CDN 拉取。前后端工程在构建时组合,后端此时拿到模板,模板中引用的资源的 URL 指向 CDN。这种方案解决了 SEO 的问题,也成功的拆分了前后端工程,但是依然存在问题:流程依然耦合,多了一步模板联调前端发布导致后端发布前端需要学习后端模板语法(后端如果是 JAVA,那页面模板很可能还是 JSP)2.4 黄金时代:大前端直到 Node BFF 的出现,前后端分离才进入了一个比较理想的时代Node BFF:Backend For Frontend,属于前端的后端。利用 NodeJS 编写 BFF 层,可以用于模板渲染和接口聚合。同构同构是利用 Vue/React 这种前端框架提供的服务端渲染能力,在服务端就将页面渲染好,本质上还是一个BFF。但是使用 SSR 的好处是,我们可以像普通的 SPA 单页面一样去写应用,并且它还解决了普通的 SPA 单页面首屏性能低、SEO 效果差问题。SSR的代表是 Vue 的 NUXTJS 和 React的 NEXTJS
1. GitHub Actions 是什么?GitHub Actions 是 GitHub 于 2018 年 10 月推出的一个 CI\CD 服务(持续集成和持续部署)。简单明了的说 就是你可以给你的代码仓库部署一系列自动化脚本,在你进行了提交/合并分支等操作后,自动执行脚本。通过 GitHub Actions 可快速搭建 GitHub Pages 静态网站(域名为 http://[username].github.io ),使用它来发布、测试、部署,是非常方便的大家知道,持续集成由很多操作组成,比如抓取代码、运行测试、登录远程服务器,发布到第三方服务等等。GitHub 把这些操作就称为 actions。 很多操作在不同项目里面是类似的,完全可以共享。GitHub 注意到了这一点,想出了一个很妙的点子,允许开发者把每个操作写成独立的脚本文件,存放到代码仓库,使得其他开发者可以引用。 如果你需要某个 action,不必自己写复杂的脚本,直接引用他人写好的 action 即可,整个持续集成过程,就变成了一个 actions 的组合。这就是 GitHub Actions 最特别的地方。2. Github Actions 概念GitHub Actions 有一些自己的术语。workflow (工作流程):持续集成一次运行的过程,就是一个 workflow。job (任务):一个 workflow 由一个或多个 jobs 构成,含义是一次持续集成的运行,可以完成多个任务。step(步骤):每个 job 由多个 step 构成,一步步完成。action (动作):每个 step 可以依次执行一个或多个命令(action)。3. workflow 文件GitHub Actions 的配置文件叫做 workflow 文件,存放在代码仓库的 .github/workflows目录。workflow 文件采用 YAML 格式,文件名可以任意取,但是后缀名统一为 .yml ,比如foo.yml。一个库可以有多个 workflow 文件。GitHub 只要发现 .github/workflows目录里面有 .yml 文件,就会自动运行该文件。workflow 文件的配置字段非常多,详见 官方文档一些基本字段。 (1)namename字段是 workflow 的名称。如果省略该字段,默认为当前 workflow 的文件名。name: GitHub Actions Demo(2) onon字段指定触发 workflow 的条件,通常是某些事件。on: push上面代码指定,push事件触发 workflow。on字段也可以是事件的数组。on: [push, pull_request]上面代码指定,push事件或 pull_request 事件都可以触发 workflow。完整的事件列表,请查看 官方文档 。除了代码库事件,GitHub Actions 也支持外部事件触发,或者定时运行。(3) on.<push|pull_request>.<tags|branches>指定触发事件时,可以限定分支或标签。on: push: branches: - master上面代码指定,只有 master 分支发生 push 事件时,才会触发 workflow。(4) jobs.<job_id>.nameworkflow 文件的主体是 jobs 字段,表示要执行的一项或多项任务。jobs 字段里面,需要写出每一项任务的 job_id,具体名称自定义。job_id里面的name字段是任务的说明。jobs: my_first_job: name: My first job my_second_job: name: My second job上面代码的 jobs 字段包含两项任务,job_id 分别是 my_first_job 和my_second_job。(5) jobs.<job_id>.needsneeds 字段指定当前任务的依赖关系,即运行顺序。jobs: job1: job2: needs: job1 job3: needs: [job1, job2]上面代码中,job1 必须先于 job2 完成,而 job3 等待 job1 和 job2 的完成才能运行。因此,这个 workflow 的运行顺序依次为:job1、job2、job3。(6) jobs.<job_id>.runs-onruns-on字段指定运行所需要的虚拟机环境。它是必填字段。目前可用的虚拟机如下。ubuntu-latest,ubuntu-18.04或ubuntu-16.04 windows-latest,windows-2019或windows-2016 macOS-latest或macOS-10.14下面代码指定虚拟机环境为 ubuntu-18.04。runs-on: ubuntu-18.04(7) jobs.<job_id>.stepssteps 字段指定每个 Job 的运行步骤,可以包含一个或多个步骤。每个步骤都可以指定以下三个字段。jobs.<job_id>.steps.name:步骤名称。jobs.<job_id>.steps.run:该步骤运行的命令或者 action。jobs.<job_id>.steps.env:该步骤所需的环境变量。下面是一个完整的 workflow 文件的范例。name: Greeting from Mona on: push jobs: my-job: name: My Job runs-on: ubuntu-latest steps: - name: Print a greeting MY_VAR: Hi there! My name is FIRST_NAME: Mona MIDDLE_NAME: The LAST_NAME: Octocat run: | echo $MY_VAR $FIRST_NAME $MIDDLE_NAME $LAST_NAME.上面代码中,steps字段只包括一个步骤。该步骤先注入四个环境变量,然后执行一条 Bash 命令。4. 实例:React 项目发布到 GitHub Pages(1) 第一件事情是我们需要先创建一个 GitHub 密钥,因为我们需要将示例部署至 Github Page ,需要写权限,创建完成后将这个秘钥保存在当前仓库的 Settings/Secrets 里面。创建秘钥可以参考 官方文档 。点击自己头像,选择 Settings :在左边栏选择 Developer settings:然后在左边栏选择 Personal access tokens 点击头上的 Generate new token 创建一个新的 Token :注意: 创建完成后需要保存好这个 Token ,它只会出现这一次。接下来,在github中创建一个项目,我这里创建的名字叫做 github-actions-demo ,然后点击项目中的 Settings ,在 Secrets 的栏目中的 Actions, 点击右上角的New repository secret,将刚才创建的 Token 填写进去:(2) 接下来是创建一个标准的 React 应用:npx create-react-app github-actions-demo(3) 打开项目中的package.json文件,添加一个homepage字段,如下:"homepage": "https://[username].github.io/github-actions-demo",将[username]替换成你自己的 GitHub 用户名(4) 在个人代码仓库中找到 action,如果你是一个前端项目,可以使用 Node.js 的模板,点击 new workflow ,生产 workflow 文件或者 这个项目中,在 .github/workflows 的目录中手动新增一个 workflow 文件,名字可以随便取,这个我这里的名称是 ci.yml(5) 我们来看看Github Action配置文件的基本构成,配置文件格式是.yml,示例如下:name: GitHub Actions Build and Deploy Demo push: branches: - master jobs: build-and-deploy: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 # If you're using actions/checkout@v2 you must set persist-credentials to false in most cases for the deployment to work correctly. with: persist-credentials: false - name: Install and Build run: | npm install npm run-script build - name: Deploy uses: JamesIves/github-pages-deploy-action@releases/v3 with: ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} BRANCH: gh-pages FOLDER: build上面这个 workflow 文件的要点如下。整个流程在 master 分支发生 push 事件时触发。只有一个 job ,运行在虚拟机环境 ubuntu-latest。第一步是获取源码,使用的 action 是 actions/checkout@v2 。如果用的是这个版本必须得加上 persist-credentials:false第二步是构建,使用npm install npm run-script build命令。第三步是部署,使用的 action 是 JamesIves/github-pages-deploy-action@releases/v3。第三步需要三个环境变量,分别为 GitHub 密钥、发布分支、构建成果所在目录、构建脚本。其中,只有 GitHub 密钥是秘密变量(就是之前配置的变量),需要写在双括号里面,其他三个都可以直接写在文件里。(6) 保存上面的文件后,将整个仓库推送到 GitHub。GitHub 发现了 workflow 文件以后,就会自动运行。你可以在网站上实时查看运行日志,日志默认保存30天。(7) 在setting中设置一下Github Page等到项目部署成功后,访问 GitHub Page,会看到构建成果已经发上网了。然后每次推送到 mater 分支,Github Action 都会自动运行,将构建产物发布至 Github Page
本篇文章目标CI/CD是什么?CI 和 CD 的实现1. CI/CD是什么?CI的全称是 Continuous Integration 也就是持续集成CD其实对应两个概念:CD-持续交付(Continuous Delivery)CD-持续部署(Continuous Deployment)1.1 CI:持续集成(Continuous Integration)“In software engineering,continuous integration (CI) is the practive of merging all developers' working copies to a shared mainline several times a day”开发人员提交新代码之后,立即进行构建、(单元)测试。根据测试结果骂我们可以确定新代码和原有代码能否正确地集成在一起。CI就是高频地、自动化地将个人开发的代码集成到开发主线中高频:高频才能保证不会出现巨大变更引起的严重问题自动化:自动化才可以保证高频主线:不一定是master分支1.2 CD-持续交付(Continuous Delivery)“Continuous delivery(CD) is a software engineering approach in which teams produce software in short cycles,ensuring that the software can be reliably released at any time and, when releasing the sofeware,doing so manually”在持续继承的基础上,将集成后的代码部署到更贴近真实运行环境的类生产环境中尽快的交付给 QA 人员在类生产环境中测试保证有一个“随时可发布”的版本部署可以是手动的1.3 CD-持续部署(Continuous Deployment)“Continuous Deployment(CD) is softwrae engineering approach in which software functionalities are delivered frequently through automated deployments”在持续交付的基础上,能够自动化地讲软件部署在真实生产环境尽快地交付给用户敏捷开发思想的体现实际上持续交付和持续部署这两个概念不用分的特别清楚,在实践中,我们往往认为持续交付和持续部署这两者是一体的1.4 CI/CD的意义和价值敏捷开发思想的体现频繁、自动化、可重复。流程像管道,代码像水快速失败,尽早出现问题,尽早解决不能保证BugFree,所以提高发布频率,降低单次发布风险快速交付用户价值,拥抱市场变化2. CI和CD的实现2.1 CI的实现CI重在个人对团队的交付,所以他关注的点有两个:保证协作质量代码风格检查版本规范Git分支规范自动化单元测试、端到端测试...保证失败可回溯测试结果通知Changelog记录Code Review机制...2.2 CD的实现持续交付和持续部署的过程是对QA和最终用户的交付的过程持续交付多种级别的测试环境QA团队对功能测试的快速响应自动化测试覆盖率的检查发布流程的标准化...持续部署完善的项目迭代机制渐进式的发布策略线上监控告警快速回滚能力...
1. TS在工程项目中的模块使用及配置1.1 声明文件什么是声明文件?声明文件就是给 js 代码补充类型标注. 这样在 ts 编译环境下就不会提示 js 文件"缺少类型".声明变量使用关键字 declare 来表示声明其后面的全局变量的类型, 比如:// packages/global.d.ts declare var __DEV__: boolean declare var __TEST__: boolean declare var __BROWSER__: boolean declare var __RUNTIME_COMPILE__: boolean declare var __COMMIT__: string declare var __VERSION__: string上面代码表示 __DEV__ 等变量是全局, 并且标注了他们的类型. 这样无论在项目中的哪个 ts 文件中使用 __DEV__, 变量 ts 编译器都会知道他是 boolean 类型.声明文件在哪里?声明文件的文件名是有规范要求的, 必须以 .d.ts 结尾。声明文件放在项目里的任意路径/文件名都可以被 ts 编译器识别, 但实际开发中发现, 为了规避一些奇怪的问题, 推荐放在根目录下.声明文件对纯js项目有什么帮助?即便你只写 js 代码, 也可以安装声明文件, 因为如果你用的是 vscode , 那么他会自动分析 js 代码, 如果存在对应的声明文件, vscode 会把声明文件的内容作为代码提示。1.2. @types和DefinitelyTyped仓库DefinitelyTyped 是一个高质量的 TypeScript 类型定义的仓库。通过 @types 方式来安装常见的第三方JavaScript库的声明适配模块1.3. lib.d.ts当你安装 TypeScript 时,会顺带安装 lib.d.ts 等声明文件。此文件包含了 JavaScript 运行时以及 DOM 中存在各种常见的环境声明。它自动包含在 TypeScript 项目的编译上下文中;它能让你快速开始书写经过类型检查的 JavaScript 代码。你可以通过指定 —noLib 的编译器命令行标志(或者在 tsconfig.json 中指定选项 noLib: true)从上下文中排除此文件。看如下例子const foo = 123; const bar = foo.toString();这段代码的类型检查正常,因为 lib.d.ts 为所有 JavaScript 对象定义了 toString 方法。如果你在 noLib 选项下,使用相同的代码,这将会出现类型检查错误:const foo = 123; const bar = foo.toString(); // Error: 属性 toString 不存在类型 number 上1.4. tsconfig.json配置文件在 TS 的项目中,TS 最终都会被编译 JS 文件执行,TS 编译器在编译 TS 文件的时候都会先在项目根目录的 tsconfig.json 文件,根据该文件的配置进行编译,默认情况下,如果该文件没有任何配置,TS 编译器会默认编译项目目录下所有的 .ts、.tsx、.d.ts文件。实际项目中,会根据自己的需求进行自定义的配置,下面就来详细了解下tsconfig.json的文件配置。文件选项配置files : 表示编译需要编译的单个文件列表"files": [ // 指定编译文件是src目录下的a.ts文件 "scr/a.ts" ]include: 表示编译需要编译的文件或目录"include": [ // "scr" // 会编译src目录下的所有文件,包括子目录 // "scr/*" // 只会编译scr一级目录下的文件 "scr/*/*" // 只会编译scr二级目录下的文件 ]exclude:表示编译器需要排除的文件或文件夹默认排除node_modules文件夹下文件"exclude": [ // 排除src目录下的lib文件夹下的文件不会编译 "src/lib" ]extends: 引入其他配置文件,继承配置// 把基础配置抽离成tsconfig.base.json文件,然后引入 "extends": "./tsconfig.base.json"compileOnSave:设置保存文件的时候自动编译vscode暂不支持该功能,可以使用'Atom'编辑器"compileOnSave": true执行 tsc --init 生成的ts.config.js会有六个初始设置{ "compilerOptions":{ "target":"es2016", // 指定编译成的是哪个版本的js "module":"commonjs", // 指定要使用的模块化的规范 "esModuleInterop":true, // 兼容JS模块无default的导入 "forceConsistentCasingInFileNames":true, // 兼容JS模块无default的导入 "strict":true, // 所有严格检查的总开关 "skipLibCheck":true // 跳过所有.d.ts文件的类型检查 }Vue3中使用TS在Vue组合式API中它会有默认的自动类型注解,另外我们可以使用泛型进行复杂类型注解。自动类型注解<script setup lang="ts"> import { ref } from "vue"; let count = ref(0); count.value = "123"; // 不能将类型“string”分配给类型“number”。 </script>手动类型注解<script setup lang="ts"> import { ref } from "vue"; let count = ref<string|number>(0); count.value = "123"; // √ </script>复杂类型注解<script setup lang="ts"> import { ref } from "vue"; interface List { let count = ref<string|number>(0); count.value = "123"; // √ </script>Vue3 + TS 组件通信父子通信// parent.vue <my-child :count="count"></my-child> // my-child // 1. vue自带的定义方式 defineProps({ count: [Number] // 2. ts的方式 interface Props({ count: number defineProps<Props>()子父通信// parent.vue <my-child @say-hello="sayHello"></my-child> const sayHello = (message: string) => { console.log({ message }); // my-child interface Emits { (e: "say-hello", message: string): void; let emit = defineEmits<Emits>(); emit("say-hello", "hello-world");VueRouter + TSRouteRecordRaw -> 路由表选项类型const routes: Array<RouteRecordRaw> = [ path: "/", name: "home", component: HomeView, ];RouteMeta -> 扩展meta的类型declare module "vue-router" { interface RouteMeta { // 是可选的 isAdmin?: boolean; // 每个路由都必须声明 requiresAuth: boolean; }RouterOptions -> createRouter的配置类型RouteLocationNormalized -> 标准化的路由地址Router -> router的实例类型调用路由的方式import { userRouter, useRoute } from 'vue-router' const router = useRouter() // 类似 this.$router const route = useRoute() // 类似 this.$routeVuex +TS导出key导出key重写useStore使用store// store.ts import { createStore, Store, useStore as baseUseStore } from "vuex"; import { InjectionKey } from "vue"; export interface State { count: number; // step 3 重写useStore export function useStore() { return baseUseStore(key); // step 1 导入key export const key: InjectionKey<Store<State>> = Symbol(); export default createStore<State>({ state: { count: 1, getters: {}, mutations: {}, actions: {}, modules: {}, // main.ts import store, { key } from "./store"; // step 2 导出key app.use(store,key)// App.vue import { useStore } from '@/store' // step 4 使用store const store = useStore() console.log(store.state.count)Pinia如何使用TS首先在main.ts中注册Pinaimport { createPina } from 'pinia' const pinia = createPinia() createApp(App).use(pinia).mount('#app')创建 /stores/counter.ts文件import { defineStore } from 'pinia' interface Counter { counter: number export const useCounterStore = defineStore('counterStore', { state: (): Counter => ({ counter: 0 actions: { add(n : number) { this,counter += n })在App.vue中使用import { storeToRefs } from 'pinia import { useCounterStore } from './stores/counter' let counterStore = useCounter() let { counter } = storeToRefs(counterStore) let handleClick = () => { counterStore.add(2) }Pinia除了选项式写法外,也支持组合式写法,主要利用的就是Vue组合式API来实现的import { defineStore } from 'pinia' impoer { ref } from 'vue' export const useCounterStore = defineStore('counterStore', () => { conter = ref<number>(0) return { counter } })Element Plus中如何使用TS如果使用Volar,在ts.config.json中通过comiplerOptions.types指定全局组件类型"types": ["element-plus/global"],会有更好的提示。
1. HTTP常见请求头1.1 请求头是什么?HTTP头字段(HTTP header fields),是指在超文本传输协议(HTTP)的请求和响应消息中的消息头部分它们定义了一个超文本传输协议事务中的操作参数HTTP 头部字段可以自己根据需要定义,因此可能在 Web 服务器和浏览器上发现非标准的头字段下面是一个 HTTP 常见的请求头:下面是一个 HTTP 常见的响应头1.2 常见HTTP头1.2.1 Content-Length发送给接受者的Body内容长度(字节)一个byte是8bitUTF-8编码的字符1-4个字节、示例:Content-Length: 3481.2.2 User-Agent帮助区分客户端特性的字符串 - 操作系统 - 浏览器 - 制造商(手机类型等) - 内核类型 - 版本号 示例:User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.361.2.3 Content-Type帮助区分资源的媒体类型(Media Type/MIME Type)text/htmltext/cssapplication/jsonimage/jpeg示例:Content-Type: application/x-www-form-urlencoded1.2.4 Origin:描述请求来源地址scheme://host:port不含路径可以是null示例: Origin: https://yewjiwei.com1.2.5 Accept建议服务端返回何种媒体类型(MIME Type)/代表所有类型(默认)多个类型用逗号隔开衍生的还有Accept-Charset能够接受的字符集 示例:Accept-Charset: utf-8Accept-Encoding能够接受的编码方式列表 示例:Accept-Encoding: gzip, deflateAccept-Language能够接受的回应内容的自然语言列表 示例:Accept-Language: en-US示例:Accept: text/plainAccept-Charset: utf-8Accept-Encoding: gzip, deflate1.2.6 Referer告诉服务端打开当前页面的上一张页面的URL;如果是ajax请求那么就告诉服务端发送请求的URL是什么非浏览器环境有时候不发送Referer常常用户行为分析1.2.7 Connection决定连接是否在当前事务完成后关闭HTTP1.0默认是closeHTTP1.1后默认是keep-alive1.2.8 Authorization用于超文本传输协议的认证的认证信息示例: Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==1.2.9 Cache-Control用来指定在这次的请求/响应链中的所有缓存机制 都必须 遵守的指令示例: Cache-Control: no-cache1.2.10 Date发送该消息的日期和时间示例: Date: Tue, 15 Nov 1994 08:12:31 GMT2. 基本方法GET 从服务器获取资源(一个网址url代表一个资源)POST 在服务器创建资源PUT 在服务器修改资源DELETE 在服务器删除资源OPTIONS 跟跨域相关TRACE 用于显示调试信息CONNECT 代理PATCH 对资源进行部分更新(极少用)3. 状态码1XX: 提供信息100 continue 情景:客户端向服务端传很大的数据,这个时候询问服务端,如果服务端返回100,客户端就继续传 (历史,现在比较少了)101 协议切换switch protocolHTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade告诉客户端把协议切换为Websocket2xx: 成功200 Ok 正常的返回成功 通常用在GET201 Created 已创建 通常用在POST202 Accepted 已接收 比如发送一个创建POST请求,服务端有些异步的操作不能马上处理先返回202,结果需要等通知或者客户端轮询获取203 Non-Authoritative Infomation 非权威内容 原始服务器的内容被修改过204 No Content 没有内容 一般PUT请求修改了但是没有返回内容205 Reset Content 重置内容206 Partial Content 服务端下发了部分内容3XX: 重定向300 Multiple Choices 用户请求了多个选项的资源(返回选项列表)301 Moved Permanently 永久转移302 Found 资源被找到(以前是临时转移)不推荐用了 302拆成了303和307303 See Other 可以使用GET方法在另一个URL找到资源304 Not Modified 没有修改305 Use Proxy 需要代理307 Temporary Redirect 临时重定向 (和303的区别是,307使用原请求的method重定向资源, 303使用GET方法重定向资源)308 Permanent Redirect 永久重定向 (和301区别是 客户端接收到308后,之前是什么method,之后也会沿用这个method到新地址。301,通常给用户会向新地址发送GET请求)4XX: 客户端错误400 Bad Request 请求格式错误401 Unauthorized 没有授权402 Payment Required 请先付费403 Forbidden 禁止访问404 Not Found 没有找到405 Method Not Allowed 方法不允许406 Not Acceptable 服务端可以提供的内容和客户端期待的不一样5XX: 服务端错误500 Internal Server Error 内部服务器错误501 Not Implemented 没有实现502 Bad Gateway 网关错误503 Service Unavailable 服务不可用 (内存用光了,线程池溢出,服务正在启动)504 Gateway Timeout 网关超时505 HTTP Version Not Supported 版本不支持
1. DNS1.1 DNS是什么?DNS(Domain Names System),域名系统,是互联网一项服务,是进行域名和与之相对应的 IP 地址进行转换的服务器简单来讲,DNS 相当于一个翻译官,负责将域名翻译成 ip 地址IP 地址:一长串能够唯一地标记网络上的计算机的数字域名:是由一串用点分隔的名字组成的 Internet 上某一台计算机或计算机组的名称,用于在数据传输时对计算机的定位标识1.2 域名域名是一个具有层次的结构,从上到下一次为根域名、顶级域名、二级域名、三级域名...例如 www.baidu.com,www 为三级域名、baidu为二级域名、com为顶级域名 ,系统为用户做了兼容,域名末尾的根域名.一般不需要输入在域名的每一层都会有一个域名服务器,如下图:1.3 查询方式DNS 查询方式有两种递归查询:如果 A 请求 B ,那么 B 作为请求的接收者一定要给 A 想要的答案迭代查询:如果接收者 B 没有请求者 A 所需要的准确内容,接收者 B 将告诉请求者 A,如何去获得这个内容,但是自己并不去发出请求1.4 域名缓存在域名服务器解析的时候,使用缓存保存域名和 IP 地址的映射计算机中 DNS 的记录也分成了两种缓存方式:浏览器缓存:浏览器在获取网站域名的实际 IP 地址后会对其进行缓存,减少网络请求的损耗操作系统缓存:操作系统的缓存其实是用户自己配置的 hosts 文件1.5 查询过程解析域名的过程如下:首先搜索浏览器的 DNS 缓存,缓存中维护一张域名与 IP 地址的对应表若没有命中,则继续搜索操作系统的 DNS 缓存若仍然没有命中,则操作系统将域名发送至本地域名服务器,本地域名服务器采用递归查询自己的 DNS 缓存,查找成功则返回结果若本地域名服务器的 DNS 缓存没有命中,则本地域名服务器向上级域名服务器进行迭代查询首先本地域名服务器向根域名服务器发起请求,根域名服务器返回顶级域名服务器的地址给本地服务器本地域名服务器拿到这个顶级域名服务器的地址后,就向其发起请求,获取权限域名服务器的地址本地域名服务器根据权限域名服务器的地址向其发起请求,最终得到该域名对应的 IP 地址本地域名服务器将得到的 IP 地址返回给操作系统,同时自己将 IP 地址缓存起来操作系统将 IP 地址返回给浏览器,同时自己也将 IP 地址缓存起至此,浏览器就得到了域名对应的 IP 地址,并将 IP 地址缓存起流程如下图所示:1.6 DNS记录DNS 记录是存储在 DNS 数据库中的特定资源记录,允许你配置和控制有关你的域名的其他信息。例如,你可以设置你的 DNS 记录,告诉世界你的域名将使用什么类型的邮件服务器(例如,微软Exchange),或者当有人访问你的网站时,应该返回哪个 IP 地址。DNS记录可以理解成一个键值对:键:域名;值:与域名关联的值;事实上,除了 IP 地址,DNS记录值还可以是 IPv6 地址、别名、文本等等。有超过30种类型的DNS记录.常见的DNS记录类型有以下:A记录:定义主机的IP地址www.example.com. IN A 139.18.28.5; 域名映射IP IN 代表 Internet互联网AAAA记录:定义主机的IPv6地址123124.s2txip6.com. 103 IN AAAA 240e:940:401:1:1a::CNAME记录:定义域名的别名www.example.com IN CNAME example.com. a.example.com IN CNAME b.example.com.MX记录:定义邮件服务器;happy.example.com作为邮件服务器 IN MX happy.example.com. ;A记录描述邮件服务器IP happy.example.com. IN A 123.123.123.123;NS记录:定义提供dns信息的服务器;定义为zhihu.com提供dns信息的服务器 zhihu.com. 52908 IN NS n24.dnsv5.com.SOA记录:定义多个ns服务器中哪个是主服务器; ns3,dnsv5.com. 是主服务器 IN SOA ns3.dnsv5.com. enterprise3dnsadmin.dnspod.com. 15947187885 3600180 1209600 180TXT记录:提供文本信息;zhihu.com提供的文本信息 zhihu.com. 600 IN TXT "m5g7qjk31l5d1hkq6m3zvcf6lg2f0h16"1.7 DNS查询工具dig(DNS look utility) 查询dns的小工具nslookup 交互式查询域名服务工具host(DNS look utility) 查询dns的小工具1.8 host修改编辑hosts文件 vim /etc/hosts添加 3.3.3.3 www.baidu.comping www.baidu.com结果2. CDN2.1 CDN是什么?CDN (全称 Content Delivery Network),即内容分发网络构建在现有网络基础之上的智能虚拟网络,依靠部署在各地的边缘服务器,通过中心平台的负载均衡、内容分发、调度等功能模块,使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。CDN 的关键技术主要有内容存储和分发技术。简单来讲,CDN 就是一个基于地理位置的分布式代理服务器/数据中心。它会根据用户位置分配最近的资源。于是,用户在上网的时候不用直接访问源站,而是访问离他“最近的”一个 CDN 节点,术语叫边缘节点,其实就是缓存了源站内容的代理服务器。如下图:2.2 CDN实现原理当用户输入一个网址,浏览器会检查这个网址上所有资源的请求。以一个 js 文件为例它首先会做 DNS 查询,通常在没有应用 CDN 时,会返回一个 ip 地址,浏览器直接根据 IP 地址请求 js 资源;应用 CDN 后,DNS 返回的不再是 IP 地址,而是一个CNAME(Canonical Name ) 别名记录浏览器对别名做 CDN 查询,返回 CDN 智能 DNS 服务的 ip 地址查询 CDN 智能调度 DNS ,它会根据以下情况,返回合适的边缘节点ip 给用户地理位置,找相对最近的边缘节点运营商网络,找相同网络的边缘节点边缘节点的负载情况,找负载较轻的节点节点的“健康状况”、服务能力、带宽、响应时间等根据节点 ip 请求 js 文件,如果请求的资源整体流程图如下:2.3 缓存代理缓存系统是 CDN 的另一个关键组成部分,缓存系统会有选择地缓存那些最常用的那些资源。其中有两个衡量 CDN 服务质量的指标:命中率:用户访问的资源恰好在缓存系统里,可以直接返回给用户,命中次数与所有访问次数之比回源率:缓存里没有,必须用代理的方式回源站取,回源次数与所有访问次数之比缓存系统也可以划分出层次,分成一级缓存节点和二级缓存节点。一级缓存配置高一些,直连源站,二级缓存配置低一些,直连用户。回源的时候二级缓存只找一级缓存,一级缓存没有才回源站,可以有效地减少真正的回源。现在的商业 CDN 命中率都在 90% 以上,相当于把源站的服务能力放大了 10 倍以上。3. 小结DNS就是个域名系统,负责将域名翻译成 ip 地址;CDN是一个基于地理位置的分布式代理服务器,会根据用户位置分配最近的资源,就是为了提高资源的访问速度。两者都有缓存设计,DNS(浏览器缓存、操作系统缓存)、CDN(一节缓存、二级缓存)参考文献面试官:DNS协议 是什么?说说DNS 完整的查询过程?根域名的知识DNS记录类型面试官:如何理解CDN?说说实现原理?
1. 前言最近自己学习写了一个基于Vue3的组件库,感觉有点意思,这篇文章来记录一下我是怎么从0快速打造一个UI组件库的附上访问地址jw-ui上面网址打不开的话可以用这个jw-ui2. 使用Vite搭建官网Vite是尤雨溪开发的一种新型前端构建工具,具体介绍可以查看官方文档2.1 创建项目2.1.1. 全局安装vite(这里我装的时候是2.7.2)$ yarn create vite@2.7.22.1.2. 构建一个vue模板(项目名可以改成自己的名字)yarn create vite jw-ui --template vue2.1.3. 装好之后按照提示逐步执行命令cd jw-ui yarn dev可以看到界面ps: 推荐的IDE和设置:VSCode + Volar2.2 基本完成官网的搭建2.2.1. 下载vue-routeryarn add vue-router@42.2.2. 创建home首页与doc文档页 以及顶部导航栏/* /views/home/index.vue 首页*/ <template> <div> </div> </template>/* /views/doc/index.vue 文档页面 */ <template> <div> </div> </template>/* /components/Topnav.vue 顶部导航栏组件 */ <template> <div class="topnav"> <router-link to="/home">首页</router-link> <router-link to="/doc">文档</router-link> </div> </template>2.2.3. 配置路由创建路由配置文件// router/index.ts import { createRouter, createWebHashHistory } from "vue-router"; const history = createWebHashHistory(); const router = createRouter({ history, routes: [ { path: "/", redirect: "" }, export default router;在main.ts里导入,使得整个应用支持路由。import { createApp } from "vue"; import App from "./App.vue"; import router from "./router"; const app = createApp(App); app.use(router); app.mount("#app"); 修改App.vue<template> <Topnav /> <router-view /> </template> <script setup> import Topnav from "./components/Topnav.vue"; </script>到目前为止的效果装饰一下顶部导航栏后的效果这里首页按照自己喜欢的来写CSS就好了,接下来讲一下文档页面。文档页需要一个侧边栏来切换不同组件的文档,这里我就举例做一个Button组件// doc/index.vue <template> <div class="layout"> <div class="content"> <aside> <router-link class="menu-item text-overflow" to="/doc/button" >Button 组件</router-link </aside> <main style="padding-left: 302px"> <router-view /> </main> </div> </template>// router/index.ts 添加一个展示的button页面 import { createRouter, createWebHashHistory } from "vue-router"; import Home from "../views/home/index.vue"; import Doc from "../views/doc/index.vue"; import ButtonDoc from "../views/doc/button/index.vue"; const history = createWebHashHistory(); const router = createRouter({ history, routes: [ { path: "/", redirect: "/home" }, { path: "/home", component: Home }, path: "/doc", component: Doc, children: [{ path: "button", component: ButtonDoc }], export default router;// /views/doc/button/index <template> <Button /> </template> <script setup> import Button from '../../../lib/button/index.vue' </script> <style lang="scss" scoped> </style> 展示效果好了到这里官网总算是基本搭建完了,我们终于就可以愉快的在src/lib/button/index.vue文件里封装组件啦。(封装的组件都放在lib文件夹里,以后打包用)3. 封装一个Button组件下面附上我写的一个Button组件以及使用效果PS: 需要注意的一点是封装的样式一定要加自己独特的前缀我这里是 jw 以避免在项目中产生样式重叠<template> <button class="jw-button" :class="classes"> <span v-if="loading" class="jw-loadingIndicator"></span> <slot> {{ theme }} </slot> </button> </template> <script setup lang="ts"> import { computed } from "vue"; const props = defineProps({ theme: { type: String, default: "default", dashed: { type: Boolean, default: false, size: { type: String, default: "default", round: { type: Boolean, default: false, disabled: { type: Boolean, default: false, loading: { type: Boolean, default: false, const { theme, dashed, size, round, disabled } = props; const classes = computed(() => { return { [`jw-theme-${theme}`]: theme, [`jw-theme-dashed`]: dashed, [`jw-size-${size}`]: size, [`is-round`]: round, [`is-disabled`]: disabled, </script> <script lang="ts"> export default { name: "JwButton", </script> <style lang="scss" scoped> $h-default: 32px; $h-small: 20px; $h-large: 48px; $white: #fff; $default-color: #333; $primary-color: #36ad6a; $info-color: #4098fc; $success-color: #85ce61; $warning-color: #f0a020; $error-color: #d03050; $grey: grey; $default-border-color: #d9d9d9; $radius: 3px; $green: #18a058; .jw-button { box-sizing: border-box; height: $h-default; background-color: #fff; padding: 0 12px; cursor: pointer; display: inline-flex; justify-content: center; align-items: center; white-space: nowrap; border-radius: $radius; box-shadow: 0 1px 0 fade-out(black, 0.95); transition: all 250ms; color: $default-color; border: 1px solid $default-border-color; user-select: none; &:focus { outline: none; &::-moz-focus-inner { border: 0; &.jw-size-large { font-size: 24px; height: $h-large; padding: 0 16px; &.jw-size-small { font-size: 12px; height: $h-small; padding: 0 8px; &.is-round.jw-size-default { border-radius: calc($h-default / 2); &.is-round.jw-size-large { border-radius: calc($h-large / 2); &.is-round.jw-size-small { border-radius: calc($h-small / 2); &.jw-theme-default { &:hover { color: $green; border-color: $green; > .jw-loadingIndicator { border-style: dashed; border-color: $green $green $green transparent; &:active { color: darken($green, 20%); border-color: darken($green, 20%); > .jw-loadingIndicator { border-style: dashed; border-color: darken($green, 20%) darken($green, 20%) darken($green, 20%) transparent; &.jw-theme-dashed { border-style: dashed; > .jw-loadingIndicator { border-style: dashed; border-color: $default-color $default-color $default-color transparent; &.jw-theme-primary { background-color: $primary-color; border-color: $primary-color; color: $white; &:hover { background: lighten($primary-color, 20%); border-color: lighten($primary-color, 20%); &:active { background-color: darken($primary-color, 20%); border-color: darken($primary-color, 20%); &.is-disabled { cursor: not-allowed; background: lighten($primary-color, 20%); border-color: lighten($primary-color, 20%); &:hover { background: lighten($primary-color, 20%); border-color: lighten($primary-color, 20%); &.jw-theme-dashed { border-style: dashed; background-color: $white !important; color: $primary-color; > .jw-loadingIndicator { border-style: dashed; border-color: $primary-color $primary-color $primary-color transparent; &.jw-theme-info { background-color: $info-color; border-color: $info-color; color: $white; &:hover { background: lighten($info-color, 20%); border-color: lighten($info-color, 20%); &:active { background-color: darken($info-color, 20%); border-color: darken($info-color, 20%); &.is-disabled { cursor: not-allowed; background: lighten($info-color, 20%); border-color: lighten($info-color, 20%); &:hover { background: lighten($info-color, 20%); border-color: lighten($info-color, 20%); &.jw-theme-dashed { border-style: dashed; background-color: $white !important; color: $info-color; > .jw-loadingIndicator { border-style: dashed; border-color: $info-color $info-color $info-color transparent; &.jw-theme-success { background-color: $success-color; border-color: $success-color; color: $white; &:hover { background: lighten($success-color, 20%); border-color: lighten($success-color, 20%); &:active { background-color: darken($success-color, 20%); border-color: darken($success-color, 20%); &.is-disabled { cursor: not-allowed; background: lighten($success-color, 20%); border-color: lighten($success-color, 20%); &:hover { background: lighten($success-color, 20%); border-color: lighten($success-color, 20%); &.jw-theme-dashed { border-style: dashed; background-color: $white !important; color: $success-color; > .jw-loadingIndicator { border-style: dashed; border-color: $success-color $success-color $success-color transparent; &.jw-theme-warning { background-color: $warning-color; border-color: $warning-color; color: $white; &:hover { background: lighten($warning-color, 20%); border-color: lighten($warning-color, 20%); &:active { background-color: darken($warning-color, 20%); border-color: darken($warning-color, 20%); &.is-disabled { cursor: not-allowed; background: lighten($warning-color, 20%); border-color: lighten($warning-color, 20%); &:hover { background: lighten($warning-color, 20%); border-color: lighten($warning-color, 20%); &.jw-theme-dashed { border-style: dashed; background-color: $white !important; color: $warning-color; > .jw-loadingIndicator { border-style: dashed; border-color: $warning-color $warning-color $warning-color transparent; &.jw-theme-error { background-color: $error-color; border-color: $error-color; color: $white; &:hover { background: lighten($error-color, 20%); border-color: lighten($error-color, 20%); &:active { background-color: darken($error-color, 20%); border-color: darken($error-color, 20%); &.is-disabled { cursor: not-allowed; background: lighten($error-color, 20%); border-color: lighten($error-color, 20%); &:hover { background: lighten($error-color, 20%); border-color: lighten($error-color, 20%); &.jw-theme-dashed { border-style: dashed; background-color: $white !important; color: $error-color; > .jw-loadingIndicator { border-style: dashed; border-color: $error-color $error-color $error-color transparent; > .jw-loadingIndicator { width: 14px; height: 14px; display: inline-block; margin-right: 4px; border-radius: 8px; border-color: $white $white $white transparent; border-style: solid; border-width: 2px; animation: jw-spin 1s infinite linear; @keyframes jw-spin { transform: rotate(0deg); 100% { transform: rotate(360deg); </style> 虽然有不完美,但差不多就这个意思吧4. 封装Markdown组件介绍文档4.1. 下载vite-plugin-markdown:一个插件可以让你导入Markdown文件作为各种格式的vite项目。github-markdown-css:复制GitHub Markdown风格yarn add github-markdown-css vite-plugin-markdown4.2. main.ts中引入import "github-markdown-css";4.3. vite.config.js中配置vite-plugin-markdown插件import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' const md = require("vite-plugin-markdown"); export default defineConfig({ plugins: [vue(), md.plugin({ mode: ["html", "vue"], 4.4. 封装Markdown组件// /components/Markdown.vue <template> <article class="markdown-body" v-html="content"></article> </template> <script setup lang="ts"> // 传入的md文件 const props = defineProps({ content: { type: String, required: true, </script> 4.5. 创建介绍页面路由import { h } from "vue"; import { createRouter, createWebHashHistory } from "vue-router"; import Home from "../views/home/index.vue"; import Doc from "../views/doc/index.vue"; import ButtonDoc from "../views/doc/button/index.vue"; const history = createWebHashHistory(); import Markdown from "../components/Markdown.vue"; const md = (string) => h(Markdown, { content: string, key: string }); import { html as Intro } from "../../markdown/intro.md"; const IntroDoc = md(Intro); const router = createRouter({ history, routes: [ { path: "/", redirect: "/home" }, { path: "/home", component: Home }, path: "/doc", component: Doc, children: [ { path: "intro", component: IntroDoc }, { path: "button", component: ButtonDoc }, export default router; 可以看到,最终md就能导入,并且生成了github上md的样式了5. 自定义代码块获取组件展示源代码5.1. 自定义插件vue-custom-blocks-pluginimport path from "path"; import fs from "fs"; import { baseParse } from "@vue/compiler-core"; const vitePluginVue = { name: "preview", transform(code, id) { !/\/src\/views\/doc\/.*\.preview\.vue/.test(id) || !/vue&type=preview/.test(id) return; let path = `.${id.match(/\/src\/views\/doc\/.*\.preview\.vue/)[0]}`; const file = fs.readFileSync(path).toString(); const parsed = baseParse(file).children.find((n) => n.tag === "preview"); const title = parsed.children[0].content; const main = file.split(parsed.loc.source).join("").trim(); return `export default function (Component) { Component.__sourceCode = ${JSON.stringify(main)} Component.__sourceCodeTitle = ${JSON.stringify(title)} }`.trim(); export default vitePluginVue; 5.2. 在vite.config.ts中配置import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' const md = require("vite-plugin-markdown"); import vitePluginVue from "./plugins/vue-custom-blocks-plugin"; export default defineConfig({ plugins: [vue(), md.plugin({ mode: ["html", "vue"], vitePluginVue] 5.3. 封装Preview组件展示<template> <div class="pre"> <h2> {{ component.__sourceCodeTitle }} <Button @click="hideCode" v-if="codeVisible">隐藏代码</Button> <Button @click="showCode" v-else>查看代码</Button> </h2> <div class="pre-component"> <component :is="component" /> </div> <div class="pre-code" v-if="codeVisible"> <pre class="language-html">{{ component__sourceCOde }}</pre> </div> </div> </template> <script setup lang="ts"> import Button from "../lib/button/index.vue"; import { computed, ref } from "vue"; const props = defineProps({ component: Object, const showCode = () => (codeVisible.value = true); const hideCode = () => (codeVisible.value = false); const codeVisible = ref(false); </script> <style lang="scss" scoped> $border-color: #d9d9d9; .pre { border: 1px solid $border-color; margin: 16px 0px 32px; max-width: 700px; min-width: 300px; > h2 { font-size: 20px; padding: 8px 16px; border-bottom: 1px solid $border-color; display: flex; justify-content: space-between; &-component { padding: 16px; &-actions { padding: 8px 16px; border-top: 1px dashed $border-color; &-code { padding: 8px 16px; border-top: 1px dashed $border-color; > pre { line-height: 1.1; font-family: Consolas, "Courier New", Courier, monospace; margin: 0; background-color: #fff; </style> 5.4. 使用Preview组件views/doc/button/index.vue<template> <div> <Preview :component="Button1" /> </div> </template> <script setup> import Button1 from "./Button1.preview.vue"; import Preview from "../../../components/Preview.vue"; </script> <style lang="scss"> .jw-button + .jw-button { margin-left: 20px; </style> /views/doc/button/Button1.preview.vue<preview>基础示例</preview> <template> <Button /> </template> <script setup lang="ts"> import Button from "../../../lib/button/index.vue"; </script> 现在,只要编写上面的以.preview.vue后缀的文件就行了。效果:5.5. 高亮源代码下载prismjsyarn add prismjs对Preview组件做修改<template> <div class="pre"> <h2> {{ component.__sourceCodeTitle }} <Button @click="hideCode" v-if="codeVisible">隐藏代码</Button> <Button @click="showCode" v-else>查看代码</Button> </h2> <div class="pre-component"> <component :is="component" /> </div> <div class="pre-code" v-if="codeVisible"> <pre class="language-html" v-html="html" /> </div> </div> </template> <script setup lang="ts"> import Button from "../lib/button/index.vue"; import { computed, ref } from "vue"; import "prismjs"; import "prismjs/themes/prism.css"; const Prism = (window as any).Prism; const props = defineProps({ component: Object, console.log(props.component.__sourceCode); const html = computed(() => { return Prism.highlight( props.component.__sourceCode, Prism.languages.html, "html" const showCode = () => (codeVisible.value = true); const hideCode = () => (codeVisible.value = false); const codeVisible = ref(false); </script>效果6. 去掉示例中的文件导入6.1. 在lib目录下创建main.ts 这个也是作为之后打包上传至npm的入口import { App } from "vue"; import JwButton from "./button/index.vue"; export { JwButton }; const components = [JwButton]; // 全局注册主键 export function registerJwUi(app: App): void { for (const component of components) { app.component(component.name, component); export default registerJwUi; 6.2. main.ts中导入注册import JwUi from "./lib/index"; app.use(JwUi);6.3. 这样在示例中就可以直接用了/src/views/doc/button/Button1.preview<preview>基础示例</preview> <template> <jw-button /> </template>6.4. 效果7. 部署到github官网7.1. 打包yarn build7.2. 上传至githubgithub创建一个新的仓库将dist上传只仓库7.3. 进入仓库Settings最底层7.4. 找到GitHub Pages7.5. 选择master分支 点击保存 链接就生成了7.6 一键部署创建deploy.sh文件rm -rf dist && yarn build && cd dist && git init && git add . && git commit -m "update" && git branch -M master && git remote add origin git@github.com:coderyjw/jw-ui-website.git && git push -f -u origin master && echo https://coderyjw.github.io/jw-ui-website/执行命令sh deploy.sh8. 上传至npm8.1. 创建rollup.config.js配置文件// 为了保证版本一致,请复制我的 package.json 到你的项目,并把 name 改成你的库名 import esbuild from 'rollup-plugin-esbuild' import vue from 'rollup-plugin-vue' import scss from 'rollup-plugin-scss' import dartSass from 'sass'; import { terser } from "rollup-plugin-terser" import alias from '@rollup/plugin-alias' import path from "path"; import resolve from 'rollup-plugin-node-resolve' export default { input: 'src/lib/index.ts', output: [{ globals: { vue: 'Vue' name: 'Yjw-ui', file: 'dist/lib/yjw-ui.js', format: 'umd', plugins: [terser()] name: 'Yjw-ui', file: 'dist/lib/yjw-ui.esm.js', format: 'es', plugins: [terser()] plugins: [ scss({ include: /\.scss$/, sass: dartSass }), esbuild({ include: /\.[jt]s$/, minify: process.env.NODE_ENV === 'production', target: 'es2015' vue({ include: /\.vue$/, alias({ entries: [ find: '@', // 别名名称,作为依赖项目需要使用项目名 replacement: path.resolve(__dirname, 'src'), customResolver: resolve({ extensions: ['.js', '.jsx', '.vue', '.sass', '.scss'] }8.2. 执行命令打包rollup -c8.3. 效果可以看到dist文件下有lib文件,就是打包后的文件8.4. 上传至npm需要先注册npm账号npm login // 先登录 npm publish // 发布9. 最后ok,终于写完了,如果你觉得我写的不错,麻烦点个赞再走吧~如果觉得我写的有错的,麻烦指出再点个赞鼓励一下吧。原文地址
比较常见的面试题包括: - 可以配置哪些属性来进行 **`webpack` 性能优化?** - **前端有哪些常见的性能优化?**(除了其他常见的,也完全可以从 `webpack` 来回答) `webpack` 的性能优化比较多,我们可以对其进行分类: 1. **打包后的结果**,上线时的性能优化。(比如分包处理、减小包体积、CDN服务器等) 2. **优化打包速度**,开发或者构建时优化打包速度。(比如 `exclude`、`cache-loader` 等) 大多数情况下,我们会更加侧重于 **第一种 vue3 源码学习,实现一个 mini-vue(十二):diff 算法核心实现