从零实现一个vue文件解析器

如何从 0 处理一个 vue 文件并实现简单的响应式?

在现在的前端工程化中,打包工具是不可或缺的,其中webpack无疑是占据了主导地位,当然也有尤大搞的vite,但是论生态和使用人数,至少在目前webpack还是更胜一筹。

打包工具能帮助我们打包前端文件,在webpack中,不同后缀的文件通过不同loader来处理。

本文就讨论下怎么实现一个处理.vue文件的loader,以及用loader处理完.vue文件怎么把内容渲染在浏览器上,并实现简单的响应式。

源码地址 gezhicui/vue-webpack

webpack 部分

首先进行 webpack 打包,把.vue 文件通过 vue-loader 处理。

实现一个简易的vue-loader,通过一系列正则,最终一个.vue 文件的内容会被包装到一个对象中

比方说我现在的.vue 文件写了下面这些内容:



那么经过 vue-loader 处理,就会变成一个对象:

{
  template:
   `

{{ count + 1 }}

`, name: 'App', data() { return { count: 0 } }, methods: { plus(num) { this.count += num; }, } }

那么,在浏览器执行这个文件的时候,我们就能通过createApp方法,把这个对象使用 createApp 进行处理,挂载到页面上

createApp 实现部分

在 vue 的main.js文件中,我们通常会把根组件传递给createApp作为入参,如:

import App from './App';
import { createApp } from '../modules/vue';
createApp(App).mount('#app');

那我们实现的重点就在于createAppvue 组件的处理,以及在createApp的返回内容(就是 vm)中添加mount方法,实现处理完的节点的挂载

接下来就一步步实现createApp,首先,我们先来定义一个 vm,一会儿所有的属性都可以放在 vm 上,同时把vue-loader解析过的文件对象中的内容给解构出来

function createApp(component) {
  const vm = {};
  const { template, methods, data } = component;
}

template 解析

在上面经过vur-loader处理后,template以字符串形式被放到对象中,所以我们可以拿到 dom 元素字符串,把他转成 dom 元素

/* 
  template:
   `

{{ count + 1 }}

`, */ vm.$node = createNode(template); function createNode(template) { const _tempNode = document.createElement('div'); _tempNode.innerHTML = template; return getFirstChildNode(_tempNode); }

这样,我们就拿到了 html 接下来就是对 js 的操作

data 响应式处理

vue 的核心就在于响应式,vue2 通过Object.defineProperty实现响应式,我们来实现个简单的响应式处理

首先拿到data,为了创建多个组件时data不被互相影响,所以data是一个函数

vm.$data = data();

for (let key in vm.$data) {
  Object.defineProperty(vm, key, {
    get() {
      return vm.$data[key];
    },
    set(newValue) {
      vm.$data[key] = newValue;
      // update触发节点更新,至于实现我放到后面再说
      update(vm, key);
    },
  });
}

这样,我们就监听了data中每个属性的getset,实现了数据的响应式处理

初始化数据池

在上面的 template 解析中,我们已经拿到了template转换过后的节点,但是有个问题,节点的内容没有经过任何处理,如{{count + 1}}会原封不动的展示在浏览器中,我们希望的是最终展示的是 count 这个变量+1 的结果,所以我们需要对双括号语法进行解析

我们先定义一个正则表达式,匹配{{}}中的内容,以及定义一个节点数据池

// 节点数据池
const exprPool = new Map();
// 正则获取双括号中内容
const regExpr = /\{\{(.+?)\}\}/;

然后,从我们刚刚定义的vm.$node中拿到所有节点,并查看该节点是否有双括号语法,如果有的话存入节点数据池中

const allNodes = $node.querySelectorAll('*');
allNodes.forEach((node) => {
  // 这里获取到的textContent是原原始的没经过任何处理的节点内容,如{{count + 1}}
  const vExpression = node.textContent;
  /* exprMatched:{
      0: "{{ count + 1 }}"
      1: " count + 1 "
      groups: undefined
      index: 0
      input: "{{ count + 1 }}"
    }
    */
  const exprMatched = vExpression.match(regExpr);
  // 如果有双括号语法
  if (exprMatched) {
    const poolInfo = checkExpressionHasData($data, exprMatched[1].trim());
    // 把节点存入节点数据池
    poolInfo && exprPool.set(node, poolInfo);
  }
});

function checkExpressionHasData(data, expression) {
  for (let key in data) {
    if (expression.includes(key) && expression !== key) {
      // count + 1,返回{key:count,expression:count+1}
      return {
        key,
        expression,
      };
    } else if (expression === key) {
      // count,返回{key:count,expression:count}
      return {
        key,
        expression: key,
      };
    } else {
      return null;
    }
  }
}

初始化事件池

处理完双括号语法,我们还需要处理@click这样的事件语法,首先,我们创建一个事件池,再定义两个正则分别匹配函数

const eventPool = new Map();
// 匹配函数名
const regStringFn = /(.+?)\((.+?)\)/;
// 匹配函数参数
const regString = /\'(.+?)\'/;

同样的,我们也需要遍历所有节点

const allNodes = $node.querySelectorAll('*');
allNodes.forEach((node) => {
  const vClickVal = node.getAttribute(`@click`);
  if (vClickVal) {
    /* 
      比如@click='plus(1)',解析完成的fnInfo就是
      fnInfo:{
        args: [1]
        methodName: "plus"
      }
      */
    const fnInfo = checkFunctionHasArgs(vClickVal);
    const handler = fnInfo
      ? //有参函数传入args
        methods[fnInfo.methodName].bind(vm, ...fnInfo.args)
      : //无参函数直接绑定
        methods[vClickVal].bind(vm);

    //存入事件池,节点为key,事件为value
    eventPool.set(node, {
      type: vClick,
      handler,
    });
    //删除dom上的attr,不然浏览器查看源代码就会显示自定义事件  这样不好
    node.removeAttribute(`@${vClick}`);
  }
});

function checkFunctionHasArgs(str) {
  const matched = str.match(regStringFn);

  if (matched) {
    const argArr = matched[2].split(',');
    const args = checkIsString(matched[2])
      ? argArr // ['1']
      : argArr.map((item) => Number(item));

    return {
      methodName: matched[1],
      args,
    };
  }
}
function checkIsString(str) {
  return str.match(regString);
}

这样,我们有拥有了节点数据池和事件池,接下来我们就要拿节点数据池和事件池做操作了

绑定事件处理

有了事件池,我们就要把事件池中的事件绑定到 dom 元素上去,让事件能够触发。这步其实是很容易的,因为我们把 vue 事件加入事件池中时,key 是 dom 元素value 是事件处理函数,只要把他们两个互相绑定就行

function (vm) {
  //node:key  info:value
  for (let [node, info] of eventPool) {
        // type:事件类型  handler:事件处理函数
    let { type, handler } = info;
    //在vue中,是用this.function 去访问方法,所以方法要被绑定到vm上
    vm[handler.name] = handler;
    //给节点绑定事件处理函数
    node.addEventListener(type, vm[handler.name], false);
  }
}

render 页面

执行完上面的内容,我们就到了最后一步 render 页面了,我们只要把节点数据池中的节点内容渲染到浏览器

function render(vm) {
  exprPool.forEach((info, node) => {
    _render(vm, node, info);
  });
}
function _render(vm, node, info) {
  //info:{key: 'count',expression 'count + 1'}
  const { expression } = info;
  //expression是一个字符串,为了执行字符串,所以我们需要new Function
  const r = new Function(
    'vm',
    'node',
    `
    with (vm) {
      node.textContent = ${expression};
    }
  `
  );

  r(vm, node);
}

在这里,我们先解决两个问题

  • with 是干啥用的?
  • 为什么_render 要抽离出来?

首先先来介绍下 with

with 的作用是用来改变标识符的查找优先级,优先从 with 指定对象的属性中查找。e.g:

var a = 1;
var obj = {
  a: 2,
};
with (obj) {
  console.log(a); //2
}

那为什么_render 要单独抽成一个函数? 因为在前面的 data 响应式处理 中,set被触发时,我们需要拿到新的数据值去update页面元素,这时候就也会用到render函数,那就简单实现下上面提到的updata

export function update(vm, key) {
  //在节点数据池中查找哪个节点的key==当前改变的key,找到则重新render
  exprPool.forEach((info, node) => {
    if (info.key === key) {
      _render(vm, node, info);
    }
  });
}

到此为止,就能实现一个完整的不通过任何第三方插件解析 vue 文件,并实现简单的响应式处理了!!

到此这篇关于实现一个vue文件解析器的文章就介绍到这了,更多相关vue文件解析器内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

你可能感兴趣的