Week6-脚手架项目和组件初始化开发

2022-10-28 11:45:59 浏览数 (1)

第一章:本周导学

1-1 本周整体内容介绍和学习方法

  • 重点:脚手架安装 项目/组件 功能开发。
  • 技术栈:ejs模版渲染(项目模板安装)和glob文件筛选。
  • 加餐:ejs源码解析、require源码解析。

第二章:脚手架安装模版功能架构设计

2-1 脚手架安装项目模板架构设计 installTemplate

2-2 脚手架组件初始化架构设计

与项目大体过程没有改变。 tiny change:

  • 文本提示名称
  • 项目名称format
  • 组件需要填写描述信息

第三章 脚手架模板安装核心实现:ejs 库功能详解

3-1 ejs模板引擎的三种基本用法

ejs主要用于模版渲染的。jsp、php是之前模版渲染的代表。ejs的实现与jsp非常类似。

  • ejs.compile(html,options)(data)
代码语言:javascript复制
const ejs = require('ejs')
const path = require('path')
//第一种方法
const html ='<div><%= user.name%></div>'
const options = {}
const data ={
    user:{
        name:'liugezhou'
    }
}

const template = ejs.compile(html,options) //// 返回一个用于解析html中模板的 function

const compileTemplate = template(data)

console.log(compileTemplate)   //<div>liugezhou</div>

//第二种用法
const renderTemplate = ejs.render(html,data,options)
console.log(renderTemplate)

//第三种用法
const renderFile = ejs.renderFile(path.resolve(__dirname,'template.html'),data,options)
renderFile.then(file => console.log(file))

3-2 ejs模板不同标签用法详解

  • <% : ‘脚本’标签,用于流程控制,无输出。
  • <%= : 输出数据到模版(输出是转义Html标签)
  • <%- : 输出非转义的数据到模版 :如果数据是liugehou ,那么输出的就是这样的格式。
  • <%# : 注释标签,不执行、不输出内容,但是会占空间。
  • <%_ : 删除前面空格空符
  • -%>: 删除紧随其后的换行符
  • _%>: 删除后面空格字符

3-3 ejs模板几种特殊用法

本节主要介绍ejs另外比较常用的三个辅助功能

  • 包含: include
  • 自定义分隔符: 我们上面默认使用的是%,我们只需要在options参数中定义 delimiter这个参数即可
  • 自定义文件加载器: 在使用ejs.renderFile读取文件之前,可以使用ejs.fileLoader做一些操作
代码语言:javascript复制
ejs.fileLoader = function(filePath){
	const content = fs.readFileSync(filePath)
  return '<div><%= user.copyright %></div>'   content
}

3-4 glob用法小结

glob最早是出现在类Unix系统的命令中的,用来匹配文件路径。

代码语言:javascript复制
const glob = require('glob')

glob('**/*.js',{ignore:['node_modules/**','webpack.config.js']},function(err,file){
    console.log(file)
})

第四章:脚手架项目模板安装功能开发

4-1 引入项目模板类型和标准安装逻辑开发

本节代码较少,主要是梳理流程,上一大周写到了下载模版到本地缓存,本节接着上周进度: 接着便需要安装模版,新建了安装模版 installTemplate()方法,并对拿到模版的type进行判断, 若为normal,则执行安装标准模版方法:installNormalTemplate() 若为custom,则执行安装自定义模版方法:installCustomTemplate()

4-2 拷贝项目模板功能开发

代码语言:javascript复制
async installNormalTemplate(){
  //拷贝模板代码至当前目录
  const spinner = spinnerStart('正在安装模板...')
  await sleep()
  try {
    // 去缓存目录中拿template下的文件路径
    const templatePath = path.resolve(this.templateNpm.cacheFilePath,'template')
    //当前执行脚手架目录
    const targetPath = process.cwd()
    fse.ensureDirSync(templatePath)//确保使用前缓存生成目录存在,若不存在则创建
    fse.ensureDirSync(targetPath)   //确保当前脚手架安装目录存在,若不存在则创建
    fse.copySync(templatePath,targetPath) //将缓存目录下文件copy至当前目录
  } catch (error) {
    throw error
  } finally{ 
    spinner.stop(true)
    log.success('模板安装成功')
  }
}

4-3 项目模板安装依赖和启动命令 | 4-4 白名单命令检测功能开发

在上一节,模板copy成功之后,紧接着:

代码语言:javascript复制
//依赖安装
const { installCommand,startCommand } = this.templateInfo
//依赖安装
await this.execCommand(installCommand,'依赖过程安装失败!')
//启动命令执行
await this.execCommand(startCommand,'启动命令执行失败失败!')
代码语言:javascript复制
const WHITE_COMMAND =['npm', 'cnpm'] 

async execCommand(command,errMsg){
        let ret 
        if(command){
            const cmdArray=command.split(' ')
            const cmd = this.checkCommand(cmdArray[0])
            if(!cmd){
                throw new Error(errMsg)
            }
            const args = cmdArray.slice(1)
            ret = await execAsync(cmd,args,{
                stdio:'inherit',
                cwd:process.cwd()
            })
            if(ret !== 0){//执行成功
                throw new Error('依赖安装过程失败')
            }
            return ret
        }
    }
    checkCommand(cmd){
        if(WHITE_COMMAND.includes(cmd)){
            return cmd
        }
        return null;
    }

4-5 项目名称自动格式化功能开发

本节使用了kebab-case这个库,将手动填入的项目名称保存在projectInfo中,以供后续package.json中的ejs渲染使用。

代码语言:javascript复制
//生成className
if(projectInfo.projectName){
  projectInfo.name = projectInfo.projectName
  projectInfo.className = require('kebab-case')(projectInfo.projectName).replace(/^-/,'');
}
if(projectInfo.projectVersion){
  projectInfo.version = projectInfo.projectVersion
}

4-6 本章核心:ejs动态渲染项目模板

  • 首先将vue2模版中package.json文件中的name以及version使用<%= className%>和<%= version%>替代,并发布新的版本至npm。
  • commands/init模块安装 ejs和glob库。
  • 核心代码如下(在4-4节中依赖安装前,ejs动态渲染)
代码语言:javascript复制
async ejsRender(options){
  const dir = process.cwd()
  const projectInfo = this.projectInfo
  return new Promise((resolve,reject)=>{
    glob('**',{
      cwd:dir,
      ignore:options.ignore || '',
      nodir:true   //不输出文件夹,只输出文件
    },(err,files) =>{
      if(err){
        reject(err)
      }
      Promise.all(files.map(file=>{
        const filePath = path.join(dir,file)
        return new Promise( (resolve1,reject1) => {
          ejs.renderFile( filePath,projectInfo,{},(err,result) => {
            if(err){
              reject1(err)
            }
            fse.writeFileSync(filePath,result)
            resolve1(result)
          })
        })
      })).then(()=>{
        resolve()
      }).catch(err=>{
        reject(err)
      })
    })
  })
}

4-7 init命令直接传入项目名称功能支持

本节完成的是 对命令行中传入项目名称的一个支持 通过判断脚手架命令是否传入项目名称,对inquirer中的prompt进行动态push。

第五章 组件模板开发及脚手架组件初始化功能支持

5-1 慕课乐高组件库模板开发

维护组件库发布至npm,然后在mongodb数据库中进行配置。

5-2 项目和组件模板数据隔离 动态配置ejs ignore

这部分完整代码如下

代码语言:javascript复制
 //1.选取创建项目或组件
const { type } = await inquirer.prompt({
  type:'list',
  name:'type',
  message:'请选择初始化类型', 
  default:TYPE_PROJECT,
  choices: [{
    name: '项目',
    value: TYPE_PROJECT,
  }, {
    name: '组件',
    value: TYPE_COMPONENT,
  }]
})

// 数据隔离核心代码
this.template = this.template.filter(template =>template.tag && template.tag.includes(type))

const title =  type === TYPE_PROJECT ? '项目':'组件'
const projectNamePrompt = {
  type:'input',
  name:'projectName',
  message:`请输入${title}的名称`,
  default:'',
  validate:function(v){
    const done = this.async()
    setTimeout(function(){
      if(!isValidName(v)){
        done(`请输入合法的${title}名称`)
        return;
      }
      done(null,true)
    }, 0);
  },
  filter:function(v){
    return v
  }
}
const projectPrompt = []
if (!isProjectNameValid) {
  projectPrompt.push(projectNamePrompt);
}
projectPrompt.push({
  type:'input',
  name:'projectVersion',
  default:'1.0.0',
  message:`请输入${title}版本号`,
  validate:function(v){
    const done = this.async();
    // Do async stuff
    setTimeout(function() {
      if (!(!!semver.valid(v))) {
        done(`请输入合法的${title}版本号`);
        return;
      }
      done(null, true);
    }, 0);
  },
  filter:function(v){
    if(semver.valid(v)){
      return semver.valid(v)
    } else {
      return v
    }
  },
},{
  type:'list',
  name:'projectTemplate',
  message:`请选择${title}模板`,
  choices: this.createTemplateChoice()
})

5-3 获取组件信息功能开发

完整核心代码如下,添加了 descriptionPrompt

代码语言:javascript复制
else if (type === TYPE_COMPONENT){
  // 获取组件的基本信息
  const descriptionPrompt = {
    type:'input',
    name:'componentDescription',
    message:'请输入组件描述信息',
    default:'',
    validate:function(v){
      const done = this.async()
      setTimeout(() => {
        if(!v){
          done('请输入组件描述信息')
          return 
        }
        done(null,true)
      }, 0);
    }
  }
  projectPrompt.push(descriptionPrompt)
  const component = await inquirer.prompt(projectPrompt)
  projectInfo = {
    ...projectInfo,
    type,
    ...component
  }
}

……
if(projectInfo.componentDescription){
  projectInfo.description = projectInfo.componentDescription
}

5-4 解决组件库初始化过程中各种工程问题

慕课乐高组件库,在发布到npm包时,安装出现问题,问题原因是 package.json中,需要将 “files”:[‘dist’] 这行代码去除,这是因为files这里限定了上传发布到npm后只有dist这个目录。

第六章 脚手架自定义初始化项目模板功能开发

6-1 自定义项目模板开发

  • 发布自定义模版 liugezhou-cli-dev-template-custom-vue2
  • mongodb中配置自定义模版数据。

6-2 自定义模板执行逻辑开发 6-3 自定义模板上线

代码语言:javascript复制
async installCustomTemplate(){
        //查询自定义模版的入口文件
  if(await this.templateNpm.exists()){
    const rootFile = this.templateNpm.getRootFilePath()
    if(fs.existsSync(rootFile)){
      log.verbose('开始执行自定义模板')
      const templatePath = path.resolve(this.templateNpm.cacheFilePath, 'template');
      const options = {
        templateInfo: this.templateInfo,
        projectInfo: this.projectInfo,
        sourcePath: templatePath,
        targetPath: process.cwd(),
      };
      const code = `require('${rootFile}')(${JSON.stringify(options)})`
      await execAsync('node',  ['-e', code], {stdio:'inherit',cwd: process.cwd()})
      log.success('自定义模版安装成功')
    }else{
      throw new Error('自定义模板入口文件不存在')
    }
  }
}

第七章 本周加餐:ejs 库源码解析 —— 彻底搞懂模板动态渲染原理

7-1 ejs.compile执行流程分析

ejs模版渲染的思路值得我们学习,于是我们就开始了了ejs的源码的学习。

点击查看【processon】

本节内容较简单,我们打开webstore,从下面的代码开始调试(11行 打断点)

代码语言:javascript复制
const ejs = require('ejs')

const html = '<div><%= user.name %></div>'
const options = {}
const data = {
	user:{
    name:'liugezhou'
  }
}

const template = ejs.compile(html,options)
const compiletemplate = template(data)
代码语言:javascript复制
//ejs.js
exports.compile = function compile(template, opts) {
  var templ;
  if (opts && opts.scope) {   //我们的opts传进来的参数为空,暂不看此判断逻辑
    ……
  }
  templ = new Template(template, opts);
  return templ.compile();
};

templ = new Template(template,opts) 我们继续进去源码,重要的有两点

  • this.templateText = text
  • this.regex = this.createRegex()

下节开始 templ.compile()

代码语言:javascript复制
function Template(text, opts) {
  opts = opts || {};
  var options = {};
  this.templateText = text;    //⭐️⭐️⭐️
  this.mode = null;
  this.truncate = false;
  this.currentLine = 1;
  this.source = '';
  options.client = opts.client || false;
  options.escapeFunction = opts.escape || opts.escapeFunction || utils.escapeXML;
  options.compileDebug = opts.compileDebug !== false;
  options.debug = !!opts.debug;
  options.filename = opts.filename;
  options.openDelimiter = opts.openDelimiter || exports.openDelimiter || _DEFAULT_OPEN_DELIMITER;
  options.closeDelimiter = opts.closeDelimiter || exports.closeDelimiter || _DEFAULT_CLOSE_DELIMITER;
  options.delimiter = opts.delimiter || exports.delimiter || _DEFAULT_DELIMITER;
  options.strict = opts.strict || false;
  options.context = opts.context;
  options.cache = opts.cache || false;
  options.rmWhitespace = opts.rmWhitespace;
  options.root = opts.root;
  options.includer = opts.includer;
  options.outputFunctionName = opts.outputFunctionName;
  options.localsName = opts.localsName || exports.localsName || _DEFAULT_LOCALS_NAME;
  options.views = opts.views;
  options.async = opts.async;
  options.destructuredLocals = opts.destructuredLocals;
  options.legacyInclude = typeof opts.legacyInclude != 'undefined' ? !!opts.legacyInclude : true;

  if (options.strict) {
    options._with = false;
  }
  else {
    options._with = typeof opts._with != 'undefined' ? opts._with : true;
  }

  this.opts = options;

  this.regex = this.createRegex();  // ⭐️⭐️⭐️:该方法是对ejs标识符号%与开始结尾符号<>,进行定制化操作
}

7-2 深入讲解ejs编译原理

上一节我们看到了 return templet.compile()处,源代码如下

代码语言:javascript复制
compile: function () {
 
  var src;
  var fn;
  var opts = this.opts;
  var prepended = '';
  var appended = '';
  var escapeFn = opts.escapeFunction;
  var ctor;
  var sanitizedFilename = opts.filename ? JSON.stringify(opts.filename) : 'undefined';

  if (!this.source) {
    this.generateSource();   //⭐️⭐️⭐️⭐️⭐️
    prepended  =
      '  var __output = "";n'  
      '  function __append(s) { if (s !== undefined && s !== null) __output  = s }n';
    if (opts.outputFunctionName) {
      prepended  = '  var '   opts.outputFunctionName   ' = __append;'   'n';
    }
    if (opts.destructuredLocals && opts.destructuredLocals.length) {
      var destructuring = '  var __locals = ('   opts.localsName   ' || {}),n';
      for (var i = 0; i < opts.destructuredLocals.length; i  ) {
        var name = opts.destructuredLocals[i];
        if (i > 0) {
          destructuring  = ',n  ';
        }
        destructuring  = name   ' = __locals.'   name;
      }
      prepended  = destructuring   ';n';
    }
    if (opts._with !== false) {
      prepended  =  '  with ('   opts.localsName   ' || {}) {'   'n';
      appended  = '  }'   'n';
    }
    appended  = '  return __output;'   'n';
    this.source = prepended   this.source   appended;
  }

  if (opts.compileDebug) {
    src = 'var __line = 1'   'n'
        '  , __lines = '   JSON.stringify(this.templateText)   'n'
        '  , __filename = '   sanitizedFilename   ';'   'n'
        'try {'   'n'
        this.source
        '} catch (e) {'   'n'
        '  rethrow(e, __lines, __filename, __line, escapeFn);'   'n'
        '}'   'n';
  }
  else {
    src = this.source;
  }

  if (opts.client) {
    src = 'escapeFn = escapeFn || '   escapeFn.toString()   ';'   'n'   src;
    if (opts.compileDebug) {
      src = 'rethrow = rethrow || '   rethrow.toString()   ';'   'n'   src;
    }
  }

  if (opts.strict) {
    src = '"use strict";n'   src;
  }
  if (opts.debug) {
    console.log(src);
  }
  if (opts.compileDebug && opts.filename) {
    src = src   'n'
        '//# sourceURL='   sanitizedFilename   'n';
  }

  try {
    if (opts.async) {
      try {
        ctor = (new Function('return (async function(){}).constructor;'))();
      }
      catch(e) {
        if (e instanceof SyntaxError) {
          throw new Error('This environment does not support async/await');
        }
        else {
          throw e;
        }
      }
    }
    else {
      ctor = Function;
    }
    fn = new ctor(opts.localsName   ', escapeFn, include, rethrow', src);
  }
  catch(e) {
    // istanbul ignore else
    if (e instanceof SyntaxError) {
      if (opts.filename) {
        e.message  = ' in '   opts.filename;
      }
      e.message  = ' while compiling ejsnn';
      e.message  = 'If the above error is not helpful, you may want to try EJS-Lint:n';
      e.message  = 'https://github.com/RyanZim/EJS-Lint';
      if (!opts.async) {
        e.message  = 'n';
        e.message  = 'Or, if you meant to create an async function, pass `async: true` as an option.';
      }
    }
    throw e;
  }

  var returnedFn = opts.client ? fn : function anonymous(data) {
    var include = function (path, includeData) {
      var d = utils.shallowCopy({}, data);
      if (includeData) {
        d = utils.shallowCopy(d, includeData);
      }
      return includeFile(path, opts)(d);
    };
    return fn.apply(opts.context, [data || {}, escapeFn, include, rethrow]);
  };
  if (opts.filename && typeof Object.defineProperty === 'function') {
    var filename = opts.filename;
    var basename = path.basename(filename, path.extname(filename));
    try {
      Object.defineProperty(returnedFn, 'name', {
        value: basename,
        writable: false,
        enumerable: false,
        configurable: true
      });
    } catch (e) {/* ignore */}
  }
  return returnedFn;
},

generateSource:(最终拿到结果this.source)

代码语言:javascript复制
generateSource: function () {
   var opts = this.opts;


   // Slurp spaces and tabs before <%_ and after _%>
   this.templateText =
     this.templateText.replace(/[ t]*<%_/gm, '<%_').replace(/_%>[ t]*/gm, '_%>');

   var self = this;
   var matches = this.parseTemplateText();    //⭐️⭐️⭐️⭐️⭐️
   var d = this.opts.delimiter;
   var o = this.opts.openDelimiter;
   var c = this.opts.closeDelimiter;

   if (matches && matches.length) {
     matches.forEach(function (line, index) {   //⭐️⭐️⭐️⭐️⭐️
       var closing;
       if ( line.indexOf(o   d) === 0        // If it is a tag
         && line.indexOf(o   d   d) !== 0) { // and is not escaped
         closing = matches[index   2];
         if (!(closing == d   c || closing == '-'   d   c || closing == '_'   d   c)) {
           throw new Error('Could not find matching close tag for "'   line   '".');
         }
       }
       self.scanLine(line); ////⭐️⭐️⭐️⭐️⭐️
     });
   }

 },

7-3 动态生成Function with用法讲解

上一节代码没有继续追踪,根据自己的源码一步一步调试,生一节调试到的代码为:

代码语言:javascript复制
// ejs.js  line662
fn = new ctor(opts.localsName   ', escapeFn, include, rethrow', src);

代码讲解: const ctor = Function; const fn = new ctor(‘a,b’,‘console.log(a,b)’) fn(1,2)

我们回到7-1节中基础代码,在optons加入参数debug为true,控制台输出内容为:

代码语言:javascript复制
var __line = 1
  , __lines = "<div><%= user.name%></div>"
  , __filename = undefined;
try {
  var __output = "";
  function __append(s) { if (s !== undefined && s !== null) __output  = s }
  with (locals || {}) {
    ; __append("<div>")
    ; __append(escapeFn( user.name))
    ; __append("</div>")
  }
  return __output;
} catch (e) {
  rethrow(e, __lines, __filename, __line, escapeFn);
}

通过代码,我们看到了‘with’,现在前端with的使用已经很不常见且不推荐使用了,这里简单了解下:

代码语言:javascript复制
const ctx = {
	user:{
  	name:'liugezhou'
  }
} 

with(ctx){
  console.log(user.name)   
}

7-4 ejs compile函数执行流程分析

apply简要解释

代码语言:javascript复制
function test(a,b,c){
	console.log(a,b,c)
  console.log(this.a)
}
test(1,2,3) //通常调用   // 1 2 3

test.apply({a:'applt'},[2,3,4]) // 2 3 4 
test.call({a:'call',2,3,4)    // 2 3 4

7-5 ejs.render和renderFile原理讲解

ejs.render的代码执行流程为:

  • const renderTemplate = ejs.render(html,data,options)
  • exports.render ==> handleCache(opts, template)
  • handleCache ==> return exports.compile(template, options);
  • handleCache(opts, template)(data)

renderFile的原理讲解

  1. const renderFile = ejs.renderFile(path.resolve(__dirname,‘template.html’),data,options)
  2. exports.renderFile
  3. tryHandleCache(opts, data, cb)
  4. handleCache(options)(data)

第八章 加餐:require源码解析,彻底搞懂 npm 模块加载原理

8-1 require源码执行流程分析

  • require使用场景

**

  • 加载模块类型
    • 加载内置模块: require(‘fs’)
    • 加载node_modules模块:require(‘ejs’)
    • 加载本地模块:require(‘./utils’)
  • 支持加载文件
    • js
    • json
    • node
    • mjs
    • 加载其它类型
  • require执行流程

我们在调试这行代码的时候,在执行栈中可以看到,之前也执行了很多代码,这里的流程以及上面分析的使用场景,我们可以先引出一些思考:

  • CommonJS模块的加载流程
  • require如何加载内置模块? loadNativeModule
  • require如何加载node_modules模块?
  • require为什么会将非js/json/node文件视为js进行加载
  • require源码

  1. 我们从 require(‘./ejs’) 这行代码在webStorm中开始调试。(点击step into )
  2. 打开 Scripts --> no domain --> internal --> modules --> cjs --> helpers.js
  3. return mod.require(path); ----> line of 77 [helpers.js]

  1. 这里的mod就是指Module对象,调试后每个字段含义为:
    1. id:源码文件路径
    2. path:源码文件对应的文件夹,通过path.dirname(id)生成
    3. exports:模块输出的内容,默认为{}
    4. parent:父模块信息
    5. filename:源码文件路径
    6. loaded:是否已经加载完毕
    7. children:子模块对象集合
    8. paths:模块查询范围
  2. 继续step into到下一步,进去Module对象的require方法
  3. 代码如下: (校验参数为 string类型且不为空)
代码语言:javascript复制
Module.prototype.require = function(id) {
  validateString(id, 'id');
  if (id === '') {
    throw new ERR_INVALID_ARG_VALUE('id', id,
                                    'must be a non-empty string');
  }
  requireDepth  ;
  try {
    return Module._load(id, this, /* isMain */ false);
  } finally {
    requireDepth--;
  }
};

  1. Module._load(id,this,false) :
  • id:传入的字符串
  • this:Module对象
  • isMain:flase表示加载的不是一个主模块
代码语言:javascript复制
Module._load = function(request, parent, isMain) {
  let relResolveCacheIdentifier;
  if (parent) {
    debug('Module._load REQUEST %s parent: %s', request, parent.id);
    relResolveCacheIdentifier = `${parent.path}x00${request}`;
    const filename = relativeResolveCache[relResolveCacheIdentifier];
    if (filename !== undefined) {
      const cachedModule = Module._cache[filename];
      if (cachedModule !== undefined) {
        updateChildren(parent, cachedModule, true);
        return cachedModule.exports;
      }
      delete relativeResolveCache[relResolveCacheIdentifier];
    }
  }

  // ✨✨✨ 
  // Module._resolveFilename是require.resolve()的核心实现,在lerna源码讲解时学过--> Module._resolveLookupPaths()
  const filename = Module._resolveFilename(request, parent, isMain);

  const cachedModule = Module._cache[filename];
  if (cachedModule !== undefined) {
    updateChildren(parent, cachedModule, true);
    return cachedModule.exports;
  }

  //✨✨✨
  // loadNativeModule 中 加载内置模块,进入该源码:通过NativeModule.map我们可以看到所有的内置模块
  const mod = loadNativeModule(filename, request, experimentalModules);
  if (mod && mod.canBeRequiredByUsers) return mod.exports;

  // 不是内置模块,new Module,其中children在new的时候完成
  const module = new Module(filename, parent);

  if (isMain) {
    process.mainModule = module;
    module.id = '.';
  }

  Module._cache[filename] = module;
  if (parent !== undefined) {
    relativeResolveCache[relResolveCacheIdentifier] = filename;
  }

  let threw = true;
  try {
    if (enableSourceMaps) {
      try {
        module.load(filename);
      } catch (err) {
        rekeySourceMap(Module._cache[filename], err);
        throw err; /* node-do-not-add-exception-line */
      }
    } else {
      // 


	

0 人点赞