( 第二篇 )仿写'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, 还有就是调用initclass 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库的编写文章列表