简单易懂的MVVM实现之指令(四)

我们都知道 html 元素是通过标签属性来控制其效果的,例如videocontrol属性,虽然只是一个简单的值,但是内部会有很复杂的实现:添加了video播放的视频的控制功能。而很多情况下,html 内置的属性无法满足千变万化的需求,于是指令(directive)便应运而生。指令最直观的理解就是扩展 html 属性,从而实现内置属性不支持功能。

生命周期

指令有初始化->更新->销毁三个生命周期。

初始化 bind

bind 发生下模板初始化时,解析到指令后就会将指令进行初始化。

更新 update

当指令绑定的值发生变化时,指令可以接收到更新的值,并进行对应的操作。

销毁 unbind

当模板卸载时,模板上面的指令也会一并销毁,如果指令中存在事件绑定,定时器等,应该在销毁时进行清理。

实现

指令的声明

指令在整个应用的模板上都应该是有效的,所以指令的声明要在全局使用,因此我们选择在 MVVM 对象上暴露一个静态方法全局声明指令。

1
2
3
4
5
class MVVM {
static directive = function(name: string, config: DirectiveConfig) {
directiveConfigMap.set(name, config);
};
}

声明一个foo指令:

1
2
3
4
5
MVVM.directive('foo', {
bind(el: any, binding) {},
update(el: any, binding) {},
unbind(el) {},
});

在多数情况下,指令并不需要在卸载时进行操作,为了方便起见,我们也支持传入一个函数简写,替换bindupdate。在指令bindupdate生命周期触发时,这个函数都会触发。

1
2
MVVM.directive('bar', (el: any, binding) {},
);

代码实现

指令的实现并不复杂,原理和Complier类似,都是解析表达式,然后通过Watcher对数据进行监听,并在数据变动时触发对应的生命周期回调。

构造函数

构造函数接受以下参数

  • owner- MVVM 上下文
  • el - dom 节点
  • exp - 表达式
  • config - 上文中的指令的声明配置
1
2
3
4
5
6
7
8
9
10
class Directive implements IDestroy {
constructor(
owner: IOwner,
el: HTMLElement,
exp: string,
config: DirectiveConfig
) {
// ...
}
}

解析表达式,并监听所有依赖项的更改事件,添加到上下文的$watcher中。并将计算的值作为value传递给指令的update生命周期。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const { expression, dependencies } = parseExpression(exp, 'this.$owner');
const fn = new Function('return ' + expression).bind(this);

this.listener = () => {
const value = fn();
if (this.config.update) {
this.config.update.call(this, el, { value, expression: exp });
}
};

dependencies.forEach(dp => {
this.$owner.$watcher.addListener(dp, this.listener);
});

this.removeListeners = () => {
dependencies.forEach(dp =>
this.$owner.$watcher.removeListener(dp, this.listener)
);
};

在构造函数中触发指令的bind函数。

1
2
3
4
this.config.bind.call(this, el, {
value: fn(),
expression: exp,
});

销毁

实现IDestroy接口,注销在$watcher上的事件监听,并触发unBind生命周期。

1
2
3
4
5
6
destroy() {
if (this.config.unbind) {
this.config.unbind.call(this, this.$el);
}
this.removeListeners();
}

指令前缀

为了区分指令与原生属性,我们需要加入x-作为前缀。

Compiler 中处理指令

Complier一文中,由于还未接触指令,我们并没有处理,现在让我们补全吧。

在属性遍历时,如果需要属性以约定的前缀开头,则认为是指令。

1
2
3
4
5
6
7
8
9
attributes.forEach(attr => {
if (!attr) return;

// 指令
if (attr.name.startsWith(DIRECTIVE_PREFIX)) {
this.initDirective(node, attr);
}
// ...
};

将指令的属性从 html 标签上移除,让标签看起来干净点。从指令配置表中找到这个指令的配置后,进行初始化,并将识别到的指令保存在Compiler的指令集合中,当Complier销毁时,内部的指令也需要一并销毁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private initDirective(node: any, attr: Attr) {
node.removeAttribute(attr.name);
const directiveName = attr.name.replace(
new RegExp('^' + DIRECTIVE_PREFIX),
''
);
const dd = directiveConfigMap.get(directiveName);

if (!dd) {
console.warn('未知的指令:', directiveName);
} else {
const directive = new Directive(this.owner, node, attr.value, dd);
this.directives.push(directive);

return directive;
}
}

优先级与子作用域

有些特殊类型的指令,例如if会控制一个模板块的销毁和重建,因此它的优先级是高于其他指令的。

同时,由于模板块需要重建和销毁,而模板的数据是来自当前 MVVM 作用域的,因此需要创建一个子作用域来控制销毁和重建,而子作用域的数据来自于父作用域。

如果if不销毁模板块,而只是控制 Dom 节点的渲染,那么模板块中的指令和组件可能会出现令人困惑的情况:页面上元素不存在却没有销毁(可能仍然在内存中)。而且这样与show指令功能似乎有点重叠。

通过加入指令的配置项scoped,我们告诉Compiler不要继续解析这个指令所在元素的子元素了。

1
2
3
if (directive && directive.$scoped) {
return false;
}

而子作用域除了实现与作用域的相同接口外,监听了父级所有数据的更改,并触发内部的更改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class ChildScope implements IOwner {
[key: string]: any;
$parent: IOwner;
$watcher: Watcher;
$el: any;
$complier: Compiler;
$parentListener = (n: any, o: any, p: string) => {
this.$watcher.trigger(p, n, o);
};

constructor(el: any, parent: IOwner) {
this.$parent = parent;
this.$el = el;
this.$watcher = new Watcher(this, {});
this.$complier = new Compiler(this);
this.$complier.init();

this.$parent.$watcher.addListener('', this.$parentListener);
}

getValue(path: string) {
const val = getValue(this, path);

if (val === undefined) {
return this.$parent.getValue(path);
}

return val;
}

setData(newData: any) {
this.$parent.setData(newData);
}

getEvent(name: string) {
return this.$parent.getEvent(name);
}

destroy() {
this.$complier.destroy();
this.$watcher.removeAllListeners();
this.$parent.$watcher.removeListener('', this.$parentListener);
}
}

实现常用指令

show

用于控制元素的显隐。

实现非常简单,由于不需要在销毁时做额外处理,所以用简写形式实现。

1
2
3
MVVM.directive('show', (el, binding) => {
el.style.display = binding.value ? '' : 'none';
});

model

用于实现表单元素与数据的绑定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
MVVM.directive('model', {
bind(el: any, binding) {
this.callback = (e: any) => {
const val = e.target.value;
this.$owner.setData({ [binding.expression]: val });
};
el.addEventListener('input', this.callback);
// 省略其他表单类型
el.value = binding.value;
},
update(el: any, binding) {
if (el.value === binding.value) return;
el.value = binding.value;
},
unbind(el) {
el.removeEventListener('input', this.callback);
// 省略其他表单类型
},
});

if

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
MVVM.directive('if', {
scoped: true,
bind(el: HTMLElement, binding) {
const html = el.outerHTML;
this.cEl = document.createComment('-- if block --');
this.el = el;
this.onHide = function() {
this.childScope && this.childScope.destroy();
this.el.replaceWith(this.cEl);
};

this.onShow = function() {
let nEl: any = document.createElement('div');
nEl.innerHTML = html;
nEl = nEl.firstChild;

this.el.replaceWith(nEl);
this.el = nEl;
this.childScope = new ChildScope(this.el, this.$owner);
this.cEl.replaceWith(this.el);
};

if (binding.value === false) {
this.onHide();
} else {
this.onShow();
}
},
update(el: any, binding) {
if (binding.value === false) {
this.onHide();
} else {
this.onShow();
}
},
unbind(el) {
this.onHide();
},
});

使用指令

下面模拟了应用的三步提交过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
<div id="app">
<div x-if="!complete">
<h2><span x-if="step === 1">欢迎注册,</span>第 {{ step }} 步</h2>
<section x-if="step === 1">
姓名:<input x-model="value1" :disabled="submiting" />
</section>
<section x-if="step === 2">
性别:<select x-model="value2" :disabled="submiting">
<option value="">请选择</option>
<option value="1"></option>
<option value="0"></option>
</select>
</section>
<section x-if="step === 3">
公司:<input x-model="value3" :disabled="submiting" />
</section>
<button @click="next">{{ step === 3 ? '提交' : '下一步' }}</button>
</div>

<div x-if="complete">
<h2>您的信息:</h2>
<p>姓名:{{ value1 }}</p>
<p>性别:{{ gender }}</p>
<p>公司:{{ value3 }}</p>
</div>
</div>

<script src="./dist/main.js"></script>

<script>
new MVVM({
el: document.getElementById('app'),
data: {
complete: false,
submiting: false,
step: 1,
value1: '',
value2: '',
value3: '',
},
computed: {
gender: [
'value2',
function() {
switch (this.value2) {
case '1':
return '男';
case '0':
return '女';
}
return '未知';
},
],
},
methods: {
next(e, count) {
this.submiting = true;

setTimeout(() => {
this.submiting = false;

if (this.step === 3) {
this.complete = true;
return;
}
this.step++;
}, 1000);
},
},
});
</script>

在线 demo
源码

下一步

大家会发现一直没有提及一个非常常用的指令 - 循环指令for。因为for指令比较特殊,相对于其他指令更加复杂。所以后续我们将单独实现它。