如何写成Strview.js之源码剖析
前言
前段时间我自己开发了一款Strview.js
,它是一个可以将字符串转换为视图的JS库。什么意思呢?就像下面这段代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Strview.js</title>
</head>
<body>
<div id="app"></div>
<script src="https://cdn.jsdelivr.net/npm/strview@1.9.0/dist/strview.global.js"></script>
<script>
Strview.createView({
el: "#app",
data: {
msg: 'Hello World'
},
template: `<p>{msg}</p>`,
});
</script>
</body>
</html>
显示如下页面:
你会看到页面上显示了一个Hello World字样,而我们看到HTML代码中除了一个ID名是app
标签之外,其他标签并没有,更没有Hello World文本。这时,继续往下看,在JS代码中,我们引入了Strview.js,并且我们调用了它一个createView
方法,最后传入了一个对象。我们在对象中发现了Hello World字符串,并且我们在template
属性中看到它多所对应的值是一个标签,就是这个标签<p>{msg}</p>
,另外,里面我们会看到使用{}
包裹的msg
字符。与data
对象中的msg
属性相对应,正好它的值为Hello World
。我们现在改变下msg
属性对应的值来看下页面是否发生改变。
果然,发生了改变,所以我们知道Strview.js
就是这么将字符串转换为视图的。
这里,我们只是简单介绍了Strview.js
的简单用法,如果想继续了解其他用法的话,可以去Strview.js中文官网:
https://www.maomin.club/site/strviewjs/zh
下面的内容呢,我们将看下Strview.js源码,看它是如何实现的。
剖析源码
本篇分析Strview.js版本为
1.9.0
首先,我们获取到源码,这里我们使用生产环境下的Strview.js,也就是上面实例中的这个地址:
https://cdn.jsdelivr.net/npm/strview@1.9.0/dist/strview.global.js
我们,先大体看下源码,加上空行,源码一共125行。不压缩的话,仅仅4kb。
var Strview = (function (exports) {
'use strict';
// global object
const globalObj = {
_nHtml: [],
_oHtml: [],
_el: null,
_data: null,
_template: null,
_sourceTemplate: null
};
// initialization
function createView(v) {
globalObj._data = v.data;
globalObj._template = v.template;
globalObj._sourceTemplate = v.template;
globalObj._el = v.el;
v.el ? document.querySelector(v.el).insertAdjacentHTML("beforeEnd", render(globalObj._template)) : console.error("Error: Please set el property!");
}
// event listeners
function eventListener(el, event, cb) {
document.querySelector(el).addEventListener(event, cb);
}
// processing simple values
function ref() {
return new Proxy(globalObj._data, {
get: (target, key) => {
return target[key]
},
set: (target, key, newValue) => {
target[key] = newValue;
setTemplate();
return true;
}
})
}
// reactiveHandlers
const reactiveHandlers = {
get: (target, key) => {
if (typeof target[key] === 'object' && target[key] !== null) {
return new Proxy(target[key], reactiveHandlers);
}
return Reflect.get(target, key);
},
set: (target, key, value) => {
Reflect.set(target, key, value);
setTemplate();
return true
}
};
// respond to complex objects
function reactive() {
return new Proxy(globalObj._data, reactiveHandlers)
}
// update the view
function setTemplate() {
const oNode = document.querySelector(globalObj._el);
const nNode = toHtml(render(globalObj._sourceTemplate));
compile(oNode, 'o');
compile(nNode, 'n');
if (globalObj._oHtml.length === globalObj._nHtml.length) {
for (let index = 0; index < globalObj._oHtml.length; index++) {
const element = globalObj._oHtml[index];
element.textContent !== globalObj._nHtml[index].textContent && (element.textContent = globalObj._nHtml[index].textContent);
}
}
}
// judge text node
function isTextNode(node) {
return node.nodeType === 3;
}
// compile DOM
function compile(node, type) {
const childNodesArr = node.childNodes;
for (let index = 0; index < Array.from(childNodesArr).length; index++) {
const item = Array.from(childNodesArr)[index];
if (item.childNodes && item.childNodes.length) {
compile(item, type);
} else if (isTextNode(item) && item.textContent.trim().length !== 0) {
type === 'o' ? globalObj._oHtml.push(item) : globalObj._nHtml.push(item);
}
}
}
// string to DOM
function toHtml(domStr) {
const parser = new DOMParser();
return parser.parseFromString(domStr, "text/html");
}
// template engine
function render(template) {
const reg = /\{(.+?)\}/;
if (reg.test(template)) {
const key = reg.exec(template)[1];
if (globalObj._data.hasOwnProperty(key)) {
template = template.replace(reg, globalObj._data[key]);
} else {
template = template.replace(reg, eval(`globalObj._data.${key}`));
}
return render(template)
}
return template;
}
// exports
exports.createView = createView;
exports.eventListener = eventListener;
exports.reactive = reactive;
exports.ref = ref;
Object.defineProperty(exports, '__esModule', { value: true });
return exports;
}({}));
首先,我们会看到最外层定义了一个Strview
变量,暴露在外面,并将一个立即执行函数(IIFE)赋予这个变量。
我们先来看下这个立即执行函数。
var Strview = (function (exports) {
// ...
}({}));
函数中需要传一个形参exports
,并且又立即传入一个空对象。
然后,我们来看下函数内的内容。
我们会看到函数中有很多变量与函数方法,那么我们就按功能来分析。
首先,我们看到了一个全局对象,全局对象中分别定义了几个属性。这样做是为了减少全局变量污染,JS可以随意定义保存所有应用资源的全局变量,但全局变量可以削弱程序灵活性,增大了模块之间的耦合性。最小化使用全局变量的一个方法是在你的应用中只创建唯一一个全局变量。
// global object
const globalObj = {
_nHtml: [], // 存放新DOM数组
_oHtml: [], // 存放旧DOM数组
_el: null, // 挂载DOM节点
_data: null, // 存放数据
_template: null, // 模板字符串
_sourceTemplate: null // 源模板字符串
};
然后,我们接着看初始化阶段,这个阶段是将模板字符串转换成视图。
// initialization
function createView(v) {
globalObj._data = v.data;
globalObj._template = v.template;
globalObj._sourceTemplate = v.template;
globalObj._el = v.el;
v.el ? document.querySelector(v.el).insertAdjacentHTML("beforeEnd", render(globalObj._template)) : console.error("Error: Please set el property!");
}
我们看到这个createView
方法传入了一个参数,也就是我们之前传入的那个对象:
Strview.createView({
el: "#app",
data: {
msg: 'Hello World'
},
template: `<p>{msg}</p>`,
});
我们看到传入的对象中的属性分别赋给全局对象globalObj
。在最后一行中通过判断v.el
是否是真值,如果是就执行这行代码:
document.querySelector(v.el).insertAdjacentHTML("beforeEnd", render(globalObj._template))
这行代码执行了insertAdjacentHTML()
方法,这个方法在MDN上是这样解释它的。
insertAdjacentHTML() 方法将指定的文本解析为 Element 元素,并将结果节点插入到DOM树中的指定位置。它不会重新解析它正在使用的元素,因此它不会破坏元素内的现有元素。这避免了额外的序列化步骤,使其比直接使用innerHTML操作更快。
insertAdjacentHTML()
方法传入的第二个参数是是要被解析为HTML或XML元素,并插入到DOM树中的DOMString
,render(globalObj._template)
这个方法就是返回的DOMString
。
如果是假,就执行console.error("Error: Please set el property!")
,在浏览器上输出错误。
既然这个用到了render(globalObj._template)
这个方法,那么我们下面来看下。
// template engine
function render(template) {
const reg = /\{(.+?)\}/;
if (reg.test(template)) {
const key = reg.exec(template)[1];
if (globalObj._data.hasOwnProperty(key)) {
template = template.replace(reg, globalObj._data[key]);
} else {
template = template.replace(reg, eval(`globalObj._data.${key}`));
}
return render(template)
}
return template;
}
首先,这个render(template)
方法传入了一个参数,第一个参数是模板字符串。
然后,我们进入这个方法中看一下,首先,我们定义了正则/\{(.+?)\}/
,用于匹配模板字符串中的{}
中的内容。如果匹配为真,就进入这个逻辑:
const key = reg.exec(template)[1];
if (globalObj._data.hasOwnProperty(key)) {
template = template.replace(reg, globalObj._data[key]);
} else {
template = template.replace(reg, eval(`globalObj._data.${key}`));
}
return render(template)
我们在第一行代码中看到了这行代码const key = reg.exec(template)[1]
,这里使用的是reg.exec()
方法,MDN这样解释它:
exec() 方法在一个指定字符串中执行一个搜索匹配。返回一个结果数组或 null。在设置了 global 或 sticky 标志位的情况下(如 /foo/g or /foo/y),JavaScript RegExp 对象是有状态的。他们会将上次成功匹配后的位置记录在 lastIndex 属性中。使用此特性,exec() 可用来对单个字符串中的多次匹配结果进行逐条的遍历(包括捕获到的匹配),而相比之下, String.prototype.match() 只会返回匹配到的结果。
所以,通过这个方法我们取到了模板字符串中的{}
中的内容,它一般是我们存取数据_data
中的属性。首先,我们判断globalObj._data
对象中是否有这个key
,如果有我们就使用字符串替换方法replace
来把对应的占位符key替换成所对应的值。下面接着进行递归,直到reg.test(template)
返回为false
。最终,render()
方法返回处理后的template
。
看完render()
方法,我们来看下事件处理阶段,也就是eventListener()
方法。
// event listeners
function eventListener(el, event, cb) {
document.querySelector(el).addEventListener(event, cb);
}
这个方法很简单,第一个参数传入DOM选择器,第二个参数传入一个事件名,第三个参数传入一个回调函数。
最后,我们来看下Strview.js
的数据响应系统。
// processing simple values
function ref() {
return new Proxy(globalObj._data, {
get: (target, key) => {
return target[key]
},
set: (target, key, newValue) => {
target[key] = newValue;
setTemplate();
return true;
}
})
}
// reactiveHandlers
const reactiveHandlers = {
get: (target, key) => {
if (typeof target[key] === 'object' && target[key] !== null) {
return new Proxy(target[key], reactiveHandlers);
}
return Reflect.get(target, key);
},
set: (target, key, value) => {
Reflect.set(target, key, value);
setTemplate();
return true
}
};
// respond to complex objects
function reactive() {
return new Proxy(globalObj._data, reactiveHandlers)
}
上面这些代码主要是reactive()
、ref()
这两个方法的实现。reactive()
方法是针对复杂数据的处理,比如嵌套对象以及数组。ref()
方法主要是针对简单数据的处理,像是原始值与单一非嵌套对象。
它们两个都是基于Proxy
代理来实现数据的拦截与响应,MDN中这样定义它。
Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。
它们两个Proxy对象第一个参数都是我们在初始化定义的globalObj._data
,第二个参数是一个通常以函数作为属性的对象。这里都定义了get()
方法、set()
方法,get()
是属性读取操作的捕捉器,set()
是属性设置操作的捕捉器。
reactive()
、ref()
这两个方法实现不一样的地方是reactive()
方法加上了对嵌套对象判断来实现递归。
我们在set()
方法中看到它们都调用了setTemplate()
方法,下面,我们来看下这个方法。
// update the view
function setTemplate() {
const oNode = document.querySelector(globalObj._el);
const nNode = toHtml(render(globalObj._sourceTemplate));
compile(oNode, 'o');
compile(nNode, 'n');
if (globalObj._oHtml.length === globalObj._nHtml.length) {
for (let index = 0; index < globalObj._oHtml.length; index++) {
const element = globalObj._oHtml[index];
element.textContent !== globalObj._nHtml[index].textContent && (element.textContent = globalObj._nHtml[index].textContent);
}
}
}
首先,我们取到初始化时挂载的DOM节点,接着我们使用toHtml()
方法将render(globalObj._sourceTemplate)
方法作为第一个参数传入。
我们先来看下toHtml()
方法,这里的第一个参数domStr
,也就是render(globalObj._sourceTemplate)
。
// string to DOM
function toHtml(domStr) {
const parser = new DOMParser();
return parser.parseFromString(domStr, "text/html");
}
toHtml()
方法第一行我们实例化了一个DOMParser
对象。一旦建立了一个解析对象以后,你就可以使用它的parseFromString
方法来解析一个html字符串。
然后,我们回到setTemplate()
方法中,变量nNode
被赋值了toHtml(render(globalObj._sourceTemplate))
,这里是被处理成一个DOM对象。
接着,执行compile()
方法。
compile(oNode, 'o');
compile(nNode, 'n');
我们来看下这个compile()
方法。
// compile DOM
function compile(node, type) {
const childNodesArr = node.childNodes;
for (let index = 0; index < Array.from(childNodesArr).length; index++) {
const item = Array.from(childNodesArr)[index];
if (item.childNodes && item.childNodes.length) {
compile(item, type);
} else if (isTextNode(item) && item.textContent.trim().length !== 0) {
type === 'o' ? globalObj._oHtml.push(item) : globalObj._nHtml.push(item);
}
}
}
这个方法是将遍历DOM元素并把每一项存储到我们初始化定义的数组里面,分别是globalObj._oHtml
和globalObj._nHtml
,这个方法中用到了isTextNode()
方法。
// judge text node
function isTextNode(node) {
return node.nodeType === 3;
}
这个方法第一个参数是一个Node节点,如果它的nodeType
属性等于3
就说明这个节点是文本节点。
最后,我们又回到setTemplate()
方法中,接着执行以下代码:
if (globalObj._oHtml.length === globalObj._nHtml.length) {
for (let index = 0; index < globalObj._oHtml.length; index++) {
const element = globalObj._oHtml[index];
element.textContent !== globalObj._nHtml[index].textContent && (element.textContent = globalObj._nHtml[index].textContent);
}
}
判断两个数组的长度是否一样,如果一样就遍历globalObj._oHtml
,最后判断globalObj._nHtml[index].textContent
是否等于globalObj._oHtml[index].textContent
,如果不相等,直接将globalObj._nHtml[index].textContent
赋于globalObj._OHtml[index].textContent
,完成更新。
最后,将这几个定义的方法赋于传入的exports
对象并返回这个对象。
// exports
exports.createView = createView;
exports.eventListener = eventListener;
exports.reactive = reactive;
exports.ref = ref;
Object.defineProperty(exports, '__esModule', { value: true });
return exports;
这里,有一行代码Object.defineProperty(exports, '__esModule', { value: true })
,这行代码其实也可以这么写exports.__esModule = true
。表面上看就是把一个导出对象标识为一个 ES 模块。
随着 JS 不断发展和 Node.js 的出现,JS 慢慢有了模块化方案。在 ES6 之前,最有名的就是 CommonJS / AMD,AMD 就不提了现在基本不用。CommonJS 被 Node.js 采用至今,与 ES 模块共存。由于 Node.js 早期模块化方案选择了 CommonJS,导致现在 NPM 上仍然存在大量的 CommonJS 模块,JS 圈子一时半会儿是丢不掉 CommonJS 了。
Webpack 实现了一套 CommonJS 模块化方案,支持打包 CommonJS 模块,同时也支持打包 ES 模块。但是两种模块格式混用的时候问题就来了,ES 模块和 CommonJS 模块并不完全兼容,CommonJS 的 module.exports 在 ES 模块中没有对应的表达方式,和默认导出 export default 是不一样的。
而__esModule
则是用来兼容 ES 模块导入 CommonJS 模块默认导出方案。
结语
至此,Strview.js
的源码分析完毕。谢谢阅读~
开发版本
推荐使用StrviewCLI
搭建StrviewApp
项目脚手架。
https://github.com/maomincoding/strview-app
生产版本
直接引入CDN链接,目前版本为1.9.0
。
https://cdn.jsdelivr.net/npm/strview@1.9.0/dist/strview.global.js
关于作者
作者:Vam的金豆之路。
目前专注于研究前端技术,活跃在多个技术社区。曾获得2019年CSDN年度博客之星,CSDN博客访问量已达到数百万。掘金博客文章多次推送到首页,总访问量已达到数十万。
另外,我的公众号:前端历劫之路
,公众号持续更新最新前端技术及相关技术文章。
欢迎关注,让我们一起在前端道路上历劫吧!
点一下,邀您进去前端技术群