轻量级前端模板

本来说的是轻量级ETemplate的实现,Git地址

说起模板引擎还是得提到jQuery之父John Resig的JavaScript Micro-Templating
之前我这里有文章专门解读Micro-Templating源码
其核心

  1. 标签解析
  2. 属性映射
  3. 函数构建

当然,因为Micro-Templating相当的短小,并没有增强的功能,比如:

  1. 模板嵌套
  2. 函数扩展
  3. 远程加载
  4. 错误捕捉和提示

1. 标签解析

一般情况下都是定义<% %>等类似这种标签,然后标签里面被认为是脚本,这和jsp,asp等是一样的思想。在前端一般是利用正则匹配去实现的。
比如看看下面模板

<script type="text/template" id='list'>
    <h3>账户信息</h3>
    <%if(logined) {%>
        已登陆
    <%} else{%>
        未登陆
    <%}%>
    <p>欢迎来到IT世界</p>
</script>

开始标签为<%,结束标签为 %>,我们先按照开始标签<%拆分字符串,得到如下四组字符串

0:"↵        <h3>账户信息</h3>↵        "
1:"if(logined) {%>↵         已登陆↵        "
2:"} else{%>↵         未登陆↵        "
3:"}%>↵        <p>欢迎来到IT世界</p>↵  

仔细瞅瞅,会发现,基本是两类情况

  1. 文本(html)
  2. 脚本 + %> + 文本(html)
    我们再对子项用结尾标签 %>的话,你会发现第二类会再转为 脚本 + 文本(html),
    这样下来,就直白很多了。 那么我先放下下面的代码短, 其中的parseHTML和parseJS是分别对文本(html)和脚本的处理。
function parse(tpl) {
    let codes = [],
        {
            startTag,
            endTag
        } = config
    // 按照开始标签拆分
    let fragments = tpl.split(startTag);
    for (var i = 0, len = fragments.length; i < len; i++) {
        var fragment = fragments[i].split(endTag);
        // 长度为1为文本
        if (fragment.length === 1) {
            codes.push(parseHTML(fragment[0]))
        } else {
        //长度大于1为2的是 脚本 + endTag + 文本
            codes.push(parseJS(fragment[0]))
            if (fragment[1]) {
                codes.push(parseHTML(fragment[1]))
            }
        }
    }
    return codes.join('')
}

处理后的依旧是字符串,用来动态构建函数。
Micro-Templating本质思想也是这样,只不过正则利用得非常666。

2. 属性映射

属性映射,就是我传入JSON对象或者数组,模板可以获得其属性。比如传入的data如下

{
    name: "Jack",
    age: 18,
    sex: "男"
}

在模板中是如何直接使用name属性的,而不是 ata.name
with
Micro-Templating使用的是with,都说with有性能问题,(-_-)
eval
构建 var = data[p]; 这种语句,然后eval

var data = {
    name: "Jack",
    age: 18,
    sex: "男"
};
var ps = ''
for(var p in data){
    ps += `var ${p} = data['${p}'];` 
}

eval(ps)

new Function()参数传入
这种方法可能比难处理一点

var data = {
    name: "Jack",
    age: 18,
    sex: "男"
}; 
new Function('name','age','console.log(name,age)')(data.name, data.age)

可能上面还是比较抽象,因为这并没有动态化,是的,我这里倒是有一个简单的case,
原理就是利用Object.keys, Object.values获取属性和值的数组,然后通过扩展运算符获取,
具体的逻辑如下, 当你复制这段代码,并执行的时候,会输出"name is :Jack, age is : 8",
这就说明属性被很好的展开了

const encode = function (code) {
    return code.replace(/\r|\n/g, '')
        .replace(/('|")/g, '\\$1')
}

// 获取属性名
const getProperties = function (obj = {}, include = false) {
    return include ? Object.keys(obj).concat('_data_') : Object.keys(obj)
}

// 获取值
const getValues = function (obj = {}, include = false) {
    return include ? Object.values(obj).concat(obj) : Object.values(obj)
}

// 创建动态参数函数
const getFunction = (function () {
    function _getFunction(params, code) {
        params = params.map(c => `'${c}'`).join()
        const funStr = `return new Function(${params}, ${code})`
        return (new Function(funStr))()
    }
    return function getFunction(...args) {
        if (args.length < 0) {
            return null
        }
        const code = args.pop()
        return _getFunction([...args], `'return(\`${encode(code)}\`)'`)
    }
})()

function getParamterNames(d) {
    return getProperties(d, true)
}

function getParamterValues(d) {
    return getValues(d, true)
}

function innerRender(tpl, data) {
    var params = getParamterNames(data)
    var values = getParamterValues(data)
    return getFunction(...params, tpl)(...values)
    }


var code =  'name is :${name}, age is : ${age}', 
data =  {
    name: "Jack",
    age: 18,
    sex: "男"
};
innerRender(code, data)
//name is :Jack, age is : 8

解构 + eval , 本质还是eval

 function spreadProperties(obj, name) {
   return `var {${Object.keys(obj).join(',')}} = ${name};`
}

var data = {
    name: "Jack",
    age: 18,
    sex: "男"
}; 

var ps = spreadProperties(data, 'data')
var code = `eval('${ps}')  ;console.log(name, age, sex)`
var fn  = new Function('data',code)
fn(data)
// Jack 18 男

3. 函数构建

在属性解析的时候已经说到了函数构建,其核心就是参数命令和值的传入,当然你也可以通过arguments来忽略参数命令问题,
比如修改为var ps = spreadProperties(data, 'arguments[0]'),
那么,你并不介意参数名是什么

 function spreadProperties(obj, name) {
   return `var {${Object.keys(obj).join(',')}} = ${name};`
}

var data = {
    name: "Jack",
    age: 18,
    sex: "男"
}; 

var ps = spreadProperties(data, 'arguments[0]')
var code = `eval('${ps}')  ;console.log(name, age, sex)`
var fn  = new Function('data',code)
fn(data)
//name is :Jack, age is : 8

4. 模板嵌套

一种是词法解析,一种是直接函数调用,当然最终肯定都是函数调用。
比如定义了一个函数为 render(data,tplName), 参数data为数据, tpl为模板名字。
那么你在模板上 <% render(address,'address') %>就可以了。
当然为了方便我们调用,render上面可以大动手脚。
比如传入一个template的id值,内部编译和生成模板,然后使用。这么的话你调用可能就是 <% render(address,'#address') %>

<script id='address' type='text/html'>
    <%province%>省<%city%>市  
</script>

5. 函数扩展 && 模板注册

举例来说,我经常输出当前时间,用语句是怎么用?

<span>当前时间:<%   new Date().toString() %> </span>

额,然后呢,当前你也可以在传入数据之前,先处理数据。但是多一种方式,多一种爽感。
所以我们可以类似如下提供registerFun方法,绑定到某个对象上面。在构建函数的时候,传入,你就可以直接使用了。

    xTemplate.registerFun('getNowDate', function(){
        var d = new Date();
        return d.getFullYear() + '年' + (d.getMonth() + 1) + '月' + ....
    })

这里是为了方便模板处理数据,其根本原理还是属性解析。想想就明白了。
模板注册,本质是把字符串编译成了可执行函数,那么模板注册只是函数扩展的一种应用。

6. 远程加载

这个嘛,属于增强。比如

eTemplate.ajaxLoad('/demo/config/tpl/simpleNoTag.html', '/demo/config/data/simple.js', '#result')

// simple.js
{
    "loc":"北京",
    "com":"阿里",
    "title":"开发"
} 

//simpleNoTag.html
<div>   
    <div>${loc}</div>
    <div>${com}</div>
    <div>${title}</div>      
<div>


//结果
北京
阿里
开发

上面的一些交代后,那我说我今天要实现的模板实现原理。

1. 标签解析

基本就是上面说的,除此以外,对文本部分的解析,采用了ES6的字符串传模板,算是增强了。
增加了parseHTML和parseJS的详细代码。
parseHTML: 利用ES6字符串模板,当然最文本中的`符号进行了转义。
parseJS:没有进行任何处理


/* 词法解析:Begin */
function parseHTML(html) {
    const r = html.replace(/\n\r/g, ' ').replace('`', '\\`')
    return !!r ? `codes.push(\`${r}\`);` : ''
}

function parseJS(code) {
    return code
}

function parse(tpl) {
    let codes = [],
        {
            startTag,
            endTag
        } = config
    // 按照开始标签拆分
    let fragments = tpl.split(startTag);
    for (var i = 0, len = fragments.length; i < len; i++) {
        var fragment = fragments[i].split(endTag);
        // 长度为1为文本
        if (fragment.length === 1) {
            codes.push(parseHTML(fragment[0]))
        } else {
            //长度大于1为2的是 脚本 + endTag + 文本
            codes.push(parseJS(fragment[0]))
            if (fragment[1]) {
                codes.push(parseHTML(fragment[1]))
            }
        }
    }
    return codes.join('')
}

2. 属性映射

采用的是 解构 + eval, 核心代码为, 这里把内置的函数或者自己注册的函数展开了。
内置函数builtFnCode,我们是明确知道属性的,每次添加和删除的函数的时候,都会去更新。
但我们并不知道"data"有哪些属性,所以动态展开。这里其实可以去考虑,是不是可以显示的传入属性值数组来提升性能呢?看好你们!

let code = `           
    let codes = [];       
    // 展开内置函数    
    ${builtFnCode};
    // 展开属性
    eval(spreadProperties(__data__,'__data__'));
    // 执行代码
    ${tpl};

    return codes.join('')
`

相关代码

function spreadProperties(obj, name) {
    if (isJSONObject(obj)) {
        return `var {${Object.keys(obj).join(',')}} = ${name};`
    }
    return ''
}
const builtInFunctions = {
    each,
    log,
    spreadProperties,
    encode,
    render
}

let builtFnCode = spreadProperties(builtInFunctions, BUILTINFN)

function refreshBuiltFnCode() {
    builtFnCode = spreadProperties(builtInFunctions, BUILTINFN)
    return builtFnCode
}

function compileRender(tpl) {
    let code = `           
        let codes = [];       
        // 展开内置函数    
        ${builtFnCode};
        // 展开属性
        eval(spreadProperties(__data__,'__data__'));
        // 执行代码
        ${tpl};
    
        return codes.join('')
    `
    try {
        var render = new Function('__data__', BUILTINFN, code);
        return function (data) {
            return render(data, builtInFunctions)
        }
    } catch (e) {
        e.code = `function anonymous(__data__, __builtFn__) {${code}}`
        throw e;
    }
}

3. 函数构建

当然是 new Function
new Function('data', BUILTINFN, code),有两个参数,"data"是数据本身,"BUILTINFN"("builtInFn")是内置和用户自己注册的函数宿主。 因为编译后渲染函数只需要data就能渲染,我们再用高阶函数封装一下。

function compileRender(tpl) {
    let code = `           
        let codes = [];       
        // 展开内置函数    
        ${builtFnCode};
        // 展开属性
        eval(spreadProperties(__data__,'__data__'));
        // 执行代码
        ${tpl};
    
        return codes.join('')
    `
    try {
        var render = new Function('__data__', BUILTINFN, code);
        return function (data) {
            return render(data, builtInFunctions)
        }
    } catch (e) {
        e.code = `function anonymous(__data__, __builtFn__) {${code}}`
        throw e;
    }
}

4. 模板嵌套

采用函数调用形式,不过如开始说的,提供了一些便捷的使用方式。
注册的模板函数,实际上是挂在builtInFunctions内置对象上面的,基于此就有缓存的概念了,不会多次编译渲染函数。
同时提供了getRenderFromStr和getRenderFromId方法,getRenderFromId可以提供一个模板id,内部会获取模板文本再调用getRenderFromStr生成渲染函数,然后缓存起来。
这就让我们可以有两种形式调用子模板。方便也是很重要的

    // 方式一 已注册的模板名
    <div>${render(data, 'renderAddress')}</div>

     // 方式一 模板节点id
    <div>${render(data, '#renderAddress')}</div>

相关代码

function getRenderFromCache(name) {
    const fn = builtInFunctions[name]
    if (name && isFunction(fn)) {
        return fn
    }
    return null
}

function getRenderFromId(name, id) {
    if (!id) {
        id = name
        name = id.slice(1)
    }
    // 检查缓存
    let fn = getRenderFromCache(name)
    if (fn) {
        return fn
    }
    const tpl = doc.querySelector(id).innerHTML
    return getRenderFromStr(name, tpl)
}

function getRenderFromStr(name, tpl) {
    // 只传入name时, name为tpl
    if (!tpl) {
        tpl = name
        name = undefined
    }
    // 检查缓存
    let fn = getRenderFromCache(name)
    if (fn) {
        return fn
    }

    const code = parse(tpl)
    fn = compileRender(code)
    if (name) {
        builtInFunctions[name] = fn
        refreshBuiltFnCode()
    }
    return fn
}

function getRender(idOrName) {
    if (idOrName.indexOf('#') === 0) {
        return getRenderFromId(idOrName)
    } else {
        return getRenderFromCache(idOrName) || getRenderFromStr(idOrName)
    }
}

5. 函数扩展

就是把方法注册到一个内置对象,并维护一些相关变量。
每次注册和取消注册的时候刷新内置函数扩展字符串。
这样做是有注意的地方的,一般情况是注册,不会取消注册。
但是注册函数后,编译了某个模板,然后取消注册,你再想想。

/**
    * 註冊函數
    * @param {函數名,也可以是對象} name 
    * @param {函數} fn 
    */
eTemplate.registerFun = function (name, fn) {
    if (isString(name) && isFunction(fn)) {
        builtInFunctions[name] = fn
    }
    if (isObject(name)) {
        for (let p in name) {
            if (isFunction(name[p])) {
                builtInFunctions[name] = name[p]
            }
        }
    }
    refreshBuiltFnCode()
}

/**
    * 取消注册的函数
    * @param {函数名} name 
    */
eTemplate.unregisterFun = function (name) {
    delete builtInFunctions[name]
    refreshBuiltFnCode()
}

6. 远程加载

虽然叫做ajaxTemplete,实际上内部采用fetch来实现。通过对参数的判断,来实现类似java的重载。
这里有一个小地方有点意思,就是Promise顺序执行的时候,怎么保存中间执行结果。我能想到的是两种思路。
1. Promise返回对象,每次都是在这个对象上添加属性
2. 闭包
因为做了类似重载的实现,那么你调用的时候,可以类似下面的调用

eTemplate.ajaxTemplate('/demo/config/tpl/simpleNoTag.html',function(tpl){ 
    console.log(tpl	)
})
eTemplate.ajaxTemplate( 'demo2', '/demo/config/tpl/simpleNoTag.html',function(tpl){ 
    console.log(tpl	)
})

eTemplate.ajaxLoad('/demo/config/tpl/simpleNoTag.html', '/demo/config/data/simple.js')

eTemplate.ajaxLoad('/demo/config/tpl/simpleNoTag.html', '/demo/config/data/simple.js',function(tpl,d){
    console.log(tpl,d)
})
eTemplate.ajaxLoad('/demo/config/tpl/simpleNoTag.html', '/demo/config/data/simple.js', '#result')
eTemplate.ajaxLoad('/demo/config/tpl/simpleNoTag.html', '/demo/config/data/simple.js', '#result' ,function(tpl,d){
    console.log(tpl,d)
})

相关代码

function loadResource(url, options = {}) {
    if (isJSONObject(url)) {
        url = url.url
        delete options.url
        options = url
    }
    return fetch(url, options).then(res => res.text())
}

/**
    * 
    * @param {模板名字} name 
    * @param {地址} url 
    * @param {回调} cb 
    */
eTemplate.ajaxTemplate = function (name, url, cb) {
    if (!name && !url && !cb) {
        return
    }
    if (!cb && !url) {
        url = name
        name = undefined
        cb = undefined
    }
    if (!cb && isFunction(url)) {
        cb = url
        url = name
        name = undefined
    }
    return loadResource(url).then(function (tpl) {
        if (name) {
            eTemplate.register(name, tpl)
        }
        if (isFunction(cb)) {
            cb(tpl)
        }
        return tpl
    })    
}

eTemplate.ajaxLoad = function (tplOption, dataOption, selector, cb) {
    if (!dataOption || !tplOption) {
        return
    }
    if (!cb && isFunction(selector)) {
        cb = selector
        selector = undefined
    }
    let data, tpl
    loadResource(dataOption) // 加载数据
        .then(function (text) {
            data = JSON.parse(text)
        }) // 转为json
        .then(function () {
            return loadResource(tplOption)
        }) // 加载模板
        .then(function (tplText) {
            tpl = tplText
            return tplText
        })
        .then(function (tplText) {
            if (selector) {
                Array.from(document.querySelectorAll(selector)).forEach(function (el) {
                    el.innerHTML = eTemplate(tpl, data)
                })
            }
            if (isFunction(cb)) {
                cb(tpl, data)
            }
        })
}

7. 错误捕捉和提示

目前处理比较弱,甚至不准确, 高性能JavaScript模板引擎原理解析 有提到方案,但是个人有点芥蒂。

try {
    var render = new Function('__data__', BUILTINFN, code);
    return function (data) {
        return render(data, builtInFunctions)
    }
} catch (e) {
    e.code = `function anonymous(__data__, __builtFn__) {${code}}`
    throw e;
}

完整代码Git地址

最后提一下,字符串拼接性能
一种是数组push然后join
一种是字符串+=
先看一下chrome65 PC下的执行时间。千万(都不会数了)级别相差才明显。
其实吧,简单好用才是好。

for(var c =0 ; c < 10 ; c++){
    console.time('arr_plus')
    var arr = []
    for(var i =0; i<10000000; i++){
        arr.push(Math.random() + '')
    }
    arr.join('')
    console.timeEnd('arr_plus')
}
//arr_plus: 5621.26611328125ms
//arr_plus: 5482.8779296875ms
//arr_plus: 5046.17236328125ms
//arr_plus: 5179.06689453125ms
//arr_plus: 5294.338134765625ms
//arr_plus: 5025.296875ms
//arr_plus: 5032.095947265625ms
//arr_plus: 5027.239990234375ms
//arr_plus: 5024.44873046875ms
//arr_plus: 5040.51611328125ms

另外一种是str+=

for(var c =0 ; c < 10 ; c++){

    console.time('str_plus')
    var str = ''
    for(var i =0; i<10000000; i++){
        str += Math.random() + ''
    }
    console.timeEnd('str_plus')
}
//str_plus: 7578.81787109375ms
//str_plus: 7479.97802734375ms
//str_plus: 7058.68115234375ms
//str_plus: 7150.984130859375ms
//str_plus: 7077.995849609375ms
//str_plus: 7063.9580078125ms
//str_plus: 6778.908203125ms
//str_plus: 7136.634033203125ms
//str_plus: 7117.470947265625ms
//str_plus: 6984.85009765625ms

JavaScript Micro-Templating
JavaScript template engine in just 20 lines
只有20行Javascript代码!手把手教你写一个页面模板引擎
template.js
各种JS模板引擎对比数据(高性能JavaScript模板引擎)
高性能JavaScript模板引擎原理解析

posted @ 2018-03-28 16:43  -云-  阅读(1106)  评论(0编辑  收藏  举报