vue3中的通过proxy实现双向数据绑定的原理
1.什么是Proxy?它的作用是?
据阮一峰文章介绍:Proxy可以理解成,在目标对象之前架设一层 "拦截",当外界对该对象访问的时候,都必须经过这层拦截,而Proxy就充当了这种机制,类似于代理的含义,它可以对外界访问对象之前进行过滤和改写该对象。
如果对vue2.xx了解或看过源码的人都知道,vue2.xx中使用 Object.defineProperty()方法对该对象通过 递归+遍历的方式来实现对数据的监控的,具体了解
Object.defineProperty可以看我上一篇文章(https://www.cnblogs.com/tugenhua0707/p/10261170.html). 但是通过上一篇Object.defineProperty文章 我们也知道,当我们使用数组的方法或改变数组的下标是不能重新触发 Object.defineProperty中的set()方法的,因此就做不到实时响应了。所以使用 Object.defineProperty 存在如下缺点:
1. 监听数组的方法不能触发Object.defineProperty方法中的set操作(如果要监听的到话,需要重新编写数组的方法)。
2. 必须遍历每个对象的每个属性,如果对象嵌套很深的话,需要使用递归调用。
因此vue3.xx中之后就改用Proxy来更好的解决如上面的问题。在学习使用Proxy实现数据双向绑定之前,我们还是一步步来,先学习了Proxy基本知识点。
Proxy基本语法
const obj = new Proxy(target, handler);
参数说明如下:
target: 被代理对象。
handler: 是一个对象,声明了代理target的一些操作。
obj: 是被代理完成之后返回的对象。
但是当外界每次对obj进行操作时,就会执行handler对象上的一些方法。handler中常用的对象方法如下:
1. get(target, propKey, receiver)
2. set(target, propKey, value, receiver)
3. has(target, propKey)
4. construct(target, args):
5. apply(target, object, args)
如上是Proxy中handler 对象的方法,其实它和Reflect里面的方法类似的,想要了解Reflect看这篇文章
如下代码演示:
const target = {
name: 'kongzhi'
}; const handler = {
get: function(target, key) {
console.log(`${key} 被读取`);
return target[key];
},
set: function(target, key, value) {
console.log(`${key} 被设置为 ${value}`);
target[key] = value;
}
}; const testObj = new Proxy(target, handler); /*
获取testObj中name属性值
会自动执行 get函数后 打印信息:name 被读取 及输出名字 kongzhi
*/
console.log(testObj.name); /*
改变target中的name属性值
打印信息如下: name 被设置为 111
*/
testObj.name = 111; console.log(target.name); // 输出 111
如上代码所示:也就是说 target是被代理的对象,handler是代理target的,那么handler上面有set和get方法,当每次打印target中的name属性值的时候会自动执行handler中get函数方法,当每次设置 target.name 属性值的时候,会自动调用 handler中的set方法,因此target对象对应的属性值会发生改变,同时改变后的 testObj对象也会发生改变。同理改变返回后 testObj对象中的属性也会改变原对象target的属性的,因为对象是引用类型的,是同一个引用的。如果这样还是不好理解的话,可以简单的看如下代码应该可以理解了:
const target = {
name: 'kongzhi'
}; const testA = target; testA.name = 'xxx'; console.log(testA.name); // 打印 xxx console.log(target.name); // 打印 xxx
2.get(target, propKey, receiver)
该方法的含义是:用于拦截某个属性的读取操作。它有三个参数,如下解析:
target: 目标对象。
propKey: 目标对象的属性。
receiver: (可选),该参数为上下文this对象
如下代码演示:
const obj = {
name: 'kongzhi'
}; const handler = {
get: function(target, propKey) {
// 使用 Reflect来判断该目标对象是否有该属性
if (Reflect.has(target, propKey)) {
// 使用Reflect 来读取该对象的属性
return Reflect.get(target, propKey);
} else {
throw new ReferenceError('该目标对象没有该属性');
}
}
}; const testObj = new Proxy(obj, handler); /*
Proxy中读取某个对象的属性值的话,
就会使用get方法进行拦截,然后返回该值。
*/
console.log(testObj.name); // kongzhi /*
如果对象没有该属性的话,就会进入else语句,就会报错:
Uncaught ReferenceError: 该目标对象没有该属性
*/
// console.log(testObj.name2); /*
其实Proxy中拦截的操作是在原型上的,因此我们也可以使用 Object.create(obj)
来实现对象的继承的。
如下代码演示:
*/
const testObj2 = Object.create(testObj);
console.log(testObj2.name); // 看看他们的原型是否相等
console.log(testObj2.__proto__ === testObj.__proto__); // 返回true
如果没有这个拦截的话,如果某个对象没有该属性的话,会输出 undefined.
3.set(target, propKey, value, receiver)
该方法是用来拦截某个属性的赋值操作,它可以接受四个参数,参数解析分别如下:
target: 目标对象。
propKey: 目标对象的属性名
value: 属性值
receiver(可选): 一般情况下是Proxy实列
如下代码演示:
const obj = {
'name': 'kongzhi'
}; const handler = {
set: function(obj, prop, value) {
return Reflect.set(obj, prop, value);
}
}; const proxy = new Proxy(obj, handler); proxy.name = '我是空智'; console.log(proxy.name); // 输出: 我是空智
console.log(obj); // 输出: {name: '我是空智'}
当然如果设置该对象的属性是不可写的,那么set方法就不起作用了,如下代码演示:
const obj = {
'name': 'kongzhi'
}; Object.defineProperty(obj, 'name', {
writable: false
}); const handler = {
set: function(obj, prop, value, receiver) {
Reflect.set(obj, prop, value);
}
}; const proxy = new Proxy(obj, handler);
proxy.name = '我是空智';
console.log(proxy.name); // 打印的是 kongzhi
注意:proxy对数组也是可以监听的;如下代码演示,数组中的 push方法监听:
const obj = [{
'name': 'kongzhi'
}]; const handler = {
set: function(obj, prop, value) {
return Reflect.set(obj, prop, value);
}
}; const proxy = new Proxy(obj, handler); proxy.push({'name': 'kongzhi222'}); proxy.forEach(function(item) {
console.log(item.name); // 打印出 kongzhi kongzhi222
});
4.has(target, propKey)
该方法是判断某个目标对象是否有该属性名。接收二个参数,分别为目标对象和属性名。返回的是一个布尔型。
如下代码演示:
const obj = {
'name': 'kongzhi'
}; const handler = {
has: function(target, key) {
if (Reflect.has(target, key)) {
return true;
} else {
return false;
}
}
}; const proxy = new Proxy(obj, handler); console.log(Reflect.has(obj, 'name')); // true
console.log(Reflect.has(obj, 'age')); // false
5.construct(target, args, newTarget):
该方法是用来拦截new命令的,它接收三个参数,分别为 目标对象,构造函数的参数对象及创造实列的对象。
第三个参数是可选的。它的作用是拦截对象属性。
如下代码演示:
function A(name) {
this.name = name;
} const handler = {
construct: function(target, args, newTarget) {
/*
输出: function A(name) {
this.name = name;
}
*/
console.log(target);
// 输出: ['kongzhi', {age: 30}]
console.log(args);
return args
}
}; const Test = new Proxy(A, handler); const obj = new Test('kongzhi', {age: 30});
console.log(obj); // 输出: ['kongzhi', {age: 30}]
6.apply(target, object, args)
该方法是拦截函数的调用的。该方法接收三个参数,分别是目标对象。目标对象上下文this对象 和 目标对象的数组;它和 Reflect.apply参数是一样的,了解
Reflect.apply(https://www.cnblogs.com/tugenhua0707/p/10291909.html#_labe2).
使用demo如下演示:
function testA(p1, p2) {
return p1 + p2;
}
const handler = {
apply: function(target, ctx, args) {
/*
这里的 ...arguments 其实就是上面的三个参数 target, ctx, args 对应的值。
分别为:
target: function testA(p1, p2) {
return p1 + p2;
}
ctx: undefined
args: [1, 2]
使用 Reflect.apply(...arguments) 调用testA函数,因此返回 (1+2) * 2 = 6
*/
console.log(...arguments);
return Reflect.apply(...arguments) * 2;
}
} const proxy = new Proxy(testA, handler); console.log(proxy(1, 2)); // 6 // 也可以如下调用
console.log(proxy.apply(null, [1, 3])); // 8 // 我们也可以使用 Reflect.apply 调用 console.log(Reflect.apply(proxy, null, [3, 5])); // 16
7.使用Proxy实现简单的vue双向绑定
vue3.x使用了Proxy来对数据进行监听了,因此我们来简单的来学习下使用Proxy来实现一个简单的vue双向绑定。
我们都知道实现数据双向绑定,需要实现如下几点:
1. 需要实现一个数据监听器 Observer, 能够对所有数据进行监听,如果有数据变动的话,拿到最新的值并通知订阅者Watcher.
2. 需要实现一个指令解析器Compile,它能够对每个元素的指令进行扫描和解析,根据指令模板替换数据,以及绑定相对应的函数。
3. 需要实现一个Watcher, 它是链接Observer和Compile的桥梁,它能够订阅并收到每个属性变动的通知,然后会执行指令绑定的相对应
的回调函数,从而更新视图。
下面是一个简单的demo源码如下(我们可以参考下,理解下原理):
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>标题</title>
</head>
<body>
<div id="app">
<input type="text" v-model='count' />
<input type="button" value="增加" @click="add" />
<input type="button" value="减少" @click="reduce" />
<div v-bind="count"></div>
</div>
<script type="text/javascript">
class Vue {
constructor(options) {
this.$el = document.querySelector(options.el);
this.$methods = options.methods;
this._binding = {};
this._observer(options.data);
this._compile(this.$el);
}
_pushWatcher(watcher) {
if (!this._binding[watcher.key]) {
this._binding[watcher.key] = [];
}
this._binding[watcher.key].push(watcher);
}
/*
observer的作用是能够对所有的数据进行监听操作,通过使用Proxy对象
中的set方法来监听,如有发生变动就会拿到最新值通知订阅者。
*/
_observer(datas) {
const me = this;
const handler = {
set(target, key, value) {
const rets = Reflect.set(target, key, value);
me._binding[key].map(item => {
item.update();
});
return rets;
}
};
this.$data = new Proxy(datas, handler);
}
/*
指令解析器,对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相对应的更新函数
*/
_compile(root) {
const nodes = Array.prototype.slice.call(root.children);
const data = this.$data;
nodes.map(node => {
if (node.children && node.children.length) {
this._compile(node.children);
}
const $input = node.tagName.toLocaleUpperCase() === "INPUT";
const $textarea = node.tagName.toLocaleUpperCase() === "TEXTAREA";
const $vmodel = node.hasAttribute('v-model');
// 如果是input框 或 textarea 的话,并且带有 v-model 属性的
if (($vmodel && $input) || ($vmodel && $textarea)) {
const key = node.getAttribute('v-model');
this._pushWatcher(new Watcher(node, 'value', data, key));
node.addEventListener('input', () => {
data[key] = node.value;
});
}
if (node.hasAttribute('v-bind')) {
const key = node.getAttribute('v-bind');
this._pushWatcher(new Watcher(node, 'innerHTML', data, key));
}
if (node.hasAttribute('@click')) {
const methodName = node.getAttribute('@click');
const method = this.$methods[methodName].bind(data);
node.addEventListener('click', method);
}
});
}
}
/*
watcher的作用是 链接Observer 和 Compile的桥梁,能够订阅并收到每个属性变动的通知,
执行指令绑定的响应的回调函数,从而更新视图。
*/
class Watcher {
constructor(node, attr, data, key) {
this.node = node;
this.attr = attr;
this.data = data;
this.key = key;
}
update() {
this.node[this.attr] = this.data[this.key];
}
}
</script>
<script type="text/javascript">
new Vue({
el: '#app',
data: {
count: 0
},
methods: {
add() {
this.count++;
},
reduce() {
this.count--;
}
}
});
</script>
</body>
</html>
如上代码我们来分析下原理如下:
首先他是使用ES6编写的语法来实现的。首先我们想实现类似vue那要的初始化代码,如下这样设想:
new Vue({
el: '#app',
data: {
count: 0
},
methods: {
add() {
this.count++;
},
reduce() {
this.count--;
}
}
});
因此使用ES6 基本语法如下:
class Vue {
constructor(options) {
this.$el = document.querySelector(options.el);
this.$methods = options.methods;
this._binding = {};
this._observer(options.data);
this._compile(this.$el);
}
}
Vue类使用new创建一个实例化的时候,就会执行 constructor方法代码,因此options是vue传入的一个对象,它有 el,data, methods等属性。 如上代码先执行 this._observer(options.data); 该 observer 函数就是监听所有数据的变动函数。基本代码如下:
1. 实现Observer对所有的数据进行监听。
_observer(datas) {
const me = this;
const handler = {
set(target, key, value) {
const rets = Reflect.set(target, key, value);
me._binding[key].map(item => {
item.update();
});
return rets;
}
};
this.$data = new Proxy(datas, handler);
}
使用了我们上面介绍的Proxy中的set方法对所有的数据进行监听,只要我们Vue实列属性data中有任何数据发生改变的话,都会自动调用Proxy中的set方法,我们上面的代码使用了 const rets = Reflect.set(target, key, value); return rets; 这样的代码,就是对我们的data中的任何数据发生改变后,使用该方法重新设置新值,然后返回给 this.$data保存到这个全局里面。
me._binding[key].map(item => {
item.update();
});
如上this._binding 是一个对象,对象里面保存了所有的指令及对应函数,如果发生改变,拿到最新值通知订阅者,因此通知Watcher类中的update方法,如下Watcher类代码如下:
/*
watcher的作用是 链接Observer 和 Compile的桥梁,能够订阅并收到每个属性变动的通知,
执行指令绑定的响应的回调函数,从而更新视图。
*/
class Watcher {
constructor(node, attr, data, key) {
this.node = node;
this.attr = attr;
this.data = data;
this.key = key;
}
update() {
this.node[this.attr] = this.data[this.key];
}
}
2. 实现Compile
如下代码初始化
class Vue {
constructor(options) {
this.$el = document.querySelector(options.el);
this._compile(this.$el);
}
}
_compile 函数的作用就是对页面中每个元素节点的指令进行解析和扫描的,根据指令模板替换数据,以及绑定相应的更新函数。
代码如下:
_compile(root) {
const nodes = Array.prototype.slice.call(root.children);
const data = this.$data;
nodes.map(node => {
if (node.children && node.children.length) {
this._compile(node.children);
}
const $input = node.tagName.toLocaleUpperCase() === "INPUT";
const $textarea = node.tagName.toLocaleUpperCase() === "TEXTAREA";
const $vmodel = node.hasAttribute('v-model');
// 如果是input框 或 textarea 的话,并且带有 v-model 属性的
if (($vmodel && $input) || ($vmodel && $textarea)) {
const key = node.getAttribute('v-model');
this._pushWatcher(new Watcher(node, 'value', data, key));
node.addEventListener('input', () => {
data[key] = node.value;
});
}
if (node.hasAttribute('v-bind')) {
const key = node.getAttribute('v-bind');
this._pushWatcher(new Watcher(node, 'innerHTML', data, key));
}
if (node.hasAttribute('@click')) {
const methodName = node.getAttribute('@click');
const method = this.$methods[methodName].bind(data);
node.addEventListener('click', method);
}
});
}
}
如上代码,
1. 拿到根元素的子节点,然后让子元素变成数组的形式,如代码:
const nodes = Array.prototype.slice.call(root.children);
2. 保存变动后的 this.$data, 如下代码:
const data = this.$data;
3. nodes子节点进行遍历,如果改子节点还有子节点的话,就会递归调用 _compile方法,如下代码:
nodes.map(node => {
if (node.children && node.children.length) {
this._compile(node.children);
}
});
4. 对子节点进行判断,如果子节点是input元素或textarea元素的话,并且有 v-model这样的指令的话,如下代码:
nodes.map(node => {
const $input = node.tagName.toLocaleUpperCase() === "INPUT";
const $textarea = node.tagName.toLocaleUpperCase() === "TEXTAREA";
const $vmodel = node.hasAttribute('v-model');
// 如果是input框 或 textarea 的话,并且带有 v-model 属性的
if (($vmodel && $input) || ($vmodel && $textarea)) {
const key = node.getAttribute('v-model');
this._pushWatcher(new Watcher(node, 'value', data, key));
node.addEventListener('input', () => {
data[key] = node.value;
});
}
});
如上代码,如果有 v-model,就获取v-model该属性值,如代码:
const key = node.getAttribute('v-model');
然后把该指令通知订阅者 Watcher; 如下代码:
this._pushWatcher(new Watcher(node, 'value', data, key));
就会调用 Watcher类的constructor的方法,如下代码:
class Watcher {
constructor(node, attr, data, key) {
this.node = node;
this.attr = attr;
this.data = data;
this.key = key;
}
}
把 node节点,attr属性,data数据,v-model指令key保存到this对象中了。然后调用 this._pushWatcher(watcher); 这样方法。
_pushWatcher代码如下:
if (!this._binding[watcher.key]) {
this._binding[watcher.key] = [];
}
this._binding[watcher.key].push(watcher);
如上代码,先判断 this._binding 有没有 v-model指令中的key, 如果没有的话,就把该 this._binding[key] = []; 设置成空数组。然后就把它存入 this._binding[key] 数组里面去。
5. 对于 input 或 textarea 这样的 v-model 会绑定相对应的函数,如下代码:
node.addEventListener('input', () => {
data[key] = node.value;
});
当input或textarea有值发生改变的话,那么就把最新的值存入 Vue类中的data对象里面去,因此data中的数据会发生改变,因此会自动触发执行 _observer 函数中的Proxy中的set方法函数,还是一样,首先更新最新值,使用代码:
const rets = Reflect.set(target, key, value);
然后遍历 保存到 this._binding 对象中对应的键;如下代码:
me._binding[key].map(item => {
console.log(item);
item.update();
});
如上,我们在input输入框输入1的时候,打印item值如下所示:
然后执行 item.update()方法,update方法如下:
class Watcher {
update() {
this.node[this.attr] = this.data[this.key];
}
}
就会更新值到视图里面去,比如input或textarea, 那么 attr = 'value', node 是该元素的节点,key 就是 v-model中的属性值,因此 this.node['value'] = this.data[key];
然后同时代码中如果有 v-bind这样的指令的话,也会和上面的逻辑一样判断和执行;如下 v-bind指令代码如下:
if (node.hasAttribute('v-bind')) {
const key = node.getAttribute('v-bind');
this._pushWatcher(new Watcher(node, 'innerHTML', data, key));
}
然后也会更新到视图里面去,那么 attr = 'innerHTML', node 是该元素的节点,key 也是 v-model中的属性值了,因此 this.node.innerHTML = thid.data['key'];
比如页面中html代码如下:
<div id="app">
<input type="text" v-model='count' />
<input type="button" value="增加" @click="add" />
<input type="button" value="减少" @click="reduce" />
<div v-bind="count"></div>
</div>
实列化代码如下:
new Vue({
el: '#app',
data: {
count: 0
},
methods: {
add() {
this.count++;
},
reduce() {
this.count--;
}
}
});
因此上面的 node 是 <input type="text" v-model='count' /> input中的node节点了,因此 node.value = this.data['count']; 因此 input框的值就更新了,同时 <div v-bind="count"></div> 该节点通过 node.innerHTML = this.data['count'] 这样的话,值也得到了更新了。
6. 对于页面中元素节点带有 @click这样的方法,也有判断,如下代码:
if (node.hasAttribute('@click')) {
const methodName = node.getAttribute('@click');
const method = this.$methods[methodName].bind(data);
node.addEventListener('click', method);
}
如上代码先判断该node是否有该属性,然后获取该属性的值,比如html页面中有 @click="add" 和 @click="reduce" 这样的,当点击的时候,也会调用 this.methods[methodName].bind(data)中对应vue实列中对应的函数的。因此也会执行函数的,其中data就是this.methods[methodName].bind(data)中对应vue实列中对应的函数的。因此也会执行函数的,其中data就是this.data,监听该对象的值发生改变的话,同样会调用 Proxy中的set函数,最后也是一样执行函数去更新视图的。如上就是使用proxy实现数据双向绑定的基本原理的。
vue3中的通过proxy实现双向数据绑定的原理的更多相关文章
- vue实现双向数据绑定的原理
vue实现双向数据绑定的原理就是利用了 Object.defineProperty() 这个方法重新定义了对象获取属性值(get)和设置属性值(set)的操作来实现的. 在MDN上对该方法的说明是:O ...
- vue的双向数据绑定实现原理
在目前的前端面试中,vue的双向数据绑定已经成为了一个非常容易考到的点,即使不能当场写出来,至少也要能说出原理.本篇文章中我将会仿照vue写一个双向数据绑定的实例,名字就叫myVue吧.结合注释,希望 ...
- vue双向数据绑定的原理-object.defineProperty() 用法
有关双向数据绑定的原理 关于数据双向绑定的理解:利用了 Object.defineProperty() 这个方法重新给对象定义了新属性,在操作新属性分别为为获取属性值(调用get方法)和设置属性值(调 ...
- vue的双向数据绑定实现原理(简单)
如果有人问你,学vue学到了什么,那双向数据绑定,是必然要说的. 我们都知道,在vue中,使用数据双向绑定我们都知道是v-modle实现的. 实现原理是通过Object.defineProperty的 ...
- 详解 vue 双向数据绑定的原理,并实现一组双向数据绑定
1:vue 双向数据绑定的原理: Object.defineProperty是ES5新增的一个API,其作用是给对象的属性增加更多的控制Object.defineProperty(obj, prop, ...
- 2-4 Vue中的属性绑定和双向数据绑定
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8&quo ...
- 转 vue实现双向数据绑定之原理及实现篇
转自:https://www.cnblogs.com/canfoo/p/6891868.html vue的双向绑定原理及实现 前言 先上个成果图来吸引各位: 代码: ...
- 第217天:深入理解Angular双向数据绑定的原理
一.理解angular双向数据绑定 双向绑定是新的前端框架中频繁出现的一个新词汇,也是mvvm的核心原理.angularjs五条核心信念中的数据驱动,便是由双向绑定进行完成. 那么什么是双向绑定,下面 ...
- Vue双向数据绑定实现原理
https://zendq1998.github.io/2018/04/12/vue%E5%8F%8C%E5%90%91%E6%95%B0%E6%8D%AE%E7%BB%91%E5%AE%9A%E5% ...
随机推荐
- P2764 最小路径覆盖问题 (最小点覆盖=顶点数-最大匹配)
题意:最小路径覆盖 题解:对于一个有向图,最小点覆盖 = 顶点数 - 最大匹配 这里的最大匹配指的是将原图中每一个点拆成入点.出点, 每条边连接起点的出点和终点的入点 源点S连接每个点的出点,汇点T连 ...
- CodeForces833 B. The Bakery 线段树维护dp
题目链接:https://vjudge.net/problem/CodeForces-833B 题意:给长度为n的数组a,和一个整数k要求把数组分成连续的k段,每段的权值是该段中不同数的个数,输出最大 ...
- CodeForces 893C (并查集板子题)
刷题刷到自闭,写个博客放松一下 题意:n个人,m对朋友,每寻找一个人传播消息需要花费相应的价钱,朋友之间传播消息不需要花钱,问最小的花费 把是朋友的归到一起,求朋友中花钱最少的,将所有最少的加起来. ...
- 要想用活Redis,Lua脚本是绕不过去的坎
前言 Redis 当中提供了许多重要的高级特性,比如发布与订阅,Lua 脚本等.Redis 当中也提供了自增的原子命令,但是假如我们需要同时执行好几个命令的同时又想让这些命令保持原子性,该怎么办呢?这 ...
- ELK + Redis 日志收集 & HAProxy
参考博文:http://www.ttlsa.com/linux/haproxy-log-configuration-syslog/ 引入 Redis 消息队列 Log-file 收集数据到 Redis ...
- python 3.7 利用socket文件传输
参考:https://www.cnblogs.com/VseYoung/p/socket_1.html 参考 https://blog.csdn.net/a19990412/article/detai ...
- LeetCode 算法面试题汇总
LeetCode 算法面试题汇总 算法面试题 https://leetcode-cn.com/problemset/algorithms/ https://leetcode-cn.com/proble ...
- CSS Box Model All In One
CSS Box Model All In One CSS 盒子模型 All In One CSS Box Model CSS Box Model Module Level 3 W3C Working ...
- SQL Tutorials & MySQL & SQL Server
SQL Tutorials SQL MySQL https://www.mysql.com/ $ mysql --version # mysql Ver 8.0.21 for osx10.15 on ...
- Angular Learning Paths
Angular Learning Paths Angular Expert refs https://app.pluralsight.com/search/?q=angular xgqfrms 201 ...