JavaScript 是如何工作的:编写自己的 Web 开发框架 + React 及其虚拟 DOM 原理

48次阅读

共计 8331 个字符,预计需要花费 21 分钟才能阅读完成。

这是专门探索 JavaScript 及其所构建的组件的系列文章的第 19 篇。
如果你错过了前面的章节,可以在这里找到它们:

JavaScript 是如何工作的:引擎,运行时和调用堆栈的概述!
JavaScript 是如何工作的:深入 V8 引擎 & 编写优化代码的 5 个技巧!
JavaScript 是如何工作的:内存管理 + 如何处理 4 个常见的内存泄漏!
JavaScript 是如何工作的:事件循环和异步编程的崛起 + 5 种使用 async/await 更好地编码方式!
JavaScript 是如何工作的:深入探索 websocket 和 HTTP/ 2 与 SSE + 如何选择正确的路径!
JavaScript 是如何工作的:与 WebAssembly 比较 及其使用场景!
JavaScript 是如何工作的:Web Workers 的构建块 + 5 个使用他们的场景!
JavaScript 是如何工作的:Service Worker 的生命周期及使用场景!
JavaScript 是如何工作的:Web 推送通知的机制!
JavaScript 是如何工作的:使用 MutationObserver 跟踪 DOM 的变化!
JavaScript 是如何工作的:渲染引擎和优化其性能的技巧!
JavaScript 是如何工作的:深入网络层 + 如何优化性能和安全!
JavaScript 是如何工作的:CSS 和 JS 动画底层原理及如何优化它们的性能!
JavaScript 是如何工作的:解析、抽象语法树(AST)+ 提升编译速度 5 个技巧!
JavaScript 是如何工作的:深入类和继承内部原理 +Babel 和 TypeScript 之间转换!
JavaScript 是如何工作的:存储引擎 + 如何选择合适的存储 API!
JavaScript 是如何工作的:Shadow DOM 的内部结构 + 如何编写独立的组件!
JavaScript 是如何工作的:WebRTC 和对等网络的机制!

响应式原理
Proxy 允许我们创建一个对象的虚拟代理(替代对象),并为我们提供了在访问或修改原始对象时,可以进行拦截的处理方法(handler),如 set()、get() 和 deleteProperty() 等等,这样我们就可以避免很常见的这两种限制(vue 中):

添加新的响应性属性要使用 Vue.$set(),删除现有的响应性属性要使用
数组的更新检测

Proxy
let proxy = new Proxy(target, habdler);

target:用 Proxy 包装的目标对象(可以是数组对象,函数,或者另一个代理)

handler:一个对象,拦截过滤代理操作的函数

实例方法

方法
描述

handler.apply()
拦截 Proxy 实例作为函数调用的操作

handler.construct()
拦截 Proxy 实例作为函数调用的操作

handler.defineProperty()
拦截 Object.defineProperty() 的操作

handler.deleteProperty()
拦截 Proxy 实例删除属性操作

handler.get()
拦截 读取属性的操作

handler.set()
截 属性赋值的操作

handler.getOwnPropertyDescriptor()
拦截 Object.getOwnPropertyDescriptor() 的操作

handler.getPrototypeOf()
拦截 获取原型对象的操作

handler.has()
拦截 属性检索操作

handler.isExtensible()
拦截 Object.isExtensible() 操作

handler.ownKeys()
拦截 Object.getOwnPropertyDescriptor() 的操作

handler.preventExtension()
截 Object().preventExtension() 操作

handler.setPrototypeOf()
拦截 Object.setPrototypeOf()操作

Proxy.revocable()
创建一个可取消的 Proxy 实例

Reflect
Reflect 是一个内置的对象,它提供拦截 JavaScript 操作的方法。这些方法与处理器对象的方法相同。Reflect 不是一个函数对象,因此它是不可构造的。
与大多数全局对象不同,Reflect 没有构造函数。你不能将其与一个 new 运算符一起使用,或者将 Reflect 对象作为一个函数来调用。Reflect 的所有属性和方法都是静态的(就像 Math 对象)。
为什么要设计 Reflect?
1. 更加有用的返回值
早期写法:
try {
Object.defineProperty(target, property, attributes);
// success
} catch (e) {
// failure
}

Reflect 写法:
if (Reflect.defineProperty(target, property, attributes)) {
// success
} else {
// failure
}

2. 函数式操作
早期写法:
‘name’ in Object //true

Reflect 写法:
Reflect.has(Object,’name’) //true

3. 可变参数形式的构造函数
一般写法:
var obj = new F(…args)

Reflect 写法:
var obj = Reflect.construct(F, args)

当然还有很多,大家可以自行到 MND 上查看
什么是代理设计模式
代理模式(Proxy),为其他对象提供一种代理以控制对这个对象的访问。代理模式使得代理对象控制具体对象的引用。代理几乎可以是任何对象:文件,资源,内存中的对象,或者是一些难以复制的东西。现实生活中的一个类比可能是银行账户的访问权限。
例如,你不能直接访问银行帐户余额并根据需要更改值,你必需向拥有此权限的人 (在本例中 你存钱的银行) 询问。
var account = {
balance: 5000
}

var bank = new Proxy(account, {
get: function (target, prop) {
return 9000000;
}
});

console.log(account.balance); // 5,000
console.log(bank.balance); // 9,000,000
console.log(bank.currency); // 9,000,000

在上面的示例中,当使用 bank 对象访问 account 余额时,getter 函数被重写,它总是返回 9,000,000 而不是属性值,即使属性不存在。
var bank = new Proxy(account, {
set: function (target, prop, value) {
// Always set property value to 0
return Reflect.set(target, prop, 0);
}
});

account.balance = 5800;
console.log(account.balance); // 5,800

bank.balance = 5400;
console.log(account.balance); // 0

通过重写 set 函数,可以修改其行为。可以更改要设置的值,更改其他属性,甚至根本不执行任何操作。
响应式
现在已经对代理设计模式的工作方式有了基本心,让就开始编写 JavaScript 框架吧。
为了简单起见,将模拟 AngularJS 语法。声明控制器并将模板元素绑定到控制器属性:
<div ng-controller=”InputController”>
<!– “Hello World!” –>
<input ng-bind=”message”/>
<input ng-bind=”message”/>
</div>

<script type=”javascript”>
function InputController () {
this.message = ‘Hello World!’;
}
angular.controller(‘InputController’, InputController);
</script>

首先,定义一个带有属性的控制器,然后在模板中使用这个控制器。最后,使用 ng-bind 属性启用与元素值的双向绑定。
解析模板并实例化控制器
要使属性绑定,需要获得一个控制器来声明这些属性,因此,有必要定义一个控制器并将其引入框架中。
在控制器声明期间,框架将查找带有 ng-controller 属性的元素。
如果它符合其中一个已声明的控制器,它将创建该控制器的新实例,这个控制器实例只负责这个特定的模板。
var controllers = {};
var addController = function (name, constructor) {
// Store controller constructor
controllers[name] = {
factory: constructor,
instances: []
};

// Look for elements using the controller
var element = document.querySelector(‘[ng-controller=’ + name + ‘]’);
if (!element){
return; // No element uses this controller
}

// Create a new instance and save it
var ctrl = new controllers[name].factory;
controllers[name].instances.push(ctrl);

// Look for bindings…..
};

addController(‘InputController’, InputController);

这是手动处理的控制器变量声明。controllers 对象包含通过调用 addController 在框架内声明的所有控制器。

对于每个控制器,保存一个 factory 函数,以便在需要时实例化一个新控制器,该框架还存储模板中使用的相同控制器的每个新实例。
查找 bind 属性
现在,已经有了控制器的一个实例和使用这个实例的一个模板,下一步是查找具有使用控制器属性的绑定的元素。
var bindings = {};

// Note: element is the dom element using the controller
Array.prototype.slice.call(element.querySelectorAll(‘[ng-bind]’))
.map(function (element) {
var boundValue = element.getAttribute(‘ng-bind’);

if(!bindings[boundValue]) {
bindings[boundValue] = {
boundValue: boundValue,
elements: []
}
}

bindings[boundValue].elements.push(element);
});

上述中,它存储对象的所有绑的值定。该变量包含要与当前值绑定的所有属性和绑定该属性的所有 DOM 元素。

双向绑定
在框架完成了初步工作之后,接下就是有趣的部分: 双向绑定。它涉及到将 controller 属性绑定到 DOM 元素,以便在代码更新属性值时更新 DOM。
另外,不要忘记将 DOM 元素绑定到 controller 属性。这样,当用户更改输入值时,它将更新 controller 属性,接着,它还将更新绑定到此属性的所有其他元素。
使用代理检测代码的更新
如上所述,Vue3 组件中通过封装 proxy 监听响应属性更改。这里仅为控制器添加代理来做同样的事情。
// Note: ctrl is the controller instance
var proxy = new Proxy(ctrl, {
set: function (target, prop, value) {
var bind = bindings[prop];
if(bind) {
// Update each DOM element bound to the property
bind.elements.forEach(function (element) {
element.value = value;
element.setAttribute(‘value’, value);
});
}
return Reflect.set(target, prop, value);
}
});

每当设置绑定属性时,代理将检查绑定到该属性的所有元素,然后用新值更新它们。
在本例中,我们只支持 input 元素绑定,因为只设置了 value 属性。
响应事件
最后要做的是响应用户交互,DOM 元素在检测到值更改时触发事件。
监听这些事件并使用事件的新值更新绑定属性,由于代理,绑定到相同属性的所有其他元素将自动更新。
Object.keys(bindings).forEach(function (boundValue) {
var bind = bindings[boundValue];

// Listen elements event and update proxy property
bind.elements.forEach(function (element) {
element.addEventListener(‘input’, function (event) {
proxy[bind.boundValue] = event.target.value; // Also triggers the proxy setter
});
})
});

React && Virtual DOM
接着将学习了解决如何使用单 个 HTML 文件运行 React,解释这些概念:functional component,函数组件,JSX 和 Virtual DOM。
React 提供了用组件构建代码的方法,收下,创建 watch 组 件。
<!– Skipping all HTML5 boilerplate –>
<script src=”https://unpkg.com/react@16.2.0/umd/react.development.js”></script>
<script src=”https://unpkg.com/react-dom@16.2.0/umd/react-dom.development.js”></script>

<!– For JSX support (with babel) –>
<script src=”https://unpkg.com/babel-standalone@6.24.2/babel.min.js” charset=”utf-8″></script>

<div id=”app”></div> <!– React mounting point–>

<script type=”text/babel”>
class Watch extends React.Component {
render() {
return <div>{this.props.hours}:{this.props.minutes}</div>;
}
}

ReactDOM.render(<Watch hours=”9″ minutes=”15″/>, document.getElementById(‘app’));
</script>

忽略依赖项的 HTML 样板和脚本,剩下的几行就是 React 代码。首先,定义 Watch 组件及其模板,然后挂载 React 到 DOM 中,来渲染 Watch 组件。
向组件中注入数据
我们的 Wacth 组件很简单,它只展示我们传给它的时和分钟。
你可以尝试修改这些属性的值(在 React 中称为 props)。它将最终显示你传给它的内容,即使它不是数字。
const Watch = (props) =>
<div>{props.hours}:{props.minutes}</div>;

ReactDOM.render(<Watch hours=”Hello” minutes=”World”/>, document.getElementById(‘app’));

props 只是通过周围组件传递给组件的数据,组件使用 props 进行业务逻辑和呈现。
但是一旦 props 不属于组件,它们就是不可变的(immutable)。因此,提供 props 的组件是能够更新 props 值的唯一代码。
使用 props 非常简单, 使用组件名称作为标记名称创建 DOM 节点。然后给它以 props 名的属性, 接着通过组件中的 this.props 可以获得传入的值。
那些不带引号的 HTML 呢?
注意到 render 函数返回的不带引号的 HTML,这个使用是 JSX 语法,它是在 React 组件中定义 HTML 模板的简写语法。
// Equivalent to JSX: <Watch hours=”9″ minutes=”15″/>
React.createElement(Watch, {‘hours’: ‘9’, ‘minutes’: ’15’});

现在你可能希望避免使用 JSX 来定义组件的模板,实际上,JSX 看起来像 语法糖。
以下代码片段,分别使用 JSX 和 React 语法以构建相同结果。
// Using JS with React.createElement
React.createElement(‘form’, null,
React.createElement(‘div’, {‘className’: ‘form-group’},
React.createElement(‘label’, {‘htmlFor’: ’email’}, ‘Email address’),
React.createElement(‘input’, {‘type’: ’email’, ‘id’: ’email’, ‘className’: ‘form-control’}),
),
React.createElement(‘button’, {‘type’: ‘submit’, ‘className’: ‘btn btn-primary’}, ‘Submit’)
)

// Using JSX
<form>
<div className=”form-group”>
<label htmlFor=”email”>Email address</label>
<input type=”email” id=”email” className=”form-control”/>
</div>
<button type=”submit” className=”btn btn-primary”>Submit</button>
</form>

进一步探索虚拟 DOM
最后一部分比较复杂,但是很有趣,这将帮助你了解 React 底层的原理。
更新页面上的元素 (DOM 树中的节点) 涉及到使用 DOM API。它将重新绘制页面,但可能很慢(请参阅本文了解原因)。
许多框架,如 React 和 Vue.js 绕过了这个问题,它们提出了一个名为虚拟 DOM 的解决方案。
{
“type”:”div”,
“props”:{“className”:”form-group”},
“children”:[
{
“type”:”label”,
“props”:{“htmlFor”:”email”},
“children”:[“Email address”]
},
{
“type”:”input”,
“props”:{“type”:”email”, “id”:”email”, “className”:”form-control”},
“children”:[]
}
]
}

想法很简单。读取和更新 DOM 树非常昂贵。因此,尽可能少地进行更改并更新尽可能少的节点。
减少对 DOM API 的调用及将 DOM 树结构保存在内存中,由于讨论的是 JavaScript 框架,因此选择 JSON 数据结构比较合理。
这种处理方式会立即展示了虚拟 DOM 中的变化。
此外虚拟 DOM 会先缓存一些更新操作,以便稍后在真正 DOM 上渲染,这个样是为了频繁操作重新渲染造成一些性能问题。
你还记得 React.createElement 吗?实际上,这个函数作用是 (直接调用或通过 JSX 调用) 在 Virtual DOM 中 创建一个新节点。
要应用更新,Virtual DOM 核心功能将发挥作用,即 协调算法,它的工作是提供最优的解决方案来解决以前和当前虚拟 DOM 状态之间的差异。
原文:
https://medium.freecodecamp.o…
https://medium.freecodecamp.o…
代码部署后可能存在的 BUG 没法实时知道,事后为了解决这些 BUG,花了大量的时间进行 log 调试,这边顺便给大家推荐一个好用的 BUG 监控工具 Fundebug。
你的点赞是我持续分享好东西的动力,欢迎点赞!
一个笨笨的码农,我的世界只能终身学习!
更多内容请关注公众号《大迁世界》!

正文完
 0