14

( 第二篇 )仿写'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库的编写文章列表


lulu_up
5.7k 声望6.9k 粉丝

自信自律, 终身学习, 创业者