第二篇-仿写Vue生态系列模板小故事

29次阅读

共计 7310 个字符,预计需要花费 19 分钟才能阅读完成。

(第二篇)仿写 ’Vue 生态 ’ 系列___’ 模板小故事.’

本次任务

  1. 承上: 完成第一篇未完成的 ’ 热更新 ’ 配置.
  2. 核心: 完成 ’ 模板解析 ’ 模块的相关编写, 很多文章对模板的解析阐述的都太浅了, 本次我们一起来深入讨论一下, 尽可能多的识别用户的语句.
  3. 启下: 在结构上为 ’ 双向绑定 ’、watch、dep 等模块的编写打基础.

最终效果图

一. 模板页面

我们既然要开发一个 mvvm, 那当然要模拟真实的使用场景, 相关的文件我们放在:’cc_vue/use’ 路径下, 代码如下:

  1. ‘cc_vue/use/1: 模板解析 /index.html’, 本篇专门用来展示模板解析的页面
  2. ‘cc_vue/use/1: 模板解析 /index.js’, 本篇专门用来展示模板解析的逻辑代码

本来要展示 html 文件的信息, 但是内容冗长而且没有什么技术可言, 所以不在此展示了.


function init(){
  new C({
    el: '#app',
    data: {
      title: '努力学习',
      ary: [1, 2, 3],
      obj: {
        name: '金毛',
        type: ['幼年期', '成熟期', '完全体']
      },
      fn() {return '大家好我是:' + this.obj.name;}
    }
  });
}

export default init;

一. 配置文件与简易的热更新

之所以说它是简易的, 原因是我们并不会去做到很细致, 比如本次不会去追求每一次的精准更新, 而是每一次都会对整体采取更新, 毕竟本次工程热更新只是一个知识点, 我们还有很多很多更重要的事要做 emmmm

一些自己的观点
热更新并不是算很神奇, 我之前配置过 vuex 的热更新相关, 后来总结了一下, 它与回调函数概念差不多, 原理就是当编辑器, 或者是 serve 检测到你的文件有相应变化的时候, 执行一个回调函数, 这个回调函数里面就是一些重新渲染, 更新 dom 等等的操作, 你可能会有疑问, vue 的热更新做的那么好, 也没看见有什么热更新的回调函数啊, 其实这都归功于 ’vue-loader’, css 热更新考的是 css-loader, 他们在处理文件的阶段就把热更新的回调代码注入了 js 文件里面, 所以我们才会是无感的, 所以没有 ’loader’ 帮助我们注入热更新, 那本次我们就自己手动实现.????

有兴趣的同学可以去看看官网的教程, 有点短小

配置文件拆分为生产环境与开发环境(虽然咱们用不上生产, 但是用于学习还挺好的);

  1. build— 生产打包
  2. common— 公共打包
  3. dev— 开发打包

common.js

const dev = require('./dev');
const path = require('path');
const build = require('./build');
const merge = require('webpack-merge');

const common = {
  entry: {main: './src/index.js'},
  output: {filename: '[name].js',
    path: path.resolve(__dirname, '../dist')
  },
  module: {
    rules: [
      {
        test: /.css$/,
        use: ['style-loader', 'css-loader', 'postcss-loader']
      },
      {
        test: /.js$/,
        exclude: /node_modules/,
        loader: 'babel-loader?cacheDirectory=true'
      }
    ]
  }
};
module.exports = env => {
  let config = env == 'dev' ? dev : build;
  return merge(common, config);
};

死磕知识点之 merge
配置过 webpack 的同学都知道, merge 算是个灵魂人物了, 基本上每个工程多会有多种类的打包, 配置文件更是不计其数, 想要把这帮配置整合起来并非易事, 那我们就来看看 webpack-merge 的效果吧.

npm i webpack-merge -D

我们进行如下的实验

let obj1 = {
    a: 1,
    b: [1],
    c: {name: 1}
  };
  let obj2 = {
    a: 2,
    b: [2],
    c: {age: 2}
  };
merge(obj1, obj2)

  结果为:{ 
    a: 2, 
    b: [1, 2], 
    c: {name: 1, age: 2}
     };
  1. 策略上, 后面的覆盖前面的.
  2. 遇到普通值, 直接覆盖.
  3. 遇到数组, 会使用 push().
  4. 遇到对象, 会选择覆盖原有属性, 若无原有属性则新增.
  5. 其实挺有趣的, 这个方法我可以把它用在其他地方, 不是局限在 webpack 配置里面, 活学活用最开心????.

解释一下本次的用法

// 导出一个函数, 只有函数才可以接收传过来的参数.
module.exports = env => {
   // 直接判断参数的类型, 来决定用那一套配置;
  let config = env == 'dev' ? dev : build;
   // 把配置与公共基础配置融合, 导出去
  return merge(common, config);
};

启动的命令的调整

  1. –hot 启动热更新, 配文件里面写了也可以不加这句.
  2. –env dev 为配置传入参数 字符串 ’dev’, 这里必须写作 –env.
  3. –config ./config/common.js 指定需要调用的配置文件是 ./config/common.js 而不是 webpack.config.js.
"serve": "webpack-dev-server --hot --env dev --config ./config/common.js",

热更新的使用
cc_vue/config/dev.js

const path = require('path');
const Webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
  mode: 'development',
  devtool: 'source-map',
  devServer: {
    port: 9999,
    hot: true, // 这里一定要开启
   // hot 和 hotOnly 的区别是在某些模块不支持热更新的情况下,前者会自动刷新页面,后者不会刷新页面,而是在控制台输出热更新失败
  },
  plugins: [
    // 相关的插件也需要载入
    new Webpack.HotModuleReplacementPlugin(),
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: path.resolve(__dirname, '../use/1: 模板解析 /index.html'),
    })
  ]
};

接下来写代码, 我们就会把热更新模块用起来

二. 编写核心 C 类

cc_vue/src/index.js

import '../public/css/index.css';
import Compiler from './Compiler.js';
// 其实这个类的作用非常单一
// vue 的源码里面这个类只是判断了 用户是否用 new, 还有就是调用 init
class C {constructor(options) {
   // 这里我们不管用户传的是什么, 直接全部挂在到自身, 添加 $ 符号表示.
    for (let key in options) {this['$' + key] = options[key];
    }
    // 我们这里实例化了模板的类, 
    // 也就是本次的主题, 模板相关解析
    new Compiler(this.$el, this);
  }
}
// 比较传统的写法, 把它挂在全局, 其实所有操作, 只暴露了这一个变量给用户;
window.C = C;

引入热更新, 只是个例子而已, 本次不会这样使用

import init from '../use/1: 模板解析 /index';
...
window.C = C;
// 执行初始化, 代码上面已经粘过了.
init();
// 当存在热更新的时候
if (module.hot) {
  // 监听这个文件的变化
  module.hot.accept('../use/1: 模板解析 /index.js', function() {
   // 变化之后做什么
    init();});
}

为什么本次不使用
原因就是, 工程刚刚起步, 还没有更新方法, 就算检测到文件变化也没有用, 比如用户输入 {{a}}, 他已经被转为了 a 对应的变量, 这个时候想要更新这个值需要触发他自身的 updater 方法, 但是这个方法咱们还没写, 所以本次用不上, 直接用刷新更新就可以了, 以后会去做特定某一模块的更新操作.

三. 模板解析模块

cc_vue/src/Compiler.js

// 解析模板系列节目
class Compiler {
  // 在你没有给 #app 的时候, 我来给你一个默认的 #app
  // 因为本次工程不涉及, '延迟挂载' 所以可以这样写
  // vue 会涉及到延迟挂载
  constructor(el = '#app', vm) {
    this.vm = vm;
    // 1: 如果传的是 dom 就直接用
    //    是字符串就获取一下
    this.el = this.isElementNode(el) ? el : document.querySelector(el);
    // 2: 制作文档碎片
    let fragment = this.node2fragment(this.el);
    // 3: 解析元素, 文档流也是对象
    //    compile 解析的核心代码, 这个逻辑下面讲
    this.compile(fragment);

    // 最后一步: 处理完再塞回去
    this.el.appendChild(fragment);
  }
  /**
   * @method 判断是不是元素节点
   * @param {node} 要判断的节点
   * @return {boolean} 是否为元素节点, 元素节点 1
   */
  isElementNode(node) {return node.nodeType === 1;}
  /**
   * @method 判断是不是文本节点
   * @param {node} 要判断的节点
   * @return {boolean} 是否为标签节点, 文本节点为 3
   */
  isTextNode(node) {return node.nodeType === 3;}
  /**
   * @method 把节点全部放入文档流里面
   * @param {node} 想要遍历的节点对象
   * @return {fragment} 返回生成的文档流
   */
  node2fragment(node) {
// 创建文档流
    let fragment = document.createDocumentFragment();
    while (node.firstChild) {
    // 插入文档流
      fragment.appendChild(node.firstChild);
    }
    return fragment;
  }
}

export default Compiler;

compile 这个方法最重要, 我们下面讲

死磕知识点

  1. 为啥管实例叫做 ’vm’, 摘自 vue 官网(vue 虽然没有完全遵循 MVVM 模型,但是 Vue 的设计也受到了它的启发。因此在文档中经常会使用 vm (ViewModel 的缩写) 这个变量名表示 Vue 实例)
  2. nodeType, 每一个节点都有对应的类型,
    // 元素节点,1
    // 属性节点,2
    // 文本节点, 3
    // 注释节点, 8 这个也很有用的
    // document, 9
    // DocumentFragment 11
  3. createDocumentFragment() 方法, 我一般管它叫做创建文档流碎片, 众所周知操作 dom 的代价高昂, 而如果你需要插入 10000 个 dom, 那就真的是 gg 了, 但是有了 fragment 问题就好解决了, 他是虚拟的, 但是可以像元素一样操作, 所以可以把元素先放在他里面, 然后把它放到目标父级里面就可以了, 这样只用插入一次, 他也不会形成元素在页面上.
  4. appendChild 与 append
    appendChild 在 Node.prototype 上
    append 在 Document.prototype 上
    都表示, 添加到元素的最后一位
    都不支持 传入字符串生成标签, 比如 '<li>1</li>’,
    append()可以同时传入多个节点或字符串,没有返回值;
    appendChild()只能传一个节点,且传字符串会报错; 除非传入 document.createTextElement(‘ 字符串 ’)

四. 处理花括号

vue 处理的挺复杂的, 本次练习我更希望锻炼自己的思维, 所以选择自己的方式来做;

...
 compile(node) {
    let childNodes = node.childNodes;
    [...childNodes].map(child => {if (this.isElementNode(child)) {
      // 元素节点处理指令方面
        this.compileElement(child);
        this.compile(child);
      } else if (this.isTextNode(child)) {// 文本节点处理 {{}} 这种事情
        this.compileText(child);
      }
    });
  }

我们本篇只针对文本节点的处理, 接下来是的 compileText 思路

  /**
   * @method 处理文本节点
   * @param {node} 想要遍历的节点对象
   */
  compileText(node) {
    let content = node.textContent;
    // 有花括号才会去处理, 没有就算了
    if (/\{\{.+?\}\}/.test(content)) {CompileUtil.text(node, content, this.vm);
    }
  }

由于工具类会很多, 所以我们单开了一个文件
cc_vue/src/CompileUtil.js
CompileUtil.text

const CompileUtil = {text(node, expr, vm) {let content = expr.replace(/\{\{(.+?)\}\}/g, ($0, $1) => {
      // 把匹配出的每一个花括号内的内容, 都交给这个函数去取值;
      return this.getVal(vm, $1);
    });
    this.updater.textUpdater(node, content);
  }
//...
  updater: {textUpdater(node, value) {node.textContent = value;}
  }
};

export default CompileUtil;

五. 取得对应值(最有趣的就是他????)

getVal 这个函数才是最有趣的, 我看到过很多版本, 最后会介绍我研究出的版本 … 各位见笑了

思路一, 网上最常见的(只处理一种情况, 只处理一层 ????‍♀️纯糊弄人性质等同诈骗)
只能处理 {{a.b}} 或者是 {{a}}

// 是个例子
getVal(){
    let str = 'a.c';
    let data ={a:{ c:2}
       }
    // 单纯的按照 '.' 拆分
    str = str.split('.');
    let result = data;
        str.map(item=>{
         // 每次取一下值
            result = result[item]
    })
    return result
}

上面的方法一看就是没经过思考, 随便应付了事 ….
无法取得 a[‘b’]这种值, 如果里面出现运算 a[‘b’]+a[‘c’] 更别说遇到函数了 …
看了很多文章, 我又看到了思路 2 的做法

思路二, 处理两种取值方式, 分开处理运算符号

getVal(){// 挺繁琐的我说一下思路吧, 毕竟不是我写的}
  1. 用指针的方式, 把字符串的每个字符逐一排查.
  2. 遇到 ‘[‘ 则把它单独拿出来, 进行 ’ 思路一 ’ 的操作, 直到遇到 ‘]’.
  3. 遇到数字要做特殊处理, 比如 a[1], 不可能处理成 a[data.1].
  4. 遇到 ”’ 或者 '”‘ 要做特殊处理 a[‘b’] 这个 b 不可处理为 data.b.
  5. 反复重复上面的操作, 把原字符串转换为 ’.’ 链接的形式, 进行思路一的方式的取值.

不知道怎么想的 … 一看就不是正道,
代码过于繁琐, 判断太多, 但是没还有覆盖全所有的情况
属于是铁憨憨写法, 但是至少能看出来, 这种做法的人动脑子了.

思路三, eval

其实我最开始想用 with 关键字来实现, 但是没有使用

  1. 性能太差了, 比正常写代码慢了 20-30 倍
  2. 严格模式不让用????

死磕知识点 with

let obj = {}
obj.a=2;
obj.b=3
let obj = {}
with(obj){
 a=2;
 b=3;
}

思路就是用户写在 {{}} 里面的语句, 没有写 this, 但是指向都是 this, 所以我要在头部给他们加 this 前缀.

  1. 循环拿出变量, 在头部拼接 this, 这个太????…
  2. 创建一个环境, 在这个环境里的变量都是 this 身上的????.

也就是我把 this.$data(再写几篇后会有变化)身上的值, 全部拿到我现在的环境里面,
举个例子 this.a = 1 那我直接 var a= this.a 函数里面的其他地方调用 a 就相当于调用 this.a 了

  getVal(vm, expression) {
    let result,
      __whoToVar = '';
      // 循环 data 上的每一个属性
    for (let i in vm.$data) {
      // data 下一篇会做成代理, 并且去掉原型上的属性
      let item = vm.$data[i];
      // 函数比较特殊, 因为需要改变声明方式
      // 兼容传参
      // 修正 this 指向为 vm 实例
      if (typeof item === 'function') {__whoToVar += `function ${i}(...arg){return vm.$data['${i}'].call(vm.$data,...arg)}`;
      } else {
      // 普通变量直接 let
        __whoToVar += `let ${i}=vm.$data['${i}'];`;
      }
    }
    // 执行出的结果用 result 接取
    __whoToVar = `${__whoToVar}result=${expression}`;
    eval(__whoToVar);
    return result;
  },

上面的做法做完, 就可以达到第一张效果图所示的目的了

end

写文章真是太卡了, 以后要控制文章的字数了,eeee.
下一章进入更有趣, 双向绑定的编写, 我很喜欢的部分.

大家都可以一起交流, 共同学习, 共同进步, 早日实现自我价值!!

github:github
个人技术博客: 个人技术博客
更多文章,ui 库的编写文章列表

正文完
 0