VUE2双向绑定——数据劫持+订阅发布模式
文章目录
前言
单向绑定非常简单,就是把Model绑定到View,当我们用JavaScript代码更新Model时,View就会自动更新。
有单向绑定,就有双向绑定。如果用户更新了View,Model的数据也自动被更新了,这种情况就是双向绑定。
什么情况下用户可以更新View呢?填写表单就是一个最直接的例子。当用户填写表单时,View的状态就被更新了,如果此时MVVM框架可以自动更新Model的状态,那就相当于我们把Model和View做了双向绑定。
双向绑定在MVVM模式中发挥着及其重要的作用,它将视图与数据绑定起来,让我们得以关注于前后端交互的数据变动,而不必过度费心于页面的刷新上。
MVVM是Model-View-ViewModel的简写。它本质上就是MVC 的改进版。MVVM 就是将其中的View 的状态和行为抽象化,让我们将视图 UI 和业务逻辑分开。
一些关键解释如下。
模型
视图
视图模型
- 视图模型是暴露公共属性和命令的视图的抽象。MVVM没有MVC模式的控制器,也没有MVP模式的presenter,有的是一个绑定器。在视图模型中,绑定器在视图和数据绑定器之间进行通信。
绑定器
- 声明性数据和命令绑定隐含在MVVM模式中。绑定器使开发人员免于*编写样板式逻辑来同步视图模型和视图。在微软的堆之外实现时,声明性数据绑定技术的出现是实现该模式的一个关键因素。
数据变化 -> 视图更新;视图交互变化(input) -> 数据model变更
数据劫持: vue3.0之前的版本是采用数据劫持结合发布者-订阅者模式的方式,通过
Object.defineProperty()
来劫持各个属性的setter
,getter
,在数据变动时发布消息给订阅者,触发相应的监听回调。
接下来,我将基于Object.defineProperty()和观察者模式,一步步实现双向绑定,构建一个简单的MVVM模式的雏形。如果还不了解Object.defineProperty()和观察者模式原理,建议可以先看看我之前的两篇波博文。
-
双向绑定基础原理——Object.defineProperty()的使用 https://blog.csdn.net/qq_41996454/article/details/107988996
-
设计模式之观察者模式——Js实现 https://blog.csdn.net/qq_41996454/article/details/108042475
参考资料
《用ES6的class模仿Vue写一个双向绑定》 https://www.jianshu.com/p/78058c7922bf
《Vue原理解析(八):一起搞明白令人头疼的diff算法》https://blog.csdn.net/u011199186/article/details/103668263
processon在线画图 https://www.processon.com/diagrams
初级版本
实现publisher
这个publisher实现的时候,要注意一下几点:
①监听data(对data中每个属性都使用Object.defineProperty加上setter和getter)
②对所有订阅者发布data的更新消息(调用订阅者的公共接口receiveTips)
那么监听到变化之后就是怎么通知订阅者了,所以接下来我们需要实现一个消息订阅器,很简单,维护一个数组,用来收集订阅者,数据变动触发notify,再调用订阅者的update方法
下面是封装成一个MVVM形式的样子。
let myMvvm = {
$data: {
data01: "test01",
data02: "test02",
data03: "test03",
},
// 指定元素
$el: {}
};
// 对data中每个属性都使用Object.defineProperty加上 setter和getter
myMvvm._initPublisher = function (data = this.$data) {
let that = this;
Object.keys(data).forEach((key) => {
let value = data[key];
// 设置
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function () {
console.log("这是get()方法");
return value;
},
set: function (newVal) {
if (value !== newVal) {
console.log("拦截到数据变化" + value + "---->" + newVal);
value = newVal;
}
},
});
});
console.log(that);
};
myMvvm._initPublisher();
myMvvm.$data.data01 = 1;
console.log(myMvvm.$data.data01);
当然,从简单的方式下入手,我们可以先不用封装成一个MVVM对象的样子。
// 初始化发布者
initPublisher = function (data, subCenter) {
Object.keys(data).forEach((key) => {
let value = data[key];
// 设置
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function () {
console.log("这是get()方法");
return value;
},
set: function (newVal) {
if (value !== newVal) {
console.log("拦截到数据变化" + value + "---->" + newVal);
subCenter.notify(key, value, newVal)
value = newVal;
}
},
});
});
};
let data = {
data01: "test01",
data02: "test02",
data03: "test03",
};
// --------------初始化发布者-------------
initPublisher(data, mySubCenter)
实现消息订阅中心
接下来就是实现一个消息订阅中心,这里可是的代码要注意全文背诵的嗷!
以生活中的例子来举例,这个消息调度中心就相当于一个出版社。
- 出版社下有很多挂名作家,这些作家的作品都在这个出版社的监控范围内(每天不停催更催稿)
- 出版社管理读者订阅退订事务(addSubscriber,removeSubcriber)
- 出版社负责通知读者最新消息
那么大致的一个思路就出来了,如下图。
// 消息订阅中心
let SubscriptionCenter = {
// 订阅者Array
subs: [],
// 添加订阅者
addSubscriber: function(sub){
this.subs.push(sub);
},
// 删除订阅者
removeSubcriber: function(sub){
index = this.subs.indexOf(sub);
if(index >= 1){
this.subs.slice(index, 1);
}
},
// 提示订阅者有新消息,触发receiveTip()这一公共接口
notify: function(){
this.subs.forEach(sub=>{
sub.receiveTip();
})
}
实现Subscriber
- 订阅者Subscriber在初始化的时候需要将自己添加进订阅中心SubscriberCenter中。
- 订阅者Subscriber内部要有一个公共的接收更新消息的接口。(receiveTip方法)
- receiveTip方法中要调用一个更新视图的回调方法。(因为这里涉及到解析attr中的“v-model”, {{}}等情况,这个方法通常由一个专门的compile来实现,在这里我只以简单的更换innerHtml或者value为例)
- 绑定初始化时,要实现添加订阅者Subscriber的操作
那么大致的框架就出来了。
// 绑定时,生成一个订阅者对象
subscriber = {
// 目标dom元素
el: el,
// 接收通知后如果做
receiveTip: function (dataKey, oldVal, newVal) {
console.log("--------------");
console.log(this.el);
console.log("收到更新消息:");
console.log(dataKey + "(" + oldVal + "----->" + newVal + ")");
// 视图如何改变,这里只做简单尝试
callback(this.el, newVal)
console.log("--------------");
},
};
实现绑定函数
接下来我们要实现一个绑定方法,要实现以下几个动作:
- 接收目标dom元素、目标dataObj、订阅的变量名、目标订阅中心、更新视图的回调函数
- 生成一个订阅者对象,并按照订阅的变量名加入到订阅中心的订阅队列里
// 将el与data绑定起来
function doubleBind(el, data, dataKey, subCenter, callback) {
if (!data[dataKey]){
console.log("无法绑定")
return
}
console.log('-------------双向绑定-----------------')
console.log(el)
console.log(dataKey)
console.log('------------------------------')
// 绑定时,生成一个订阅者对象
subscriber = {
// 目标dom元素
el: el,
// 接收通知后如果做
receiveTip: function (dataKey, oldVal, newVal) {
console.log("--------------");
console.log(this.el);
console.log("收到更新消息:");
console.log(dataKey + "(" + oldVal + "----->" + newVal + ")");
// 视图如何改变,这里只做简单尝试
callback(this.el, newVal)
console.log("--------------");
},
};
subCenter.addSubscriber(dataKey, subscriber);
}
到这里,我们已经实现了使用订阅发布模式来实现从el向data的绑定,大致效果如下图。
完整代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>debounce</title>
<style>
.container {
padding-top: 100px;
padding-right: 30px;
padding-left: 30px;
margin-right: auto;
margin-left: auto;
}
</style>
</head>
<body>
<div class="container" id="app">
<input type="text" value="xxx" id="id_input_01" />
<p>当前绑定的data的值:<span id="id_text_01"></span></p>
<input type="text" value="xxx" id="id_input_02" />
</div>
<script>
// 初始化发布者
initPublisher = function (data, subCenter) {
Object.keys(data).forEach((key) => {
let value = data[key];
// 设置
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function () {
console.log("这是get()方法");
return value;
},
set: function (newVal) {
if (value !== newVal) {
console.log("拦截到数据变化" + value + "---->" + newVal);
subCenter.notify(key, value, newVal)
value = newVal;
}
},
});
});
};
// 订阅中心
class SubscriptionCenter {
constructor() {
// 订阅者Array由SubscriptionCenter来维护
this.subsList = [];
}
// 按订阅的变量名添加订阅者
addSubscriber(dataKey, sub) {
if (!this.subsList[dataKey]) {
this.subsList[dataKey] = [];
}
this.subsList[dataKey].push(sub);
}
// 删除订阅者
removeSubcriber(dataKey, sub) {
let subs = this.subsList[targetType];
if (!subs) {
return false;
}
if (!sub) {
subs.length = 0;
}
let index = subs.indexOf(sub);
if (index >= 0) {
this.subs.splice(index, 1);
}
}
// 提示订阅者有新消息,触发receiveTip()这一公共接口
notify(dataKey, oldVal, newVal) {
// 没有订阅就什么都不做
if(!this.subsList[dataKey]){
return
}
this.subsList[dataKey].forEach((sub) => {
sub.receiveTip(dataKey, oldVal, newVal);
});
}
}
// 将el与data双向绑定起来
function doubleBind(el, data, dataKey, subCenter, callback) {
if (!data[dataKey]){
console.log("无法绑定")
return
}
console.log('-------------双向绑定-----------------')
console.log(el)
console.log(dataKey)
console.log('------------------------------')
// 绑定时,生成一个订阅者对象
subscriber = {
// 目标dom元素
el: el,
// 接收通知后如果做
receiveTip: function (dataKey, oldVal, newVal) {
console.log("--------------");
console.log(this.el);
console.log("收到更新消息:");
console.log(dataKey + "(" + oldVal + "----->" + newVal + ")");
// 视图如何改变,这里只做简单尝试
callback(this.el, newVal)
console.log("--------------");
},
};
// 加入订阅者队列
subCenter.addSubscriber(dataKey, subscriber);
// 从dom向data的绑定
el.addEventListener("keyup", function(e){
console.log("------------从dom向data的绑定-------")
console.log(dataKey + "(" + dataKey[dataKey] + "----->" + e.target.value + ")")
data[dataKey] = e.target.value;
console.log("----------------------")
})
}
// -------------------主程序--------------------------------
let eInput01 = document.getElementById("id_input_01");
let eInput02 = document.getElementById("id_input_02");
let eText01 = document.getElementById("id_text_01");
let data = {
data01: "test01",
data02: "test02",
data03: "test03",
};
// ---------------建立订阅中心-------------
mySubCenter = new SubscriptionCenter();
// --------------初始化发布者-------------
initPublisher(data, mySubCenter)
// ---------------自定义回调事件--------------------------------
let input_01_callback = function(el, newVal){
el.value = newVal;
}
let text_01_callback = function(el, newVal){
el.innerText = newVal;
}
// ------------------将el与data双向绑定-----------------
doubleBind(eInput01, data, "data01", mySubCenter, input_01_callback);
doubleBind(eInput02, data, "data01", mySubCenter, input_01_callback);
doubleBind(eText01, data, "data01", mySubCenter, text_01_callback);
</script>
</body>
</html>
运行结果如下。不过这里有个bug,就是从dom向data绑定的这条方向上,会发生按键盘过快导致data丢失而变成undefined;而且每次input.value的值改变而修改value的值时,又会触发一次set()中的发布方法。
进阶
现在我想仿照vue那样的调用形式来调用双向绑定,大概调用方式如下:
- 能使用{{}}来绑定
- 能使用v-model来绑定
- 能使用@click调用方法改变data值
<div id="app">
<input type="text" v-model="text1"><br>
<input type="text" v-model="text2"><br>
<textarea type="text" v-model="text3"></textarea><br>
<button @click="add">加一</button>
<h1>输入的是:{{text1}}+{{text2}}+{{text3}}</h1>
</div>
<script>
let app = new TinyVue({
el: '#app',
data: {
text1: 123,
text2: 456,
text3: '文本框',
},
methods: {
add() {
this.text1 ++
this.text2 ++
}
}
})
</script>
那么,整个框架的运作方式大概如下图。
引入compile,并封装成MVVM
compiler要完成的几个关键动作:
-
解析模板指令,并替换模板数据,初始化视图
-
将模板指令对应的节点绑定对应的更新函数,初始化相应的订阅器
为了解析模板,首先需要获取到dom元素,然后对含有dom元素上含有指令的节点进行处理,因此这个环节需要对dom操作比较频繁,所有可以先建一个fragment片段,将需要解析的dom节点存入fragment片段里再进行处理。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>debounce</title>
<style>
.container {
padding-top: 100px;
padding-right: 30px;
padding-left: 30px;
margin-right: auto;
margin-left: auto;
}
</style>
</head>
<body>
<div id="app">
<h2>{{title}}</h2>
<input v-model="name" />
<h1>{{name}}</h1>
<button v-on:click="clickMe">click me!</button>
</div>
<script>
// -------------------------------订阅中心 Start --------------------------------
function SubCenter() {
this.subs = [];
}
SubCenter.prototype = {
addSub: function (sub) {
this.subs.push(sub);
},
notify: function () {
this.subs.forEach(function (sub) {
console.log("收到更新")
sub.update();
});
},
};
SubCenter.target = null;
// -------------------------------订阅中心 End --------------------------------
// ----------------------发布者 Start-----------------------------
function Publisher(data) {
this.data = data;
this.work(data);
}
Publisher.prototype = {
work: function (data) {
let self = this;
Object.keys(data).forEach(function (key) {
self.defineReactive(data, key, data[key]);
});
},
// 劫持data中每个属性 set get
defineReactive: function (data, key, val) {
let subCenter = new SubCenter();
let childObj = initPublisher(val);
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function getter() {
if (subCenter.target) {
subCenter.addSub(subCenter.target);
}
return val;
},
set: function setter(newVal) {
if (newVal === val) {
return;
}
val = newVal;
// 通知订阅中心, 有新消息了
subCenter.notify();
},
});
},
};
function initPublisher(value, vm) {
if (!value || typeof value !== "object") {
return;
}
return new Publisher(value);
}
// -----------------------------发布者 End-----------------------------
// ---------------------------订阅者 Start--------------------------------
function Subscriber(vm, exp, cb) {
this.cb = cb; // 收到新通知后执行的callback方法
this.vm = vm; // mvvm obj
this.exp = exp; // 期望获得的健值 exp
this.value = this.get(); // 将自己添加到订阅器的操作
}
Subscriber.prototype = {
update: function () {
this.run();
},
run: function () {
let value = this.vm.data[this.exp];
let oldVal = this.value;
if (value !== oldVal) {
this.value = value;
this.cb.call(this.vm, value, oldVal);
}
},
get: function () {
SubCenter.target = this; // 缓存自己
let value = this.vm.data[this.exp]; // 强制执行监听器里的get函数
SubCenter.target = null; // 释放自己
return value;
},
};
// ---------------------------订阅者 End--------------------------------
// ---------------------------编译者 Start--------------------------------
function Compiler(el, vm) {
this.vm = vm;
this.el = document.querySelector(el);
this.fragment = null;
this.init();
}
Compiler.prototype = {
// 初始化视图
init: function () {
if (this.el) {
this.fragment = this.nodeToFragment(this.el);
this.compileElement(this.fragment);
this.el.appendChild(this.fragment);
} else {
console.log("Dom元素不存在");
}
},
//
nodeToFragment: function (el) {
let fragment = document.createDocumentFragment();
let child = el.firstChild;
while (child) {
// 将Dom元素移入fragment中
fragment.appendChild(child);
child = el.firstChild;
}
return fragment;
},
compileElement: function (el) {
let childNodes = el.childNodes;
let self = this;
[].slice.call(childNodes).forEach(function (node) {
let reg = /\{\{(.*)\}\}/;
let text = node.textContent;
if (self.isElementNode(node)) {
self.Compiler(node);
} else if (self.isTextNode(node) && reg.test(text)) {
self.compileText(node, reg.exec(text)[1]);
}
if (node.childNodes && node.childNodes.length) {
self.compileElement(node);
}
});
},
Compiler: function (node) {
let nodeAttrs = node.attributes;
let self = this;
Array.prototype.forEach.call(nodeAttrs, function (attr) {
let attrName = attr.name;
if (self.isDirective(attrName)) {
let exp = attr.value;
let dir = attrName.substring(2);
if (self.isEventDirective(dir)) {
// 事件指令
self.compileEvent(node, self.vm, exp, dir);
} else {
// v-model 指令
self.compileModel(node, self.vm, exp, dir);
}
node.removeAttribute(attrName);
}
});
},
compileText: function (node, exp) {
let self = this;
let initText = this.vm[exp];
this.updateText(node, initText);
new Subscriber(this.vm, exp, function (value) {
self.updateText(node, value);
});
},
compileEvent: function (node, vm, exp, dir) {
let eventType = dir.split(":")[1];
let cb = vm.methods && vm.methods[exp];
if (eventType && cb) {
node.addEventListener(eventType, cb.bind(vm), false);
}
},
compileModel: function (node, vm, exp, dir) {
let self = this;
let val = this.vm[exp];
this.modelUpdater(node, val);
new Subscriber(this.vm, exp, function (value) {
self.modelUpdater(node, value);
});
node.addEventListener("input", function (e) {
let newValue = e.target.value;
if (val === newValue) {
return;
}
self.vm[exp] = newValue;
val = newValue;
});
},
updateText: function (node, value) {
node.textContent = typeof value == "undefined" ? "" : value;
},
modelUpdater: function (node, value, oldValue) {
node.value = typeof value == "undefined" ? "" : value;
},
isDirective: function (attr) {
return attr.indexOf("v-") == 0;
},
isEventDirective: function (dir) {
return dir.indexOf("on:") === 0;
},
isElementNode: function (node) {
return node.nodeType == 1;
},
isTextNode: function (node) {
return node.nodeType == 3;
},
};
// ---------------------------编译者 End--------------------------------
// ---------------------------MVVM Start--------------------------------
function MyMVVM(options) {
let self = this;
this.data = options.data;
this.methods = options.methods;
// 设置别名
Object.keys(this.data).forEach(function (key) {
self.proxyKeys(key);
});
initPublisher(this.data);
new Compiler(options.el, this);
options.mounted.call(this); // 所有事情处理好后执行mounted函数
}
MyMVVM.prototype = {
proxyKeys: function (key) {
let self = this;
Object.defineProperty(this, key, {
enumerable: false,
configurable: true,
get: function getter() {
return self.data[key];
},
set: function setter(newVal) {
self.data[key] = newVal;
},
});
},
};
// ---------------------------MVVM End--------------------------------
testMvvm = new MyMVVM({
el: "#app",
data: {
title: "hello world",
name: "xxxxxxxxx",
},
methods: {
clickMe: function () {
this.title = "hello world";
},
},
mounted: function () {
window.setTimeout(() => {
this.title = "你好";
}, 1000);
},
});
</script>
</body>
</html>
ES6 class语法版本
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>debounce</title>
<style>
.container {
padding-top: 100px;
padding-right: 30px;
padding-left: 30px;
margin-right: auto;
margin-left: auto;
}
</style>
</head>
<body>
<div class="container">
<div id="app">
<input type="text" v-model="text1"><br>
<input type="text" v-model="text2"><br>
<textarea type="text" v-model="text3"></textarea><br>
<button @click="add">加一</button>
<h1>您输入的是:{{text1}}+{{text2}}+{{text3}}</h1>
<select v-model="select">
<option value="volvo">Volvo</option>
<option value="saab">Saab</option>
</select>
<select v-model="select">
<option value="volvo">Volvo</option>
<option value="saab">Saab</option>
</select>
<h1>选择了:{{select}}</h1>
</div>
<script>
class TinyVue{
constructor({el, data, methods}){
this.$data = data
this.$el = document.querySelector(el)
this.$methods = methods
this._compiler()
this._updater()
this._initPublisher()
}
_initPublisher(data = this.$data) {
let that = this
Object.keys(data).forEach(i => {
let value = data[i]
Object.defineProperty(data, i, {
enumerable: true,
configurable: true,
get: function () {
return value;
},
set: function (newVal) {
if (value !== newVal) {
value = newVal;
that._updater()
}
}
})
})
}
_initEvents(el, attr, callBack) {
this.$el.querySelectorAll(el).forEach(i => {
if(i.hasAttribute(attr)) {
let key = i.getAttribute(attr)
callBack(i, key)
}
})
}
_initView(el, attr, callBack) {
this.$el.querySelectorAll(el, attr, callBack).forEach(i => {
if(i.hasAttribute(attr)) {
let key = i.getAttribute(attr),
data = this.$data[key]
callBack(i, key, data)
}
})
}
_updater() {
this._initView('input, textarea', 'v-model', (i, key, data) => {
i.value = data
})
this._initView('select', 'v-model', (i, key, data) => {
i.querySelectorAll('option').forEach(v => {
if(v.value == data) v.setAttribute('selected', true)
else v.removeAttribute('selected')
})
})
let regExpInner = /\{{ *([\w_\-]+) *\}}/g
this.$el.querySelectorAll("*").forEach(i => {
let replaceList = i.innerHTML.match(regExpInner) || (i.hasAttribute('vueID') && i.getAttribute('vueID').match(regExpInner))
if(replaceList) {
if(!i.hasAttribute('vueID')) {
i.setAttribute('vueID', i.innerHTML)
}
i.innerHTML = i.getAttribute('vueID')
replaceList.forEach(v => {
let key = v.slice(2, v.length - 2)
i.innerHTML = i.innerHTML.replace(v, this.$data[key])
})
}
})
}
_compiler() {
this._initEvents('*', '@click', (i, key) => {
i.addEventListener('click', () => this.$methods[key].bind(this.$data)())
})
this._initEvents('input, textarea', 'v-model', (i, key) => {
i.addEventListener('input', () => {
Object.assign(this.$data, {[key]: i.value})
})
})
}
}
let app = new TinyVue({
el: '#app',
data: {
text1: 123,
text2: 456,
text3: '文本框',
select: 'saab'
},
methods: {
add() {
this.text1 ++
this.text2 ++
}
}
})
</script>
</body>
</html>
结语
我在写这篇博文的时候,得知VUE3即将正式发布了,我这才了解到vue3中的双向绑定改用Proxy来实现了。
前后对比如下:
VUE3之前:
对象:会递归得去循环vue得每一个属性,(这也是浪费性能的地方)会给每个属性增加getter和setter,当属性发生变化的时候会更新视图。
数组:重写了数组的方法,当调用数组方法时会触发更新,也会对数组中的每一项进行监控。
缺点:对象只监控自带的属性,新增的属性不监控,也就不生效。若是后续需要这个自带属性,就要再初始化的时候给它一个undefined值,后续再改这个值
数组的索引发生变化或者数组的长度发生变化不会触发实体更新。可以监控引用数组中引用类型值,若是一个普通值并不会监控,例如:[1, 2, {a: 3}] ,只能监控a
VUE3:
Proxy消除了之前 Vue2.x
中基于 Object.defineProperty
的实现所存在的这些限制:无法监听 属性的添加和删除、数组索引和长度的变更,并可以支持 Map
、Set
、WeakMap
和 WeakSet
。
let obj = {
name:{name:'hhh'},
arr: ['吃','喝','玩']
}
//proxy兼容性差 可以代理13种方法 get set
//defineProperty 只对特定 的属性进行拦截
let handler = {
get (target,key) { //target就是obj key就是要取obj里面的哪个属性
console.log('收集依赖')
return target[key]
},
set (target,key,value) {
console.log('触发更新')
target[key] = value
}
}
let proxy = new Proxy(obj,handler)
//通过代理后的对象取值和设置值
proxy.arr
proxy.name = '123'
哈哈…看来又得学起来了,不得不说前端的东西真是又多又杂,太难了QAQ
下次我会争取写一篇有关proxy的介绍笔记。