duck2u

duck2u 查看完整档案

重庆编辑  |  填写毕业院校Deloitte  |  Senior 编辑 wongjorie.top 编辑
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 个人简介什么都没有

个人动态

duck2u 发布了文章 · 2月12日

ts映射类型

在映射类型里,新类型以相同的形式去转换旧类型的每个属性。

Partial

将每个属性转换为可选属性

type Partial<T> = {
    [P in keyof T]?: T[P];
}

例子:

type PersonPartial = Partial<Person>;
//   ^ = type PersonPartial = {
//       name?: string | undefined;
//       age?: number | undefined;
//   }

Readonly

将每个属性转换为只读属性

type Readonly<T> = {
    readonly [P in keyof T]: T[P];
}

例子:

type ReadonlyPerson = Readonly<Person>;
//   ^ = type ReadonlyPerson = {
//       readonly name: string;
//       readonly age: number;
//   }

Nullable

转换为旧类型和null的联合类型

type Nullable<T> = { 
  [P in keyof T]: T[P] | null 
}

例子:

type NullablePerson = Nullable<Person>;
//   ^ = type NullablePerson = {
//       name: string | null;
//       age: number | null;
//   }

Pick

选取一组属性指定新类型

type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
}

例子:

interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

type TodoPreview = Pick<Todo, "title" | "completed">;

const todo: TodoPreview = {
  title: "Clean room",
  completed: false,
};

todo;
// ^ = const todo: TodoPreview

Record

创建一组属性指定新类型,常用来声明普通Object对象

type Record<K extends keyof any, T> = {
  [P in K]: T;
}
Record属于非同态,本质上会创建新属性,不会拷贝属性修饰符。

例子:

interface PageInfo {
  title: string;
}

type Page = "home" | "about" | "contact";

const nav: Record<Page, PageInfo> = {
  about: { title: "about" },
  contact: { title: "contact" },
  home: { title: "home" },
};

nav.about;
// ^ = const nav: Record

Exclude

去除交集,返回剩余的部分

type Exclude<T, U> = T extends U ? never : T

例子:

interface Props {
  a?: number;
  b?: string;
}

const obj: Props = { a: 5 };

const obj2: Required<Props> = { a: 5 };
Property 'b' is missing in type '{ a: number; }' but required in type 'Required<Props>'.

Omit

适用于键值对对象的Exclude,去除类型中包含的键值对

type Omit = Pick<T, Exclude<keyof T, K>>

例子:

interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

type TodoPreview = Omit<Todo, "description">;

const todo: TodoPreview = {
  title: "Clean room",
  completed: false,
};

ReturnType

获取返回值类型,一般为函数

type ReturnType<T extends (...args: any) => any>
  = T extends (...args: any) => infer R ? R : any;

例子:

declare function f1(): { a: number; b: string };
type T4 = ReturnType<typeof f1>;
//    ^ = type T4 = {
//        a: number;
//        b: string;
//    }

Required

将每个属性转换为必选属性

type Required<T> = {
  [P in keyof T]-?: T[P]
}

例子:

interface Props {
  a?: number;
  b?: string;
}

const obj: Props = { a: 5 };

const obj2: Required<Props> = { a: 5 };
// Property 'b' is missing in type '{ a: number; }' but required in type 'Required<Props>'.

除此以外,还有很多类似的映射类型,可以参考TypeScript: Documentation获得更详细的信息。

查看原文

赞 2 收藏 1 评论 0

duck2u 收藏了文章 · 2020-12-25

iTerm2 + Oh My Zsh 打造舒适终端体验

写在前面

最终效果图:

ImageInitIterm

本文严重抄袭自:https://www.jianshu.com/p/7de...

因排版和原文中的一些bug,参照搜索引擎和原文有了本篇文章。

因为powerline以及homebrew均需要安装command line tool,网络条件优越的同学在执行本文下面内容之前,可以先安装XCode并打开运行一次(会初始化安装components),省去以后在iterm2中的等待时间。

另外,git也是必要的,各位可以自行下载安装,除了网络没有任何坑:

https://git-scm.com

介于此,本文默认各位同学已经安装了git环境和xcode(command line tools),遇到提示找不到git命令或需要安装command line tool的地方,文中不再赘述了。

下载iTerm2

可以直接去官网下载:https://www.iterm2.com/

安装完成后,在/bin目录下会多出一个zsh的文件。

Mac系统默认使用dash作为终端,可以使用命令修改默认使用zsh:

chsh -s /bin/zsh

如果想修改回默认dash,同样使用chsh命令即可:

chsh -s /bin/bash

OK,这就是iTerm2初始的样子,下面我们来美化它,让它变得更好用!

ImageInitIterm

安装Oh my zsh

安装方法有两种,可以使用curl或wget,看自己环境或喜好:

# curl 安装方式
sh -c "$(curl -fsSL https://raw.githubusercontent.com/robbyrussell/oh-my-zsh/master/tools/install.sh)"
# wget 安装方式
sh -c "$(wget https://raw.githubusercontent.com/robbyrussell/oh-my-zsh/master/tools/install.sh -O -)"

安装命令和安装完成后的截图:

ImageInitIterm

oh-my-zsh开源地址:https://github.com/robbyrusse...

安装PowerLine

powerline官网:http://powerline.readthedocs....

安装powerline的方式依然简单,也只需要一条命令:

pip install powerline-status --user

没有安装pip的同学可能会碰到zsh: command not found: pip。

ImageInitIterm

使用命令安装pip即可:

sudo easy_install pip

安装后再次执行安装powerline的命令即可。

ImageInitIterm

安装PowerFonts

安装字体库需要首先将项目git clone至本地,然后执行源码中的install.sh。

在你习惯的位置新建一个文件夹,如:~/Desktop/OpenSource/

ImageInitIterm

在此文件夹下执行git clone命令:

# git clone
git clone https://github.com/powerline/fonts.git --depth=1
# cd to folder
cd fonts
# run install shell
./install.sh

执行结果如下:

ImageInitIterm

安装好字体库之后,我们来设置iTerm2的字体,具体的操作是iTerm2 -> Preferences -> Profiles -> Text,在Font区域选中Change Font,然后找到Meslo LG字体。有L、M、S可选,看个人喜好:

ImageInitIterm

安装配色方案

配色方案在使用VIM或Colorful Log时会变得非常有用,同时界面也不会一片黑绿一样死板。

同样使用git clone的方式下载源码进行安装:

cd ~/Desktop/OpenSource
git clone https://github.com/altercation/solarized
cd solarized/iterm2-colors-solarized/
open .

在打开的finder窗口中,双击Solarized Dark.itermcolors和Solarized Light.itermcolors即可安装明暗两种配色:

ImageInitIterm

再次进入iTerm2 -> Preferences -> Profiles -> Colors -> Color Presets中根据个人喜好选择这两种配色中的一种即可:

ImageInitIterm

安装主题

下载agnoster主题,执行脚本安装:

cd ~/Desktop/OpenSource
git clone https://github.com/fcamblor/oh-my-zsh-agnoster-fcamblor.git
cd oh-my-zsh-agnoster-fcamblor/
./install

执行上面的命令会将主题拷贝到oh my zsh的themes中:

ImageInitIterm

拷贝完成后,执行命令打开zshrc配置文件,将ZSH_THEME后面的字段改为agnoster。

vi ~/.zshrc

ImageInitIterm

修改完成后按一下esc调出vi命令,输入:wq保存并退出vi模式。

此时command+Q或source配置文件后,iTerm2变了模样:

ImageInitIterm

安装高亮插件

这是oh my zsh的一个插件,安装方式与theme大同小异:

cd ~/.oh-my-zsh/custom/plugins/
git clone https://github.com/zsh-users/zsh-syntax-highlighting.git
vi ~/.zshrc

这时我们再次打开zshrc文件进行编辑。找到plugins,此时plugins中应该已经有了git,我们需要把高亮插件也加上:

ImageInitIterm

请务必保证插件顺序,zsh-syntax-highlighting必须在最后一个。

然后在文件的最后一行添加:source ~/.oh-my-zsh/custom/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh

按一下esc调出vi命令,输入:wq保存并退出vi模式。

执行命令使刚才的修改生效:

source ~/.zshrc

至此大功告成,请看最终效果图:

ImageInitIterm

背景图片取自微软Surface Studio的4K壁纸(将近12MB大小),非常漂亮,需要的可以自取:

链接: https://pan.baidu.com/s/17zGm...
提取码: hg67

更换背景图片方式:iTerm2 -> Preferences -> Profiles -> Window -> BackGround Image勾选图片即可。

可选择、命令补全

跟代码高亮的安装方式一样,这也是一个zsh的插件,叫做zsh-autosuggestion,用于命令建议和补全。

cd ~/.oh-my-zsh/custom/plugins/
git clone https://github.com/zsh-users/zsh-autosuggestions
vi ~/.zshrc

找到plugins,加上这个插件即可:

ImageInitIterm

插件效果:

ImageInitIterm

Github:https://github.com/sirius1024...

喜欢请Star哦

查看原文

duck2u 赞了文章 · 2020-12-25

iTerm2 + Oh My Zsh 打造舒适终端体验

写在前面

最终效果图:

ImageInitIterm

本文严重抄袭自:https://www.jianshu.com/p/7de...

因排版和原文中的一些bug,参照搜索引擎和原文有了本篇文章。

因为powerline以及homebrew均需要安装command line tool,网络条件优越的同学在执行本文下面内容之前,可以先安装XCode并打开运行一次(会初始化安装components),省去以后在iterm2中的等待时间。

另外,git也是必要的,各位可以自行下载安装,除了网络没有任何坑:

https://git-scm.com

介于此,本文默认各位同学已经安装了git环境和xcode(command line tools),遇到提示找不到git命令或需要安装command line tool的地方,文中不再赘述了。

下载iTerm2

可以直接去官网下载:https://www.iterm2.com/

安装完成后,在/bin目录下会多出一个zsh的文件。

Mac系统默认使用dash作为终端,可以使用命令修改默认使用zsh:

chsh -s /bin/zsh

如果想修改回默认dash,同样使用chsh命令即可:

chsh -s /bin/bash

OK,这就是iTerm2初始的样子,下面我们来美化它,让它变得更好用!

ImageInitIterm

安装Oh my zsh

安装方法有两种,可以使用curl或wget,看自己环境或喜好:

# curl 安装方式
sh -c "$(curl -fsSL https://raw.githubusercontent.com/robbyrussell/oh-my-zsh/master/tools/install.sh)"
# wget 安装方式
sh -c "$(wget https://raw.githubusercontent.com/robbyrussell/oh-my-zsh/master/tools/install.sh -O -)"

安装命令和安装完成后的截图:

ImageInitIterm

oh-my-zsh开源地址:https://github.com/robbyrusse...

安装PowerLine

powerline官网:http://powerline.readthedocs....

安装powerline的方式依然简单,也只需要一条命令:

pip install powerline-status --user

没有安装pip的同学可能会碰到zsh: command not found: pip。

ImageInitIterm

使用命令安装pip即可:

sudo easy_install pip

安装后再次执行安装powerline的命令即可。

ImageInitIterm

安装PowerFonts

安装字体库需要首先将项目git clone至本地,然后执行源码中的install.sh。

在你习惯的位置新建一个文件夹,如:~/Desktop/OpenSource/

ImageInitIterm

在此文件夹下执行git clone命令:

# git clone
git clone https://github.com/powerline/fonts.git --depth=1
# cd to folder
cd fonts
# run install shell
./install.sh

执行结果如下:

ImageInitIterm

安装好字体库之后,我们来设置iTerm2的字体,具体的操作是iTerm2 -> Preferences -> Profiles -> Text,在Font区域选中Change Font,然后找到Meslo LG字体。有L、M、S可选,看个人喜好:

ImageInitIterm

安装配色方案

配色方案在使用VIM或Colorful Log时会变得非常有用,同时界面也不会一片黑绿一样死板。

同样使用git clone的方式下载源码进行安装:

cd ~/Desktop/OpenSource
git clone https://github.com/altercation/solarized
cd solarized/iterm2-colors-solarized/
open .

在打开的finder窗口中,双击Solarized Dark.itermcolors和Solarized Light.itermcolors即可安装明暗两种配色:

ImageInitIterm

再次进入iTerm2 -> Preferences -> Profiles -> Colors -> Color Presets中根据个人喜好选择这两种配色中的一种即可:

ImageInitIterm

安装主题

下载agnoster主题,执行脚本安装:

cd ~/Desktop/OpenSource
git clone https://github.com/fcamblor/oh-my-zsh-agnoster-fcamblor.git
cd oh-my-zsh-agnoster-fcamblor/
./install

执行上面的命令会将主题拷贝到oh my zsh的themes中:

ImageInitIterm

拷贝完成后,执行命令打开zshrc配置文件,将ZSH_THEME后面的字段改为agnoster。

vi ~/.zshrc

ImageInitIterm

修改完成后按一下esc调出vi命令,输入:wq保存并退出vi模式。

此时command+Q或source配置文件后,iTerm2变了模样:

ImageInitIterm

安装高亮插件

这是oh my zsh的一个插件,安装方式与theme大同小异:

cd ~/.oh-my-zsh/custom/plugins/
git clone https://github.com/zsh-users/zsh-syntax-highlighting.git
vi ~/.zshrc

这时我们再次打开zshrc文件进行编辑。找到plugins,此时plugins中应该已经有了git,我们需要把高亮插件也加上:

ImageInitIterm

请务必保证插件顺序,zsh-syntax-highlighting必须在最后一个。

然后在文件的最后一行添加:source ~/.oh-my-zsh/custom/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh

按一下esc调出vi命令,输入:wq保存并退出vi模式。

执行命令使刚才的修改生效:

source ~/.zshrc

至此大功告成,请看最终效果图:

ImageInitIterm

背景图片取自微软Surface Studio的4K壁纸(将近12MB大小),非常漂亮,需要的可以自取:

链接: https://pan.baidu.com/s/17zGm...
提取码: hg67

更换背景图片方式:iTerm2 -> Preferences -> Profiles -> Window -> BackGround Image勾选图片即可。

可选择、命令补全

跟代码高亮的安装方式一样,这也是一个zsh的插件,叫做zsh-autosuggestion,用于命令建议和补全。

cd ~/.oh-my-zsh/custom/plugins/
git clone https://github.com/zsh-users/zsh-autosuggestions
vi ~/.zshrc

找到plugins,加上这个插件即可:

ImageInitIterm

插件效果:

ImageInitIterm

Github:https://github.com/sirius1024...

喜欢请Star哦

查看原文

赞 147 收藏 83 评论 34

duck2u 发布了文章 · 2020-10-18

前端docker指南

前端项目发布主要用到nginx和node两个镜像,可在官方的Docker Hub找到相应的版本拉取使用。

构建镜像

拉取官方镜像之前,可通过项目下的Dockerfile文件对将要构建的镜像进行配置,在官方镜像的基础上实现定制化。

使用nginx镜像发布的前端项目的Dockerfile常用配置:

# 设置从公共仓库拉取的基础镜像、版本、别名
FROM nginx:latest
# 添加本地文件、目录或远程文件到镜像下的目录
ADD ./dist /usr/share/nginx/html

使用node镜像发布的前端项目的Dockerfile常用配置:

FROM node:latest
# 设置工作文件夹用作命令执行目录
WORKDIR /usr/src/app/node
# 复制文件或目录
COPY package*.json ./
# 执行命令
RUN npm install
COPY . .
# 设置监听端口
EXPOSE 8080
# 提供默认执行入口
CMD ["node", "server.js"]

完成配置后,运行如下命令,开始构建镜像:

docker build --tag fe:latest .

运行容器

镜像构建完成后,可以通过docker run命令和一些参数运行容器,也可以通过docker-compose的配置更方便快捷地运行容器。

在前端项目中新建docker-compose.yml,配置如下:

# docker-compose版本
version: '3.8'
services:
    nginx:
        # 指向构建好的镜像
        image: fe:latest
        # 映射端口
        ports:
            - 8080:80
        # 本地目录挂载至容器目录
        volumes:
            - ./dist:/usr/share/nginx/html

在前端项目下运行docker-compose up,启动完成后,即可访问前端应用。

查看原文

赞 0 收藏 0 评论 0

duck2u 关注了标签 · 2020-08-23

node.js

图片描述
Node 是一个 Javascript 运行环境(runtime)。实际上它是对 Google V8 引擎(应用于 Google Chrome 浏览器)进行了封装。V8 引擎执行 Javascript 的速度非常快,性能非常好。Node 对一些特殊用例进行了优化,提供了替代的 API,使得 V8 在非浏览器环境下运行得更好。例如,在服务器环境中,处理二进制数据通常是必不可少的,但 Javascript 对此支持不足,因此,V8.Node 增加了 Buffer 类,方便并且高效地 处理二进制数据。因此,Node 不仅仅简单的使用了 V8,还对其进行了优化,使其在各环境下更加给力。

关注 81955

duck2u 关注了标签 · 2020-08-23

小程序

小程序是一种不需要下载安装即可使用的应用,它实现了应用“触手可及”的梦想,用户扫一扫或者搜一下即可打开应用。也体现了“用完即走”的理念,用户不用关心是否安装太多应用的问题。应用将无处不在,随时可用,但又无需安装卸载。

关注 2074

duck2u 关注了标签 · 2020-08-23

typescript

TypeScript 是微软开发的 JavaScript 的超集,TypeScript兼容JavaScript,可以载入JavaScript代码然后运行。TypeScript与JavaScript相比进步的地方。包括:加入注释,让编译器理解所支持的对象和函数,编译器会移除注释,不会增加开销;增加一个完整的类结构,使之更新是传统的面向对象语言。

关注 32573

duck2u 收藏了文章 · 2020-08-23

Vue模板编译原理

写在开头

写过 Vue 的同学肯定体验过, .vue 这种单文件组件有多么方便。但是我们也知道,Vue 底层是通过虚拟 DOM 来进行渲染的,那么 .vue 文件的模板到底是怎么转换成虚拟 DOM 的呢?这一块对我来说一直是个黑盒,之前也没有深入研究过,今天打算一探究竟。

Virtual Dom

Vue 3 发布在即,本来想着直接看看 Vue 3 的模板编译,但是我打开 Vue 3 源码的时候,发现我好像连 Vue 2 是怎么编译模板的都不知道。从小鲁迅就告诉我们,不能一口吃成一个胖子,那我只能回头看看 Vue 2 的模板编译源码,至于 Vue 3 就留到正式发布的时候再看。

Vue 的版本

很多人使用 Vue 的时候,都是直接通过 vue-cli 生成的模板代码,并不知道 Vue 其实提供了两个构建版本。

  • vue.js: 完整版本,包含了模板编译的能力;
  • vue.runtime.js: 运行时版本,不提供模板编译能力,需要通过 vue-loader 进行提前编译。

Vue不同构建版本

完整版与运行时版区别

简单来说,就是如果你用了 vue-loader ,就可以使用 vue.runtime.min.js,将模板编译的过程交过 vue-loader,如果你是在浏览器中直接通过 script 标签引入 Vue,需要使用 vue.min.js,运行的时候编译模板。

编译入口

了解了 Vue 的版本,我们看看 Vue 完整版的入口文件(src/platforms/web/entry-runtime-with-compiler.js)。

// 省略了部分代码,只保留了关键部分
import { compileToFunctions } from './compiler/index'

const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (el) {
  const options = this.$options
  
  // 如果没有 render 方法,则进行 template 编译
  if (!options.render) {
    let template = options.template
    if (template) {
      // 调用 compileToFunctions,编译 template,得到 render 方法
      const { render, staticRenderFns } = compileToFunctions(template, {
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)
      // 这里的 render 方法就是生成生成虚拟 DOM 的方法
      options.render = render
    }
  }
  return mount.call(this, el, hydrating)
}

再看看 ./compiler/index 文件的 compileToFunctions 方法从何而来。

import { baseOptions } from './options'
import { createCompiler } from 'compiler/index'

// 通过 createCompiler 方法生成编译函数
const { compile, compileToFunctions } = createCompiler(baseOptions)
export { compile, compileToFunctions }

后续的主要逻辑都在 compiler 模块中,这一块有些绕,因为本文不是做源码分析,就不贴整段源码了。简单看看这一段的逻辑是怎么样的。

export function createCompiler(baseOptions) {
  const baseCompile = (template, options) => {
    // 解析 html,转化为 ast
    const ast = parse(template.trim(), options)
    // 优化 ast,标记静态节点
    optimize(ast, options)
    // 将 ast 转化为可执行代码
    const code = generate(ast, options)
    return {
      ast,
      render: code.render,
      staticRenderFns: code.staticRenderFns
    }
  }
  const compile = (template, options) => {
    const tips = []
    const errors = []
    // 收集编译过程中的错误信息
    options.warn = (msg, tip) => {
      (tip ? tips : errors).push(msg)
    }
    // 编译
    const compiled = baseCompile(template, options)
    compiled.errors = errors
    compiled.tips = tips

    return compiled
  }
  const createCompileToFunctionFn = () => {
    // 编译缓存
    const cache = Object.create(null)
    return (template, options, vm) => {
      // 已编译模板直接走缓存
      if (cache[template]) {
        return cache[template]
      }
      const compiled = compile(template, options)
        return (cache[key] = compiled)
    }
  }
  return {
    compile,
    compileToFunctions: createCompileToFunctionFn(compile)
  }
}

主流程

可以看到主要的编译逻辑基本都在 baseCompile 方法内,主要分为三个步骤:

  1. 模板编译,将模板代码转化为 AST;
  2. 优化 AST,方便后续虚拟 DOM 更新;
  3. 生成代码,将 AST 转化为可执行的代码;
const baseCompile = (template, options) => {
  // 解析 html,转化为 ast
  const ast = parse(template.trim(), options)
  // 优化 ast,标记静态节点
  optimize(ast, options)
  // 将 ast 转化为可执行代码
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
}

parse

AST

首先看到 parse 方法,该方法的主要作用就是解析 HTML,并转化为 AST(抽象语法树),接触过 ESLint、Babel 的同学肯定对 AST 不陌生,我们可以先看看经过 parse 之后的 AST 长什么样。

下面是一段普普通通的 Vue 模板:

new Vue({
  el: '#app',
  template: `
    <div>
      <h2 v-if="message">{{message}}</h2>
      <button @click="showName">showName</button>
    </div>
  `,
  data: {
    name: 'shenfq',
    message: 'Hello Vue!'
  },
  methods: {
    showName() {
      alert(this.name)
    }
  }
})

经过 parse 之后的 AST:

Template AST

AST 为一个树形结构的对象,每一层表示一个节点,第一层就是 divtag: "div")。div 的子节点都在 children 属性中,分别是 h2 标签、空行、button 标签。我们还可以注意到有一个用来标记节点类型的属性:type,这里 div 的 type 为 1,表示是一个元素节点,type 一共有三种类型:

  1. 元素节点;
  2. 表达式;
  3. 文本;

h2button 标签之间的空行就是 type 为 3 的文本节点,而 h2 标签下就是一个表达式节点。

节点类型

解析HTML

parse 的整体逻辑较为复杂,我们可以先简化一下代码,看看 parse 的流程。

import { parseHTML } from './html-parser'

export function parse(template, options) {
  let root
  parseHTML(template, {
    // some options...
    start() {}, // 解析到标签位置开始的回调
    end() {}, // 解析到标签位置结束的回调
    chars() {}, // 解析到文本时的回调
    comment() {} // 解析到注释时的回调
  })
  return root
}

可以看到 parse 主要通过 parseHTML 进行工作,这个 parseHTML 本身来自于开源库:htmlparser.js,只不过经过了 Vue 团队的一些修改,修复了相关 issue。

HTML parser

下面我们一起来理一理 parseHTML 的逻辑。

export function parseHTML(html, options) {
  let index = 0
  let last,lastTag
  const stack = []
  while(html) {
    last = html
    let textEnd = html.indexOf('<')

    // "<" 字符在当前 html 字符串开始位置
    if (textEnd === 0) {
      // 1、匹配到注释: <!-- -->
      if (/^<!\--/.test(html)) {
        const commentEnd = html.indexOf('-->')
        if (commentEnd >= 0) {
          // 调用 options.comment 回调,传入注释内容
          options.comment(html.substring(4, commentEnd))
          // 裁切掉注释部分
          advance(commentEnd + 3)
          continue
        }
      }

      // 2、匹配到条件注释: <![if !IE]>  <![endif]>
      if (/^<!\[/.test(html)) {
        // ... 逻辑与匹配到注释类似
      }

      // 3、匹配到 Doctype: <!DOCTYPE html>
      const doctypeMatch = html.match(/^<!DOCTYPE [^>]+>/i)
      if (doctypeMatch) {
        // ... 逻辑与匹配到注释类似
      }

      // 4、匹配到结束标签: </div>
      const endTagMatch = html.match(endTag)
      if (endTagMatch) {}

      // 5、匹配到开始标签: <div>
      const startTagMatch = parseStartTag()
      if (startTagMatch) {}
    }
    // "<" 字符在当前 html 字符串中间位置
    let text, rest, next
    if (textEnd > 0) {
      // 提取中间字符
      rest = html.slice(textEnd)
      // 这一部分当成文本处理
      text = html.substring(0, textEnd)
      advance(textEnd)
    }
    // "<" 字符在当前 html 字符串中不存在
    if (textEnd < 0) {
      text = html
      html = ''
    }
    
    // 如果存在 text 文本
    // 调用 options.chars 回调,传入 text 文本
    if (options.chars && text) {
      // 字符相关回调
      options.chars(text)
    }
  }
  // 向前推进,裁切 html
  function advance(n) {
    index += n
    html = html.substring(n)
  }
}

上述代码为简化后的 parseHTML,while 循环中每次截取一段 html 文本,然后通过正则判断文本的类型进行处理,这就类似于编译原理中常用的有限状态机。每次拿到 "<" 字符前后的文本,"<" 字符前的就当做文本处理,"<" 字符后的通过正则判断,可推算出有限的几种状态。

html的几种状态

其他的逻辑处理都不复杂,主要是开始标签与结束标签,我们先看看关于开始标签与结束标签相关的正则。

const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)

这段正则看起来很长,但是理清之后也不是很难。这里推荐一个正则可视化工具。我们到工具上看看startTagOpen:

startTagOpen

这里比较疑惑的点就是为什么 tagName 会存在 :,这个是 XML 的 命名空间,现在已经很少使用了,我们可以直接忽略,所以我们简化一下这个正则:

const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const startTagOpen = new RegExp(`^<${ncname}`)
const startTagClose = /^\s*(\/?)>/
const endTag = new RegExp(`^<\\/${ncname}[^>]*>`)

startTagOpen

endTag

除了上面关于标签开始和结束的正则,还有一段用来提取标签属性的正则,真的是又臭又长。

const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/

把正则放到工具上就一目了然了,以 = 为分界,前面为属性的名字,后面为属性的值。

attribute

理清正则后可以更加方便我们看后面的代码。

while(html) {
  last = html
  let textEnd = html.indexOf('<')

  // "<" 字符在当前 html 字符串开始位置
  if (textEnd === 0) {
    // some code ...

    // 4、匹配到标签结束位置: </div>
    const endTagMatch = html.match(endTag)
    if (endTagMatch) {
      const curIndex = index
      advance(endTagMatch[0].length)
      parseEndTag(endTagMatch[1], curIndex, index)
      continue
    }

    // 5、匹配到标签开始位置: <div>
    const startTagMatch = parseStartTag()
    if (startTagMatch) {
      handleStartTag(startTagMatch)
      continue
    }
  }
}
// 向前推进,裁切 html
function advance(n) {
  index += n
  html = html.substring(n)
}

// 判断是否标签开始位置,如果是,则提取标签名以及相关属性
function parseStartTag () {
  // 提取 <xxx
  const start = html.match(startTagOpen)
  if (start) {
    const [fullStr, tag] = start
    const match = {
      attrs: [],
      start: index,
      tagName: tag,
    }
    advance(fullStr.length)
    let end, attr
    // 递归提取属性,直到出现 ">" 或 "/>" 字符
    while (
      !(end = html.match(startTagClose)) &&
      (attr = html.match(attribute))
    ) {
      advance(attr[0].length)
      match.attrs.push(attr)
    }
    if (end) {
      // 如果是 "/>" 表示单标签
      match.unarySlash = end[1]
      advance(end[0].length)
      match.end = index
      return match
    }
  }
}

// 处理开始标签
function handleStartTag (match) {
  const tagName = match.tagName
  const unary = match.unarySlash
  const len = match.attrs.length
  const attrs = new Array(len)
  for (let i = 0; i < l; i++) {
    const args = match.attrs[i]
    // 这里的 3、4、5 分别对应三种不同复制属性的方式
    // 3: attr="xxx" 双引号
    // 4: attr='xxx' 单引号
    // 5: attr=xxx   省略引号
    const value = args[3] || args[4] || args[5] || ''
    attrs[i] = {
      name: args[1],
      value
    }
  }

  if (!unary) {
    // 非单标签,入栈
    stack.push({
      tag: tagName,
      lowerCasedTag:
      tagName.toLowerCase(),
      attrs: attrs
    })
    lastTag = tagName
  }

  if (options.start) {
    // 开始标签的回调
    options.start(tagName, attrs, unary, match.start, match.end)
  }
}

// 处理闭合标签
function parseEndTag (tagName, start, end) {
  let pos, lowerCasedTagName
  if (start == null) start = index
  if (end == null) end = index

  if (tagName) {
    lowerCasedTagName = tagName.toLowerCase()
  }

  // 在栈内查找相同类型的未闭合标签
  if (tagName) {
    for (pos = stack.length - 1; pos >= 0; pos--) {
      if (stack[pos].lowerCasedTag === lowerCasedTagName) {
        break
      }
    }
  } else {
    pos = 0
  }

  if (pos >= 0) {
    // 关闭该标签内的未闭合标签,更新堆栈
    for (let i = stack.length - 1; i >= pos; i--) {
      if (options.end) {
        // end 回调
        options.end(stack[i].tag, start, end)
      }
    }

    // 堆栈中删除已关闭标签
    stack.length = pos
    lastTag = pos && stack[pos - 1].tag
  }
}

在解析开始标签的时候,如果该标签不是单标签,会将该标签放入到一个堆栈当中,每次闭合标签的时候,会从栈顶向下查找同名标签,直到找到同名标签,这个操作会闭合同名标签上面的所有标签。接下来我们举个例子:

<div>
  <h2>test</h2>
  <p>
  <p>
</div>

在解析了 div 和 h2 的开始标签后,栈内就存在了两个元素。h2 闭合后,就会将 h2 出栈。然后会解析两个未闭合的 p 标签,此时,栈内存在三个元素(div、p、p)。如果这个时候,解析了 div 的闭合标签,除了将 div 闭合外,div 内两个未闭合的 p 标签也会跟随闭合,此时栈被清空。

为了便于理解,特地录制了一个动图,如下:

入栈与出栈

理清了 parseHTML 的逻辑后,我们回到调用 parseHTML 的位置,调用该方法的时候,一共会传入四个回调,分别对应标签的开始和结束、文本、注释。

parseHTML(template, {
  // some options...

  // 解析到标签位置开始的回调
  start(tag, attrs, unary) {},
  // 解析到标签位置结束的回调
  end(tag) {},
  // 解析到文本时的回调
  chars(text: string) {},
  // 解析到注释时的回调
  comment(text: string) {}
})

处理开始标签

首先看解析到开始标签时,会生成一个 AST 节点,然后处理标签上的属性,最后将 AST 节点放入树形结构中。

function makeAttrsMap(attrs) {
  const map = {}
  for (let i = 0, l = attrs.length; i < l; i++) {
    const { name, value } = attrs[i]
    map[name] = value
  }
  return map
}
function createASTElement(tag, attrs, parent) {
  const attrsList = attrs
  const attrsMap = makeAttrsMap(attrsList)
  return {
    type: 1,       // 节点类型
    tag,           // 节点名称
    attrsMap,      // 节点属性映射
    attrsList,     // 节点属性数组
    parent,        // 父节点
    children: [],  // 子节点
  }
}

const stack = []
let root // 根节点
let currentParent // 暂存当前的父节点
parseHTML(template, {
  // some options...

  // 解析到标签位置开始的回调
  start(tag, attrs, unary) {
    // 创建 AST 节点
    let element = createASTElement(tag, attrs, currentParent)

    // 处理指令: v-for v-if v-once
    processFor(element)
    processIf(element)
    processOnce(element)
    processElement(element, options)

    // 处理 AST 树
    // 根节点不存在,则设置该元素为根节点
       if (!root) {
      root = element
      checkRootConstraints(root)
    }
    // 存在父节点
    if (currentParent) {
      // 将该元素推入父节点的子节点中
      currentParent.children.push(element)
      element.parent = currentParent
    }
    if (!unary) {
        // 非单标签需要入栈,且切换当前父元素的位置
      currentParent = element
      stack.push(element)
    }
  }
})

处理结束标签

标签结束的逻辑就比较简单了,只需要去除栈内最后一个未闭合标签,进行闭合即可。

parseHTML(template, {
  // some options...

  // 解析到标签位置结束的回调
  end() {
    const element = stack[stack.length - 1]
    const lastNode = element.children[element.children.length - 1]
    // 处理尾部空格的情况
    if (lastNode && lastNode.type === 3 && lastNode.text === ' ') {
      element.children.pop()
    }
    // 出栈,重置当前的父节点
    stack.length -= 1
    currentParent = stack[stack.length - 1]
  }
})

处理文本

处理完标签后,还需要对标签内的文本进行处理。文本的处理分两种情况,一种是带表达式的文本,还一种就是纯静态的文本。

parseHTML(template, {
  // some options...

  // 解析到文本时的回调
  chars(text) {
    if (!currentParent) {
      // 文本节点外如果没有父节点则不处理
      return
    }
    
    const children = currentParent.children
    text = text.trim()
    if (text) {
      // parseText 用来解析表达式
      // delimiters 表示表达式标识符,默认为 ['{{', '}}']
      const res = parseText(text, delimiters))
      if (res) {
        // 表达式
        children.push({
          type: 2,
          expression: res.expression,
          tokens: res.tokens,
          text
        })
      } else {
        // 静态文本
        children.push({
          type: 3,
          text
        })
      }
    }
  }
})

下面我们看看 parseText 如何解析表达式。

// 构造匹配表达式的正则
const buildRegex = delimiters => {
  const open = delimiters[0]
  const close = delimiters[1]
  return new RegExp(open + '((?:.|\\n)+?)' + close, 'g')
}

function parseText (text, delimiters){
  // delimiters 默认为 {{ }}
  const tagRE = buildRegex(delimiters || ['{{', '}}'])
  // 未匹配到表达式,直接返回
  if (!tagRE.test(text)) {
    return
  }
  const tokens = []
  const rawTokens = []
  let lastIndex = tagRE.lastIndex = 0
  let match, index, tokenValue
  while ((match = tagRE.exec(text))) {
    // 表达式开始的位置
    index = match.index
    // 提取表达式开始位置前面的静态字符,放入 token 中
    if (index > lastIndex) {
      rawTokens.push(tokenValue = text.slice(lastIndex, index))
      tokens.push(JSON.stringify(tokenValue))
    }
    // 提取表达式内部的内容,使用 _s() 方法包裹
    const exp = match[1].trim()
    tokens.push(`_s(${exp})`)
    rawTokens.push({ '@binding': exp })
    lastIndex = index + match[0].length
  }
  // 表达式后面还有其他静态字符,放入 token 中
  if (lastIndex < text.length) {
    rawTokens.push(tokenValue = text.slice(lastIndex))
    tokens.push(JSON.stringify(tokenValue))
  }
  return {
    expression: tokens.join('+'),
    tokens: rawTokens
  }
}

首先通过一段正则来提取表达式:

提取表达式

看代码可能有点难,我们直接看例子,这里有一个包含表达式的文本。

<div>是否登录:{{isLogin ? '是' : '否'}}</div>

运行结果

解析文本

optimize

通过上述一些列处理,我们就得到了 Vue 模板的 AST。由于 Vue 是响应式设计,所以拿到 AST 之后还需要进行一系列优化,确保静态的数据不会进入虚拟 DOM 的更新阶段,以此来优化性能。

export function optimize (root, options) {
  if (!root) return
  // 标记静态节点
  markStatic(root)
}

简单来说,就是把所以静态节点的 static 属性设置为 true。

function isStatic (node) {
  if (node.type === 2) { // 表达式,返回 false
    return false
  }
  if (node.type === 3) { // 静态文本,返回 true
    return true
  }
  // 此处省略了部分条件
  return !!(
    !node.hasBindings && // 没有动态绑定
    !node.if && !node.for && // 没有 v-if/v-for
    !isBuiltInTag(node.tag) && // 不是内置组件 slot/component
    !isDirectChildOfTemplateFor(node) && // 不在 template for 循环内
    Object.keys(node).every(isStaticKey) // 非静态节点
  )
}

function markStatic (node) {
  node.static = isStatic(node)
  if (node.type === 1) {
    // 如果是元素节点,需要遍历所有子节点
    for (let i = 0, l = node.children.length; i < l; i++) {
      const child = node.children[i]
      markStatic(child)
      if (!child.static) {
        // 如果有一个子节点不是静态节点,则该节点也必须是动态的
        node.static = false
      }
    }
  }
}

generate

得到优化的 AST 之后,就需要将 AST 转化为 render 方法。还是用之前的模板,先看看生成的代码长什么样:

<div>
  <h2 v-if="message">{{message}}</h2>
  <button @click="showName">showName</button>
</div>
{
  render: "with(this){return _c('div',[(message)?_c('h2',[_v(_s(message))]):_e(),_v(" "),_c('button',{on:{"click":showName}},[_v("showName")])])}"
}

将生成的代码展开:

with (this) {
    return _c(
      'div',
      [
        (message) ? _c('h2', [_v(_s(message))]) : _e(),
        _v(' '),
        _c('button', { on: { click: showName } }, [_v('showName')])
      ])
    ;
}

看到这里一堆的下划线肯定很懵逼,这里的 _c 对应的是虚拟 DOM 中的 createElement 方法。其他的下划线方法在 core/instance/render-helpers 中都有定义,每个方法具体做了什么不做展开。

render-helpers`

具体转化方法就是一些简单的字符拼接,下面是简化了逻辑的部分,不做过多讲述。

export function generate(ast, options) {
  const state = new CodegenState(options)
  const code = ast ? genElement(ast, state) : '_c("div")'
  return {
    render: `with(this){return ${code}}`,
    staticRenderFns: state.staticRenderFns
  }
}

export function genElement (el, state) {
  let code
  const data = genData(el, state)
  const children = genChildren(el, state, true)
  code = `_c('${el.tag}'${
    data ? `,${data}` : '' // data
  }${
    children ? `,${children}` : '' // children
  })`
  return code
}

总结

理清了 Vue 模板编译的整个过程,重点都放在了解析 HTML 生成 AST 的部分。本文只是大致讲述了主要流程,其中省略了特别多的细节,比如:对 template/slot 的处理、指令的处理等等,如果想了解其中的细节可以直接阅读源码。希望大家在阅读这篇文章后有所收获。

image

查看原文

duck2u 赞了文章 · 2020-08-22

Vue模板编译原理

写在开头

写过 Vue 的同学肯定体验过, .vue 这种单文件组件有多么方便。但是我们也知道,Vue 底层是通过虚拟 DOM 来进行渲染的,那么 .vue 文件的模板到底是怎么转换成虚拟 DOM 的呢?这一块对我来说一直是个黑盒,之前也没有深入研究过,今天打算一探究竟。

Virtual Dom

Vue 3 发布在即,本来想着直接看看 Vue 3 的模板编译,但是我打开 Vue 3 源码的时候,发现我好像连 Vue 2 是怎么编译模板的都不知道。从小鲁迅就告诉我们,不能一口吃成一个胖子,那我只能回头看看 Vue 2 的模板编译源码,至于 Vue 3 就留到正式发布的时候再看。

Vue 的版本

很多人使用 Vue 的时候,都是直接通过 vue-cli 生成的模板代码,并不知道 Vue 其实提供了两个构建版本。

  • vue.js: 完整版本,包含了模板编译的能力;
  • vue.runtime.js: 运行时版本,不提供模板编译能力,需要通过 vue-loader 进行提前编译。

Vue不同构建版本

完整版与运行时版区别

简单来说,就是如果你用了 vue-loader ,就可以使用 vue.runtime.min.js,将模板编译的过程交过 vue-loader,如果你是在浏览器中直接通过 script 标签引入 Vue,需要使用 vue.min.js,运行的时候编译模板。

编译入口

了解了 Vue 的版本,我们看看 Vue 完整版的入口文件(src/platforms/web/entry-runtime-with-compiler.js)。

// 省略了部分代码,只保留了关键部分
import { compileToFunctions } from './compiler/index'

const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (el) {
  const options = this.$options
  
  // 如果没有 render 方法,则进行 template 编译
  if (!options.render) {
    let template = options.template
    if (template) {
      // 调用 compileToFunctions,编译 template,得到 render 方法
      const { render, staticRenderFns } = compileToFunctions(template, {
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)
      // 这里的 render 方法就是生成生成虚拟 DOM 的方法
      options.render = render
    }
  }
  return mount.call(this, el, hydrating)
}

再看看 ./compiler/index 文件的 compileToFunctions 方法从何而来。

import { baseOptions } from './options'
import { createCompiler } from 'compiler/index'

// 通过 createCompiler 方法生成编译函数
const { compile, compileToFunctions } = createCompiler(baseOptions)
export { compile, compileToFunctions }

后续的主要逻辑都在 compiler 模块中,这一块有些绕,因为本文不是做源码分析,就不贴整段源码了。简单看看这一段的逻辑是怎么样的。

export function createCompiler(baseOptions) {
  const baseCompile = (template, options) => {
    // 解析 html,转化为 ast
    const ast = parse(template.trim(), options)
    // 优化 ast,标记静态节点
    optimize(ast, options)
    // 将 ast 转化为可执行代码
    const code = generate(ast, options)
    return {
      ast,
      render: code.render,
      staticRenderFns: code.staticRenderFns
    }
  }
  const compile = (template, options) => {
    const tips = []
    const errors = []
    // 收集编译过程中的错误信息
    options.warn = (msg, tip) => {
      (tip ? tips : errors).push(msg)
    }
    // 编译
    const compiled = baseCompile(template, options)
    compiled.errors = errors
    compiled.tips = tips

    return compiled
  }
  const createCompileToFunctionFn = () => {
    // 编译缓存
    const cache = Object.create(null)
    return (template, options, vm) => {
      // 已编译模板直接走缓存
      if (cache[template]) {
        return cache[template]
      }
      const compiled = compile(template, options)
        return (cache[key] = compiled)
    }
  }
  return {
    compile,
    compileToFunctions: createCompileToFunctionFn(compile)
  }
}

主流程

可以看到主要的编译逻辑基本都在 baseCompile 方法内,主要分为三个步骤:

  1. 模板编译,将模板代码转化为 AST;
  2. 优化 AST,方便后续虚拟 DOM 更新;
  3. 生成代码,将 AST 转化为可执行的代码;
const baseCompile = (template, options) => {
  // 解析 html,转化为 ast
  const ast = parse(template.trim(), options)
  // 优化 ast,标记静态节点
  optimize(ast, options)
  // 将 ast 转化为可执行代码
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
}

parse

AST

首先看到 parse 方法,该方法的主要作用就是解析 HTML,并转化为 AST(抽象语法树),接触过 ESLint、Babel 的同学肯定对 AST 不陌生,我们可以先看看经过 parse 之后的 AST 长什么样。

下面是一段普普通通的 Vue 模板:

new Vue({
  el: '#app',
  template: `
    <div>
      <h2 v-if="message">{{message}}</h2>
      <button @click="showName">showName</button>
    </div>
  `,
  data: {
    name: 'shenfq',
    message: 'Hello Vue!'
  },
  methods: {
    showName() {
      alert(this.name)
    }
  }
})

经过 parse 之后的 AST:

Template AST

AST 为一个树形结构的对象,每一层表示一个节点,第一层就是 divtag: "div")。div 的子节点都在 children 属性中,分别是 h2 标签、空行、button 标签。我们还可以注意到有一个用来标记节点类型的属性:type,这里 div 的 type 为 1,表示是一个元素节点,type 一共有三种类型:

  1. 元素节点;
  2. 表达式;
  3. 文本;

h2button 标签之间的空行就是 type 为 3 的文本节点,而 h2 标签下就是一个表达式节点。

节点类型

解析HTML

parse 的整体逻辑较为复杂,我们可以先简化一下代码,看看 parse 的流程。

import { parseHTML } from './html-parser'

export function parse(template, options) {
  let root
  parseHTML(template, {
    // some options...
    start() {}, // 解析到标签位置开始的回调
    end() {}, // 解析到标签位置结束的回调
    chars() {}, // 解析到文本时的回调
    comment() {} // 解析到注释时的回调
  })
  return root
}

可以看到 parse 主要通过 parseHTML 进行工作,这个 parseHTML 本身来自于开源库:htmlparser.js,只不过经过了 Vue 团队的一些修改,修复了相关 issue。

HTML parser

下面我们一起来理一理 parseHTML 的逻辑。

export function parseHTML(html, options) {
  let index = 0
  let last,lastTag
  const stack = []
  while(html) {
    last = html
    let textEnd = html.indexOf('<')

    // "<" 字符在当前 html 字符串开始位置
    if (textEnd === 0) {
      // 1、匹配到注释: <!-- -->
      if (/^<!\--/.test(html)) {
        const commentEnd = html.indexOf('-->')
        if (commentEnd >= 0) {
          // 调用 options.comment 回调,传入注释内容
          options.comment(html.substring(4, commentEnd))
          // 裁切掉注释部分
          advance(commentEnd + 3)
          continue
        }
      }

      // 2、匹配到条件注释: <![if !IE]>  <![endif]>
      if (/^<!\[/.test(html)) {
        // ... 逻辑与匹配到注释类似
      }

      // 3、匹配到 Doctype: <!DOCTYPE html>
      const doctypeMatch = html.match(/^<!DOCTYPE [^>]+>/i)
      if (doctypeMatch) {
        // ... 逻辑与匹配到注释类似
      }

      // 4、匹配到结束标签: </div>
      const endTagMatch = html.match(endTag)
      if (endTagMatch) {}

      // 5、匹配到开始标签: <div>
      const startTagMatch = parseStartTag()
      if (startTagMatch) {}
    }
    // "<" 字符在当前 html 字符串中间位置
    let text, rest, next
    if (textEnd > 0) {
      // 提取中间字符
      rest = html.slice(textEnd)
      // 这一部分当成文本处理
      text = html.substring(0, textEnd)
      advance(textEnd)
    }
    // "<" 字符在当前 html 字符串中不存在
    if (textEnd < 0) {
      text = html
      html = ''
    }
    
    // 如果存在 text 文本
    // 调用 options.chars 回调,传入 text 文本
    if (options.chars && text) {
      // 字符相关回调
      options.chars(text)
    }
  }
  // 向前推进,裁切 html
  function advance(n) {
    index += n
    html = html.substring(n)
  }
}

上述代码为简化后的 parseHTML,while 循环中每次截取一段 html 文本,然后通过正则判断文本的类型进行处理,这就类似于编译原理中常用的有限状态机。每次拿到 "<" 字符前后的文本,"<" 字符前的就当做文本处理,"<" 字符后的通过正则判断,可推算出有限的几种状态。

html的几种状态

其他的逻辑处理都不复杂,主要是开始标签与结束标签,我们先看看关于开始标签与结束标签相关的正则。

const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)

这段正则看起来很长,但是理清之后也不是很难。这里推荐一个正则可视化工具。我们到工具上看看startTagOpen:

startTagOpen

这里比较疑惑的点就是为什么 tagName 会存在 :,这个是 XML 的 命名空间,现在已经很少使用了,我们可以直接忽略,所以我们简化一下这个正则:

const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const startTagOpen = new RegExp(`^<${ncname}`)
const startTagClose = /^\s*(\/?)>/
const endTag = new RegExp(`^<\\/${ncname}[^>]*>`)

startTagOpen

endTag

除了上面关于标签开始和结束的正则,还有一段用来提取标签属性的正则,真的是又臭又长。

const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/

把正则放到工具上就一目了然了,以 = 为分界,前面为属性的名字,后面为属性的值。

attribute

理清正则后可以更加方便我们看后面的代码。

while(html) {
  last = html
  let textEnd = html.indexOf('<')

  // "<" 字符在当前 html 字符串开始位置
  if (textEnd === 0) {
    // some code ...

    // 4、匹配到标签结束位置: </div>
    const endTagMatch = html.match(endTag)
    if (endTagMatch) {
      const curIndex = index
      advance(endTagMatch[0].length)
      parseEndTag(endTagMatch[1], curIndex, index)
      continue
    }

    // 5、匹配到标签开始位置: <div>
    const startTagMatch = parseStartTag()
    if (startTagMatch) {
      handleStartTag(startTagMatch)
      continue
    }
  }
}
// 向前推进,裁切 html
function advance(n) {
  index += n
  html = html.substring(n)
}

// 判断是否标签开始位置,如果是,则提取标签名以及相关属性
function parseStartTag () {
  // 提取 <xxx
  const start = html.match(startTagOpen)
  if (start) {
    const [fullStr, tag] = start
    const match = {
      attrs: [],
      start: index,
      tagName: tag,
    }
    advance(fullStr.length)
    let end, attr
    // 递归提取属性,直到出现 ">" 或 "/>" 字符
    while (
      !(end = html.match(startTagClose)) &&
      (attr = html.match(attribute))
    ) {
      advance(attr[0].length)
      match.attrs.push(attr)
    }
    if (end) {
      // 如果是 "/>" 表示单标签
      match.unarySlash = end[1]
      advance(end[0].length)
      match.end = index
      return match
    }
  }
}

// 处理开始标签
function handleStartTag (match) {
  const tagName = match.tagName
  const unary = match.unarySlash
  const len = match.attrs.length
  const attrs = new Array(len)
  for (let i = 0; i < l; i++) {
    const args = match.attrs[i]
    // 这里的 3、4、5 分别对应三种不同复制属性的方式
    // 3: attr="xxx" 双引号
    // 4: attr='xxx' 单引号
    // 5: attr=xxx   省略引号
    const value = args[3] || args[4] || args[5] || ''
    attrs[i] = {
      name: args[1],
      value
    }
  }

  if (!unary) {
    // 非单标签,入栈
    stack.push({
      tag: tagName,
      lowerCasedTag:
      tagName.toLowerCase(),
      attrs: attrs
    })
    lastTag = tagName
  }

  if (options.start) {
    // 开始标签的回调
    options.start(tagName, attrs, unary, match.start, match.end)
  }
}

// 处理闭合标签
function parseEndTag (tagName, start, end) {
  let pos, lowerCasedTagName
  if (start == null) start = index
  if (end == null) end = index

  if (tagName) {
    lowerCasedTagName = tagName.toLowerCase()
  }

  // 在栈内查找相同类型的未闭合标签
  if (tagName) {
    for (pos = stack.length - 1; pos >= 0; pos--) {
      if (stack[pos].lowerCasedTag === lowerCasedTagName) {
        break
      }
    }
  } else {
    pos = 0
  }

  if (pos >= 0) {
    // 关闭该标签内的未闭合标签,更新堆栈
    for (let i = stack.length - 1; i >= pos; i--) {
      if (options.end) {
        // end 回调
        options.end(stack[i].tag, start, end)
      }
    }

    // 堆栈中删除已关闭标签
    stack.length = pos
    lastTag = pos && stack[pos - 1].tag
  }
}

在解析开始标签的时候,如果该标签不是单标签,会将该标签放入到一个堆栈当中,每次闭合标签的时候,会从栈顶向下查找同名标签,直到找到同名标签,这个操作会闭合同名标签上面的所有标签。接下来我们举个例子:

<div>
  <h2>test</h2>
  <p>
  <p>
</div>

在解析了 div 和 h2 的开始标签后,栈内就存在了两个元素。h2 闭合后,就会将 h2 出栈。然后会解析两个未闭合的 p 标签,此时,栈内存在三个元素(div、p、p)。如果这个时候,解析了 div 的闭合标签,除了将 div 闭合外,div 内两个未闭合的 p 标签也会跟随闭合,此时栈被清空。

为了便于理解,特地录制了一个动图,如下:

入栈与出栈

理清了 parseHTML 的逻辑后,我们回到调用 parseHTML 的位置,调用该方法的时候,一共会传入四个回调,分别对应标签的开始和结束、文本、注释。

parseHTML(template, {
  // some options...

  // 解析到标签位置开始的回调
  start(tag, attrs, unary) {},
  // 解析到标签位置结束的回调
  end(tag) {},
  // 解析到文本时的回调
  chars(text: string) {},
  // 解析到注释时的回调
  comment(text: string) {}
})

处理开始标签

首先看解析到开始标签时,会生成一个 AST 节点,然后处理标签上的属性,最后将 AST 节点放入树形结构中。

function makeAttrsMap(attrs) {
  const map = {}
  for (let i = 0, l = attrs.length; i < l; i++) {
    const { name, value } = attrs[i]
    map[name] = value
  }
  return map
}
function createASTElement(tag, attrs, parent) {
  const attrsList = attrs
  const attrsMap = makeAttrsMap(attrsList)
  return {
    type: 1,       // 节点类型
    tag,           // 节点名称
    attrsMap,      // 节点属性映射
    attrsList,     // 节点属性数组
    parent,        // 父节点
    children: [],  // 子节点
  }
}

const stack = []
let root // 根节点
let currentParent // 暂存当前的父节点
parseHTML(template, {
  // some options...

  // 解析到标签位置开始的回调
  start(tag, attrs, unary) {
    // 创建 AST 节点
    let element = createASTElement(tag, attrs, currentParent)

    // 处理指令: v-for v-if v-once
    processFor(element)
    processIf(element)
    processOnce(element)
    processElement(element, options)

    // 处理 AST 树
    // 根节点不存在,则设置该元素为根节点
       if (!root) {
      root = element
      checkRootConstraints(root)
    }
    // 存在父节点
    if (currentParent) {
      // 将该元素推入父节点的子节点中
      currentParent.children.push(element)
      element.parent = currentParent
    }
    if (!unary) {
        // 非单标签需要入栈,且切换当前父元素的位置
      currentParent = element
      stack.push(element)
    }
  }
})

处理结束标签

标签结束的逻辑就比较简单了,只需要去除栈内最后一个未闭合标签,进行闭合即可。

parseHTML(template, {
  // some options...

  // 解析到标签位置结束的回调
  end() {
    const element = stack[stack.length - 1]
    const lastNode = element.children[element.children.length - 1]
    // 处理尾部空格的情况
    if (lastNode && lastNode.type === 3 && lastNode.text === ' ') {
      element.children.pop()
    }
    // 出栈,重置当前的父节点
    stack.length -= 1
    currentParent = stack[stack.length - 1]
  }
})

处理文本

处理完标签后,还需要对标签内的文本进行处理。文本的处理分两种情况,一种是带表达式的文本,还一种就是纯静态的文本。

parseHTML(template, {
  // some options...

  // 解析到文本时的回调
  chars(text) {
    if (!currentParent) {
      // 文本节点外如果没有父节点则不处理
      return
    }
    
    const children = currentParent.children
    text = text.trim()
    if (text) {
      // parseText 用来解析表达式
      // delimiters 表示表达式标识符,默认为 ['{{', '}}']
      const res = parseText(text, delimiters))
      if (res) {
        // 表达式
        children.push({
          type: 2,
          expression: res.expression,
          tokens: res.tokens,
          text
        })
      } else {
        // 静态文本
        children.push({
          type: 3,
          text
        })
      }
    }
  }
})

下面我们看看 parseText 如何解析表达式。

// 构造匹配表达式的正则
const buildRegex = delimiters => {
  const open = delimiters[0]
  const close = delimiters[1]
  return new RegExp(open + '((?:.|\\n)+?)' + close, 'g')
}

function parseText (text, delimiters){
  // delimiters 默认为 {{ }}
  const tagRE = buildRegex(delimiters || ['{{', '}}'])
  // 未匹配到表达式,直接返回
  if (!tagRE.test(text)) {
    return
  }
  const tokens = []
  const rawTokens = []
  let lastIndex = tagRE.lastIndex = 0
  let match, index, tokenValue
  while ((match = tagRE.exec(text))) {
    // 表达式开始的位置
    index = match.index
    // 提取表达式开始位置前面的静态字符,放入 token 中
    if (index > lastIndex) {
      rawTokens.push(tokenValue = text.slice(lastIndex, index))
      tokens.push(JSON.stringify(tokenValue))
    }
    // 提取表达式内部的内容,使用 _s() 方法包裹
    const exp = match[1].trim()
    tokens.push(`_s(${exp})`)
    rawTokens.push({ '@binding': exp })
    lastIndex = index + match[0].length
  }
  // 表达式后面还有其他静态字符,放入 token 中
  if (lastIndex < text.length) {
    rawTokens.push(tokenValue = text.slice(lastIndex))
    tokens.push(JSON.stringify(tokenValue))
  }
  return {
    expression: tokens.join('+'),
    tokens: rawTokens
  }
}

首先通过一段正则来提取表达式:

提取表达式

看代码可能有点难,我们直接看例子,这里有一个包含表达式的文本。

<div>是否登录:{{isLogin ? '是' : '否'}}</div>

运行结果

解析文本

optimize

通过上述一些列处理,我们就得到了 Vue 模板的 AST。由于 Vue 是响应式设计,所以拿到 AST 之后还需要进行一系列优化,确保静态的数据不会进入虚拟 DOM 的更新阶段,以此来优化性能。

export function optimize (root, options) {
  if (!root) return
  // 标记静态节点
  markStatic(root)
}

简单来说,就是把所以静态节点的 static 属性设置为 true。

function isStatic (node) {
  if (node.type === 2) { // 表达式,返回 false
    return false
  }
  if (node.type === 3) { // 静态文本,返回 true
    return true
  }
  // 此处省略了部分条件
  return !!(
    !node.hasBindings && // 没有动态绑定
    !node.if && !node.for && // 没有 v-if/v-for
    !isBuiltInTag(node.tag) && // 不是内置组件 slot/component
    !isDirectChildOfTemplateFor(node) && // 不在 template for 循环内
    Object.keys(node).every(isStaticKey) // 非静态节点
  )
}

function markStatic (node) {
  node.static = isStatic(node)
  if (node.type === 1) {
    // 如果是元素节点,需要遍历所有子节点
    for (let i = 0, l = node.children.length; i < l; i++) {
      const child = node.children[i]
      markStatic(child)
      if (!child.static) {
        // 如果有一个子节点不是静态节点,则该节点也必须是动态的
        node.static = false
      }
    }
  }
}

generate

得到优化的 AST 之后,就需要将 AST 转化为 render 方法。还是用之前的模板,先看看生成的代码长什么样:

<div>
  <h2 v-if="message">{{message}}</h2>
  <button @click="showName">showName</button>
</div>
{
  render: "with(this){return _c('div',[(message)?_c('h2',[_v(_s(message))]):_e(),_v(" "),_c('button',{on:{"click":showName}},[_v("showName")])])}"
}

将生成的代码展开:

with (this) {
    return _c(
      'div',
      [
        (message) ? _c('h2', [_v(_s(message))]) : _e(),
        _v(' '),
        _c('button', { on: { click: showName } }, [_v('showName')])
      ])
    ;
}

看到这里一堆的下划线肯定很懵逼,这里的 _c 对应的是虚拟 DOM 中的 createElement 方法。其他的下划线方法在 core/instance/render-helpers 中都有定义,每个方法具体做了什么不做展开。

render-helpers`

具体转化方法就是一些简单的字符拼接,下面是简化了逻辑的部分,不做过多讲述。

export function generate(ast, options) {
  const state = new CodegenState(options)
  const code = ast ? genElement(ast, state) : '_c("div")'
  return {
    render: `with(this){return ${code}}`,
    staticRenderFns: state.staticRenderFns
  }
}

export function genElement (el, state) {
  let code
  const data = genData(el, state)
  const children = genChildren(el, state, true)
  code = `_c('${el.tag}'${
    data ? `,${data}` : '' // data
  }${
    children ? `,${children}` : '' // children
  })`
  return code
}

总结

理清了 Vue 模板编译的整个过程,重点都放在了解析 HTML 生成 AST 的部分。本文只是大致讲述了主要流程,其中省略了特别多的细节,比如:对 template/slot 的处理、指令的处理等等,如果想了解其中的细节可以直接阅读源码。希望大家在阅读这篇文章后有所收获。

image

查看原文

赞 8 收藏 8 评论 0

duck2u 评论了文章 · 2019-04-18

vue2+mint-ui简历投递

vue-resume

基于的简历投递项目

运行项目:npm install ---> npm run start

多环境运行命令:npm run start -- --st1

若安装依赖不成功,安装淘宝镜像 npm install -g cnpm --registry=https://registry.npm.taobao.org

前言

项目接口为mock数据

项目实现多环境编译 通过cross-env来修改环境变量以此来解决项目开发中配合后台、测试多环境的需求

项目基于mint-ui框架,用到dialog和日历等组件

实现组件模块化开发,一个组件页面拆分多个子组件

设计稿尺寸为750

项目地址

https://github.com/bailichen/vue-resume

技术栈

vue2+router+webpack4+less+svg+mint-ui+es6/7

项目运行

git clone https://github.com/bailichen/vue-resume.git

cd vue-weixin

npm install

npm run start
查看原文

认证与成就

  • 获得 43 次点赞
  • 获得 4 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 4 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2017-08-01
个人主页被 952 人浏览